diff --git a/pype/hooks/aftereffects/pre_launch_args.py b/pype/hooks/aftereffects/pre_launch_args.py new file mode 100644 index 0000000000..e39247b983 --- /dev/null +++ b/pype/hooks/aftereffects/pre_launch_args.py @@ -0,0 +1,45 @@ +import os + +from pype.lib import PreLaunchHook + + +class AfterEffectsPrelaunchHook(PreLaunchHook): + """Launch arguments preparation. + + Hook add python executable and execute python script of AfterEffects + implementation before AfterEffects executable. + """ + app_groups = ["aftereffects"] + + def execute(self): + # Pop tvpaint executable + aftereffects_executable = self.launch_context.launch_args.pop(0) + + # Pop rest of launch arguments - There should not be other arguments! + remainders = [] + while self.launch_context.launch_args: + remainders.append(self.launch_context.launch_args.pop(0)) + + new_launch_args = [ + self.python_executable(), + "-c", + ( + "import avalon.aftereffects;" + "avalon.aftereffects.launch(\"{}\")" + ).format(aftereffects_executable) + ] + + # Append as whole list as these areguments should not be separated + self.launch_context.launch_args.append(new_launch_args) + + if remainders: + self.log.warning(( + "There are unexpected launch arguments " + "in AfterEffects launch. {}" + ).format(str(remainders))) + self.launch_context.launch_args.extend(remainders) + + def python_executable(self): + """Should lead to python executable.""" + # TODO change in Pype 3 + return os.environ["PYPE_PYTHON_EXE"] diff --git a/pype/hooks/celaction/pre_celaction_registers.py b/pype/hooks/celaction/pre_celaction_registers.py new file mode 100644 index 0000000000..3f9d81fb98 --- /dev/null +++ b/pype/hooks/celaction/pre_celaction_registers.py @@ -0,0 +1,127 @@ +import os +import shutil +import winreg +from pype.lib import PreLaunchHook +from pype.hosts import celaction + + +class CelactionPrelaunchHook(PreLaunchHook): + """ + This hook will check if current workfile path has Unreal + project inside. IF not, it initialize it and finally it pass + path to the project by environment variable to Unreal launcher + shell script. + """ + workfile_ext = "scn" + app_groups = ["celaction"] + platforms = ["windows"] + + def execute(self): + # Add workfile path to launch arguments + workfile_path = self.workfile_path() + if workfile_path: + self.launch_context.launch_args.append( + "\"{}\"".format(workfile_path) + ) + + project_name = self.data["project_name"] + asset_name = self.data["asset_name"] + task_name = self.data["task_name"] + + # get publish version of celaction + app = "celaction_publish" + + # setting output parameters + path = r"Software\CelAction\CelAction2D\User Settings" + winreg.CreateKey(winreg.HKEY_CURRENT_USER, path) + hKey = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + "Software\\CelAction\\CelAction2D\\User Settings", 0, + winreg.KEY_ALL_ACCESS) + + # TODO: change to root path and pyblish standalone to premiere way + pype_root_path = os.getenv("PYPE_SETUP_PATH") + path = os.path.join(pype_root_path, "pype.bat") + + winreg.SetValueEx(hKey, "SubmitAppTitle", 0, winreg.REG_SZ, path) + + parameters = [ + "launch", + f"--app {app}", + f"--project {project_name}", + f"--asset {asset_name}", + f"--task {task_name}", + "--currentFile \\\"\"*SCENE*\"\\\"", + "--chunk 10", + "--frameStart *START*", + "--frameEnd *END*", + "--resolutionWidth *X*", + "--resolutionHeight *Y*", + # "--programDir \"'*PROGPATH*'\"" + ] + winreg.SetValueEx(hKey, "SubmitParametersTitle", 0, winreg.REG_SZ, + " ".join(parameters)) + + # setting resolution parameters + path = r"Software\CelAction\CelAction2D\User Settings\Dialogs" + path += r"\SubmitOutput" + winreg.CreateKey(winreg.HKEY_CURRENT_USER, path) + hKey = winreg.OpenKey(winreg.HKEY_CURRENT_USER, path, 0, + winreg.KEY_ALL_ACCESS) + winreg.SetValueEx(hKey, "SaveScene", 0, winreg.REG_DWORD, 1) + winreg.SetValueEx(hKey, "CustomX", 0, winreg.REG_DWORD, 1920) + winreg.SetValueEx(hKey, "CustomY", 0, winreg.REG_DWORD, 1080) + + # making sure message dialogs don't appear when overwriting + path = r"Software\CelAction\CelAction2D\User Settings\Messages" + path += r"\OverwriteScene" + winreg.CreateKey(winreg.HKEY_CURRENT_USER, path) + hKey = winreg.OpenKey(winreg.HKEY_CURRENT_USER, path, 0, + winreg.KEY_ALL_ACCESS) + winreg.SetValueEx(hKey, "Result", 0, winreg.REG_DWORD, 6) + winreg.SetValueEx(hKey, "Valid", 0, winreg.REG_DWORD, 1) + + path = r"Software\CelAction\CelAction2D\User Settings\Messages" + path += r"\SceneSaved" + winreg.CreateKey(winreg.HKEY_CURRENT_USER, path) + hKey = winreg.OpenKey(winreg.HKEY_CURRENT_USER, path, 0, + winreg.KEY_ALL_ACCESS) + winreg.SetValueEx(hKey, "Result", 0, winreg.REG_DWORD, 1) + winreg.SetValueEx(hKey, "Valid", 0, winreg.REG_DWORD, 1) + + def workfile_path(self): + workfile_path = self.data["last_workfile_path"] + + # copy workfile from template if doesnt exist any on path + if not os.path.exists(workfile_path): + # TODO add ability to set different template workfile path via + # settings + pype_celaction_dir = os.path.dirname( + os.path.abspath(celaction.__file__) + ) + template_path = os.path.join( + pype_celaction_dir, + "celaction_template_scene.scn" + ) + + if not os.path.exists(template_path): + self.log.warning( + "Couldn't find workfile template file in {}".format( + template_path + ) + ) + return + + self.log.info( + f"Creating workfile from template: \"{template_path}\"" + ) + + # Copy template workfile to new destinantion + shutil.copy2( + os.path.normpath(template_path), + os.path.normpath(workfile_path) + ) + + self.log.info(f"Workfile to open: \"{workfile_path}\"") + + return workfile_path diff --git a/pype/hooks/fusion/pre_fusion_setup.py b/pype/hooks/fusion/pre_fusion_setup.py new file mode 100644 index 0000000000..d4402e9a04 --- /dev/null +++ b/pype/hooks/fusion/pre_fusion_setup.py @@ -0,0 +1,50 @@ +import os +import importlib +from pype.lib import PreLaunchHook +from pype.hosts.fusion import utils + + +class FusionPrelaunch(PreLaunchHook): + """ + This hook will check if current workfile path has Fusion + project inside. + """ + app_groups = ["fusion"] + + def execute(self): + # making sure pyton 3.6 is installed at provided path + py36_dir = os.path.normpath(self.env.get("PYTHON36", "")) + assert os.path.isdir(py36_dir), ( + "Python 3.6 is not installed at the provided folder path. Either " + "make sure the `environments\resolve.json` is having correctly " + "set `PYTHON36` or make sure Python 3.6 is installed " + f"in given path. \nPYTHON36E: `{py36_dir}`" + ) + self.log.info(f"Path to Fusion Python folder: `{py36_dir}`...") + self.env["PYTHON36"] = py36_dir + + # setting utility scripts dir for scripts syncing + us_dir = os.path.normpath( + self.env.get("FUSION_UTILITY_SCRIPTS_DIR", "") + ) + assert os.path.isdir(us_dir), ( + "Fusion utility script dir does not exists. Either make sure " + "the `environments\fusion.json` is having correctly set " + "`FUSION_UTILITY_SCRIPTS_DIR` or reinstall DaVinci Resolve. \n" + f"FUSION_UTILITY_SCRIPTS_DIR: `{us_dir}`" + ) + + try: + __import__("avalon.fusion") + __import__("pyblish") + + except ImportError: + self.log.warning( + "pyblish: Could not load Fusion integration.", + exc_info=True + ) + + else: + # Resolve Setup integration + importlib.reload(utils) + utils.setup(self.env) diff --git a/pype/hooks/global/post_ftrack_changes.py b/pype/hooks/global/post_ftrack_changes.py new file mode 100644 index 0000000000..144f618620 --- /dev/null +++ b/pype/hooks/global/post_ftrack_changes.py @@ -0,0 +1,184 @@ +import os + +import ftrack_api +from pype.api import config +from pype.lib import PostLaunchHook + + +class PostFtrackHook(PostLaunchHook): + order = None + + def execute(self): + project_name = self.data.get("project_name") + asset_name = self.data.get("asset_name") + task_name = self.data.get("task_name") + + missing_context_keys = set() + if not project_name: + missing_context_keys.add("project_name") + if not asset_name: + missing_context_keys.add("asset_name") + if not task_name: + missing_context_keys.add("task_name") + + if missing_context_keys: + missing_keys_str = ", ".join([ + "\"{}\"".format(key) for key in missing_context_keys + ]) + self.log.debug("Hook {} skipped. Missing data keys: {}".format( + self.__class__.__name__, missing_keys_str + )) + return + + required_keys = ("FTRACK_SERVER", "FTRACK_API_USER", "FTRACK_API_KEY") + for key in required_keys: + if not os.environ.get(key): + self.log.debug(( + "Missing required environment \"{}\"" + " for Ftrack after launch procedure." + ).format(key)) + return + + try: + session = ftrack_api.Session(auto_connect_event_hub=True) + self.log.debug("Ftrack session created") + except Exception: + self.log.warning("Couldn't create Ftrack session") + return + + try: + entity = self.find_ftrack_task_entity( + session, project_name, asset_name, task_name + ) + if entity: + self.ftrack_status_change(session, entity, project_name) + self.start_timer(session, entity, ftrack_api) + except Exception: + self.log.warning( + "Couldn't finish Ftrack procedure.", exc_info=True + ) + return + + finally: + session.close() + + def find_ftrack_task_entity( + self, session, project_name, asset_name, task_name + ): + project_entity = session.query( + "Project where full_name is \"{}\"".format(project_name) + ).first() + if not project_entity: + self.log.warning( + "Couldn't find project \"{}\" in Ftrack.".format(project_name) + ) + return + + potential_task_entities = session.query(( + "TypedContext where parent.name is \"{}\" and project_id is \"{}\"" + ).format(asset_name, project_entity["id"])).all() + filtered_entities = [] + for _entity in potential_task_entities: + if ( + _entity.entity_type.lower() == "task" + and _entity["name"] == task_name + ): + filtered_entities.append(_entity) + + if not filtered_entities: + self.log.warning(( + "Couldn't find task \"{}\" under parent \"{}\" in Ftrack." + ).format(task_name, asset_name)) + return + + if len(filtered_entities) > 1: + self.log.warning(( + "Found more than one task \"{}\"" + " under parent \"{}\" in Ftrack." + ).format(task_name, asset_name)) + return + + return filtered_entities[0] + + def ftrack_status_change(self, session, entity, project_name): + # TODO use settings + presets = config.get_presets(project_name)["ftrack"]["ftrack_config"] + statuses = presets.get("status_update") + if not statuses: + return + + actual_status = entity["status"]["name"].lower() + already_tested = set() + ent_path = "/".join( + [ent["name"] for ent in entity["link"]] + ) + while True: + next_status_name = None + for key, value in statuses.items(): + if key in already_tested: + continue + if actual_status in value or "_any_" in value: + if key != "_ignore_": + next_status_name = key + already_tested.add(key) + break + already_tested.add(key) + + if next_status_name is None: + break + + try: + query = "Status where name is \"{}\"".format( + next_status_name + ) + status = session.query(query).one() + + entity["status"] = status + session.commit() + self.log.debug("Changing status to \"{}\" <{}>".format( + next_status_name, ent_path + )) + break + + except Exception: + session.rollback() + msg = ( + "Status \"{}\" in presets wasn't found" + " on Ftrack entity type \"{}\"" + ).format(next_status_name, entity.entity_type) + self.log.warning(msg) + + def start_timer(self, session, entity, _ftrack_api): + """Start Ftrack timer on task from context.""" + self.log.debug("Triggering timer start.") + + user_entity = session.query("User where username is \"{}\"".format( + os.environ["FTRACK_API_USER"] + )).first() + if not user_entity: + self.log.warning( + "Couldn't find user with username \"{}\" in Ftrack".format( + os.environ["FTRACK_API_USER"] + ) + ) + return + + source = { + "user": { + "id": user_entity["id"], + "username": user_entity["username"] + } + } + event_data = { + "actionIdentifier": "start.timer", + "selection": [{"entityId": entity["id"], "entityType": "task"}] + } + session.event_hub.publish( + _ftrack_api.event.base.Event( + topic="ftrack.action.launch", + data=event_data, + source=source + ), + on_error="ignore" + ) + self.log.debug("Timer start triggered successfully.") diff --git a/pype/hooks/global/pre_global_host_data.py b/pype/hooks/global/pre_global_host_data.py new file mode 100644 index 0000000000..3f403b43f5 --- /dev/null +++ b/pype/hooks/global/pre_global_host_data.py @@ -0,0 +1,363 @@ +import os +import re +import json +import getpass +import copy + +from pype.api import ( + Anatomy, + config +) +from pype.lib import ( + env_value_to_bool, + PreLaunchHook, + ApplicationLaunchFailed +) + +import acre +import avalon.api + + +class GlobalHostDataHook(PreLaunchHook): + order = -100 + + def execute(self): + """Prepare global objects to `data` that will be used for sure.""" + if not self.application.is_host: + self.log.info( + "Skipped hook {}. Application is not marked as host.".format( + self.__class__.__name__ + ) + ) + return + + self.prepare_global_data() + self.prepare_host_environments() + self.prepare_context_environments() + + def prepare_global_data(self): + """Prepare global objects to `data` that will be used for sure.""" + # Mongo documents + project_name = self.data.get("project_name") + if not project_name: + self.log.info( + "Skipping global data preparation." + " Key `project_name` was not found in launch context." + ) + return + + self.log.debug("Project name is set to \"{}\"".format(project_name)) + # Anatomy + self.data["anatomy"] = Anatomy(project_name) + + # Mongo connection + dbcon = avalon.api.AvalonMongoDB() + dbcon.Session["AVALON_PROJECT"] = project_name + dbcon.install() + + self.data["dbcon"] = dbcon + + # Project document + project_doc = dbcon.find_one({"type": "project"}) + self.data["project_doc"] = project_doc + + asset_name = self.data.get("asset_name") + if not asset_name: + self.log.warning( + "Asset name was not set. Skipping asset document query." + ) + return + + asset_doc = dbcon.find_one({ + "type": "asset", + "name": asset_name + }) + self.data["asset_doc"] = asset_doc + + def _merge_env(self, env, current_env): + """Modified function(merge) from acre module.""" + result = current_env.copy() + for key, value in env.items(): + # Keep missing keys by not filling `missing` kwarg + value = acre.lib.partial_format(value, data=current_env) + result[key] = value + return result + + def prepare_host_environments(self): + """Modify launch environments based on launched app and context.""" + # Keys for getting environments + env_keys = [self.app_group, self.app_name] + + asset_doc = self.data.get("asset_doc") + if asset_doc: + # Add tools environments + for key in asset_doc["data"].get("tools_env") or []: + tool = self.manager.tools.get(key) + if tool: + if tool.group_name not in env_keys: + env_keys.append(tool.group_name) + + if tool.name not in env_keys: + env_keys.append(tool.name) + + self.log.debug( + "Finding environment groups for keys: {}".format(env_keys) + ) + + settings_env = self.data["settings_env"] + env_values = {} + for env_key in env_keys: + _env_values = settings_env.get(env_key) + if not _env_values: + continue + + # Choose right platform + tool_env = acre.parse(_env_values) + # Merge dictionaries + env_values = self._merge_env(tool_env, env_values) + + final_env = self._merge_env( + acre.compute(env_values), self.launch_context.env + ) + + # Update env + self.launch_context.env.update(final_env) + + def prepare_context_environments(self): + """Modify launch environemnts with context data for launched host.""" + # Context environments + project_doc = self.data.get("project_doc") + asset_doc = self.data.get("asset_doc") + task_name = self.data.get("task_name") + if ( + not project_doc + or not asset_doc + or not task_name + ): + self.log.info( + "Skipping context environments preparation." + " Launch context does not contain required data." + ) + return + + workdir_data = self._prepare_workdir_data( + project_doc, asset_doc, task_name + ) + self.data["workdir_data"] = workdir_data + + hierarchy = workdir_data["hierarchy"] + anatomy = self.data["anatomy"] + + try: + anatomy_filled = anatomy.format(workdir_data) + workdir = os.path.normpath(anatomy_filled["work"]["folder"]) + if not os.path.exists(workdir): + self.log.debug( + "Creating workdir folder: \"{}\"".format(workdir) + ) + os.makedirs(workdir) + + except Exception as exc: + raise ApplicationLaunchFailed( + "Error in anatomy.format: {}".format(str(exc)) + ) + + context_env = { + "AVALON_PROJECT": project_doc["name"], + "AVALON_ASSET": asset_doc["name"], + "AVALON_TASK": task_name, + "AVALON_APP": self.host_name, + "AVALON_APP_NAME": self.app_name, + "AVALON_HIERARCHY": hierarchy, + "AVALON_WORKDIR": workdir + } + self.log.debug( + "Context environemnts set:\n{}".format( + json.dumps(context_env, indent=4) + ) + ) + self.launch_context.env.update(context_env) + + self.prepare_last_workfile(workdir) + + def _prepare_workdir_data(self, project_doc, asset_doc, task_name): + hierarchy = "/".join(asset_doc["data"]["parents"]) + + data = { + "project": { + "name": project_doc["name"], + "code": project_doc["data"].get("code") + }, + "task": task_name, + "asset": asset_doc["name"], + "app": self.host_name, + "hierarchy": hierarchy + } + return data + + def prepare_last_workfile(self, workdir): + """last workfile workflow preparation. + + Function check if should care about last workfile workflow and tries + to find the last workfile. Both information are stored to `data` and + environments. + + Last workfile is filled always (with version 1) even if any workfile + exists yet. + + Args: + workdir (str): Path to folder where workfiles should be stored. + """ + _workdir_data = self.data.get("workdir_data") + if not _workdir_data: + self.log.info( + "Skipping last workfile preparation." + " Key `workdir_data` not filled." + ) + return + + workdir_data = copy.deepcopy(_workdir_data) + project_name = self.data["project_name"] + task_name = self.data["task_name"] + start_last_workfile = self.should_start_last_workfile( + project_name, self.host_name, task_name + ) + self.data["start_last_workfile"] = start_last_workfile + + # Store boolean as "0"(False) or "1"(True) + self.launch_context.env["AVALON_OPEN_LAST_WORKFILE"] = ( + str(int(bool(start_last_workfile))) + ) + + _sub_msg = "" if start_last_workfile else " not" + self.log.debug( + "Last workfile should{} be opened on start.".format(_sub_msg) + ) + + # Last workfile path + last_workfile_path = "" + extensions = avalon.api.HOST_WORKFILE_EXTENSIONS.get( + self.host_name + ) + if extensions: + anatomy = self.data["anatomy"] + # Find last workfile + file_template = anatomy.templates["work"]["file"] + workdir_data.update({ + "version": 1, + "user": os.environ.get("PYPE_USERNAME") or getpass.getuser(), + "ext": extensions[0] + }) + + last_workfile_path = avalon.api.last_workfile( + workdir, file_template, workdir_data, extensions, True + ) + + if os.path.exists(last_workfile_path): + self.log.debug(( + "Workfiles for launch context does not exists" + " yet but path will be set." + )) + self.log.debug( + "Setting last workfile path: {}".format(last_workfile_path) + ) + + self.launch_context.env["AVALON_LAST_WORKFILE"] = last_workfile_path + self.data["last_workfile_path"] = last_workfile_path + + def should_start_last_workfile(self, project_name, host_name, task_name): + """Define if host should start last version workfile if possible. + + Default output is `False`. Can be overriden with environment variable + `AVALON_OPEN_LAST_WORKFILE`, valid values without case sensitivity are + `"0", "1", "true", "false", "yes", "no"`. + + Args: + project_name (str): Name of project. + host_name (str): Name of host which is launched. In avalon's + application context it's value stored in app definition under + key `"application_dir"`. Is not case sensitive. + task_name (str): Name of task which is used for launching the host. + Task name is not case sensitive. + + Returns: + bool: True if host should start workfile. + + """ + default_output = env_value_to_bool( + "AVALON_OPEN_LAST_WORKFILE", default=False + ) + # TODO convert to settings + try: + startup_presets = ( + config.get_presets(project_name) + .get("tools", {}) + .get("workfiles", {}) + .get("last_workfile_on_startup") + ) + except Exception: + startup_presets = None + self.log.warning("Couldn't load pype's presets", exc_info=True) + + if not startup_presets: + return default_output + + host_name_lowered = host_name.lower() + task_name_lowered = task_name.lower() + + max_points = 2 + matching_points = -1 + matching_item = None + for item in startup_presets: + hosts = item.get("hosts") or tuple() + tasks = item.get("tasks") or tuple() + + hosts_lowered = tuple(_host_name.lower() for _host_name in hosts) + # Skip item if has set hosts and current host is not in + if hosts_lowered and host_name_lowered not in hosts_lowered: + continue + + tasks_lowered = tuple(_task_name.lower() for _task_name in tasks) + # Skip item if has set tasks and current task is not in + if tasks_lowered: + task_match = False + for task_regex in self.compile_list_of_regexes(tasks_lowered): + if re.match(task_regex, task_name_lowered): + task_match = True + break + + if not task_match: + continue + + points = int(bool(hosts_lowered)) + int(bool(tasks_lowered)) + if points > matching_points: + matching_item = item + matching_points = points + + if matching_points == max_points: + break + + if matching_item is not None: + output = matching_item.get("enabled") + if output is None: + output = default_output + return output + return default_output + + @staticmethod + def compile_list_of_regexes(in_list): + """Convert strings in entered list to compiled regex objects.""" + regexes = list() + if not in_list: + return regexes + + for item in in_list: + if item: + try: + regexes.append(re.compile(item)) + except TypeError: + print(( + "Invalid type \"{}\" value \"{}\"." + " Expected string based object. Skipping." + ).format(str(type(item)), str(item))) + return regexes diff --git a/pype/hooks/harmony/pre_launch_args.py b/pype/hooks/harmony/pre_launch_args.py new file mode 100644 index 0000000000..70c05eb352 --- /dev/null +++ b/pype/hooks/harmony/pre_launch_args.py @@ -0,0 +1,44 @@ +import os + +from pype.lib import PreLaunchHook + + +class HarmonyPrelaunchHook(PreLaunchHook): + """Launch arguments preparation. + + Hook add python executable and execute python script of harmony + implementation before harmony executable. + """ + app_groups = ["harmony"] + + def execute(self): + # Pop tvpaint executable + harmony_executable = self.launch_context.launch_args.pop(0) + + # Pop rest of launch arguments - There should not be other arguments! + remainders = [] + while self.launch_context.launch_args: + remainders.append(self.launch_context.launch_args.pop(0)) + + new_launch_args = [ + self.python_executable(), + "-c", + ( + "import avalon.harmony;" + "avalon.harmony.launch(\"{}\")" + ).format(harmony_executable) + ] + + # Append as whole list as these areguments should not be separated + self.launch_context.launch_args.append(new_launch_args) + + if remainders: + self.log.warning(( + "There are unexpected launch arguments in Harmony launch. {}" + ).format(str(remainders))) + self.launch_context.launch_args.extend(remainders) + + def python_executable(self): + """Should lead to python executable.""" + # TODO change in Pype 3 + return os.environ["PYPE_PYTHON_EXE"] diff --git a/pype/hooks/hiero/pre_launch_args.py b/pype/hooks/hiero/pre_launch_args.py new file mode 100644 index 0000000000..feca6dc3eb --- /dev/null +++ b/pype/hooks/hiero/pre_launch_args.py @@ -0,0 +1,17 @@ +import os +from pype.lib import PreLaunchHook + + +class HieroLaunchArguments(PreLaunchHook): + order = 0 + app_groups = ["hiero"] + + def execute(self): + """Prepare suprocess launch arguments for Hiero.""" + # Add path to workfile to arguments + if self.data.get("start_last_workfile"): + last_workfile = self.data.get("last_workfile_path") + if os.path.exists(last_workfile): + self.launch_context.launch_args.append( + "\"{}\"".format(last_workfile) + ) diff --git a/pype/hooks/maya/pre_launch_args.py b/pype/hooks/maya/pre_launch_args.py new file mode 100644 index 0000000000..8b37bac15b --- /dev/null +++ b/pype/hooks/maya/pre_launch_args.py @@ -0,0 +1,18 @@ +import os +from pype.lib import PreLaunchHook + + +class MayaLaunchArguments(PreLaunchHook): + """Add path to last workfile to launch arguments.""" + order = 0 + app_groups = ["maya"] + + def execute(self): + """Prepare suprocess launch arguments for Maya.""" + # Add path to workfile to arguments + if self.data.get("start_last_workfile"): + last_workfile = self.data.get("last_workfile_path") + if os.path.exists(last_workfile): + self.launch_context.launch_args.append( + "\"{}\"".format(last_workfile) + ) diff --git a/pype/hooks/nukestudio/pre_launch_args.py b/pype/hooks/nukestudio/pre_launch_args.py new file mode 100644 index 0000000000..e572ca32a2 --- /dev/null +++ b/pype/hooks/nukestudio/pre_launch_args.py @@ -0,0 +1,17 @@ +import os +from pype.lib import PreLaunchHook + + +class NukeStudioLaunchArguments(PreLaunchHook): + order = 0 + app_groups = ["nukestudio"] + + def execute(self): + """Prepare suprocess launch arguments for NukeStudio.""" + # Add path to workfile to arguments + if self.data.get("start_last_workfile"): + last_workfile = self.data.get("last_workfile_path") + if os.path.exists(last_workfile): + self.launch_context.launch_args.append( + "\"{}\"".format(last_workfile) + ) diff --git a/pype/hooks/nukex/pre_launch_args.py b/pype/hooks/nukex/pre_launch_args.py new file mode 100644 index 0000000000..f0e5cf7733 --- /dev/null +++ b/pype/hooks/nukex/pre_launch_args.py @@ -0,0 +1,17 @@ +import os +from pype.lib import PreLaunchHook + + +class NukeXLaunchArguments(PreLaunchHook): + order = 0 + app_groups = ["nukex"] + + def execute(self): + """Prepare suprocess launch arguments for NukeX.""" + # Add path to workfile to arguments + if self.data.get("start_last_workfile"): + last_workfile = self.data.get("last_workfile_path") + if os.path.exists(last_workfile): + self.launch_context.launch_args.append( + "\"{}\"".format(last_workfile) + ) diff --git a/pype/hooks/photoshop/pre_launch_args.py b/pype/hooks/photoshop/pre_launch_args.py new file mode 100644 index 0000000000..b13e7d1e0f --- /dev/null +++ b/pype/hooks/photoshop/pre_launch_args.py @@ -0,0 +1,44 @@ +import os + +from pype.lib import PreLaunchHook + + +class PhotoshopPrelaunchHook(PreLaunchHook): + """Launch arguments preparation. + + Hook add python executable and execute python script of photoshop + implementation before photoshop executable. + """ + app_groups = ["photoshop"] + + def execute(self): + # Pop tvpaint executable + photoshop_executable = self.launch_context.launch_args.pop(0) + + # Pop rest of launch arguments - There should not be other arguments! + remainders = [] + while self.launch_context.launch_args: + remainders.append(self.launch_context.launch_args.pop(0)) + + new_launch_args = [ + self.python_executable(), + "-c", + ( + "import avalon.photoshop;" + "avalon.photoshop.launch(\"{}\")" + ).format(photoshop_executable) + ] + + # Append as whole list as these areguments should not be separated + self.launch_context.launch_args.append(new_launch_args) + + if remainders: + self.log.warning(( + "There are unexpected launch arguments in Photoshop launch. {}" + ).format(str(remainders))) + self.launch_context.launch_args.extend(remainders) + + def python_executable(self): + """Should lead to python executable.""" + # TODO change in Pype 3 + return os.environ["PYPE_PYTHON_EXE"] diff --git a/pype/hooks/resolve/pre_resolve_setup.py b/pype/hooks/resolve/pre_resolve_setup.py new file mode 100644 index 0000000000..4f6d33c6eb --- /dev/null +++ b/pype/hooks/resolve/pre_resolve_setup.py @@ -0,0 +1,58 @@ +import os +import importlib +from pype.lib import PreLaunchHook +from pype.hosts.resolve import utils + + +class ResolvePrelaunch(PreLaunchHook): + """ + This hook will check if current workfile path has Resolve + project inside. IF not, it initialize it and finally it pass + path to the project by environment variable to Premiere launcher + shell script. + """ + app_groups = ["resolve"] + + def execute(self): + # making sure pyton 3.6 is installed at provided path + py36_dir = os.path.normpath(self.env.get("PYTHON36_RESOLVE", "")) + assert os.path.isdir(py36_dir), ( + "Python 3.6 is not installed at the provided folder path. Either " + "make sure the `environments\resolve.json` is having correctly " + "set `PYTHON36_RESOLVE` or make sure Python 3.6 is installed " + f"in given path. \nPYTHON36_RESOLVE: `{py36_dir}`" + ) + self.log.info(f"Path to Resolve Python folder: `{py36_dir}`...") + self.env["PYTHON36_RESOLVE"] = py36_dir + + # setting utility scripts dir for scripts syncing + us_dir = os.path.normpath( + self.env.get("RESOLVE_UTILITY_SCRIPTS_DIR", "") + ) + assert os.path.isdir(us_dir), ( + "Resolve utility script dir does not exists. Either make sure " + "the `environments\resolve.json` is having correctly set " + "`RESOLVE_UTILITY_SCRIPTS_DIR` or reinstall DaVinci Resolve. \n" + f"RESOLVE_UTILITY_SCRIPTS_DIR: `{us_dir}`" + ) + self.log.debug(f"-- us_dir: `{us_dir}`") + + # correctly format path for pre python script + pre_py_sc = os.path.normpath(self.env.get("PRE_PYTHON_SCRIPT", "")) + self.env["PRE_PYTHON_SCRIPT"] = pre_py_sc + self.log.debug(f"-- pre_py_sc: `{pre_py_sc}`...") + try: + __import__("pype.hosts.resolve") + __import__("pyblish") + + except ImportError: + self.log.warning( + "pyblish: Could not load Resolve integration.", + exc_info=True + ) + + else: + # Resolve Setup integration + importlib.reload(utils) + self.log.debug(f"-- utils.__file__: `{utils.__file__}`") + utils.setup(self.env) diff --git a/pype/hooks/tvpaint/pre_install_pywin.py b/pype/hooks/tvpaint/pre_install_pywin.py new file mode 100644 index 0000000000..ca9242c4c8 --- /dev/null +++ b/pype/hooks/tvpaint/pre_install_pywin.py @@ -0,0 +1,35 @@ +from pype.lib import ( + PreLaunchHook, + ApplicationLaunchFailed, + _subprocess +) + + +class PreInstallPyWin(PreLaunchHook): + """Hook makes sure there is installed python module pywin32 on windows.""" + # WARNING This hook will probably be deprecated in Pype 3 - kept for test + order = 10 + app_groups = ["tvpaint"] + platforms = ["windows"] + + def execute(self): + installed = False + try: + from win32com.shell import shell + self.log.debug("Python module `pywin32` already installed.") + installed = True + except Exception: + pass + + if installed: + return + + try: + output = _subprocess( + ["pip", "install", "pywin32==227"] + ) + self.log.debug("Pip install pywin32 output:\n{}'".format(output)) + except RuntimeError: + msg = "Installation of python module `pywin32` crashed." + self.log.warning(msg, exc_info=True) + raise ApplicationLaunchFailed(msg) diff --git a/pype/hooks/tvpaint/pre_launch_args.py b/pype/hooks/tvpaint/pre_launch_args.py new file mode 100644 index 0000000000..13ec320fa0 --- /dev/null +++ b/pype/hooks/tvpaint/pre_launch_args.py @@ -0,0 +1,105 @@ +import os +import shutil + +from pype.hosts import tvpaint +from pype.lib import PreLaunchHook + +import avalon + + +class TvpaintPrelaunchHook(PreLaunchHook): + """Launch arguments preparation. + + Hook add python executable and script path to tvpaint implementation before + tvpaint executable and add last workfile path to launch arguments. + + Existence of last workfile is checked. If workfile does not exists tries + to copy templated workfile from predefined path. + """ + app_groups = ["tvpaint"] + + def execute(self): + # Pop tvpaint executable + tvpaint_executable = self.launch_context.launch_args.pop(0) + + # Pop rest of launch arguments - There should not be other arguments! + remainders = [] + while self.launch_context.launch_args: + remainders.append(self.launch_context.launch_args.pop(0)) + + new_launch_args = [ + self.main_executable(), + self.launch_script_path(), + tvpaint_executable + ] + + # Add workfile to launch arguments + workfile_path = self.workfile_path() + if workfile_path: + new_launch_args.append( + "\"{}\"".format(workfile_path) + ) + + # How to create new command line + # if platform.system().lower() == "windows": + # new_launch_args = [ + # "cmd.exe", + # "/c", + # "Call cmd.exe /k", + # *new_launch_args + # ] + + # Append as whole list as these areguments should not be separated + self.launch_context.launch_args.append(new_launch_args) + + if remainders: + self.log.warning(( + "There are unexpected launch arguments in TVPaint launch. {}" + ).format(str(remainders))) + self.launch_context.launch_args.extend(remainders) + + def main_executable(self): + """Should lead to python executable.""" + # TODO change in Pype 3 + return os.path.normpath(os.environ["PYPE_PYTHON_EXE"]) + + def launch_script_path(self): + avalon_dir = os.path.dirname(os.path.abspath(avalon.__file__)) + script_path = os.path.join( + avalon_dir, + "tvpaint", + "launch_script.py" + ) + return script_path + + def workfile_path(self): + workfile_path = self.data["last_workfile_path"] + + # copy workfile from template if doesnt exist any on path + if not os.path.exists(workfile_path): + # TODO add ability to set different template workfile path via + # settings + pype_dir = os.path.dirname(os.path.abspath(tvpaint.__file__)) + template_path = os.path.join(pype_dir, "template.tvpp") + + if not os.path.exists(template_path): + self.log.warning( + "Couldn't find workfile template file in {}".format( + template_path + ) + ) + return + + self.log.info( + f"Creating workfile from template: \"{template_path}\"" + ) + + # Copy template workfile to new destinantion + shutil.copy2( + os.path.normpath(template_path), + os.path.normpath(workfile_path) + ) + + self.log.info(f"Workfile to open: \"{workfile_path}\"") + + return workfile_path diff --git a/pype/hooks/unreal/pre_workfile_preparation.py b/pype/hooks/unreal/pre_workfile_preparation.py new file mode 100644 index 0000000000..f0e09669dc --- /dev/null +++ b/pype/hooks/unreal/pre_workfile_preparation.py @@ -0,0 +1,95 @@ +import os + +from pype.lib import ( + PreLaunchHook, + ApplicationLaunchFailed +) +from pype.hosts.unreal import lib as unreal_lib + + +class UnrealPrelaunchHook(PreLaunchHook): + """ + This hook will check if current workfile path has Unreal + project inside. IF not, it initialize it and finally it pass + path to the project by environment variable to Unreal launcher + shell script. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.signature = "( {} )".format(self.__class__.__name__) + + def execute(self): + asset_name = self.data["asset_name"] + task_name = self.data["task_name"] + workdir = self.env["AVALON_WORKDIR"] + engine_version = self.app_name.split("_")[-1] + unreal_project_name = f"{asset_name}_{task_name}" + + # Unreal is sensitive about project names longer then 20 chars + if len(unreal_project_name) > 20: + self.log.warning(( + f"Project name exceed 20 characters ({unreal_project_name})!" + )) + + # Unreal doesn't accept non alphabet characters at the start + # of the project name. This is because project name is then used + # in various places inside c++ code and there variable names cannot + # start with non-alpha. We append 'P' before project name to solve it. + # 😱 + if not unreal_project_name[:1].isalpha(): + self.log.warning(( + "Project name doesn't start with alphabet " + f"character ({unreal_project_name}). Appending 'P'" + )) + unreal_project_name = f"P{unreal_project_name}" + + project_path = os.path.join(workdir, unreal_project_name) + + self.log.info(( + f"{self.signature} requested UE4 version: " + f"[ {engine_version} ]" + )) + + detected = unreal_lib.get_engine_versions() + detected_str = ', '.join(detected.keys()) or 'none' + self.log.info(( + f"{self.signature} detected UE4 versions: " + f"[ {detected_str} ]" + )) + + engine_version = ".".join(engine_version.split(".")[:2]) + if engine_version not in detected.keys(): + raise ApplicationLaunchFailed(( + f"{self.signature} requested version not " + f"detected [ {engine_version} ]" + )) + + os.makedirs(project_path, exist_ok=True) + + project_file = os.path.join( + project_path, + f"{unreal_project_name}.uproject" + ) + if not os.path.isfile(project_file): + engine_path = detected[engine_version] + self.log.info(( + f"{self.signature} creating unreal " + f"project [ {unreal_project_name} ]" + )) + # Set "AVALON_UNREAL_PLUGIN" to current process environment for + # execution of `create_unreal_project` + env_key = "AVALON_UNREAL_PLUGIN" + if self.env.get(env_key): + os.environ[env_key] = self.env[env_key] + + unreal_lib.create_unreal_project( + unreal_project_name, + engine_version, + project_path, + engine_path=engine_path + ) + + # Append project file to launch arguments + self.launch_context.launch_args.append(f"\"{project_file}\"") diff --git a/pype/lib/__init__.py b/pype/lib/__init__.py index 188dd68039..ecdd155c99 100644 --- a/pype/lib/__init__.py +++ b/pype/lib/__init__.py @@ -11,6 +11,12 @@ from .env_tools import ( get_paths_from_environ ) +from .python_module_tools import ( + modules_from_path, + recursive_bases_from_class, + classes_from_module +) + from .avalon_context import ( is_latest, any_outdated, @@ -28,6 +34,8 @@ from .applications import ( ApplictionExecutableNotFound, ApplicationNotFound, ApplicationManager, + PreLaunchHook, + PostLaunchHook, launch_application, ApplicationAction, _subprocess @@ -53,6 +61,10 @@ __all__ = [ "env_value_to_bool", "get_paths_from_environ", + "modules_from_path", + "recursive_bases_from_class", + "classes_from_module", + "is_latest", "any_outdated", "get_asset", @@ -68,6 +80,8 @@ __all__ = [ "ApplictionExecutableNotFound", "ApplicationNotFound", "ApplicationManager", + "PreLaunchHook", + "PostLaunchHook", "launch_application", "ApplicationAction", diff --git a/pype/lib/applications.py b/pype/lib/applications.py index 600530a00f..ddff9ddcd2 100644 --- a/pype/lib/applications.py +++ b/pype/lib/applications.py @@ -1,13 +1,15 @@ import os import sys -import re import getpass -import json import copy import platform +import inspect import logging import subprocess +import distutils.spawn +from abc import ABCMeta, abstractmethod +import six import acre import avalon.lib @@ -20,9 +22,13 @@ from ..api import ( system_settings, environments ) +from .python_module_tools import ( + modules_from_path, + classes_from_module +) from .hooks import execute_hook from .deprecated import get_avalon_database -from .env_tools import env_value_to_bool + log = logging.getLogger(__name__) @@ -51,8 +57,8 @@ class ApplictionExecutableNotFound(Exception): " are not available on this machine." ) details = "Defined paths:" - for executable_path in application.executables: - details += "\n- " + executable_path + for executable in application.executables: + details += "\n- " + executable.executable_path self.msg = msg.format(application.full_label, application.app_name) self.details = details @@ -73,24 +79,6 @@ class ApplicationLaunchFailed(Exception): pass -def compile_list_of_regexes(in_list): - """Convert strings in entered list to compiled regex objects.""" - regexes = list() - if not in_list: - return regexes - - for item in in_list: - if item: - try: - regexes.append(re.compile(item)) - except TypeError: - print(( - "Invalid type \"{}\" value \"{}\"." - " Expected string based object. Skipping." - ).format(str(type(item)), str(item))) - return regexes - - def launch_application(project_name, asset_name, task_name, app_name): """Launch host application with filling required environments. @@ -540,13 +528,13 @@ class ApplicationManager: """Refresh applications from settings.""" settings = system_settings() - hosts_definitions = settings["global"]["applications"] - for host_name, variant_definitions in hosts_definitions.items(): + hosts_definitions = settings["applications"] + for app_group, variant_definitions in hosts_definitions.items(): enabled = variant_definitions["enabled"] - label = variant_definitions.get("label") or host_name + label = variant_definitions.get("label") or app_group variants = variant_definitions.get("variants") or {} icon = variant_definitions.get("icon") - is_host = variant_definitions.get("is_host", False) + group_host_name = variant_definitions.get("host_name") or None for app_name, app_data in variants.items(): if app_name in self.applications: raise AssertionError(( @@ -564,14 +552,15 @@ class ApplicationManager: if not app_data.get("icon"): app_data["icon"] = icon - is_host = app_data.get("is_host", is_host) - app_data["is_host"] = is_host + host_name = app_data.get("host_name") or group_host_name + + app_data["is_host"] = host_name is not None self.applications[app_name] = Application( - host_name, app_name, app_data, self + app_group, app_name, host_name, app_data, self ) - tools_definitions = settings["global"]["tools"] + tools_definitions = settings["tools"] for tool_group_name, tool_group_data in tools_definitions.items(): enabled = tool_group_data.get("enabled", True) tool_variants = tool_group_data.get("variants") or {} @@ -640,25 +629,62 @@ class ApplicationTool: return self.enabled +class ApplicationExecutable: + def __init__(self, executable): + default_launch_args = [] + if isinstance(executable, str): + executable_path = executable + + elif isinstance(executable, list): + executable_path = None + for arg in executable: + if arg: + if executable_path is None: + executable_path = arg + else: + default_launch_args.append(arg) + + self.executable_path = executable_path + self.default_launch_args = default_launch_args + + def __iter__(self): + yield distutils.spawn.find_executable(self.executable_path) + for arg in self.default_launch_args: + yield arg + + def __str__(self): + return self.executable_path + + def as_args(self): + return list(self) + + def exists(self): + if not self.executable_path: + return False + return bool(distutils.spawn.find_executable(self.executable_path)) + + class Application: """Hold information about application. Object by itself does nothing special. Args: - host_name (str): Host name or rather name of host implementation. + app_group (str): App group name. e.g. "maya", "nuke", "photoshop", etc. app_name (str): Specific version (or variant) of host. e.g. "maya2020", "nuke11.3", etc. + host_name (str): Name of host implementation. app_data (dict): Data for the version containing information about executables, label, variant label, icon or if is enabled. Only required key is `executables`. manager (ApplicationManager): Application manager that created object. """ - def __init__(self, host_name, app_name, app_data, manager): - self.host_name = host_name + def __init__(self, app_group, app_name, host_name, app_data, manager): + self.app_group = app_group self.app_name = app_name + self.host_name = host_name self.app_data = app_data self.manager = manager @@ -668,12 +694,17 @@ class Application: self.enabled = app_data.get("enabled", True) self.is_host = app_data.get("is_host", False) - executables = app_data["executables"] - if isinstance(executables, dict): - executables = executables.get(platform.system().lower()) or [] + _executables = app_data["executables"] + if not _executables: + _executables = [] + + elif isinstance(_executables, dict): + _executables = _executables.get(platform.system().lower()) or [] + + executables = [] + for executable in _executables: + executables.append(ApplicationExecutable(executable)) - if not isinstance(executables, list): - executables = [executables] self.executables = executables @property @@ -693,9 +724,9 @@ class Application: Returns (str): Path to executable from `executables` or None if any exists. """ - for executable_path in self.executables: - if os.path.exists(executable_path): - return executable_path + for executable in self.executables: + if executable.exists(): + return executable return None def launch(self, *args, **kwargs): @@ -713,6 +744,128 @@ class Application: return self.manager.launch(self.app_name, *args, **kwargs) +@six.add_metaclass(ABCMeta) +class LaunchHook: + """Abstract base class of launch hook.""" + # Order of prelaunch hook, will be executed as last if set to None. + order = None + # List of host implementations, skipped if empty. + hosts = [] + # List of application groups + app_groups = [] + # List of specific application names + app_names = [] + # List of platform availability, skipped if empty. + platforms = [] + + def __init__(self, launch_context): + """Constructor of launch hook. + + Always should be called + """ + self.log = Logger().get_logger(self.__class__.__name__) + + self.launch_context = launch_context + + is_valid = self.class_validation(launch_context) + if is_valid: + is_valid = self.validate() + + self.is_valid = is_valid + + @classmethod + def class_validation(cls, launch_context): + """Validation of class attributes by launch context. + + Args: + launch_context (ApplicationLaunchContext): Context of launching + application. + + Returns: + bool: Is launch hook valid for the context by class attributes. + """ + if cls.platforms: + low_platforms = tuple( + _platform.lower() + for _platform in cls.platforms + ) + if platform.system().lower() not in low_platforms: + return False + + if cls.hosts: + if launch_context.host_name not in cls.hosts: + return False + + if cls.app_groups: + if launch_context.app_group not in cls.app_groups: + return False + + if cls.app_names: + if launch_context.app_name not in cls.app_names: + return False + + return True + + @property + def data(self): + return self.launch_context.data + + @property + def application(self): + return getattr(self.launch_context, "application", None) + + @property + def manager(self): + return getattr(self.application, "manager", None) + + @property + def host_name(self): + return getattr(self.application, "host_name", None) + + @property + def app_group(self): + return getattr(self.application, "app_group", None) + + @property + def app_name(self): + return getattr(self.application, "app_name", None) + + def validate(self): + """Optional validation of launch hook on initialization. + + Returns: + bool: Hook is valid (True) or invalid (False). + """ + # QUESTION Not sure if this method has any usable potential. + # - maybe result can be based on settings + return True + + @abstractmethod + def execute(self, *args, **kwargs): + """Abstract execute method where logic of hook is.""" + pass + + +class PreLaunchHook(LaunchHook): + """Abstract class of prelaunch hook. + + This launch hook will be processed before application is launched. + + If any exception will happen during processing the application won't be + launched. + """ + + +class PostLaunchHook(LaunchHook): + """Abstract class of postlaunch hook. + + This launch hook will be processed after application is launched. + + Nothing will happen if any exception will happen during processing. And + processing of other postlaunch hooks won't stop either. + """ + + class ApplicationLaunchContext: """Context of launching application. @@ -733,7 +886,7 @@ class ApplicationLaunchContext: Args: application (Application): Application definition. - executable (str): Path to executable. + executable (ApplicationExecutable): Object with path to executable. **data (dict): Any additional data. Data may be used during preparation to store objects usable in multiple places. """ @@ -749,14 +902,6 @@ class ApplicationLaunchContext: self.data = dict(data) - # Handle launch environemtns - passed_env = self.data.pop("env", None) - if passed_env is None: - env = os.environ - else: - env = passed_env - self.env = copy.deepcopy(env) - # Load settings if were not passed in data settings_env = self.data.get("settings_env") if settings_env is None: @@ -764,10 +909,18 @@ class ApplicationLaunchContext: self.data["settings_env"] = settings_env # subprocess.Popen launch arguments (first argument in constructor) - self.launch_args = [executable] + self.launch_args = executable.as_args() + + # Handle launch environemtns + passed_env = self.data.pop("env", None) + if passed_env is None: + env = os.environ + else: + env = passed_env + # subprocess.Popen keyword arguments self.kwargs = { - "env": self.env + "env": copy.deepcopy(env) } if platform.system().lower() == "windows": @@ -778,12 +931,134 @@ class ApplicationLaunchContext: ) self.kwargs["creationflags"] = flags + self.prelaunch_hooks = None + self.postlaunch_hooks = None + self.process = None - # TODO move these to pre-paunch hook - self.prepare_global_data() - self.prepare_host_environments() - self.prepare_context_environments() + @property + def env(self): + if ( + "env" not in self.kwargs + or self.kwargs["env"] is None + ): + self.kwargs["env"] = {} + return self.kwargs["env"] + + @env.setter + def env(self, value): + if not isinstance(value, dict): + raise ValueError( + "'env' attribute expect 'dict' object. Got: {}".format( + str(type(value)) + ) + ) + self.kwargs["env"] = value + + def paths_to_launch_hooks(self): + """Directory paths where to look for launch hooks.""" + # This method has potential to be part of application manager (maybe). + + # TODO find better way how to define dir path to default launch hooks + import pype + pype_dir = os.path.dirname(os.path.abspath(pype.__file__)) + hooks_dir = os.path.join(pype_dir, "hooks") + + # TODO load additional studio paths from settings + # TODO add paths based on used modules (like `ftrack`) + paths = [] + subfolder_names = ["global", self.host_name, self.app_name] + for subfolder_name in subfolder_names: + path = os.path.join(hooks_dir, subfolder_name) + if os.path.exists(path) and os.path.isdir(path): + paths.append(path) + return paths + + def discover_launch_hooks(self, force=False): + """Load and prepare launch hooks.""" + if ( + self.prelaunch_hooks is not None + or self.postlaunch_hooks is not None + ): + if not force: + self.log.info("Launch hooks were already discovered.") + return + + self.prelaunch_hooks.clear() + self.postlaunch_hooks.clear() + + self.log.debug("Discovery of launch hooks started.") + + paths = self.paths_to_launch_hooks() + self.log.debug("Paths where will look for launch hooks:{}".format( + "\n- ".join(paths) + )) + + all_classes = { + "pre": [], + "post": [] + } + for path in paths: + if not os.path.exists(path): + self.log.info( + "Path to launch hooks does not exists: \"{}\"".format(path) + ) + continue + + modules = modules_from_path(path) + for _module in modules: + all_classes["pre"].extend( + classes_from_module(PreLaunchHook, _module) + ) + all_classes["post"].extend( + classes_from_module(PostLaunchHook, _module) + ) + + for launch_type, classes in all_classes.items(): + hooks_with_order = [] + hooks_without_order = [] + for klass in classes: + try: + hook = klass(self) + if not hook.is_valid: + self.log.debug( + "Hook is not valid for curent launch context." + ) + continue + + if inspect.isabstract(hook): + self.log.debug("Skipped abstract hook: {}".format( + str(hook) + )) + continue + + # Separate hooks by pre/post class + if hook.order is None: + hooks_without_order.append(hook) + else: + hooks_with_order.append(hook) + + except Exception: + self.log.warning( + "Initialization of hook failed. {}".format(str(klass)), + exc_info=True + ) + + # Sort hooks with order by order + ordered_hooks = list(sorted( + hooks_with_order, key=lambda obj: obj.order + )) + # Extend ordered hooks with hooks without defined order + ordered_hooks.extend(hooks_without_order) + + if launch_type == "pre": + self.prelaunch_hooks = ordered_hooks + else: + self.postlaunch_hooks = ordered_hooks + + self.log.debug("Found {} prelaunch and {} postlaunch hooks.".format( + len(self.prelaunch_hooks), len(self.postlaunch_hooks) + )) @property def app_name(self): @@ -793,6 +1068,10 @@ class ApplicationLaunchContext: def host_name(self): return self.application.host_name + @property + def app_group(self): + return self.application.app_group + @property def manager(self): return self.application.manager @@ -809,20 +1088,46 @@ class ApplicationLaunchContext: self.log.warning("Application was already launched.") return + # Discover launch hooks + self.discover_launch_hooks() + + # Execute prelaunch hooks + for prelaunch_hook in self.prelaunch_hooks: + self.log.debug("Executing prelaunch hook: {}".format( + str(prelaunch_hook) + )) + prelaunch_hook.execute() + + self.log.debug("All prelaunch hook executed. Starting new process.") + + # Prepare subprocess args args = self.clear_launch_args(self.launch_args) self.log.debug( - "Launching \"{}\" with args: {}".format(self.app_name, args) + "Launching \"{}\" with args ({}): {}".format( + self.app_name, len(args), args + ) ) + # Run process self.process = subprocess.Popen(args, **self.kwargs) - # TODO do this with after-launch hooks - try: - self.after_launch_procedures() - except Exception: - self.log.warning( - "After launch procedures were not successful.", - exc_info=True - ) + # Process post launch hooks + for postlaunch_hook in self.postlaunch_hooks: + self.log.debug("Executing postlaunch hook: {}".format( + str(prelaunch_hook) + )) + + # TODO how to handle errors? + # - store to variable to let them accesible? + try: + postlaunch_hook.execute() + + except Exception: + self.log.warning( + "After launch procedures were not successful.", + exc_info=True + ) + + self.log.debug("Launch of {} finished.".format(self.app_name)) return self.process @@ -854,481 +1159,9 @@ class ApplicationLaunchContext: for _arg in arg: new_args.append(_arg) else: - new_args.append(args) + new_args.append(arg) args = new_args if all_cleared: break return args - - def prepare_global_data(self): - """Prepare global objects to `data` that will be used for sure.""" - # Mongo documents - project_name = self.data.get("project_name") - if not project_name: - self.log.info( - "Skipping global data preparation." - " Key `project_name` was not found in launch context." - ) - return - - self.log.debug("Project name is set to \"{}\"".format(project_name)) - # Anatomy - self.data["anatomy"] = Anatomy(project_name) - - # Mongo connection - dbcon = avalon.api.AvalonMongoDB() - dbcon.Session["AVALON_PROJECT"] = project_name - dbcon.install() - - self.data["dbcon"] = dbcon - - # Project document - project_doc = dbcon.find_one({"type": "project"}) - self.data["project_doc"] = project_doc - - asset_name = self.data.get("asset_name") - if not asset_name: - self.log.warning( - "Asset name was not set. Skipping asset document query." - ) - return - - asset_doc = dbcon.find_one({ - "type": "asset", - "name": asset_name - }) - self.data["asset_doc"] = asset_doc - - def _merge_env(self, env, current_env): - """Modified function(merge) from acre module.""" - result = current_env.copy() - for key, value in env.items(): - # Keep missing keys by not filling `missing` kwarg - value = acre.lib.partial_format(value, data=current_env) - result[key] = value - return result - - def prepare_host_environments(self): - """Modify launch environments based on launched app and context.""" - # Keys for getting environments - env_keys = [self.host_name, self.app_name] - - asset_doc = self.data.get("asset_doc") - if asset_doc: - # Add tools environments - for key in asset_doc["data"].get("tools_env") or []: - tool = self.manager.tools.get(key) - if tool: - if tool.group_name not in env_keys: - env_keys.append(tool.group_name) - - if tool.name not in env_keys: - env_keys.append(tool.name) - - self.log.debug( - "Finding environment groups for keys: {}".format(env_keys) - ) - - settings_env = self.data["settings_env"] - env_values = {} - for env_key in env_keys: - _env_values = settings_env.get(env_key) - if not _env_values: - continue - - # Choose right platform - tool_env = acre.parse(_env_values) - # Merge dictionaries - env_values = self._merge_env(tool_env, env_values) - - final_env = self._merge_env(acre.compute(env_values), self.env) - - # Update env - self.env.update(final_env) - - def prepare_context_environments(self): - """Modify launch environemnts with context data for launched host.""" - # Context environments - project_doc = self.data.get("project_doc") - asset_doc = self.data.get("asset_doc") - task_name = self.data.get("task_name") - if ( - not project_doc - or not asset_doc - or not task_name - ): - self.log.info( - "Skipping context environments preparation." - " Launch context does not contain required data." - ) - return - - workdir_data = self._prepare_workdir_data( - project_doc, asset_doc, task_name - ) - self.data["workdir_data"] = workdir_data - - hierarchy = workdir_data["hierarchy"] - anatomy = self.data["anatomy"] - - try: - anatomy_filled = anatomy.format(workdir_data) - workdir = os.path.normpath(anatomy_filled["work"]["folder"]) - if not os.path.exists(workdir): - self.log.debug( - "Creating workdir folder: \"{}\"".format(workdir) - ) - os.makedirs(workdir) - - except Exception as exc: - raise ApplicationLaunchFailed( - "Error in anatomy.format: {}".format(str(exc)) - ) - - context_env = { - "AVALON_PROJECT": project_doc["name"], - "AVALON_ASSET": asset_doc["name"], - "AVALON_TASK": task_name, - "AVALON_APP": self.host_name, - "AVALON_APP_NAME": self.app_name, - "AVALON_HIERARCHY": hierarchy, - "AVALON_WORKDIR": workdir - } - self.log.debug( - "Context environemnts set:\n{}".format( - json.dumps(context_env, indent=4) - ) - ) - self.env.update(context_env) - - self.prepare_last_workfile(workdir) - - def _prepare_workdir_data(self, project_doc, asset_doc, task_name): - hierarchy = "/".join(asset_doc["data"]["parents"]) - - data = { - "project": { - "name": project_doc["name"], - "code": project_doc["data"].get("code") - }, - "task": task_name, - "asset": asset_doc["name"], - "app": self.host_name, - "hierarchy": hierarchy - } - return data - - def prepare_last_workfile(self, workdir): - """last workfile workflow preparation. - - Function check if should care about last workfile workflow and tries - to find the last workfile. Both information are stored to `data` and - environments. - - Last workfile is filled always (with version 1) even if any workfile - exists yet. - - Args: - workdir (str): Path to folder where workfiles should be stored. - """ - _workdir_data = self.data.get("workdir_data") - if not _workdir_data: - self.log.info( - "Skipping last workfile preparation." - " Key `workdir_data` not filled." - ) - return - - workdir_data = copy.deepcopy(_workdir_data) - project_name = self.data["project_name"] - task_name = self.data["task_name"] - start_last_workfile = self.should_start_last_workfile( - project_name, self.host_name, task_name - ) - self.data["start_last_workfile"] = start_last_workfile - - # Store boolean as "0"(False) or "1"(True) - self.env["AVALON_OPEN_LAST_WORKFILE"] = ( - str(int(bool(start_last_workfile))) - ) - - _sub_msg = "" if start_last_workfile else " not" - self.log.debug( - "Last workfile should{} be opened on start.".format(_sub_msg) - ) - - # Last workfile path - last_workfile_path = "" - extensions = avalon.api.HOST_WORKFILE_EXTENSIONS.get(self.host_name) - if extensions: - anatomy = self.data["anatomy"] - # Find last workfile - file_template = anatomy.templates["work"]["file"] - workdir_data.update({ - "version": 1, - "user": os.environ.get("PYPE_USERNAME") or getpass.getuser(), - "ext": extensions[0] - }) - - last_workfile_path = avalon.api.last_workfile( - workdir, file_template, workdir_data, extensions, True - ) - - if os.path.exists(last_workfile_path): - self.log.debug(( - "Workfiles for launch context does not exists" - " yet but path will be set." - )) - self.log.debug( - "Setting last workfile path: {}".format(last_workfile_path) - ) - - self.env["AVALON_LAST_WORKFILE"] = last_workfile_path - self.data["last_workfile_path"] = last_workfile_path - - def should_start_last_workfile(self, project_name, host_name, task_name): - """Define if host should start last version workfile if possible. - - Default output is `False`. Can be overriden with environment variable - `AVALON_OPEN_LAST_WORKFILE`, valid values without case sensitivity are - `"0", "1", "true", "false", "yes", "no"`. - - Args: - project_name (str): Name of project. - host_name (str): Name of host which is launched. In avalon's - application context it's value stored in app definition under - key `"application_dir"`. Is not case sensitive. - task_name (str): Name of task which is used for launching the host. - Task name is not case sensitive. - - Returns: - bool: True if host should start workfile. - - """ - default_output = env_value_to_bool( - "AVALON_OPEN_LAST_WORKFILE", default=False - ) - # TODO convert to settings - try: - startup_presets = ( - config.get_presets(project_name) - .get("tools", {}) - .get("workfiles", {}) - .get("last_workfile_on_startup") - ) - except Exception: - startup_presets = None - self.log.warning("Couldn't load pype's presets", exc_info=True) - - if not startup_presets: - return default_output - - host_name_lowered = host_name.lower() - task_name_lowered = task_name.lower() - - max_points = 2 - matching_points = -1 - matching_item = None - for item in startup_presets: - hosts = item.get("hosts") or tuple() - tasks = item.get("tasks") or tuple() - - hosts_lowered = tuple(_host_name.lower() for _host_name in hosts) - # Skip item if has set hosts and current host is not in - if hosts_lowered and host_name_lowered not in hosts_lowered: - continue - - tasks_lowered = tuple(_task_name.lower() for _task_name in tasks) - # Skip item if has set tasks and current task is not in - if tasks_lowered: - task_match = False - for task_regex in compile_list_of_regexes(tasks_lowered): - if re.match(task_regex, task_name_lowered): - task_match = True - break - - if not task_match: - continue - - points = int(bool(hosts_lowered)) + int(bool(tasks_lowered)) - if points > matching_points: - matching_item = item - matching_points = points - - if matching_points == max_points: - break - - if matching_item is not None: - output = matching_item.get("enabled") - if output is None: - output = default_output - return output - return default_output - - def after_launch_procedures(self): - self._ftrack_after_launch_procedure() - - def _ftrack_after_launch_procedure(self): - # TODO move to launch hook - project_name = self.data.get("project_name") - asset_name = self.data.get("asset_name") - task_name = self.data.get("task_name") - if ( - not project_name - or not asset_name - or not task_name - ): - return - - required_keys = ("FTRACK_SERVER", "FTRACK_API_USER", "FTRACK_API_KEY") - for key in required_keys: - if not os.environ.get(key): - self.log.debug(( - "Missing required environment \"{}\"" - " for Ftrack after launch procedure." - ).format(key)) - return - - try: - import ftrack_api - session = ftrack_api.Session(auto_connect_event_hub=True) - self.log.debug("Ftrack session created") - except Exception: - self.log.warning("Couldn't create Ftrack session") - return - - try: - entity = self._find_ftrack_task_entity( - session, project_name, asset_name, task_name - ) - self._ftrack_status_change(session, entity, project_name) - self._start_timer(session, entity, ftrack_api) - except Exception: - self.log.warning( - "Couldn't finish Ftrack procedure.", exc_info=True - ) - return - - finally: - session.close() - - def _find_ftrack_task_entity( - self, session, project_name, asset_name, task_name - ): - project_entity = session.query( - "Project where full_name is \"{}\"".format(project_name) - ).first() - if not project_entity: - self.log.warning( - "Couldn't find project \"{}\" in Ftrack.".format(project_name) - ) - return - - potential_task_entities = session.query(( - "TypedContext where parent.name is \"{}\" and project_id is \"{}\"" - ).format(asset_name, project_entity["id"])).all() - filtered_entities = [] - for _entity in potential_task_entities: - if ( - _entity.entity_type.lower() == "task" - and _entity["name"] == task_name - ): - filtered_entities.append(_entity) - - if not filtered_entities: - self.log.warning(( - "Couldn't find task \"{}\" under parent \"{}\" in Ftrack." - ).format(task_name, asset_name)) - return - - if len(filtered_entities) > 1: - self.log.warning(( - "Found more than one task \"{}\"" - " under parent \"{}\" in Ftrack." - ).format(task_name, asset_name)) - return - - return filtered_entities[0] - - def _ftrack_status_change(self, session, entity, project_name): - from pype.api import config - presets = config.get_presets(project_name)["ftrack"]["ftrack_config"] - statuses = presets.get("status_update") - if not statuses: - return - - actual_status = entity["status"]["name"].lower() - already_tested = set() - ent_path = "/".join( - [ent["name"] for ent in entity["link"]] - ) - while True: - next_status_name = None - for key, value in statuses.items(): - if key in already_tested: - continue - if actual_status in value or "_any_" in value: - if key != "_ignore_": - next_status_name = key - already_tested.add(key) - break - already_tested.add(key) - - if next_status_name is None: - break - - try: - query = "Status where name is \"{}\"".format( - next_status_name - ) - status = session.query(query).one() - - entity["status"] = status - session.commit() - self.log.debug("Changing status to \"{}\" <{}>".format( - next_status_name, ent_path - )) - break - - except Exception: - session.rollback() - msg = ( - "Status \"{}\" in presets wasn't found" - " on Ftrack entity type \"{}\"" - ).format(next_status_name, entity.entity_type) - self.log.warning(msg) - - def _start_timer(self, session, entity, _ftrack_api): - self.log.debug("Triggering timer start.") - - user_entity = session.query("User where username is \"{}\"".format( - os.environ["FTRACK_API_USER"] - )).first() - if not user_entity: - self.log.warning( - "Couldn't find user with username \"{}\" in Ftrack".format( - os.environ["FTRACK_API_USER"] - ) - ) - return - - source = { - "user": { - "id": user_entity["id"], - "username": user_entity["username"] - } - } - event_data = { - "actionIdentifier": "start.timer", - "selection": [{"entityId": entity["id"], "entityType": "task"}] - } - session.event_hub.publish( - _ftrack_api.event.base.Event( - topic="ftrack.action.launch", - data=event_data, - source=source - ), - on_error="ignore" - ) - self.log.debug("Timer start triggered successfully.") diff --git a/pype/lib/env_tools.py b/pype/lib/env_tools.py index f31426103b..a5176b814d 100644 --- a/pype/lib/env_tools.py +++ b/pype/lib/env_tools.py @@ -20,9 +20,9 @@ def env_value_to_bool(env_key=None, value=None, default=False): if value is not None: value = str(value).lower() - if value in ("true", "yes", "1"): + if value in ("true", "yes", "1", "on"): return True - elif value in ("false", "no", "0"): + elif value in ("false", "no", "0", "off"): return False return default diff --git a/pype/lib/python_module_tools.py b/pype/lib/python_module_tools.py new file mode 100644 index 0000000000..2ce2f60dca --- /dev/null +++ b/pype/lib/python_module_tools.py @@ -0,0 +1,113 @@ +import os +import sys +import types +import importlib +import inspect +import logging + +log = logging.getLogger(__name__) +PY3 = sys.version_info[0] == 3 + + +def modules_from_path(folder_path): + """Get python scripts as modules from a path. + + Arguments: + path (str): Path to folder containing python scripts. + + Returns: + List of modules. + """ + + folder_path = os.path.normpath(folder_path) + + modules = [] + if not os.path.isdir(folder_path): + log.warning("Not a directory path: {}".format(folder_path)) + return modules + + for filename in os.listdir(folder_path): + # Ignore files which start with underscore + if filename.startswith("_"): + continue + + mod_name, mod_ext = os.path.splitext(filename) + if not mod_ext == ".py": + continue + + full_path = os.path.join(folder_path, filename) + if not os.path.isfile(full_path): + continue + + try: + # Prepare module object where content of file will be parsed + module = types.ModuleType(mod_name) + + if PY3: + # Use loader so module has full specs + module_loader = importlib.machinery.SourceFileLoader( + mod_name, full_path + ) + module_loader.exec_module(module) + else: + # Execute module code and store content to module + with open(full_path) as _stream: + # Execute content and store it to module object + exec(_stream.read(), module.__dict__) + + module.__file__ = full_path + + modules.append(module) + + except Exception: + log.warning( + "Failed to load path: \"{0}\"".format(full_path), + exc_info=True + ) + continue + + return modules + + +def recursive_bases_from_class(klass): + """Extract all bases from entered class.""" + result = [] + bases = klass.__bases__ + result.extend(bases) + for base in bases: + result.extend(recursive_bases_from_class(base)) + return result + + +def classes_from_module(superclass, module): + """Return plug-ins from module + + Arguments: + superclass (superclass): Superclass of subclasses to look for + module (types.ModuleType): Imported module from which to + parse valid Avalon plug-ins. + + Returns: + List of plug-ins, or empty list if none is found. + + """ + + classes = list() + for name in dir(module): + # It could be anything at this point + obj = getattr(module, name) + if not inspect.isclass(obj): + continue + + # These are subclassed from nothing, not even `object` + if not len(obj.__bases__) > 0: + continue + + # Use string comparison rather than `issubclass` + # in order to support reloading of this module. + bases = recursive_bases_from_class(obj) + if not any(base.__name__ == superclass.__name__ for base in bases): + continue + + classes.append(obj) + return classes diff --git a/pype/modules/ftrack/lib/avalon_sync.py b/pype/modules/ftrack/lib/avalon_sync.py index 7ff5283d6a..97116317af 100644 --- a/pype/modules/ftrack/lib/avalon_sync.py +++ b/pype/modules/ftrack/lib/avalon_sync.py @@ -17,7 +17,10 @@ from bson.errors import InvalidId from pymongo import UpdateOne import ftrack_api from pype.api import config - +from pype.lib import ( + ApplicationManager, + env_value_to_bool +) log = Logger().get_logger(__name__) @@ -186,12 +189,28 @@ def get_project_apps(in_app_list): dictionary of warnings """ apps = [] + warnings = collections.defaultdict(list) + + if env_value_to_bool("PYPE_USE_APP_MANAGER", default=False): + missing_app_msg = "Missing definition of application" + application_manager = ApplicationManager() + for app_name in in_app_list: + app = application_manager.applications.get(app_name) + if app: + apps.append({ + "name": app_name, + "label": app.full_label + }) + else: + warnings[missing_app_msg].append(app_name) + return apps, warnings + # TODO report missing_toml_msg = "Missing config file for application" error_msg = ( "Unexpected error happend during preparation of application" ) - warnings = collections.defaultdict(list) + for app in in_app_list: try: toml_path = avalon.lib.which_app(app) diff --git a/pype/settings/defaults/system_settings/applications.json b/pype/settings/defaults/system_settings/applications.json index 4bcea2fa30..e5cd249ffe 100644 --- a/pype/settings/defaults/system_settings/applications.json +++ b/pype/settings/defaults/system_settings/applications.json @@ -3,7 +3,7 @@ "enabled": true, "label": "Autodesk Maya", "icon": "{}/app_icons/maya.png", - "is_host": true, + "host_name": "maya", "environment": { "__environment_keys__": { "maya": [ @@ -39,11 +39,17 @@ "icon": "", "executables": { "windows": [ - "C:\\Program Files\\Autodesk\\Maya2020\\bin\\maya.exe" + [ + "C:\\Program Files\\Autodesk\\Maya2020\\bin\\maya.exe", + "" + ] ], "darwin": [], "linux": [ - "/usr/autodesk/maya2020/bin/maya" + [ + "/usr/autodesk/maya2020/bin/maya", + "" + ] ] }, "environment": { @@ -62,11 +68,17 @@ "icon": "", "executables": { "windows": [ - "C:\\Program Files\\Autodesk\\Maya2019\\bin\\maya.exe" + [ + "C:\\Program Files\\Autodesk\\Maya2019\\bin\\maya.exe", + "" + ] ], "darwin": [], "linux": [ - "/usr/autodesk/maya2019/bin/maya" + [ + "/usr/autodesk/maya2019/bin/maya", + "" + ] ] }, "environment": { @@ -85,11 +97,17 @@ "icon": "", "executables": { "windows": [ - "C:\\Program Files\\Autodesk\\Maya2017\\bin\\maya.exe" + [ + "C:\\Program Files\\Autodesk\\Maya2017\\bin\\maya.exe", + "" + ] ], "darwin": [], "linux": [ - "/usr/autodesk/maya2018/bin/maya" + [ + "/usr/autodesk/maya2018/bin/maya", + "" + ] ] }, "environment": { @@ -107,7 +125,7 @@ "enabled": true, "label": "Autodesk MayaBatch", "icon": "{}/app_icons/maya.png", - "is_host": false, + "host_name": "maya", "environment": { "__environment_keys__": { "mayabatch": [ @@ -143,7 +161,10 @@ "icon": "", "executables": { "windows": [ - "C:\\Program Files\\Autodesk\\Maya2020\\bin\\mayabatch.exe" + [ + "C:\\Program Files\\Autodesk\\Maya2020\\bin\\mayabatch.exe", + "" + ] ], "darwin": [], "linux": [] @@ -164,7 +185,10 @@ "icon": "", "executables": { "windows": [ - "C:\\Program Files\\Autodesk\\Maya2019\\bin\\mayabatch.exe" + [ + "C:\\Program Files\\Autodesk\\Maya2019\\bin\\mayabatch.exe", + "" + ] ], "darwin": [], "linux": [] @@ -185,7 +209,10 @@ "icon": "", "executables": { "windows": [ - "C:\\Program Files\\Autodesk\\Maya2018\\bin\\mayabatch.exe" + [ + "C:\\Program Files\\Autodesk\\Maya2018\\bin\\mayabatch.exe", + "" + ] ], "darwin": [], "linux": [] @@ -205,7 +232,7 @@ "enabled": true, "label": "Nuke", "icon": "{}/app_icons/nuke.png", - "is_host": true, + "host_name": "nuke", "environment": { "__environment_keys__": { "nuke": [ @@ -230,11 +257,17 @@ "icon": "", "executables": { "windows": [ - "C:\\Program Files\\Nuke12.0v1\\Nuke12.0.exe" + [ + "C:\\Program Files\\Nuke12.0v1\\Nuke12.0.exe", + "" + ] ], "darwin": [], "linux": [ - "/usr/local/Nuke12.0v1/Nuke12.0" + [ + "/usr/local/Nuke12.0v1/Nuke12.0", + "" + ] ] }, "environment": { @@ -250,11 +283,17 @@ "icon": "", "executables": { "windows": [ - "C:\\Program Files\\Nuke11.3v1\\Nuke11.3.exe" + [ + "C:\\Program Files\\Nuke11.3v1\\Nuke11.3.exe", + "" + ] ], "darwin": [], "linux": [ - "/usr/local/Nuke11.3v5/Nuke11.3" + [ + "/usr/local/Nuke11.3v5/Nuke11.3", + "" + ] ] }, "environment": { @@ -270,7 +309,10 @@ "icon": "", "executables": { "windows": [ - "C:\\Program Files\\Nuke11.2v2\\Nuke11.2.exe" + [ + "C:\\Program Files\\Nuke11.2v2\\Nuke11.2.exe", + "" + ] ], "darwin": [], "linux": [] @@ -287,7 +329,7 @@ "enabled": true, "label": "Nuke X", "icon": "{}/app_icons/nuke.png", - "is_host": true, + "host_name": "nuke", "environment": { "__environment_keys__": { "nukex": [ @@ -312,11 +354,17 @@ "icon": "", "executables": { "windows": [ - "C:\\Program Files\\Nuke12.0v1\\Nuke12.0.exe" + [ + "C:\\Program Files\\Nuke12.0v1\\Nuke12.0.exe", + "--nukex" + ] ], "darwin": [], "linux": [ - "/usr/local/Nuke12.0v1/Nuke12.0" + [ + "/usr/local/Nuke12.0v1/Nuke12.0", + "--nukex" + ] ] }, "environment": { @@ -332,11 +380,17 @@ "icon": "", "executables": { "windows": [ - "C:\\Program Files\\Nuke11.3v1\\Nuke11.3.exe" + [ + "C:\\Program Files\\Nuke11.3v1\\Nuke11.3.exe", + "--nukex" + ] ], "darwin": [], "linux": [ - "/usr/local/Nuke11.3v5/Nuke11.3" + [ + "/usr/local/Nuke11.3v5/Nuke11.3", + "--nukex" + ] ] }, "environment": { @@ -352,7 +406,10 @@ "icon": "", "executables": { "windows": [ - "C:\\Program Files\\Nuke11.2v2\\Nuke11.2.exe" + [ + "C:\\Program Files\\Nuke11.2v2\\Nuke11.2.exe", + "--nukex" + ] ], "darwin": [], "linux": [] @@ -369,7 +426,7 @@ "enabled": true, "label": "Nuke Studio", "icon": "{}/app_icons/nuke.png", - "is_host": true, + "host_name": "hiero", "environment": { "__environment_keys__": { "nukestudio": [ @@ -398,11 +455,17 @@ "icon": "", "executables": { "windows": [ - "C:\\Program Files\\Nuke12.0v1\\Nuke12.0.exe" + [ + "C:\\Program Files\\Nuke12.0v1\\Nuke12.0.exe", + "--studio" + ] ], "darwin": [], "linux": [ - "/usr/local/Nuke12.0v1/Nuke12.0" + [ + "/usr/local/Nuke12.0v1/Nuke12.0", + "--studio" + ] ] }, "environment": { @@ -418,11 +481,17 @@ "icon": "", "executables": { "windows": [ - "C:\\Program Files\\Nuke11.3v1\\Nuke11.3.exe" + [ + "C:\\Program Files\\Nuke11.3v1\\Nuke11.3.exe", + "--studio" + ] ], "darwin": [], "linux": [ - "/usr/local/Nuke11.3v5/Nuke11.3" + [ + "/usr/local/Nuke11.3v5/Nuke11.3", + "--studio" + ] ] }, "environment": { @@ -453,7 +522,7 @@ "enabled": true, "label": "Hiero", "icon": "{}/app_icons/hiero.png", - "is_host": true, + "host_name": "hiero", "environment": { "__environment_keys__": { "hiero": [ @@ -482,11 +551,17 @@ "icon": "", "executables": { "windows": [ - "C:\\Program Files\\Nuke12.0v1\\Nuke12.0.exe" + [ + "C:\\Program Files\\Nuke12.0v1\\Nuke12.0.exe", + "--hiero" + ] ], "darwin": [], "linux": [ - "/usr/local/Nuke12.0v1/Nuke12.0" + [ + "/usr/local/Nuke12.0v1/Nuke12.0", + "--hiero" + ] ] }, "environment": { @@ -502,11 +577,17 @@ "icon": "", "executables": { "windows": [ - "C:\\Program Files\\Nuke11.3v1\\Nuke11.3.exe" + [ + "C:\\Program Files\\Nuke11.3v1\\Nuke11.3.exe", + "--hiero" + ] ], "darwin": [], "linux": [ - "/usr/local/Nuke11.3v5/Nuke11.3" + [ + "/usr/local/Nuke11.3v5/Nuke11.3", + "--hiero" + ] ] }, "environment": { @@ -522,7 +603,10 @@ "icon": "", "executables": { "windows": [ - "C:\\Program Files\\Nuke11.2v2\\Nuke11.2.exe" + [ + "C:\\Program Files\\Nuke11.2v2\\Nuke11.2.exe", + "--hiero" + ] ], "darwin": [], "linux": [] @@ -539,7 +623,7 @@ "enabled": true, "label": "BlackMagic Fusion", "icon": "{}/app_icons/fusion.png", - "is_host": true, + "host_name": "fusion", "environment": { "__environment_keys__": { "fusion": [] @@ -584,7 +668,7 @@ "enabled": true, "label": "Blackmagic DaVinci Resolve", "icon": "{}/app_icons/resolve.png", - "is_host": true, + "host_name": "resolve", "environment": { "__environment_keys__": { "resolve": [ @@ -662,7 +746,7 @@ "enabled": true, "label": "SideFX Houdini", "icon": "{}/app_icons/houdini.png", - "is_host": true, + "host_name": "houdini", "environment": { "__environment_keys__": { "houdini": [ @@ -720,7 +804,7 @@ "enabled": true, "label": "Blender", "icon": "{}/app_icons/blender.png", - "is_host": true, + "host_name": "blender", "environment": { "__environment_keys__": { "blender": [ @@ -743,7 +827,12 @@ "variant_label": "2.90", "icon": "", "executables": { - "windows": [], + "windows": [ + [ + "C:\\Program Files\\Blender Foundation\\Blender 2.90\\blender.exe", + "" + ] + ], "darwin": [], "linux": [] }, @@ -759,7 +848,12 @@ "variant_label": "2.83", "icon": "", "executables": { - "windows": [], + "windows": [ + [ + "C:\\Program Files\\Blender Foundation\\Blender 2.83\\blender.exe", + "" + ] + ], "darwin": [], "linux": [] }, @@ -775,7 +869,7 @@ "enabled": true, "label": "Toon Boom Harmony", "icon": "{}/app_icons/harmony.png", - "is_host": true, + "host_name": "harmony", "environment": { "__environment_keys__": { "harmony": [ @@ -843,7 +937,10 @@ "executables": { "windows": [], "darwin": [ - "/Applications/Toon Boom Harmony 17 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium" + [ + "/Applications/Toon Boom Harmony 17 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium", + "" + ] ], "linux": [] }, @@ -855,11 +952,69 @@ } } }, + "tvpaint": { + "enabled": true, + "label": "TVPaint", + "icon": "{}/app_icons/tvpaint.png", + "host_name": "tvpaint", + "environment": { + "__environment_keys__": { + "tvpaint": [ + "PYPE_LOG_NO_COLORS" + ] + }, + "PYPE_LOG_NO_COLORS": "True" + }, + "variants": { + "tvpaint_Animation 11 (64bits)": { + "enabled": true, + "label": "", + "variant_label": "Animation 11 (64bits)", + "icon": "", + "executables": { + "windows": [ + [ + "C:\\Program Files\\TVPaint Developpement\\TVPaint Animation 11 (64bits)\\TVPaint Animation 11 (64bits).exe", + "" + ] + ], + "darwin": [], + "linux": [] + }, + "environment": { + "__environment_keys__": { + "tvpaint_Animation 11 (64bits)": [] + } + } + }, + "tvpaint_Animation 11 (32bits)": { + "enabled": true, + "label": "", + "variant_label": "Animation 11 (32bits)", + "icon": "", + "executables": { + "windows": [ + [ + "C:\\Program Files (x86)\\TVPaint Developpement\\TVPaint Animation 11 (32bits)\\TVPaint Animation 11 (32bits).exe", + "" + ] + ], + "darwin": [], + "linux": [] + }, + "environment": { + "__environment_keys__": { + "tvpaint_Animation 11 (32bits)": [] + } + } + } + } + }, "photoshop": { "enabled": true, "label": "Adobe Photoshop", "icon": "{}/app_icons/photoshop.png", - "is_host": true, + "host_name": "photoshop", "environment": { "__environment_keys__": { "photoshop": [ @@ -883,7 +1038,12 @@ "variant_label": "2020", "icon": "", "executables": { - "windows": [], + "windows": [ + [ + "C:\\Program Files\\Adobe\\Adobe Photoshop 2020\\Photoshop.exe", + "" + ] + ], "darwin": [], "linux": [] }, @@ -892,6 +1052,93 @@ "photoshop_2020": [] } } + }, + "photoshop_2021": { + "enabled": true, + "label": "", + "variant_label": "2021", + "icon": "", + "executables": { + "windows": [ + [ + "C:\\Program Files\\Adobe\\Adobe Photoshop 2021\\Photoshop.exe", + "" + ] + ], + "darwin": [], + "linux": [] + }, + "environment": { + "__environment_keys__": { + "photoshop_2021": [] + } + } + } + } + }, + "aftereffects": { + "enabled": true, + "label": "Adobe AfterEffects", + "icon": "{}/app_icons/aftereffects.png", + "host_name": "aftereffects", + "environment": { + "__environment_keys__": { + "aftereffects": [ + "AVALON_AFTEREFFECTS_WORKFILES_ON_LAUNCH", + "PYTHONPATH", + "PYPE_LOG_NO_COLORS", + "WEBSOCKET_URL", + "WORKFILES_SAVE_AS" + ] + }, + "AVALON_AFTEREFFECTS_WORKFILES_ON_LAUNCH": "1", + "PYTHONPATH": "{PYTHONPATH}", + "PYPE_LOG_NO_COLORS": "Yes", + "WEBSOCKET_URL": "ws://localhost:8097/ws/", + "WORKFILES_SAVE_AS": "Yes" + }, + "variants": { + "aftereffects_2020": { + "enabled": true, + "label": "", + "variant_label": "2020", + "icon": "", + "executables": { + "windows": [ + [ + "C:\\Program Files\\Adobe\\Adobe After Effects 2020\\Support Files\\AfterFX.exe", + "" + ] + ], + "darwin": [], + "linux": [] + }, + "environment": { + "__environment_keys__": { + "aftereffects_2020": [] + } + } + }, + "aftereffects_2021": { + "enabled": true, + "label": "", + "variant_label": "2021", + "icon": "", + "executables": { + "windows": [ + [ + "C:\\Program Files\\Adobe\\Adobe After Effects 2021\\Support Files\\AfterFX.exe", + "" + ] + ], + "darwin": [], + "linux": [] + }, + "environment": { + "__environment_keys__": { + "aftereffects_2021": [] + } + } } } }, @@ -899,7 +1146,7 @@ "enabled": true, "label": "CelAction 2D", "icon": "app_icons/celaction.png", - "is_host": true, + "host_name": "celaction", "environment": { "__environment_keys__": { "celaction": [ @@ -914,7 +1161,10 @@ "label": "", "variant_label": "Local", "icon": "{}/app_icons/celaction_local.png", - "executables": "", + "executables": [ + "", + "" + ], "environment": { "__environment_keys__": { "celation_Local": [] @@ -926,7 +1176,10 @@ "label": "", "variant_label": "Pulblish", "icon": "", - "executables": "", + "executables": [ + "", + "" + ], "environment": { "__environment_keys__": { "celation_Publish": [] @@ -939,7 +1192,7 @@ "enabled": true, "label": "Unreal Editor", "icon": "{}/app_icons/ue4.png'", - "is_host": true, + "host_name": "unreal", "environment": { "__environment_keys__": { "unreal": [ @@ -1033,7 +1286,7 @@ "enabled": true, "label": "DJV View", "icon": "{}/app_icons/djvView.png", - "is_host": false, + "host_name": "", "environment": { "__environment_keys__": { "djvview": [] diff --git a/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/schema_aftereffects.json b/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/schema_aftereffects.json new file mode 100644 index 0000000000..073d57b870 --- /dev/null +++ b/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/schema_aftereffects.json @@ -0,0 +1,41 @@ +{ + "type": "dict", + "key": "aftereffects", + "label": "Adobe AfterEffects", + "collapsable": true, + "checkbox_key": "enabled", + "children": [{ + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "schema_template", + "name": "template_host_unchangables" + }, + { + "key": "environment", + "label": "Environment", + "type": "raw-json", + "env_group_key": "aftereffects" + }, + { + "type": "dict-invisible", + "key": "variants", + "children": [{ + "type": "schema_template", + "name": "template_host_variant", + "template_data": [ + { + "host_version": "2020", + "host_name": "aftereffects" + }, + { + "host_version": "2021", + "host_name": "aftereffects" + } + ] + }] + } + ] +} diff --git a/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/schema_harmony.json b/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/schema_harmony.json index d7b9e61bda..8ca793f90b 100644 --- a/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/schema_harmony.json +++ b/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/schema_harmony.json @@ -30,14 +30,6 @@ "host_version": "20", "host_name": "harmony" }, - { - "host_version": "19", - "host_name": "harmony" - }, - { - "host_version": "18", - "host_name": "harmony" - }, { "host_version": "17", "host_name": "harmony" diff --git a/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/schema_photoshop.json b/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/schema_photoshop.json index 755cad12c4..dd8e4008be 100644 --- a/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/schema_photoshop.json +++ b/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/schema_photoshop.json @@ -29,6 +29,10 @@ { "host_version": "2020", "host_name": "photoshop" + }, + { + "host_version": "2021", + "host_name": "photoshop" } ] }] diff --git a/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/schema_tvpaint.json b/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/schema_tvpaint.json new file mode 100644 index 0000000000..09e5b1d907 --- /dev/null +++ b/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/schema_tvpaint.json @@ -0,0 +1,41 @@ +{ + "type": "dict", + "key": "tvpaint", + "label": "TVPaint", + "collapsable": true, + "checkbox_key": "enabled", + "children": [{ + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "schema_template", + "name": "template_host_unchangables" + }, + { + "key": "environment", + "label": "Environment", + "type": "raw-json", + "env_group_key": "tvpaint" + }, + { + "type": "dict-invisible", + "key": "variants", + "children": [{ + "type": "schema_template", + "name": "template_host_variant", + "template_data": [ + { + "host_version": "Animation 11 (64bits)", + "host_name": "tvpaint" + }, + { + "host_version": "Animation 11 (32bits)", + "host_name": "tvpaint" + } + ] + }] + } + ] +} diff --git a/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/template_host_unchangables.json b/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/template_host_unchangables.json index 732fd06c30..5fde8e9c1e 100644 --- a/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/template_host_unchangables.json +++ b/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/template_host_unchangables.json @@ -13,8 +13,24 @@ "roles": ["developer"] }, { - "type": "boolean", - "key": "is_host", - "label": "Has host implementation", + "type": "enum", + "key": "host_name", + "label": "Host implementation", + "enum_items": [ + {"": "< without host >"}, + {"aftereffects": "aftereffects"}, + {"blender": "blender"}, + {"celaction": "celaction"}, + {"fusion": "fusion"}, + {"harmony": "harmony"}, + {"hiero": "hiero"}, + {"houdini": "houdini"}, + {"maya": "maya"}, + {"nuke": "nuke"}, + {"photoshop": "photoshop"}, + {"resolve": "resolve"}, + {"tvpaint": "tvpaint"}, + {"unreal": "unreal"} + ], "roles": ["developer"] }] diff --git a/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/template_host_variant.json b/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/template_host_variant.json index ce3a75e871..cea7da3a81 100644 --- a/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/template_host_variant.json +++ b/pype/tools/settings/settings/gui_schemas/system_schema/host_settings/template_host_variant.json @@ -43,7 +43,8 @@ "key": "executables", "label": "Executables", "multiplatform": "{multiplatform}", - "multipath": "{multipath_executables}" + "multipath": "{multipath_executables}", + "with_arguments": true }, { "key": "environment", diff --git a/pype/tools/settings/settings/gui_schemas/system_schema/schema_applications.json b/pype/tools/settings/settings/gui_schemas/system_schema/schema_applications.json index ebfa4482bb..1c983bcff2 100644 --- a/pype/tools/settings/settings/gui_schemas/system_schema/schema_applications.json +++ b/pype/tools/settings/settings/gui_schemas/system_schema/schema_applications.json @@ -65,10 +65,18 @@ "type": "schema", "name": "schema_harmony" }, + { + "type": "schema", + "name": "schema_tvpaint" + }, { "type": "schema", "name": "schema_photoshop" }, + { + "type": "schema", + "name": "schema_aftereffects" + }, { "type": "schema", "name": "schema_celaction" diff --git a/pype/tools/settings/settings/widgets/item_types.py b/pype/tools/settings/settings/widgets/item_types.py index 25bbe4a515..1f5615a240 100644 --- a/pype/tools/settings/settings/widgets/item_types.py +++ b/pype/tools/settings/settings/widgets/item_types.py @@ -5,7 +5,6 @@ from .widgets import ( IconButton, ExpandingWidget, NumberSpinBox, - PathInput, GridLabelWidget, ComboBox, NiceCheckbox @@ -1084,7 +1083,7 @@ class TextWidget(QtWidgets.QWidget, InputObject): class PathInputWidget(QtWidgets.QWidget, InputObject): default_input_value = "" value_changed = QtCore.Signal(object) - valid_value_types = (str, ) + valid_value_types = (str, list) def __init__( self, schema_data, parent, as_widget=False, parent_widget=None @@ -1095,6 +1094,8 @@ class PathInputWidget(QtWidgets.QWidget, InputObject): self.initial_attributes(schema_data, parent, as_widget) + self.with_arguments = schema_data.get("with_arguments", False) + def create_ui(self, label_widget=None): layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -1106,22 +1107,37 @@ class PathInputWidget(QtWidgets.QWidget, InputObject): layout.addWidget(label_widget, 0) self.label_widget = label_widget - self.input_field = PathInput(self) - self.setFocusProxy(self.input_field) - layout.addWidget(self.input_field, 1) + self.input_field = QtWidgets.QLineEdit(self) + self.args_input_field = None + if self.with_arguments: + self.input_field.setPlaceholderText("Executable path") + self.args_input_field = QtWidgets.QLineEdit(self) + self.args_input_field.setPlaceholderText("Arguments") + self.setFocusProxy(self.input_field) + layout.addWidget(self.input_field, 8) self.input_field.textChanged.connect(self._on_value_change) + if self.args_input_field: + layout.addWidget(self.args_input_field, 2) + self.args_input_field.textChanged.connect(self._on_value_change) + def set_value(self, value): self.validate_value(value) - self.input_field.setText(value) - def focusOutEvent(self, event): - self.input_field.clear_end_path() - super(PathInput, self).focusOutEvent(event) + if not isinstance(value, list): + self.input_field.setText(value) + elif self.with_arguments: + self.input_field.setText(value[0]) + self.args_input_field.setText(value[1]) + else: + self.input_field.setText(value[0]) def item_value(self): - return self.input_field.text() + path_value = self.input_field.text() + if self.with_arguments: + return [path_value, self.args_input_field.text()] + return path_value class EnumeratorWidget(QtWidgets.QWidget, InputObject): @@ -3191,6 +3207,7 @@ class PathWidget(QtWidgets.QWidget, SettingObject): self.multiplatform = schema_data.get("multiplatform", False) self.multipath = schema_data.get("multipath", False) + self.with_arguments = schema_data.get("with_arguments", False) self.input_field = None @@ -3230,8 +3247,11 @@ class PathWidget(QtWidgets.QWidget, SettingObject): def create_ui_inputs(self): if not self.multiplatform and not self.multipath: - input_data = {"key": self.key} - path_input = PathInputWidget(input_data, self, as_widget=True) + item_schema = { + "key": self.key, + "with_arguments": self.with_arguments + } + path_input = PathInputWidget(item_schema, self, as_widget=True) path_input.create_ui(label_widget=self.label_widget) self.setFocusProxy(path_input) @@ -3243,7 +3263,10 @@ class PathWidget(QtWidgets.QWidget, SettingObject): if not self.multiplatform: item_schema = { "key": self.key, - "object_type": "path-input" + "object_type": { + "type": "path-input", + "with_arguments": self.with_arguments + } } input_widget = ListWidget(item_schema, self, as_widget=True) input_widget.create_ui(label_widget=self.label_widget) @@ -3266,9 +3289,13 @@ class PathWidget(QtWidgets.QWidget, SettingObject): } if self.multipath: child_item["type"] = "list" - child_item["object_type"] = "path-input" + child_item["object_type"] = { + "type": "path-input", + "with_arguments": self.with_arguments + } else: child_item["type"] = "path-input" + child_item["with_arguments"] = self.with_arguments item_schema["children"].append(child_item) diff --git a/pype/tools/settings/settings/widgets/widgets.py b/pype/tools/settings/settings/widgets/widgets.py index 66afa565a3..092591c165 100644 --- a/pype/tools/settings/settings/widgets/widgets.py +++ b/pype/tools/settings/settings/widgets/widgets.py @@ -51,6 +51,11 @@ class ComboBox(QtWidgets.QComboBox): super(ComboBox, self).__init__(*args, **kwargs) self.currentIndexChanged.connect(self._on_change) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + def wheelEvent(self, event): + if self.hasFocus(): + return super(ComboBox, self).wheelEvent(event) def _on_change(self, *args, **kwargs): self.value_changed.emit() @@ -66,35 +71,6 @@ class ComboBox(QtWidgets.QComboBox): return self.itemData(self.currentIndex(), role=QtCore.Qt.UserRole) -class PathInput(QtWidgets.QLineEdit): - def clear_end_path(self): - value = self.text().strip() - if value.endswith("/"): - while value and value[-1] == "/": - value = value[:-1] - self.setText(value) - - def keyPressEvent(self, event): - # Always change backslash `\` for forwardslash `/` - if event.key() == QtCore.Qt.Key_Backslash: - event.accept() - new_event = QtGui.QKeyEvent( - event.type(), - QtCore.Qt.Key_Slash, - event.modifiers(), - "/", - event.isAutoRepeat(), - event.count() - ) - QtWidgets.QApplication.sendEvent(self, new_event) - return - super(PathInput, self).keyPressEvent(event) - - def focusOutEvent(self, event): - super(PathInput, self).focusOutEvent(event) - self.clear_end_path() - - class ClickableWidget(QtWidgets.QWidget): clicked = QtCore.Signal()