diff --git a/pype.py b/pype.py index 992e0c35ba..769e8c8f6f 100644 --- a/pype.py +++ b/pype.py @@ -260,4 +260,5 @@ def get_info() -> list: return formatted -boot() +if __name__ == "__main__": + boot() diff --git a/pype/hosts/maya/expected_files.py b/pype/hosts/maya/expected_files.py index 52c8893e4b..d39e5fa204 100644 --- a/pype/hosts/maya/expected_files.py +++ b/pype/hosts/maya/expected_files.py @@ -32,6 +32,9 @@ Attributes: ImagePrefixes (dict): Mapping between renderers and their respective image prefix atrribute names. +Todo: + Determine `multipart` from render instance. + """ import types @@ -94,6 +97,10 @@ class ExpectedFiles: multipart = False + def __init__(self, render_instance): + """Constructor.""" + self._render_instance = render_instance + def get(self, renderer, layer): """Get expected files for given renderer and render layer. @@ -114,15 +121,20 @@ class ExpectedFiles: renderSetup.instance().switchToLayerUsingLegacyName(layer) if renderer.lower() == "arnold": - return self._get_files(ExpectedFilesArnold(layer)) + return self._get_files(ExpectedFilesArnold(layer, + self._render_instance)) elif renderer.lower() == "vray": - return self._get_files(ExpectedFilesVray(layer)) + return self._get_files(ExpectedFilesVray( + layer, self._render_instance)) elif renderer.lower() == "redshift": - return self._get_files(ExpectedFilesRedshift(layer)) + return self._get_files(ExpectedFilesRedshift( + layer, self._render_instance)) elif renderer.lower() == "mentalray": - return self._get_files(ExpectedFilesMentalray(layer)) + return self._get_files(ExpectedFilesMentalray( + layer, self._render_instance)) elif renderer.lower() == "renderman": - return self._get_files(ExpectedFilesRenderman(layer)) + return self._get_files(ExpectedFilesRenderman( + layer, self._render_instance)) else: raise UnsupportedRendererException( "unsupported {}".format(renderer) @@ -149,9 +161,10 @@ class AExpectedFiles: layer = None multipart = False - def __init__(self, layer): + def __init__(self, layer, render_instance): """Constructor.""" self.layer = layer + self.render_instance = render_instance @abstractmethod def get_aovs(self): @@ -460,9 +473,9 @@ class ExpectedFilesArnold(AExpectedFiles): "maya": "", } - def __init__(self, layer): + def __init__(self, layer, render_instance): """Constructor.""" - super(ExpectedFilesArnold, self).__init__(layer) + super(ExpectedFilesArnold, self).__init__(layer, render_instance) self.renderer = "arnold" def get_aovs(self): @@ -531,9 +544,9 @@ class ExpectedFilesArnold(AExpectedFiles): class ExpectedFilesVray(AExpectedFiles): """Expected files for V-Ray renderer.""" - def __init__(self, layer): + def __init__(self, layer, render_instance): """Constructor.""" - super(ExpectedFilesVray, self).__init__(layer) + super(ExpectedFilesVray, self).__init__(layer, render_instance) self.renderer = "vray" def get_renderer_prefix(self): @@ -614,24 +627,25 @@ class ExpectedFilesVray(AExpectedFiles): if default_ext == "exr (multichannel)" or default_ext == "exr (deep)": default_ext = "exr" + # add beauty as default enabled_aovs.append( (u"beauty", default_ext) ) - if not self.maya_is_true( - cmds.getAttr("vraySettings.relements_enableall") - ): - return enabled_aovs + # handle aovs from references + use_ref_aovs = self.render_instance.data.get( + "vrayUseReferencedAovs", False) or False - # filter all namespace prefixed AOVs - they are pulled in from - # references and are not rendered. - vr_aovs = [ - n - for n in cmds.ls( - type=["VRayRenderElement", "VRayRenderElementSet"] - ) - if len(n.split(":")) == 1 - ] + # this will have list of all aovs no matter if they are coming from + # reference or not. + vr_aovs = cmds.ls( + type=["VRayRenderElement", "VRayRenderElementSet"]) or [] + if not use_ref_aovs: + ref_aovs = cmds.ls( + type=["VRayRenderElement", "VRayRenderElementSet"], + referencedNodes=True) or [] + # get difference + vr_aovs = list(set(vr_aovs) - set(ref_aovs)) for aov in vr_aovs: enabled = self.maya_is_true(cmds.getAttr("{}.enabled".format(aov))) @@ -703,9 +717,9 @@ class ExpectedFilesRedshift(AExpectedFiles): ext_mapping = ["iff", "exr", "tif", "png", "tga", "jpg"] - def __init__(self, layer): + def __init__(self, layer, render_instance): """Construtor.""" - super(ExpectedFilesRedshift, self).__init__(layer) + super(ExpectedFilesRedshift, self).__init__(layer, render_instance) self.renderer = "redshift" def get_renderer_prefix(self): @@ -822,9 +836,9 @@ class ExpectedFilesRenderman(AExpectedFiles): This is very rudimentary and needs more love and testing. """ - def __init__(self, layer): + def __init__(self, layer, render_instance): """Constructor.""" - super(ExpectedFilesRenderman, self).__init__(layer) + super(ExpectedFilesRenderman, self).__init__(layer, render_instance) self.renderer = "renderman" def get_aovs(self): @@ -887,7 +901,7 @@ class ExpectedFilesRenderman(AExpectedFiles): class ExpectedFilesMentalray(AExpectedFiles): """Skeleton unimplemented class for Mentalray renderer.""" - def __init__(self, layer): + def __init__(self, layer, render_instance): """Constructor. Raises: diff --git a/pype/lib/__init__.py b/pype/lib/__init__.py index ae022e3073..c6d9ce7fba 100644 --- a/pype/lib/__init__.py +++ b/pype/lib/__init__.py @@ -56,7 +56,11 @@ from .plugin_tools import ( filter_pyblish_plugins, source_hash, get_unique_layer_name, - get_background_layers + get_background_layers, + oiio_supported, + decompress, + get_decompress_dir, + should_decompress ) from .user_settings import ( @@ -119,6 +123,10 @@ __all__ = [ "source_hash", "get_unique_layer_name", "get_background_layers", + "oiio_supported", + "decompress", + "get_decompress_dir", + "should_decompress", "version_up", "get_version_from_path", diff --git a/pype/lib/applications.py b/pype/lib/applications.py index cccc50d397..253ffa0ad2 100644 --- a/pype/lib/applications.py +++ b/pype/lib/applications.py @@ -1,5 +1,4 @@ import os -import copy import platform import inspect import subprocess @@ -531,15 +530,23 @@ class ApplicationLaunchContext: self.launch_args = executable.as_args() # Handle launch environemtns - passed_env = self.data.pop("env", None) - if passed_env is None: + env = self.data.pop("env", None) + if env is not None and not isinstance(env, dict): + self.log.warning(( + "Passed `env` kwarg has invalid type: {}. Expected: `dict`." + " Using `os.environ` instead." + ).format(str(type(env)))) + env = None + + if env is None: env = os.environ - else: - env = passed_env # subprocess.Popen keyword arguments self.kwargs = { - "env": copy.deepcopy(env) + "env": { + key: str(value) + for key, value in env.items() + } } if platform.system().lower() == "windows": @@ -580,7 +587,6 @@ class ApplicationLaunchContext: paths = [] # TODO load additional studio paths from settings - # TODO add paths based on used modules (like `ftrack`) import pype pype_dir = os.path.dirname(os.path.abspath(pype.__file__)) @@ -610,6 +616,13 @@ class ApplicationLaunchContext: and path not in paths ): paths.append(path) + + # Load modules paths + from pype.modules import ModulesManager + + manager = ModulesManager() + paths.extend(manager.collect_launch_hook_paths()) + return paths def discover_launch_hooks(self, force=False): diff --git a/pype/lib/log.py b/pype/lib/log.py index f64d677ea8..47f379d952 100644 --- a/pype/lib/log.py +++ b/pype/lib/log.py @@ -378,6 +378,9 @@ class PypeLogger: Terminal.echo(line) _mongo_logging = False + # Do not propagate logs to root logger + logger.propagate = False + return logger diff --git a/pype/lib/plugin_tools.py b/pype/lib/plugin_tools.py index 13d311d96c..6d074329bc 100644 --- a/pype/lib/plugin_tools.py +++ b/pype/lib/plugin_tools.py @@ -5,6 +5,9 @@ import inspect import logging import re import json +import tempfile + +from . import execute from pype.settings import get_project_settings @@ -134,3 +137,115 @@ def get_background_layers(file_url): layer.get("filename")). replace("\\", "/")) return layers + + +def oiio_supported(): + """ + Checks if oiiotool is configured for this platform. + + Expects full path to executable. + + 'should_decompress' will throw exception if configured, + but not present or not working. + Returns: + (bool) + """ + oiio_path = os.getenv("PYPE_OIIO_PATH", "") + if not oiio_path or not os.path.exists(oiio_path): + log.debug("OIIOTool is not configured or not present at {}". + format(oiio_path)) + return False + + return True + + +def decompress(target_dir, file_url, + input_frame_start=None, input_frame_end=None, log=None): + """ + Decompresses DWAA 'file_url' .exr to 'target_dir'. + + Creates uncompressed files in 'target_dir', they need to be cleaned. + + File url could be for single file or for a sequence, in that case + %0Xd will be as a placeholder for frame number AND input_frame* will + be filled. + In that case single oiio command with '--frames' will be triggered for + all frames, this should be faster then looping and running sequentially + + Args: + target_dir (str): extended from stagingDir + file_url (str): full urls to source file (with or without %0Xd) + input_frame_start (int) (optional): first frame + input_frame_end (int) (optional): last frame + log (Logger) (optional): pype logger + """ + is_sequence = input_frame_start is not None and \ + input_frame_end is not None and \ + (int(input_frame_end) > int(input_frame_start)) + + oiio_cmd = [] + oiio_cmd.append(os.getenv("PYPE_OIIO_PATH")) + + oiio_cmd.append("--compression none") + + base_file_name = os.path.basename(file_url) + oiio_cmd.append(file_url) + + if is_sequence: + oiio_cmd.append("--frames {}-{}".format(input_frame_start, + input_frame_end)) + + oiio_cmd.append("-o") + oiio_cmd.append(os.path.join(target_dir, base_file_name)) + + subprocess_exr = " ".join(oiio_cmd) + + if not log: + log = logging.getLogger(__name__) + + log.debug("Decompressing {}".format(subprocess_exr)) + execute.execute( + subprocess_exr, shell=True, logger=log + ) + + +def get_decompress_dir(): + """ + Creates temporary folder for decompressing. + Its local, in case of farm it is 'local' to the farm machine. + + Should be much faster, needs to be cleaned up later. + """ + return os.path.normpath( + tempfile.mkdtemp(prefix="pyblish_tmp_") + ) + + +def should_decompress(file_url): + """ + Tests that 'file_url' is compressed with DWAA. + + Uses 'oiio_supported' to check that OIIO tool is available for this + platform. + + Shouldn't throw exception as oiiotool is guarded by check function. + Currently implemented this way as there is no support for Mac and Linux + In the future, it should be more strict and throws exception on + misconfiguration. + + Args: + file_url (str): path to rendered file (in sequence it would be + first file, if that compressed it is expected that whole seq + will be too) + Returns: + (bool): 'file_url' is DWAA compressed and should be decompressed + and we can decompress (oiiotool supported) + """ + if oiio_supported(): + output = execute.execute([ + os.getenv("PYPE_OIIO_PATH"), + "--info", "-v", file_url]) + return "compression: \"dwaa\"" in output or \ + "compression: \"dwab\"" in output + + return False diff --git a/pype/lib/python_module_tools.py b/pype/lib/python_module_tools.py index 2ce2f60dca..b5400c9981 100644 --- a/pype/lib/python_module_tools.py +++ b/pype/lib/python_module_tools.py @@ -18,10 +18,20 @@ def modules_from_path(folder_path): Returns: List of modules. """ + modules = [] + # Just skip and return empty list if path is not set + if not folder_path: + return modules + + # Do not allow relative imports + if folder_path.startswith("."): + log.warning(( + "BUG: Relative paths are not allowed for security reasons. {}" + ).format(folder_path)) + return 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 diff --git a/pype/lib/terminal.py b/pype/lib/terminal.py index 461d13f84a..d54d52e9be 100644 --- a/pype/lib/terminal.py +++ b/pype/lib/terminal.py @@ -74,6 +74,84 @@ class Terminal: def __init__(self): pass + from pype.lib import env_value_to_bool + use_colors = env_value_to_bool( + "PYPE_LOG_NO_COLORS", default=Terminal.use_colors + ) + if not use_colors: + Terminal.use_colors = use_colors + Terminal._initialized = True + return + + try: + # Try to import `blessed` module and create `Terminal` object + import blessed + term = blessed.Terminal() + + except Exception: + # Do not use colors if crashed + Terminal.use_colors = False + Terminal.echo( + "Module `blessed` failed on import or terminal creation." + " Pype terminal won't use colors." + ) + Terminal._initialized = True + return + + # shortcuts for blessed codes + _SB = term.bold + _RST = "" + _LR = term.tomato2 + _LG = term.aquamarine3 + _LB = term.turquoise2 + _LM = term.slateblue2 + _LY = term.gold + _R = term.red + _G = term.green + _B = term.blue + _C = term.cyan + _Y = term.yellow + _W = term.white + + # dictionary replacing string sequences with colorized one + Terminal._sdict = { + r">>> ": _SB + _LG + r">>> " + _RST, + r"!!!(?!\sCRI|\sERR)": _SB + _R + r"!!! " + _RST, + r"\-\-\- ": _SB + _C + r"--- " + _RST, + r"\*\*\*(?!\sWRN)": _SB + _LY + r"***" + _RST, + r"\*\*\* WRN": _SB + _LY + r"*** WRN" + _RST, + r" \- ": _SB + _LY + r" - " + _RST, + r"\[ ": _SB + _LG + r"[ " + _RST, + r"\]": _SB + _LG + r"]" + _RST, + r"{": _LG + r"{", + r"}": r"}" + _RST, + r"\(": _LY + r"(", + r"\)": r")" + _RST, + r"^\.\.\. ": _SB + _LR + r"... " + _RST, + r"!!! ERR: ": + _SB + _LR + r"!!! ERR: " + _RST, + r"!!! CRI: ": + _SB + _R + r"!!! CRI: " + _RST, + r"(?i)failed": _SB + _LR + "FAILED" + _RST, + r"(?i)error": _SB + _LR + "ERROR" + _RST + } + + Terminal._SB = _SB + Terminal._RST = _RST + Terminal._LR = _LR + Terminal._LG = _LG + Terminal._LB = _LB + Terminal._LM = _LM + Terminal._LY = _LY + Terminal._R = _R + Terminal._G = _G + Terminal._B = _B + Terminal._C = _C + Terminal._Y = _Y + Terminal._W = _W + + Terminal._initialized = True + @staticmethod def _multiple_replace(text, adict): """Replace multiple tokens defined in dict. @@ -126,12 +204,25 @@ class Terminal: """ T = Terminal + # Initialize if not yet initialized and use thread lock to avoid race + # condition issues + if not T._initialized: + # Check if lock is already locked to be sure `_initialize` is not + # executed multiple times + if not T._init_lock.locked(): + with T._init_lock: + T._initialize() + else: + # If lock is locked wait until is finished + while T._init_lock.locked(): + time.sleep(0.1) + # if we dont want colors, just print raw message if not T._sdict or os.environ.get('PYPE_LOG_NO_COLORS'): return message - else: - message = re.sub(r'\[(.*)\]', '[ ' + T._SB + T._W + - r'\1' + T._RST + ' ]', message) - message = T._multiple_replace(message + T._RST, T._sdict) - return message + message = re.sub(r'\[(.*)\]', '[ ' + T._SB + T._W + + r'\1' + T._RST + ' ]', message) + message = T._multiple_replace(message + T._RST, T._sdict) + + return message diff --git a/pype/modules/README.md b/pype/modules/README.md new file mode 100644 index 0000000000..818375461f --- /dev/null +++ b/pype/modules/README.md @@ -0,0 +1,103 @@ +# Pype modules +Pype modules should contain separated logic of specific kind of implementation, like Ftrack connection and usage code or Deadline farm rendering. + +## Base class `PypeModule` +- abstract class as base for each module +- implementation should be module's api withou GUI parts +- may implement `get_global_environments` method which should return dictionary of environments that are globally appliable and value is the same for whole studio if launched at any workstation (except os specific paths) +- abstract parts: + - `name` attribute - name of a module + - `initialize` method - method for own initialization of a module (should not override `__init__`) + - `connect_with_modules` method - where module may look for it's interfaces implementations or check for other modules +- `__init__` should not be overriden and `initialize` should not do time consuming part but only prepare base data about module + - also keep in mind that they may be initialized in headless mode +- connection with other modules is made with help of interfaces + +# Interfaces +- interface is class that has defined abstract methods to implement and may contain preimplemented helper methods +- module that inherit from an interface must implement those abstract methods otherwise won't be initialized +- it is easy to find which module object inherited from which interfaces withh 100% chance they have implemented required methods + +## Global interfaces +- few interfaces are implemented for global usage + +### IPluginPaths +- module want to add directory path/s to avalon or publish plugins +- module must implement `get_plugin_paths` which must return dictionary with possible keys `"publish"`, `"load"`, `"create"` or `"actions"` + - each key may contain list or string with path to directory with plugins + +### ITrayModule +- module has more logic when used in tray + - it is possible that module can be used only in tray +- abstract methods + - `tray_init` - initialization triggered after `initialize` when used in `TrayModulesManager` and before `connect_with_modules` + - `tray_menu` - add actions to tray widget's menu that represent the module + - `tray_start` - start of module's login in tray + - module is initialized and connected with other modules + - `tray_exit` - module's cleanup like stop and join threads etc. + - order of calling is based on implementation this order is how it works with `TrayModulesManager` + - it is recommended to import and use GUI implementaion only in these methods +- has attribute `tray_initialized` (bool) which is set to False by default and is set by `TrayModulesManager` to True after `tray_init` + - if module has logic only in tray or for both then should be checking for `tray_initialized` attribute to decide how should handle situations + +### ITrayService +- inherit from `ITrayModule` and implement `tray_menu` method for you + - add action to submenu "Services" in tray widget menu with icon and label +- abstract atttribute `label` + - label shown in menu +- interface has preimplemented methods to change icon color + - `set_service_running` - green icon + - `set_service_failed` - red icon + - `set_service_idle` - orange icon + - these states must be set by module itself `set_service_running` is default state on initialization + +### ITrayAction +- inherit from `ITrayModule` and implement `tray_menu` method for you + - add action to tray widget menu with label +- abstract atttribute `label` + - label shown in menu +- abstract method `on_action_trigger` + - what should happen when action is triggered +- NOTE: It is good idea to implement logic in `on_action_trigger` to api method and trigger that methods on callbacks this gives ability to trigger that method outside tray + +## Modules interfaces +- modules may have defined their interfaces to be able recognize other modules that would want to use their features +- +### Example: +- Ftrack module has `IFtrackEventHandlerPaths` which helps to tell Ftrack module which of other modules want to add paths to server/user event handlers + - Clockify module use `IFtrackEventHandlerPaths` and return paths to clockify ftrack synchronizers + +- Clockify has more inharitance it's class definition looks like +``` +class ClockifyModule( + PypeModule, # Says it's Pype module so ModulesManager will try to initialize. + ITrayModule, # Says has special implementation when used in tray. + IPluginPaths, # Says has plugin paths that want to register (paths to clockify actions for launcher). + IFtrackEventHandlerPaths, # Says has Ftrack actions/events for user/server. + ITimersManager # Listen to other modules with timer and can trigger changes in other module timers through `TimerManager` module. +): +``` + +### ModulesManager +- collect module classes and tries to initialize them +- important attributes + - `modules` - list of available attributes + - `modules_by_id` - dictionary of modules mapped by their ids + - `modules_by_name` - dictionary of modules mapped by their names + - all these attributes contain all found modules even if are not enabled +- helper methods + - `collect_global_environments` to collect all global environments from enabled modules with calling `get_global_environments` on each of them + - `collect_plugin_paths` collect plugin paths from all enabled modules + - output is always dictionary with all keys and values as list + ``` + { + "publish": [], + "create": [], + "load": [], + "actions": [] + } + ``` + +### TrayModulesManager +- inherit from `ModulesManager` +- has specific implementations for Pype Tray tool and handle `ITrayModule` methods diff --git a/pype/modules/__init__.py b/pype/modules/__init__.py index 9fa985ce57..3a73647da3 100644 --- a/pype/modules/__init__.py +++ b/pype/modules/__init__.py @@ -2,12 +2,14 @@ from .base import ( PypeModule, ITrayModule, + ITrayAction, ITrayService, IPluginPaths, + ILaunchHookPaths, ModulesManager, TrayModulesManager ) -from .settings_module import SettingsModule +from .settings_action import SettingsAction from .rest_api import ( RestApiModule, IRestApi @@ -25,14 +27,15 @@ from .timers_manager import ( ITimersManager ) from .avalon_apps import AvalonModule +from .launcher_action import LauncherAction from .ftrack import ( FtrackModule, IFtrackEventHandlerPaths ) from .clockify import ClockifyModule -from .logging import LoggingModule +from .log_viewer import LogViewModule from .muster import MusterModule -from .standalonepublish import StandAlonePublishModule +from .standalonepublish_action import StandAlonePublishAction from .websocket_server import WebsocketModule from .sync_server import SyncServer @@ -40,12 +43,14 @@ from .sync_server import SyncServer __all__ = ( "PypeModule", "ITrayModule", + "ITrayAction", "ITrayService", "IPluginPaths", + "ILaunchHookPaths", "ModulesManager", "TrayModulesManager", - "SettingsModule", + "SettingsAction", "UserModule", "IUserModule", @@ -60,15 +65,16 @@ __all__ = ( "IRestApi", "AvalonModule", + "LauncherAction", "FtrackModule", "IFtrackEventHandlerPaths", "ClockifyModule", "IdleManager", - "LoggingModule", + "LogViewModule", "MusterModule", - "StandAlonePublishModule", + "StandAlonePublishAction", "WebsocketModule", "SyncServer" diff --git a/pype/modules/avalon_apps/avalon_app.py b/pype/modules/avalon_apps/avalon_app.py index 683d804412..d00a306e9e 100644 --- a/pype/modules/avalon_apps/avalon_app.py +++ b/pype/modules/avalon_apps/avalon_app.py @@ -44,7 +44,6 @@ class AvalonModule(PypeModule, ITrayModule, IRestApi): ) # Tray attributes - self.app_launcher = None self.libraryloader = None self.rest_api_obj = None @@ -99,29 +98,8 @@ class AvalonModule(PypeModule, ITrayModule, IRestApi): exc_info=True ) - # Add launcher - try: - from pype.tools.launcher import LauncherWindow - self.app_launcher = LauncherWindow() - except Exception: - self.log.warning( - "Couldn't load Launch for tray.", - exc_info=True - ) - def connect_with_modules(self, _enabled_modules): - plugin_paths = self.manager.collect_plugin_paths()["actions"] - if plugin_paths: - env_paths_str = os.environ.get("AVALON_ACTIONS") or "" - env_paths = env_paths_str.split(os.pathsep) - env_paths.extend(plugin_paths) - os.environ["AVALON_ACTIONS"] = os.pathsep.join(env_paths) - - if self.tray_initialized: - from pype.tools.launcher import actions - # actions.register_default_actions() - actions.register_config_actions() - actions.register_environment_actions() + return def rest_api_initialization(self, rest_api_module): if self.tray_initialized: @@ -132,15 +110,12 @@ class AvalonModule(PypeModule, ITrayModule, IRestApi): def tray_menu(self, tray_menu): from Qt import QtWidgets # Actions - action_launcher = QtWidgets.QAction("Launcher", tray_menu) action_library_loader = QtWidgets.QAction( "Library loader", tray_menu ) - action_launcher.triggered.connect(self.show_launcher) action_library_loader.triggered.connect(self.show_library_loader) - tray_menu.addAction(action_launcher) tray_menu.addAction(action_library_loader) def tray_start(self, *_a, **_kw): @@ -149,12 +124,6 @@ class AvalonModule(PypeModule, ITrayModule, IRestApi): def tray_exit(self, *_a, **_kw): return - def show_launcher(self): - # if app_launcher don't exist create it/otherwise only show main window - self.app_launcher.show() - self.app_launcher.raise_() - self.app_launcher.activateWindow() - def show_library_loader(self): self.libraryloader.show() diff --git a/pype/modules/base.py b/pype/modules/base.py index 3c2c2e7e21..97e5f891af 100644 --- a/pype/modules/base.py +++ b/pype/modules/base.py @@ -84,6 +84,19 @@ class IPluginPaths: pass +@six.add_metaclass(ABCMeta) +class ILaunchHookPaths: + """Module has launch hook paths to return. + + Expected result is list of paths. + ["path/to/launch_hooks_dir"] + """ + + @abstractmethod + def get_launch_hook_paths(self): + pass + + @six.add_metaclass(ABCMeta) class ITrayModule: """Module has special procedures when used in Pype Tray. @@ -124,6 +137,41 @@ class ITrayModule: pass +class ITrayAction(ITrayModule): + """Implementation of Tray action. + + Add action to tray menu which will trigger `on_action_trigger`. + It is expected to be used for showing tools. + + Methods `tray_start`, `tray_exit` and `connect_with_modules` are overriden + as it's not expected that action will use them. But it is possible if + necessary. + """ + + @property + @abstractmethod + def label(self): + """Service label showed in menu.""" + pass + + @abstractmethod + def on_action_trigger(self): + """What happens on actions click.""" + pass + + def tray_menu(self, tray_menu): + from Qt import QtWidgets + action = QtWidgets.QAction(self.label, tray_menu) + action.triggered.connect(self.on_action_trigger) + tray_menu.addAction(action) + + def tray_start(self): + return + + def tray_exit(self): + return + + class ITrayService(ITrayModule): # Module's property menu_action = None @@ -287,7 +335,13 @@ class ModulesManager: enabled_modules = self.get_enabled_modules() self.log.debug("Has {} enabled modules.".format(len(enabled_modules))) for module in enabled_modules: - module.connect_with_modules(enabled_modules) + try: + module.connect_with_modules(enabled_modules) + except Exception: + self.log.error( + "BUG: Module failed on connection with other modules.", + exc_info=True + ) def get_enabled_modules(self): """Enabled modules initialized by the manager. @@ -380,6 +434,40 @@ class ModulesManager: ).format(expected_keys, " | ".join(msg_items))) return output + def collect_launch_hook_paths(self): + """Helper to collect hooks from modules inherited ILaunchHookPaths. + + Returns: + list: Paths to launch hook directories. + """ + str_type = type("") + expected_types = (list, tuple, set) + + output = [] + for module in self.get_enabled_modules(): + # Skip module that do not inherit from `ILaunchHookPaths` + if not isinstance(module, ILaunchHookPaths): + continue + + hook_paths = module.get_launch_hook_paths() + if not hook_paths: + continue + + # Convert string to list + if isinstance(hook_paths, str_type): + hook_paths = [hook_paths] + + # Skip invalid types + if not isinstance(hook_paths, expected_types): + self.log.warning(( + "Result of `get_launch_hook_paths`" + " has invalid type {}. Expected {}" + ).format(type(hook_paths), expected_types)) + continue + + output.extend(hook_paths) + return output + class TrayModulesManager(ModulesManager): # Define order of modules in menu @@ -387,6 +475,7 @@ class TrayModulesManager(ModulesManager): "user", "ftrack", "muster", + "launcher_tool", "avalon", "clockify", "standalonepublish_tool", diff --git a/pype/modules/ftrack/actions/action_applications.py b/pype/modules/ftrack/actions/action_applications.py index cf047a658d..5b6657793a 100644 --- a/pype/modules/ftrack/actions/action_applications.py +++ b/pype/modules/ftrack/actions/action_applications.py @@ -28,8 +28,8 @@ class AppplicationsAction(BaseAction): identifier = "pype_app.{}.".format(str(uuid4())) icon_url = os.environ.get("PYPE_STATICS_SERVER") - def __init__(self, session, plugins_presets=None): - super().__init__(session, plugins_presets) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.application_manager = ApplicationManager() self.dbcon = AvalonMongoDB() @@ -210,6 +210,6 @@ class AppplicationsAction(BaseAction): } -def register(session, plugins_presets=None): +def register(session): """Register action. Called when used as an event plugin.""" - AppplicationsAction(session, plugins_presets).register() + AppplicationsAction(session).register() diff --git a/pype/modules/ftrack/actions/action_batch_task_creation.py b/pype/modules/ftrack/actions/action_batch_task_creation.py index ef370d55eb..477971773d 100644 --- a/pype/modules/ftrack/actions/action_batch_task_creation.py +++ b/pype/modules/ftrack/actions/action_batch_task_creation.py @@ -158,7 +158,7 @@ class BatchTasksAction(BaseAction): } -def register(session, plugins_presets=None): +def register(session): '''Register action. Called when used as an event plugin.''' - BatchTasksAction(session, plugins_presets).register() + BatchTasksAction(session).register() diff --git a/pype/modules/ftrack/actions/action_clean_hierarchical_attributes.py b/pype/modules/ftrack/actions/action_clean_hierarchical_attributes.py index e81e587f0a..f9824ec8ea 100644 --- a/pype/modules/ftrack/actions/action_clean_hierarchical_attributes.py +++ b/pype/modules/ftrack/actions/action_clean_hierarchical_attributes.py @@ -9,7 +9,6 @@ class CleanHierarchicalAttrsAction(BaseAction): label = "Pype Admin" variant = "- Clean hierarchical custom attributes" description = "Unset empty hierarchical attribute values." - role_list = ["Pypeclub", "Administrator", "Project Manager"] icon = statics_icon("ftrack", "action_icons", "PypeAdmin.svg") all_project_entities_query = ( @@ -20,12 +19,17 @@ class CleanHierarchicalAttrsAction(BaseAction): "select value, entity_id from CustomAttributeValue " "where entity_id in ({}) and configuration_id is \"{}\"" ) + settings_key = "clean_hierarchical_attr" def discover(self, session, entities, event): """Show only on project entity.""" - if len(entities) == 1 and entities[0].entity_type.lower() == "project": - return True - return False + if ( + len(entities) != 1 + or entities[0].entity_type.lower() != "project" + ): + return False + + return self.valid_roles(session, entities, event) def launch(self, session, entities, event): project = entities[0] @@ -98,7 +102,7 @@ class CleanHierarchicalAttrsAction(BaseAction): return True -def register(session, plugins_presets={}): +def register(session): '''Register plugin. Called when used as an plugin.''' - CleanHierarchicalAttrsAction(session, plugins_presets).register() + CleanHierarchicalAttrsAction(session).register() diff --git a/pype/modules/ftrack/actions/action_client_review_sort.py b/pype/modules/ftrack/actions/action_client_review_sort.py index 72387fe695..1c5c429cf2 100644 --- a/pype/modules/ftrack/actions/action_client_review_sort.py +++ b/pype/modules/ftrack/actions/action_client_review_sort.py @@ -84,7 +84,7 @@ class ClientReviewSort(BaseAction): } -def register(session, plugins_presets={}): +def register(session): '''Register action. Called when used as an event plugin.''' - ClientReviewSort(session, plugins_presets).register() + ClientReviewSort(session).register() diff --git a/pype/modules/ftrack/actions/action_component_open.py b/pype/modules/ftrack/actions/action_component_open.py index 5fe8fe831b..2928f54b15 100644 --- a/pype/modules/ftrack/actions/action_component_open.py +++ b/pype/modules/ftrack/actions/action_component_open.py @@ -60,7 +60,7 @@ class ComponentOpen(BaseAction): } -def register(session, plugins_presets={}): +def register(session): '''Register action. Called when used as an event plugin.''' - ComponentOpen(session, plugins_presets).register() + ComponentOpen(session).register() diff --git a/pype/modules/ftrack/actions/action_create_cust_attrs.py b/pype/modules/ftrack/actions/action_create_cust_attrs.py index a63c77c198..a6601775f1 100644 --- a/pype/modules/ftrack/actions/action_create_cust_attrs.py +++ b/pype/modules/ftrack/actions/action_create_cust_attrs.py @@ -131,9 +131,8 @@ class CustomAttributes(BaseAction): variant = '- Create/Update Avalon Attributes' #: Action description. description = 'Creates Avalon/Mongo ID for double check' - #: roles that are allowed to register this action - role_list = ['Pypeclub', 'Administrator'] icon = statics_icon("ftrack", "action_icons", "PypeAdmin.svg") + settings_key = "create_update_attributes" required_keys = ("key", "label", "type") @@ -150,7 +149,7 @@ class CustomAttributes(BaseAction): Validation - action is only for Administrators ''' - return True + return self.valid_roles(session, entities, event) def launch(self, session, entities, event): # JOB SETTINGS @@ -814,7 +813,7 @@ class CustomAttributes(BaseAction): } -def register(session, plugins_presets={}): +def register(session): '''Register plugin. Called when used as an plugin.''' - CustomAttributes(session, plugins_presets).register() + CustomAttributes(session).register() diff --git a/pype/modules/ftrack/actions/action_create_folders.py b/pype/modules/ftrack/actions/action_create_folders.py index a131a0e35b..d70232ae8f 100644 --- a/pype/modules/ftrack/actions/action_create_folders.py +++ b/pype/modules/ftrack/actions/action_create_folders.py @@ -243,6 +243,6 @@ class CreateFolders(BaseAction): return os.path.normpath(filled_template.split("{")[0]) -def register(session, plugins_presets={}): +def register(session): """Register plugin. Called when used as an plugin.""" - CreateFolders(session, plugins_presets).register() + CreateFolders(session).register() diff --git a/pype/modules/ftrack/actions/action_create_project_structure.py b/pype/modules/ftrack/actions/action_create_project_structure.py index 0815f82a69..64b4ba6727 100644 --- a/pype/modules/ftrack/actions/action_create_project_structure.py +++ b/pype/modules/ftrack/actions/action_create_project_structure.py @@ -238,5 +238,5 @@ class CreateProjectFolders(BaseAction): os.makedirs(path.format(project_root=project_root)) -def register(session, plugins_presets={}): - CreateProjectFolders(session, plugins_presets).register() +def register(session): + CreateProjectFolders(session).register() diff --git a/pype/modules/ftrack/actions/action_delete_asset.py b/pype/modules/ftrack/actions/action_delete_asset.py index 7d2dac3320..3bdbbe2470 100644 --- a/pype/modules/ftrack/actions/action_delete_asset.py +++ b/pype/modules/ftrack/actions/action_delete_asset.py @@ -18,8 +18,8 @@ class DeleteAssetSubset(BaseAction): #: Action description. description = "Removes from Avalon with all childs and asset from Ftrack" icon = statics_icon("ftrack", "action_icons", "DeleteAsset.svg") - #: roles that are allowed to register this action - role_list = ["Pypeclub", "Administrator", "Project Manager"] + + settings_key = "delete_asset_subset" #: Db connection dbcon = AvalonMongoDB() @@ -32,17 +32,21 @@ class DeleteAssetSubset(BaseAction): """ Validation """ task_ids = [] for ent_info in event["data"]["selection"]: - entType = ent_info.get("entityType", "") - if entType == "task": + if ent_info.get("entityType") == "task": task_ids.append(ent_info["entityId"]) + is_valid = False for entity in entities: - ftrack_id = entity["id"] - if ftrack_id not in task_ids: - continue - if entity.entity_type.lower() != "task": - return True - return False + if ( + entity["id"] in task_ids + and entity.entity_type.lower() != "task" + ): + is_valid = True + break + + if is_valid: + is_valid = self.valid_roles(session, entities, event) + return is_valid def _launch(self, event): try: @@ -662,7 +666,7 @@ class DeleteAssetSubset(BaseAction): } -def register(session, plugins_presets={}): +def register(session): '''Register plugin. Called when used as an plugin.''' - DeleteAssetSubset(session, plugins_presets).register() + DeleteAssetSubset(session).register() diff --git a/pype/modules/ftrack/actions/action_delete_old_versions.py b/pype/modules/ftrack/actions/action_delete_old_versions.py index b55f091fdc..e1c1e173a3 100644 --- a/pype/modules/ftrack/actions/action_delete_old_versions.py +++ b/pype/modules/ftrack/actions/action_delete_old_versions.py @@ -21,7 +21,6 @@ class DeleteOldVersions(BaseAction): "Delete files from older publishes so project can be" " archived with only lates versions." ) - role_list = ["Pypeclub", "Project Manager", "Administrator"] icon = statics_icon("ftrack", "action_icons", "PypeAdmin.svg") dbcon = AvalonMongoDB() @@ -31,13 +30,16 @@ class DeleteOldVersions(BaseAction): sequence_splitter = "__sequence_splitter__" def discover(self, session, entities, event): - ''' Validation ''' - selection = event["data"].get("selection") or [] - for entity in selection: - entity_type = (entity.get("entityType") or "").lower() - if entity_type == "assetversion": - return True - return False + """ Validation. """ + is_valid = False + for entity in entities: + if entity.entity_type.lower() == "assetversion": + is_valid = True + break + + if is_valid: + is_valid = self.valid_roles(session, entities, event) + return is_valid def interface(self, session, entities, event): # TODO Add roots existence validation @@ -577,7 +579,7 @@ class DeleteOldVersions(BaseAction): return (os.path.normpath(path), sequence_path) -def register(session, plugins_presets={}): +def register(session): '''Register plugin. Called when used as an plugin.''' - DeleteOldVersions(session, plugins_presets).register() + DeleteOldVersions(session).register() diff --git a/pype/modules/ftrack/actions/action_delivery.py b/pype/modules/ftrack/actions/action_delivery.py index 0f63f7f7ea..e9e939bb47 100644 --- a/pype/modules/ftrack/actions/action_delivery.py +++ b/pype/modules/ftrack/actions/action_delivery.py @@ -23,6 +23,7 @@ class Delivery(BaseAction): description = "Deliver data to client" role_list = ["Pypeclub", "Administrator", "Project manager"] icon = statics_icon("ftrack", "action_icons", "Delivery.svg") + settings_key = "delivery_action" def __init__(self, *args, **kwargs): self.db_con = AvalonMongoDB() @@ -30,11 +31,15 @@ class Delivery(BaseAction): super(Delivery, self).__init__(*args, **kwargs) def discover(self, session, entities, event): + is_valid = False for entity in entities: if entity.entity_type.lower() == "assetversion": - return True + is_valid = True + break - return False + if is_valid: + is_valid = self.valid_roles(session, entities, event) + return is_valid def interface(self, session, entities, event): if event["data"].get("values", {}): @@ -692,7 +697,7 @@ class Delivery(BaseAction): } -def register(session, plugins_presets={}): +def register(session): '''Register plugin. Called when used as an plugin.''' - Delivery(session, plugins_presets).register() + Delivery(session).register() diff --git a/pype/modules/ftrack/actions/action_djvview.py b/pype/modules/ftrack/actions/action_djvview.py index 6f667c0604..6036f9a35b 100644 --- a/pype/modules/ftrack/actions/action_djvview.py +++ b/pype/modules/ftrack/actions/action_djvview.py @@ -20,9 +20,8 @@ class DJVViewAction(BaseAction): "sgi", "rgba", "rgb", "bw", "tga", "tiff", "tif", "img" ] - def __init__(self, session, plugins_presets): - '''Expects a ftrack_api.Session instance''' - super().__init__(session, plugins_presets) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.djv_path = self.find_djv_path() @@ -208,7 +207,7 @@ class DJVViewAction(BaseAction): return True -def register(session, plugins_presets={}): +def register(session): """Register hooks.""" - DJVViewAction(session, plugins_presets).register() + DJVViewAction(session).register() diff --git a/pype/modules/ftrack/actions/action_job_killer.py b/pype/modules/ftrack/actions/action_job_killer.py index ff23da2a54..1ddd1383a7 100644 --- a/pype/modules/ftrack/actions/action_job_killer.py +++ b/pype/modules/ftrack/actions/action_job_killer.py @@ -13,13 +13,12 @@ class JobKiller(BaseAction): #: Action description. description = 'Killing selected running jobs' #: roles that are allowed to register this action - role_list = ['Pypeclub', 'Administrator'] icon = statics_icon("ftrack", "action_icons", "PypeAdmin.svg") + settings_key = "job_killer" def discover(self, session, entities, event): ''' Validation ''' - - return True + return self.valid_roles(session, entities, event) def interface(self, session, entities, event): if not event['data'].get('values', {}): @@ -112,7 +111,7 @@ class JobKiller(BaseAction): } -def register(session, plugins_presets={}): +def register(session): '''Register plugin. Called when used as an plugin.''' - JobKiller(session, plugins_presets).register() + JobKiller(session).register() diff --git a/pype/modules/ftrack/actions/action_multiple_notes.py b/pype/modules/ftrack/actions/action_multiple_notes.py index c1a5cc6ce0..d88a91dd92 100644 --- a/pype/modules/ftrack/actions/action_multiple_notes.py +++ b/pype/modules/ftrack/actions/action_multiple_notes.py @@ -104,7 +104,7 @@ class MultipleNotes(BaseAction): return True -def register(session, plugins_presets={}): +def register(session): '''Register plugin. Called when used as an plugin.''' - MultipleNotes(session, plugins_presets).register() + MultipleNotes(session).register() diff --git a/pype/modules/ftrack/actions/action_prepare_project.py b/pype/modules/ftrack/actions/action_prepare_project.py index 970bb3d86b..3a955067d8 100644 --- a/pype/modules/ftrack/actions/action_prepare_project.py +++ b/pype/modules/ftrack/actions/action_prepare_project.py @@ -16,22 +16,23 @@ class PrepareProject(BaseAction): #: Action description. description = 'Set basic attributes on the project' #: roles that are allowed to register this action - role_list = ["Pypeclub", "Administrator", "Project manager"] icon = statics_icon("ftrack", "action_icons", "PrepareProject.svg") + settings_key = "prepare_project" + # Key to store info about trigerring create folder structure create_project_structure_key = "create_folder_structure" item_splitter = {'type': 'label', 'value': '---'} def discover(self, session, entities, event): ''' Validation ''' - if len(entities) != 1: + if ( + len(entities) != 1 + or entities[0].entity_type.lower() != "project" + ): return False - if entities[0].entity_type.lower() != "project": - return False - - return True + return self.valid_roles(session, entities, event) def interface(self, session, entities, event): if event['data'].get('values', {}): @@ -454,6 +455,6 @@ class PrepareProject(BaseAction): self.log.debug("*** Creating project specifig configs Finished ***") -def register(session, plugins_presets={}): +def register(session): '''Register plugin. Called when used as an plugin.''' - PrepareProject(session, plugins_presets).register() + PrepareProject(session).register() diff --git a/pype/modules/ftrack/actions/action_rv.py b/pype/modules/ftrack/actions/action_rv.py index eeb5672047..1c5ccfaed0 100644 --- a/pype/modules/ftrack/actions/action_rv.py +++ b/pype/modules/ftrack/actions/action_rv.py @@ -19,13 +19,8 @@ class RVAction(BaseAction): allowed_types = ["img", "mov", "exr", "mp4"] - def __init__(self, session, plugins_presets): - """ Constructor - - :param session: ftrack Session - :type session: :class:`ftrack_api.Session` - """ - super().__init__(session, plugins_presets) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) # QUESTION load RV application data from AppplicationManager? rv_path = None @@ -317,7 +312,7 @@ class RVAction(BaseAction): return paths -def register(session, plugins_presets={}): +def register(session): """Register hooks.""" - RVAction(session, plugins_presets).register() + RVAction(session).register() diff --git a/pype/modules/ftrack/actions/action_seed.py b/pype/modules/ftrack/actions/action_seed.py index d6288a03aa..549afc660c 100644 --- a/pype/modules/ftrack/actions/action_seed.py +++ b/pype/modules/ftrack/actions/action_seed.py @@ -15,7 +15,6 @@ class SeedDebugProject(BaseAction): #: priority priority = 100 #: roles that are allowed to register this action - role_list = ["Pypeclub"] icon = statics_icon("ftrack", "action_icons", "SeedProject.svg") # Asset names which will be created in `Assets` entity @@ -58,9 +57,12 @@ class SeedDebugProject(BaseAction): existing_projects = None new_project_item = "< New Project >" current_project_item = "< Current Project >" + settings_key = "seed_project" def discover(self, session, entities, event): ''' Validation ''' + if not self.valid_roles(session, entities, event): + return False return True def interface(self, session, entities, event): @@ -428,7 +430,7 @@ class SeedDebugProject(BaseAction): return True -def register(session, plugins_presets={}): +def register(session): '''Register plugin. Called when used as an plugin.''' - SeedDebugProject(session, plugins_presets).register() + SeedDebugProject(session).register() diff --git a/pype/modules/ftrack/actions/action_store_thumbnails_to_avalon.py b/pype/modules/ftrack/actions/action_store_thumbnails_to_avalon.py index e6f98d23cd..84f857e37a 100644 --- a/pype/modules/ftrack/actions/action_store_thumbnails_to_avalon.py +++ b/pype/modules/ftrack/actions/action_store_thumbnails_to_avalon.py @@ -21,8 +21,8 @@ class StoreThumbnailsToAvalon(BaseAction): # Action description description = 'Test action' # roles that are allowed to register this action - role_list = ["Pypeclub", "Administrator", "Project Manager"] icon = statics_icon("ftrack", "action_icons", "PypeAdmin.svg") + settings_key = "store_thubmnail_to_avalon" thumbnail_key = "AVALON_THUMBNAIL_ROOT" @@ -31,10 +31,15 @@ class StoreThumbnailsToAvalon(BaseAction): super(StoreThumbnailsToAvalon, self).__init__(*args, **kwargs) def discover(self, session, entities, event): + is_valid = False for entity in entities: if entity.entity_type.lower() == "assetversion": - return True - return False + is_valid = True + break + + if is_valid: + is_valid = self.valid_roles(session, entities, event) + return is_valid def launch(self, session, entities, event): user = session.query( @@ -457,5 +462,5 @@ class StoreThumbnailsToAvalon(BaseAction): return output -def register(session, plugins_presets={}): - StoreThumbnailsToAvalon(session, plugins_presets).register() +def register(session): + StoreThumbnailsToAvalon(session).register() diff --git a/pype/modules/ftrack/actions/action_sync_to_avalon.py b/pype/modules/ftrack/actions/action_sync_to_avalon.py index dfe1f2c464..b86b469d1c 100644 --- a/pype/modules/ftrack/actions/action_sync_to_avalon.py +++ b/pype/modules/ftrack/actions/action_sync_to_avalon.py @@ -41,20 +41,26 @@ class SyncToAvalonLocal(BaseAction): #: priority priority = 200 #: roles that are allowed to register this action - role_list = ["Pypeclub"] icon = statics_icon("ftrack", "action_icons", "PypeAdmin.svg") + settings_key = "sync_to_avalon_local" + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.entities_factory = SyncEntitiesFactory(self.log, self.session) def discover(self, session, entities, event): - ''' Validation ''' + """ Validate selection. """ + is_valid = False for ent in event["data"]["selection"]: # Ignore entities that are not tasks or projects if ent["entityType"].lower() in ["show", "task"]: - return True - return False + is_valid = True + break + + if is_valid: + is_valid = self.valid_roles(session, entities, event) + return is_valid def launch(self, session, in_entities, event): time_start = time.time() @@ -187,7 +193,7 @@ class SyncToAvalonLocal(BaseAction): pass -def register(session, plugins_presets={}): +def register(session): '''Register plugin. Called when used as an plugin.''' - SyncToAvalonLocal(session, plugins_presets).register() + SyncToAvalonLocal(session).register() diff --git a/pype/modules/ftrack/actions/action_test.py b/pype/modules/ftrack/actions/action_test.py index e4936274b3..c12906e340 100644 --- a/pype/modules/ftrack/actions/action_test.py +++ b/pype/modules/ftrack/actions/action_test.py @@ -22,5 +22,5 @@ class TestAction(BaseAction): return True -def register(session, plugins_presets={}): - TestAction(session, plugins_presets).register() +def register(session): + TestAction(session).register() diff --git a/pype/modules/ftrack/actions/action_thumbnail_to_childern.py b/pype/modules/ftrack/actions/action_thumbnail_to_childern.py index 3c6af10b43..b90dfa027c 100644 --- a/pype/modules/ftrack/actions/action_thumbnail_to_childern.py +++ b/pype/modules/ftrack/actions/action_thumbnail_to_childern.py @@ -15,11 +15,9 @@ class ThumbToChildren(BaseAction): icon = statics_icon("ftrack", "action_icons", "Thumbnail.svg") def discover(self, session, entities, event): - ''' Validation ''' - - if (len(entities) != 1 or entities[0].entity_type in ['Project']): + """Show only on project.""" + if (len(entities) != 1 or entities[0].entity_type in ["Project"]): return False - return True def launch(self, session, entities, event): @@ -59,7 +57,7 @@ class ThumbToChildren(BaseAction): } -def register(session, plugins_presets={}): +def register(session): '''Register action. Called when used as an event plugin.''' - ThumbToChildren(session, plugins_presets).register() + ThumbToChildren(session).register() diff --git a/pype/modules/ftrack/actions/action_thumbnail_to_parent.py b/pype/modules/ftrack/actions/action_thumbnail_to_parent.py index fb473f9aa5..5734ea6abc 100644 --- a/pype/modules/ftrack/actions/action_thumbnail_to_parent.py +++ b/pype/modules/ftrack/actions/action_thumbnail_to_parent.py @@ -85,7 +85,7 @@ class ThumbToParent(BaseAction): } -def register(session, plugins_presets={}): +def register(session): '''Register action. Called when used as an event plugin.''' - ThumbToParent(session, plugins_presets).register() + ThumbToParent(session).register() diff --git a/pype/modules/ftrack/actions/action_where_run_ask.py b/pype/modules/ftrack/actions/action_where_run_ask.py index 42640fb506..64957208da 100644 --- a/pype/modules/ftrack/actions/action_where_run_ask.py +++ b/pype/modules/ftrack/actions/action_where_run_ask.py @@ -27,7 +27,7 @@ class ActionAskWhereIRun(BaseAction): return True -def register(session, plugins_presets={}): +def register(session): '''Register plugin. Called when used as an plugin.''' - ActionAskWhereIRun(session, plugins_presets).register() + ActionAskWhereIRun(session).register() diff --git a/pype/modules/ftrack/actions/action_where_run_show.py b/pype/modules/ftrack/actions/action_where_run_show.py index a084547a45..f872d17d27 100644 --- a/pype/modules/ftrack/actions/action_where_run_show.py +++ b/pype/modules/ftrack/actions/action_where_run_show.py @@ -76,7 +76,7 @@ class ActionShowWhereIRun(BaseAction): return True -def register(session, plugins_presets={}): +def register(session): '''Register plugin. Called when used as an plugin.''' - ActionShowWhereIRun(session, plugins_presets).register() + ActionShowWhereIRun(session).register() diff --git a/pype/modules/ftrack/events/action_push_frame_values_to_task.py b/pype/modules/ftrack/events/action_push_frame_values_to_task.py index de61728a62..87d9d5afe9 100644 --- a/pype/modules/ftrack/events/action_push_frame_values_to_task.py +++ b/pype/modules/ftrack/events/action_push_frame_values_to_task.py @@ -430,5 +430,5 @@ class PushHierValuesToNonHier(ServerAction): session.commit() -def register(session, plugins_presets={}): - PushHierValuesToNonHier(session, plugins_presets).register() +def register(session): + PushHierValuesToNonHier(session).register() diff --git a/pype/modules/ftrack/events/action_sync_to_avalon.py b/pype/modules/ftrack/events/action_sync_to_avalon.py index 80b5939d84..486b977f04 100644 --- a/pype/modules/ftrack/events/action_sync_to_avalon.py +++ b/pype/modules/ftrack/events/action_sync_to_avalon.py @@ -182,6 +182,6 @@ class SyncToAvalonServer(ServerAction): pass -def register(session, plugins_presets={}): +def register(session): '''Register plugin. Called when used as an plugin.''' - SyncToAvalonServer(session, plugins_presets).register() + SyncToAvalonServer(session).register() diff --git a/pype/modules/ftrack/events/event_del_avalon_id_from_new.py b/pype/modules/ftrack/events/event_del_avalon_id_from_new.py index ee82c9589d..21e581e76a 100644 --- a/pype/modules/ftrack/events/event_del_avalon_id_from_new.py +++ b/pype/modules/ftrack/events/event_del_avalon_id_from_new.py @@ -47,6 +47,6 @@ class DelAvalonIdFromNew(BaseEvent): continue -def register(session, plugins_presets): +def register(session): '''Register plugin. Called when used as an plugin.''' - DelAvalonIdFromNew(session, plugins_presets).register() + DelAvalonIdFromNew(session).register() diff --git a/pype/modules/ftrack/events/event_first_version_status.py b/pype/modules/ftrack/events/event_first_version_status.py index 8754d092ab..cfca047c09 100644 --- a/pype/modules/ftrack/events/event_first_version_status.py +++ b/pype/modules/ftrack/events/event_first_version_status.py @@ -182,7 +182,7 @@ class FirstVersionStatus(BaseEvent): return filtered_ents -def register(session, plugins_presets): +def register(session): '''Register plugin. Called when used as an plugin.''' - FirstVersionStatus(session, plugins_presets).register() + FirstVersionStatus(session).register() diff --git a/pype/modules/ftrack/events/event_next_task_update.py b/pype/modules/ftrack/events/event_next_task_update.py index deb789f981..025bac0d07 100644 --- a/pype/modules/ftrack/events/event_next_task_update.py +++ b/pype/modules/ftrack/events/event_next_task_update.py @@ -225,5 +225,5 @@ class NextTaskUpdate(BaseEvent): ) -def register(session, plugins_presets): - NextTaskUpdate(session, plugins_presets).register() +def register(session): + NextTaskUpdate(session).register() diff --git a/pype/modules/ftrack/events/event_push_frame_values_to_task.py b/pype/modules/ftrack/events/event_push_frame_values_to_task.py index 00457c8bfc..061002c13f 100644 --- a/pype/modules/ftrack/events/event_push_frame_values_to_task.py +++ b/pype/modules/ftrack/events/event_push_frame_values_to_task.py @@ -364,5 +364,5 @@ class PushFrameValuesToTaskEvent(BaseEvent): return output, hiearchical -def register(session, plugins_presets): - PushFrameValuesToTaskEvent(session, plugins_presets).register() +def register(session): + PushFrameValuesToTaskEvent(session).register() diff --git a/pype/modules/ftrack/events/event_radio_buttons.py b/pype/modules/ftrack/events/event_radio_buttons.py index b2ab4e75ec..90811e5f45 100644 --- a/pype/modules/ftrack/events/event_radio_buttons.py +++ b/pype/modules/ftrack/events/event_radio_buttons.py @@ -34,7 +34,7 @@ class RadioButtons(BaseEvent): session.commit() -def register(session, plugins_presets): +def register(session): '''Register plugin. Called when used as an plugin.''' - RadioButtons(session, plugins_presets).register() + RadioButtons(session).register() diff --git a/pype/modules/ftrack/events/event_sync_to_avalon.py b/pype/modules/ftrack/events/event_sync_to_avalon.py index 2a69a559bd..0209dfd53a 100644 --- a/pype/modules/ftrack/events/event_sync_to_avalon.py +++ b/pype/modules/ftrack/events/event_sync_to_avalon.py @@ -53,7 +53,7 @@ class SyncToAvalonEvent(BaseEvent): created_entities = [] report_splitter = {"type": "label", "value": "---"} - def __init__(self, session, plugins_presets={}): + def __init__(self, session): '''Expects a ftrack_api.Session instance''' # Debug settings # - time expiration in seconds @@ -67,7 +67,7 @@ class SyncToAvalonEvent(BaseEvent): self.dbcon = AvalonMongoDB() # Set processing session to not use global self.set_process_session(session) - super().__init__(session, plugins_presets) + super().__init__(session) def debug_logs(self): """This is debug method for printing small debugs messages. """ @@ -2513,6 +2513,6 @@ class SyncToAvalonEvent(BaseEvent): return mongo_id_configuration_id -def register(session, plugins_presets): +def register(session): '''Register plugin. Called when used as an plugin.''' - SyncToAvalonEvent(session, plugins_presets).register() + SyncToAvalonEvent(session).register() diff --git a/pype/modules/ftrack/events/event_task_to_parent_status.py b/pype/modules/ftrack/events/event_task_to_parent_status.py index f14c52e3a6..9b1f61911e 100644 --- a/pype/modules/ftrack/events/event_task_to_parent_status.py +++ b/pype/modules/ftrack/events/event_task_to_parent_status.py @@ -3,293 +3,264 @@ from pype.modules.ftrack import BaseEvent class TaskStatusToParent(BaseEvent): - # Parent types where we care about changing of status - parent_types = ["shot", "asset build"] + settings_key = "status_task_to_parent" - # All parent's tasks must have status name in `task_statuses` key to apply - # status name in `new_status` - parent_status_match_all_task_statuses = [ - { - "new_status": "approved", - "task_statuses": [ - "approved", "omitted" - ] - } - ] + def launch(self, session, event): + """Propagates status from task to parent when changed.""" - # Task's status was changed to something in `task_statuses` to apply - # `new_status` on it's parent - # - this is done only if `parent_status_match_all_task_statuses` filtering - # didn't found matching status - parent_status_by_task_status = [ - { - "new_status": "in progress", - "task_statuses": [ - "in progress" - ] - } - ] + filtered_entities_info = self.filter_entities_info(event) + if not filtered_entities_info: + return - def register(self, *args, **kwargs): - result = super(TaskStatusToParent, self).register(*args, **kwargs) - # Clean up presetable attributes - _new_all_match = [] - if self.parent_status_match_all_task_statuses: - for item in self.parent_status_match_all_task_statuses: - _new_all_match.append({ - "new_status": item["new_status"].lower(), - "task_statuses": [ - status_name.lower() - for status_name in item["task_statuses"] - ] - }) - self.parent_status_match_all_task_statuses = _new_all_match + for project_id, entities_info in filtered_entities_info.items(): + self.process_by_project(session, event, project_id, entities_info) - _new_single_match = [] - if self.parent_status_by_task_status: - for item in self.parent_status_by_task_status: - _new_single_match.append({ - "new_status": item["new_status"].lower(), - "task_statuses": [ - status_name.lower() - for status_name in item["task_statuses"] - ] - }) - self.parent_status_by_task_status = _new_single_match - - self.parent_types = [ - parent_type.lower() - for parent_type in self.parent_types - ] - - return result - - def filter_entities_info(self, session, event): + def filter_entities_info(self, event): # Filter if event contain relevant data entities_info = event["data"].get("entities") if not entities_info: return - filtered_entities = [] + filtered_entity_info = collections.defaultdict(list) + status_ids = set() for entity_info in entities_info: # Care only about tasks if entity_info.get("entityType") != "task": continue # Care only about changes of status - changes = entity_info.get("changes") or {} - statusid_changes = changes.get("statusid") or {} + changes = entity_info.get("changes") + if not changes: + continue + statusid_changes = changes.get("statusid") + if not statusid_changes: + continue + + new_status_id = entity_info["changes"]["statusid"]["new"] if ( - statusid_changes.get("new") is None - or statusid_changes.get("old") is None + statusid_changes.get("old") is None + or new_status_id is None ): continue - filtered_entities.append(entity_info) + project_id = None + for parent_item in reversed(entity_info["parents"]): + if parent_item["entityType"] == "show": + project_id = parent_item["entityId"] + break - if not filtered_entities: - return + if project_id: + filtered_entity_info[project_id].append(entity_info) + status_ids.add(new_status_id) - status_ids = [ - entity_info["changes"]["statusid"]["new"] - for entity_info in filtered_entities - ] - statuses_by_id = self.get_statuses_by_id( - session, status_ids=status_ids + return filtered_entity_info + + def process_by_project(self, session, event, project_id, entities_info): + # Get project name + project_name = self.get_project_name_from_event( + session, event, project_id + ) + # Load settings + project_settings = self.get_project_settings_from_event( + event, project_name ) - # Care only about tasks having status with state `Done` - output = [] - for entity_info in filtered_entities: - status_id = entity_info["changes"]["statusid"]["new"] - entity_info["status_entity"] = statuses_by_id[status_id] - output.append(entity_info) - return output + # Prepare loaded settings and check if can be processed + result = self.prepare_settings(project_settings, project_name) + if not result: + return - def get_parents_by_id(self, session, entities_info, object_types): - task_type_id = None - valid_object_type_ids = [] + # Unpack the result + parent_object_types, all_match, single_match = result + + # Prepare valid object type ids for object types from settings + object_types = session.query("select id, name from ObjectType").all() + object_type_id_by_low_name = { + object_type["name"].lower(): object_type["id"] + for object_type in object_types + } + + valid_object_type_ids = set() + for object_type_name in parent_object_types: + if object_type_name in object_type_id_by_low_name: + valid_object_type_ids.add( + object_type_id_by_low_name[object_type_name] + ) + else: + self.log.warning( + "Unknown object type \"{}\" set on project \"{}\".".format( + object_type_name, project_name + ) + ) + + if not valid_object_type_ids: + return + + # Prepare parent ids + parent_ids = set() + for entity_info in entities_info: + parent_id = entity_info["parentId"] + if parent_id: + parent_ids.add(parent_id) + + # Query parent ids by object type ids and parent ids + parent_entities = session.query( + ( + "select id, status_id, object_type_id, link from TypedContext" + " where id in ({}) and object_type_id in ({})" + ).format( + self.join_query_keys(parent_ids), + self.join_query_keys(valid_object_type_ids) + ) + ).all() + # Skip if none of parents match the filtering + if not parent_entities: + return + + obj_ids = set() + for entity in parent_entities: + obj_ids.add(entity["object_type_id"]) + + types_mapping = { + _type.lower(): _type + for _type in session.types + } + # Map object type id by lowered and modified object type name + object_type_name_by_id = {} for object_type in object_types: - object_name_low = object_type["name"].lower() - if object_name_low == "task": - task_type_id = object_type["id"] + mapping_name = object_type["name"].lower().replace(" ", "") + obj_id = object_type["id"] + object_type_name_by_id[obj_id] = types_mapping[mapping_name] - if object_name_low in self.parent_types: - valid_object_type_ids.append(object_type["id"]) + project_entity = session.get("Project", project_id) + project_schema = project_entity["project_schema"] + available_statuses_by_obj_id = {} + for obj_id in obj_ids: + obj_name = object_type_name_by_id[obj_id] + statuses = project_schema.get_statuses(obj_name) + statuses_by_low_name = { + status["name"].lower(): status + for status in statuses + } + valid = False + for name in all_match.keys(): + if name in statuses_by_low_name: + valid = True + break - parent_ids = [ - "\"{}\"".format(entity_info["parentId"]) - for entity_info in entities_info - if entity_info["objectTypeId"] == task_type_id - ] - if not parent_ids: - return {} + if not valid: + for item in single_match: + if item["new_status"] in statuses_by_low_name: + valid = True + break + if valid: + available_statuses_by_obj_id[obj_id] = statuses_by_low_name - parent_entities = session.query(( - "TypedContext where id in ({}) and object_type_id in ({})" - ).format( - ", ".join(parent_ids), ", ".join(valid_object_type_ids)) - ).all() + valid_parent_ids = set() + status_ids = set() + valid_parent_entities = [] + for entity in parent_entities: + if entity["object_type_id"] not in available_statuses_by_obj_id: + continue - return { - entity["id"]: entity - for entity in parent_entities - } + valid_parent_entities.append(entity) + valid_parent_ids.add(entity["id"]) + status_ids.add(entity["status_id"]) + + if not valid_parent_ids: + return - def get_tasks_by_id(self, session, parent_ids): - joined_parent_ids = ",".join([ - "\"{}\"".format(parent_id) - for parent_id in parent_ids - ]) task_entities = session.query( - "Task where parent_id in ({})".format(joined_parent_ids) + ( + "select id, parent_id, status_id from TypedContext" + " where parent_id in ({}) and object_type_id is \"{}\"" + ).format( + self.join_query_keys(valid_parent_ids), + object_type_id_by_low_name["task"] + ) ).all() - return { - entity["id"]: entity - for entity in task_entities - } + # This should not happen but it is safer + if not task_entities: + return - def get_statuses_by_id(self, session, task_entities=None, status_ids=None): - if task_entities is None and status_ids is None: - return {} + task_entities_by_parent_id = collections.defaultdict(list) + for task_entity in task_entities: + status_ids.add(task_entity["status_id"]) + parent_id = task_entity["parent_id"] + task_entities_by_parent_id[parent_id].append(task_entity) - if status_ids is None: - status_ids = [] - for task_entity in task_entities: - status_ids.append(task_entity["status_id"]) + status_entities = session.query(( + "select id, name from Status where id in ({})" + ).format(self.join_query_keys(status_ids))).all() - if not status_ids: - return {} - - status_entities = session.query( - "Status where id in ({})".format(", ".join(status_ids)) - ).all() - - return { + statuses_by_id = { entity["id"]: entity for entity in status_entities } - def launch(self, session, event): - '''Propagates status from version to task when changed''' - - entities_info = self.filter_entities_info(session, event) - if not entities_info: - return - - object_types = session.query("select id, name from ObjectType").all() - parents_by_id = self.get_parents_by_id( - session, entities_info, object_types - ) - if not parents_by_id: - return - tasks_by_id = self.get_tasks_by_id( - session, tuple(parents_by_id.keys()) - ) - - # Just collect them in one variable - entities_by_id = {} - for entity_id, entity in parents_by_id.items(): - entities_by_id[entity_id] = entity - for entity_id, entity in tasks_by_id.items(): - entities_by_id[entity_id] = entity - - # Map task entities by their parents - tasks_by_parent_id = collections.defaultdict(list) - for task_entity in tasks_by_id.values(): - tasks_by_parent_id[task_entity["parent_id"]].append(task_entity) - - # Found status entities for all queried entities - statuses_by_id = self.get_statuses_by_id( - session, - entities_by_id.values() - ) - # New status determination logic new_statuses_by_parent_id = self.new_status_by_all_task_statuses( - parents_by_id.keys(), tasks_by_parent_id, statuses_by_id + task_entities_by_parent_id, statuses_by_id, all_match ) + task_entities_by_id = { + task_entity["id"]: task_entity + for task_entity in task_entities + } # Check if there are remaining any parents that does not have # determined new status yet remainder_tasks_by_parent_id = collections.defaultdict(list) for entity_info in entities_info: + entity_id = entity_info["entityId"] + if entity_id not in task_entities_by_id: + continue parent_id = entity_info["parentId"] if ( # Skip if already has determined new status parent_id in new_statuses_by_parent_id # Skip if parent is not in parent mapping # - if was not found or parent type is not interesting - or parent_id not in parents_by_id + or parent_id not in task_entities_by_parent_id ): continue remainder_tasks_by_parent_id[parent_id].append( - entities_by_id[entity_info["entityId"]] + task_entities_by_id[entity_id] ) # Try to find new status for remained parents new_statuses_by_parent_id.update( self.new_status_by_remainders( remainder_tasks_by_parent_id, - statuses_by_id + statuses_by_id, + single_match ) ) - # Make sure new_status is set to valid value - for parent_id in tuple(new_statuses_by_parent_id.keys()): - new_status_name = new_statuses_by_parent_id[parent_id] - if not new_status_name: - new_statuses_by_parent_id.pop(parent_id) - # If there are not new statuses then just skip if not new_statuses_by_parent_id: return - # Get project schema from any available entity - _entity = None - for _ent in entities_by_id.values(): - _entity = _ent - break - - project_entity = self.get_project_from_entity(_entity) - project_schema = project_entity["project_schema"] - - # Map type names by lowere type names - types_mapping = { - _type.lower(): _type - for _type in session.types + parent_entities_by_id = { + parent_entity["id"]: parent_entity + for parent_entity in valid_parent_entities } - # Map object type id by lowered and modified object type name - object_type_mapping = {} - for object_type in object_types: - mapping_name = object_type["name"].lower().replace(" ", "") - object_type_mapping[object_type["id"]] = mapping_name - - statuses_by_obj_id = {} for parent_id, new_status_name in new_statuses_by_parent_id.items(): if not new_status_name: continue - parent_entity = entities_by_id[parent_id] - obj_id = parent_entity["object_type_id"] - # Find statuses for entity type by object type name - # in project's schema and cache them - if obj_id not in statuses_by_obj_id: - mapping_name = object_type_mapping[obj_id] - mapped_name = types_mapping.get(mapping_name) - statuses = project_schema.get_statuses(mapped_name) - statuses_by_obj_id[obj_id] = { - status["name"].lower(): status - for status in statuses - } - - statuses_by_name = statuses_by_obj_id[obj_id] - new_status = statuses_by_name.get(new_status_name) + parent_entity = parent_entities_by_id[parent_id] ent_path = "/".join( [ent["name"] for ent in parent_entity["link"]] ) + + obj_id = parent_entity["object_type_id"] + statuses_by_low_name = available_statuses_by_obj_id.get(obj_id) + if not statuses_by_low_name: + continue + + new_status = statuses_by_low_name.get(new_status_name) if not new_status: self.log.warning(( "\"{}\" Couldn't change status to \"{}\"." @@ -299,18 +270,18 @@ class TaskStatusToParent(BaseEvent): )) continue - current_status_name = parent_entity["status"]["name"] + current_status = parent_entity["status"] # Do nothing if status is already set - if new_status["name"] == current_status_name: + if new_status["id"] == current_status["id"]: self.log.debug( "\"{}\" Status \"{}\" already set.".format( - ent_path, current_status_name + ent_path, current_status["name"] ) ) continue try: - parent_entity["status"] = new_status + parent_entity["status_id"] = new_status["id"] session.commit() self.log.info( "\"{}\" changed status to \"{}\"".format( @@ -326,8 +297,63 @@ class TaskStatusToParent(BaseEvent): exc_info=True ) + def prepare_settings(self, project_settings, project_name): + event_settings = ( + project_settings["ftrack"]["events"][self.settings_key] + ) + + if not event_settings["enabled"]: + self.log.debug("Project \"{}\" has disabled {}.".format( + project_name, self.__class__.__name__ + )) + return + + _parent_object_types = event_settings["parent_object_types"] + if not _parent_object_types: + self.log.debug(( + "Project \"{}\" does not have set" + " parent object types filtering." + ).format(project_name)) + return + + _all_match = ( + event_settings["parent_status_match_all_task_statuses"] + ) + _single_match = ( + event_settings["parent_status_by_task_status"] + ) + + if not _all_match and not _single_match: + self.log.debug(( + "Project \"{}\" does not have set" + " parent status mappings." + ).format(project_name)) + return + + parent_object_types = [ + item.lower() + for item in _parent_object_types + ] + all_match = {} + for new_status_name, task_statuses in _all_match.items(): + all_match[new_status_name.lower()] = [ + status_name.lower() + for status_name in task_statuses + ] + + single_match = [] + for item in _single_match: + single_match.append({ + "new_status": item["new_status"].lower(), + "task_statuses": [ + status_name.lower() + for status_name in item["task_statuses"] + ] + }) + return parent_object_types, all_match, single_match + def new_status_by_all_task_statuses( - self, parent_ids, tasks_by_parent_id, statuses_by_id + self, tasks_by_parent_id, statuses_by_id, all_match ): """All statuses of parent entity must match specific status names. @@ -335,23 +361,23 @@ class TaskStatusToParent(BaseEvent): determined. """ output = {} - for parent_id in parent_ids: + for parent_id, task_entities in tasks_by_parent_id.items(): task_statuses_lowered = set() - for task_entity in tasks_by_parent_id[parent_id]: + for task_entity in task_entities: task_status = statuses_by_id[task_entity["status_id"]] low_status_name = task_status["name"].lower() task_statuses_lowered.add(low_status_name) new_status = None - for item in self.parent_status_match_all_task_statuses: + for _new_status, task_statuses in all_match.items(): valid_item = True for status_name_low in task_statuses_lowered: - if status_name_low not in item["task_statuses"]: + if status_name_low not in task_statuses: valid_item = False break if valid_item: - new_status = item["new_status"] + new_status = _new_status break if new_status is not None: @@ -360,7 +386,7 @@ class TaskStatusToParent(BaseEvent): return output def new_status_by_remainders( - self, remainder_tasks_by_parent_id, statuses_by_id + self, remainder_tasks_by_parent_id, statuses_by_id, single_match ): """By new task status can be determined new status of parent.""" output = {} @@ -373,15 +399,13 @@ class TaskStatusToParent(BaseEvent): # For cases there are multiple tasks in changes # - task status which match any new status item by order in the - # list `parent_status_by_task_status` is preffered - best_order = len(self.parent_status_by_task_status) + # list `single_match` is preffered + best_order = len(single_match) best_order_status = None for task_entity in task_entities: task_status = statuses_by_id[task_entity["status_id"]] low_status_name = task_status["name"].lower() - for order, item in enumerate( - self.parent_status_by_task_status - ): + for order, item in enumerate(single_match): if order >= best_order: break @@ -395,5 +419,5 @@ class TaskStatusToParent(BaseEvent): return output -def register(session, plugins_presets): - TaskStatusToParent(session, plugins_presets).register() +def register(session): + TaskStatusToParent(session).register() diff --git a/pype/modules/ftrack/events/event_task_to_version_status.py b/pype/modules/ftrack/events/event_task_to_version_status.py index e07be67b18..d27a7f9e98 100644 --- a/pype/modules/ftrack/events/event_task_to_version_status.py +++ b/pype/modules/ftrack/events/event_task_to_version_status.py @@ -5,34 +5,18 @@ from pype.modules.ftrack import BaseEvent class TaskToVersionStatus(BaseEvent): """Changes status of task's latest AssetVersions on its status change.""" + settings_key = "status_task_to_version" + # Attribute for caching session user id _cached_user_id = None - # Presets usage - asset_types_of_focus = [] - - def register(self, *args, **kwargs): - # Skip registration if attribute `asset_types_of_focus` is not set - modified_asset_types_of_focus = list() - if self.asset_types_of_focus: - if isinstance(self.asset_types_of_focus, str): - self.asset_types_of_focus = [self.asset_types_of_focus] - - for asset_type_name in self.asset_types_of_focus: - modified_asset_types_of_focus.append( - asset_type_name.lower() - ) - - if not modified_asset_types_of_focus: - raise Exception(( - "Event handler \"{}\" does not" - " have set presets for attribute \"{}\"" - ).format(self.__class__.__name__, "asset_types_of_focus")) - - self.asset_types_of_focus = modified_asset_types_of_focus - return super(TaskToVersionStatus, self).register(*args, **kwargs) - def is_event_invalid(self, session, event): + """Skip task status changes for session user changes. + + It is expected that there may be another event handler that set + version status to task in that case skip all events caused by same + user as session has to avoid infinite loop of status changes. + """ # Cache user id of currently running session if self._cached_user_id is None: session_user_entity = session.query( @@ -58,15 +42,19 @@ class TaskToVersionStatus(BaseEvent): return user_id == self._cached_user_id def filter_event_entities(self, event): - # Filter if event contain relevant data + """Filter if event contain relevant data. + + Event cares only about changes of `statusid` on `entity_type` "Task". + """ + entities_info = event["data"].get("entities") if not entities_info: return - filtered_entities = [] + filtered_entity_info = collections.defaultdict(list) for entity_info in entities_info: # Care only about tasks - if entity_info.get("entityType") != "task": + if entity_info.get("entity_type") != "Task": continue # Care only about changes of status @@ -78,9 +66,17 @@ class TaskToVersionStatus(BaseEvent): ): continue - filtered_entities.append(entity_info) + # Get project id from entity info + project_id = None + for parent_item in reversed(entity_info["parents"]): + if parent_item["entityType"] == "show": + project_id = parent_item["entityId"] + break - return filtered_entities + if project_id: + filtered_entity_info[project_id].append(entity_info) + + return filtered_entity_info def _get_ent_path(self, entity): return "/".join( @@ -96,127 +92,286 @@ class TaskToVersionStatus(BaseEvent): if not filtered_entity_infos: return - task_ids = [ - entity_info["entityId"] - for entity_info in filtered_entity_infos - ] - joined_ids = ",".join( - ["\"{}\"".format(entity_id) for entity_id in task_ids] - ) + for project_id, entities_info in filtered_entity_infos.items(): + self.process_by_project(session, event, project_id, entities_info) - # Query tasks' AssetVersions - asset_versions = session.query(( - "AssetVersion where task_id in ({}) order by version descending" - ).format(joined_ids)).all() - - last_asset_version_by_task_id = ( - self.last_asset_version_by_task_id(asset_versions, task_ids) - ) - if not last_asset_version_by_task_id: + def process_by_project(self, session, event, project_id, entities_info): + if not entities_info: return + project_name = self.get_project_name_from_event( + session, event, project_id + ) + # Load settings + project_settings = self.get_project_settings_from_event( + event, project_name + ) + + event_settings = ( + project_settings["ftrack"]["events"][self.settings_key] + ) + _status_mapping = event_settings["mapping"] + if not event_settings["enabled"]: + self.log.debug("Project \"{}\" has disabled {}.".format( + project_name, self.__class__.__name__ + )) + return + + if not _status_mapping: + self.log.debug(( + "Project \"{}\" does not have set status mapping for {}." + ).format(project_name, self.__class__.__name__)) + return + + status_mapping = { + key.lower(): value + for key, value in _status_mapping.items() + } + + asset_types_filter = event_settings["asset_types_filter"] + + task_ids = [ + entity_info["entityId"] + for entity_info in entities_info + ] + + last_asset_versions_by_task_id = ( + self.find_last_asset_versions_for_task_ids( + session, task_ids, asset_types_filter + ) + ) + # Query Task entities for last asset versions - joined_filtered_ids = ",".join([ - "\"{}\"".format(entity_id) - for entity_id in last_asset_version_by_task_id.keys() - ]) + joined_filtered_ids = self.join_query_keys( + last_asset_versions_by_task_id.keys() + ) + if not joined_filtered_ids: + return + task_entities = session.query( - "Task where id in ({})".format(joined_filtered_ids) + "select status_id, link from Task where id in ({})".format( + joined_filtered_ids + ) ).all() if not task_entities: return + status_ids = set() + for task_entity in task_entities: + status_ids.add(task_entity["status_id"]) + + task_status_entities = session.query( + "select id, name from Status where id in ({})".format( + self.join_query_keys(status_ids) + ) + ).all() + task_status_name_by_id = { + status_entity["id"]: status_entity["name"] + for status_entity in task_status_entities + } + # Final process of changing statuses - av_statuses_by_low_name = self.asset_version_statuses(task_entities[0]) + project_entity = session.get("Project", project_id) + av_statuses_by_low_name, av_statuses_by_id = ( + self.get_asset_version_statuses(project_entity) + ) + + asset_ids = set() + for asset_versions in last_asset_versions_by_task_id.values(): + for asset_version in asset_versions: + asset_ids.add(asset_version["asset_id"]) + + asset_entities = session.query( + "select name from Asset where id in ({})".format( + self.join_query_keys(asset_ids) + ) + ).all() + asset_names_by_id = { + asset_entity["id"]: asset_entity["name"] + for asset_entity in asset_entities + } for task_entity in task_entities: task_id = task_entity["id"] + status_id = task_entity["status_id"] task_path = self._get_ent_path(task_entity) - task_status_name = task_entity["status"]["name"] + + task_status_name = task_status_name_by_id[status_id] task_status_name_low = task_status_name.lower() - last_asset_versions = last_asset_version_by_task_id[task_id] - for last_asset_version in last_asset_versions: - self.log.debug(( - "Trying to change status of last AssetVersion {}" - " for task \"{}\"" - ).format(last_asset_version["version"], task_path)) + new_asset_version_status = None + mapped_status_names = status_mapping.get(task_status_name_low) + if mapped_status_names: + for status_name in mapped_status_names: + _status = av_statuses_by_low_name.get(status_name.lower()) + if _status: + new_asset_version_status = _status + break + if not new_asset_version_status: new_asset_version_status = av_statuses_by_low_name.get( task_status_name_low ) - # Skip if tasks status is not available to AssetVersion - if not new_asset_version_status: - self.log.debug(( - "AssetVersion does not have matching status to \"{}\"" - ).format(task_status_name)) - continue + # Skip if tasks status is not available to AssetVersion + if not new_asset_version_status: + self.log.debug(( + "AssetVersion does not have matching status to \"{}\"" + ).format(task_status_name)) + continue + last_asset_versions = last_asset_versions_by_task_id[task_id] + for asset_version in last_asset_versions: + version = asset_version["version"] + self.log.debug(( + "Trying to change status of last AssetVersion {}" + " for task \"{}\"" + ).format(version, task_path)) + + asset_id = asset_version["asset_id"] + asset_type_name = asset_names_by_id[asset_id] av_ent_path = task_path + " Asset {} AssetVersion {}".format( - last_asset_version["asset"]["name"], - last_asset_version["version"] + asset_type_name, + version ) # Skip if current AssetVersion's status is same - current_status_name = last_asset_version["status"]["name"] + status_id = asset_version["status_id"] + current_status_name = av_statuses_by_id[status_id]["name"] if current_status_name.lower() == task_status_name_low: self.log.debug(( "AssetVersion already has set status \"{}\". \"{}\"" ).format(current_status_name, av_ent_path)) continue + new_status_id = new_asset_version_status["id"] + new_status_name = new_asset_version_status["name"] + # Skip if status is already same + if asset_version["status_id"] == new_status_id: + continue + # Change the status try: - last_asset_version["status"] = new_asset_version_status + asset_version["status_id"] = new_status_id session.commit() self.log.info("[ {} ] Status updated to [ {} ]".format( - av_ent_path, new_asset_version_status["name"] + av_ent_path, new_status_name )) except Exception: session.rollback() self.log.warning( "[ {} ]Status couldn't be set to \"{}\"".format( - av_ent_path, new_asset_version_status["name"] + av_ent_path, new_status_name ), exc_info=True ) - def asset_version_statuses(self, entity): - project_entity = self.get_project_from_entity(entity) + def get_asset_version_statuses(self, project_entity): + """Status entities for AssetVersion from project's schema. + + Load statuses from project's schema and store them by id and name. + + Args: + project_entity (ftrack_api.Entity): Entity of ftrack's project. + + Returns: + tuple: 2 items are returned first are statuses by name + second are statuses by id. + """ project_schema = project_entity["project_schema"] # Get all available statuses for Task statuses = project_schema.get_statuses("AssetVersion") # map lowered status name with it's object - av_statuses_by_low_name = { - status["name"].lower(): status for status in statuses - } - return av_statuses_by_low_name + av_statuses_by_low_name = {} + av_statuses_by_id = {} + for status in statuses: + av_statuses_by_low_name[status["name"].lower()] = status + av_statuses_by_id[status["id"]] = status - def last_asset_version_by_task_id(self, asset_versions, task_ids): - last_asset_version_by_task_id = collections.defaultdict(list) - last_version_by_task_id = {} - poping_entity_ids = set(task_ids) - for asset_version in asset_versions: - asset_type_name_low = ( - asset_version["asset"]["type"]["name"].lower() + return av_statuses_by_low_name, av_statuses_by_id + + def find_last_asset_versions_for_task_ids( + self, session, task_ids, asset_types_filter + ): + """Find latest AssetVersion entities for task. + + Find first latest AssetVersion for task and all AssetVersions with + same version for the task. + + Args: + asset_versions (list): AssetVersion entities sorted by "version". + task_ids (list): Task ids. + asset_types_filter (list): Asset types short names that will be + used to filter AssetVersions. Filtering is skipped if entered + value is empty list. + """ + + # Allow event only on specific asset type names + asset_query_part = "" + if asset_types_filter: + # Query all AssetTypes + asset_types = session.query( + "select id, short from AssetType" + ).all() + # Store AssetTypes by id + asset_type_short_by_id = { + asset_type["id"]: asset_type["short"] + for asset_type in asset_types + } + + # Lower asset types from settings + # WARNING: not sure if is good idea to lower names as Ftrack may + # contain asset type with name "Scene" and "scene"! + asset_types_filter_low = set( + asset_types_name.lower() + for asset_types_name in asset_types_filter ) - if asset_type_name_low not in self.asset_types_of_focus: + asset_type_ids = [] + for type_id, short in asset_type_short_by_id.items(): + # TODO log if asset type name is not found + if short.lower() in asset_types_filter_low: + asset_type_ids.append(type_id) + + # TODO log that none of asset type names were found in ftrack + if asset_type_ids: + asset_query_part = " and asset.type_id in ({})".format( + self.join_query_keys(asset_type_ids) + ) + + # Query tasks' AssetVersions + asset_versions = session.query(( + "select status_id, version, task_id, asset_id" + " from AssetVersion where task_id in ({}){}" + " order by version descending" + ).format(self.join_query_keys(task_ids), asset_query_part)).all() + + last_asset_versions_by_task_id = collections.defaultdict(list) + last_version_by_task_id = {} + not_finished_task_ids = set(task_ids) + for asset_version in asset_versions: + task_id = asset_version["task_id"] + # Check if task id is still in `not_finished_task_ids` + if task_id not in not_finished_task_ids: continue - task_id = asset_version["task_id"] + version = asset_version["version"] + + # Find last version in `last_version_by_task_id` last_version = last_version_by_task_id.get(task_id) if last_version is None: - last_version_by_task_id[task_id] = asset_version["version"] + # If task id does not have version set yet then it's first + # AssetVersion for this task + last_version_by_task_id[task_id] = version - elif last_version != asset_version["version"]: - poping_entity_ids.remove(task_id) + elif last_version > version: + # Skip processing if version is lower than last version + # and pop task id from `not_finished_task_ids` + not_finished_task_ids.remove(task_id) + continue - if not poping_entity_ids: - break + # Add AssetVersion entity to output dictionary + last_asset_versions_by_task_id[task_id].append(asset_version) - if task_id in poping_entity_ids: - last_asset_version_by_task_id[task_id].append(asset_version) - return last_asset_version_by_task_id + return last_asset_versions_by_task_id -def register(session, plugins_presets): - TaskToVersionStatus(session, plugins_presets).register() +def register(session): + TaskToVersionStatus(session).register() diff --git a/pype/modules/ftrack/events/event_test.py b/pype/modules/ftrack/events/event_test.py index 0a86bd1754..c07f8b8d16 100644 --- a/pype/modules/ftrack/events/event_test.py +++ b/pype/modules/ftrack/events/event_test.py @@ -1,7 +1,3 @@ -import os -import sys -import re -import ftrack_api from pype.modules.ftrack import BaseEvent @@ -20,7 +16,7 @@ class TestEvent(BaseEvent): return True -def register(session, plugins_presets): +def register(session): '''Register plugin. Called when used as an plugin.''' - TestEvent(session, plugins_presets).register() + TestEvent(session).register() diff --git a/pype/modules/ftrack/events/event_thumbnail_updates.py b/pype/modules/ftrack/events/event_thumbnail_updates.py index 9d816a79e5..b71322c894 100644 --- a/pype/modules/ftrack/events/event_thumbnail_updates.py +++ b/pype/modules/ftrack/events/event_thumbnail_updates.py @@ -5,12 +5,6 @@ from pype.modules.ftrack import BaseEvent class ThumbnailEvents(BaseEvent): settings_key = "thumbnail_updates" - # TODO remove `join_query_keys` as it should be in `BaseHandler` - @staticmethod - def join_query_keys(keys): - """Helper to join keys to query.""" - return ",".join(["\"{}\"".format(key) for key in keys]) - def launch(self, session, event): """Updates thumbnails of entities from new AssetVersion.""" filtered_entities = self.filter_entities(event) @@ -25,14 +19,14 @@ class ThumbnailEvents(BaseEvent): def process_project_entities( self, session, event, project_id, entities_info ): - project_entity = self.get_project_entity_from_event( + project_name = self.get_project_name_from_event( session, event, project_id ) - project_settings = self.get_settings_for_project( - session, event, project_entity=project_entity + # Load settings + project_settings = self.get_project_settings_from_event( + event, project_name ) - project_name = project_entity["full_name"] event_settings = ( project_settings ["ftrack"] @@ -157,5 +151,5 @@ class ThumbnailEvents(BaseEvent): return filtered_entities_info -def register(session, plugins_presets): - ThumbnailEvents(session, plugins_presets).register() +def register(session): + ThumbnailEvents(session).register() diff --git a/pype/modules/ftrack/events/event_user_assigment.py b/pype/modules/ftrack/events/event_user_assigment.py index 9b0dfe84d1..59880fabe5 100644 --- a/pype/modules/ftrack/events/event_user_assigment.py +++ b/pype/modules/ftrack/events/event_user_assigment.py @@ -250,9 +250,9 @@ class UserAssigmentEvent(BaseEvent): return True -def register(session, plugins_presets): +def register(session): """ Register plugin. Called when used as an plugin. """ - UserAssigmentEvent(session, plugins_presets).register() + UserAssigmentEvent(session).register() diff --git a/pype/modules/ftrack/events/event_version_to_task_statuses.py b/pype/modules/ftrack/events/event_version_to_task_statuses.py index ed47d2f8a9..6a15a697e3 100644 --- a/pype/modules/ftrack/events/event_version_to_task_statuses.py +++ b/pype/modules/ftrack/events/event_version_to_task_statuses.py @@ -14,12 +14,6 @@ class VersionToTaskStatus(BaseEvent): for project_id, entities_info in filtered_entities_info.items(): self.process_by_project(session, event, project_id, entities_info) - # TODO remove `join_query_keys` as it should be in `BaseHandler` - @staticmethod - def join_query_keys(keys): - """Helper to join keys to query.""" - return ",".join(["\"{}\"".format(key) for key in keys]) - def filter_entity_info(self, event): filtered_entity_info = {} for entity_info in event["data"].get("entities", []): @@ -54,14 +48,15 @@ class VersionToTaskStatus(BaseEvent): def process_by_project(self, session, event, project_id, entities_info): # Check for project data if event is enabled for event handler status_mapping = None - project_entity = self.get_project_entity_from_event( + + project_name = self.get_project_name_from_event( session, event, project_id ) - project_settings = self.get_settings_for_project( - session, event, project_entity=project_entity + # Load settings + project_settings = self.get_project_settings_from_event( + event, project_name ) - project_name = project_entity["full_name"] # Load status mapping from presets event_settings = ( project_settings["ftrack"]["events"]["status_version_to_task"] @@ -153,7 +148,7 @@ class VersionToTaskStatus(BaseEvent): # Qeury statuses statusese_by_obj_id = self.statuses_for_tasks( - session, task_entities, project_entity + session, task_entities, project_id ) # Prepare status names by their ids status_name_by_id = { @@ -230,11 +225,12 @@ class VersionToTaskStatus(BaseEvent): exc_info=True ) - def statuses_for_tasks(self, session, task_entities, project_entity): + def statuses_for_tasks(self, session, task_entities, project_id): task_type_ids = set() for task_entity in task_entities: task_type_ids.add(task_entity["type_id"]) + project_entity = session.get("Project", project_id) project_schema = project_entity["project_schema"] output = {} for task_type_id in task_type_ids: @@ -247,7 +243,7 @@ class VersionToTaskStatus(BaseEvent): return output -def register(session, plugins_presets): +def register(session): '''Register plugin. Called when used as an plugin.''' - VersionToTaskStatus(session, plugins_presets).register() + VersionToTaskStatus(session).register() diff --git a/pype/modules/ftrack/ftrack_module.py b/pype/modules/ftrack/ftrack_module.py index 44607681ec..d2de27e1b9 100644 --- a/pype/modules/ftrack/ftrack_module.py +++ b/pype/modules/ftrack/ftrack_module.py @@ -3,9 +3,16 @@ from abc import ABCMeta, abstractmethod import six import pype from pype.modules import ( - PypeModule, ITrayModule, IPluginPaths, ITimersManager, IUserModule + PypeModule, + ITrayModule, + IPluginPaths, + ITimersManager, + IUserModule, + ILaunchHookPaths ) +FTRACK_MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) + @six.add_metaclass(ABCMeta) class IFtrackEventHandlerPaths: @@ -19,7 +26,12 @@ class IFtrackEventHandlerPaths: class FtrackModule( - PypeModule, ITrayModule, IPluginPaths, ITimersManager, IUserModule + PypeModule, + ITrayModule, + IPluginPaths, + ITimersManager, + IUserModule, + ILaunchHookPaths ): name = "ftrack" @@ -54,6 +66,10 @@ class FtrackModule( "publish": [os.path.join(pype.PLUGINS_DIR, "ftrack", "publish")] } + def get_launch_hook_paths(self): + """Implementation of `ILaunchHookPaths`.""" + return os.path.join(FTRACK_MODULE_DIR, "launch_hooks") + def connect_with_modules(self, enabled_modules): for module in enabled_modules: if not isinstance(module, IFtrackEventHandlerPaths): diff --git a/pype/modules/ftrack/ftrack_server/ftrack_server.py b/pype/modules/ftrack/ftrack_server/ftrack_server.py index 93c7cd3a67..3e0c752596 100644 --- a/pype/modules/ftrack/ftrack_server/ftrack_server.py +++ b/pype/modules/ftrack/ftrack_server/ftrack_server.py @@ -108,21 +108,10 @@ class FtrackServer: " in registered paths: \"{}\"" ).format("| ".join(paths))) - # TODO replace with settings or get rid of passing the dictionary - plugins_presets = {} - - function_counter = 0 for function_dict in register_functions_dict: register = function_dict["register"] try: - if len(inspect.signature(register).parameters) == 1: - register(self.session) - else: - register(self.session, plugins_presets=plugins_presets) - - if function_counter % 7 == 0: - time.sleep(0.1) - function_counter += 1 + register(self.session) except Exception as exc: msg = '"{}" - register was not successful ({})'.format( function_dict['name'], str(exc) diff --git a/pype/hooks/global/post_ftrack_changes.py b/pype/modules/ftrack/launch_hooks/post_ftrack_changes.py similarity index 100% rename from pype/hooks/global/post_ftrack_changes.py rename to pype/modules/ftrack/launch_hooks/post_ftrack_changes.py diff --git a/pype/modules/ftrack/lib/ftrack_action_handler.py b/pype/modules/ftrack/lib/ftrack_action_handler.py index a550d9e7d3..f42469c675 100644 --- a/pype/modules/ftrack/lib/ftrack_action_handler.py +++ b/pype/modules/ftrack/lib/ftrack_action_handler.py @@ -29,7 +29,9 @@ class BaseAction(BaseHandler): icon = None type = 'Action' - def __init__(self, session, plugins_presets={}): + settings_frack_subkey = "user_handlers" + + def __init__(self, session): '''Expects a ftrack_api.Session instance''' if self.label is None: raise ValueError('Action missing label.') @@ -37,7 +39,7 @@ class BaseAction(BaseHandler): if self.identifier is None: raise ValueError('Action missing identifier.') - super().__init__(session, plugins_presets) + super().__init__(session) def register(self): ''' @@ -67,6 +69,9 @@ class BaseAction(BaseHandler): def _discover(self, event): entities = self._translate_event(event) + if not entities: + return + accepts = self.discover(self.session, entities, event) if not accepts: return @@ -146,21 +151,18 @@ class BaseAction(BaseHandler): def _launch(self, event): entities = self._translate_event(event) + if not entities: + return preactions_launched = self._handle_preactions(self.session, event) if preactions_launched is False: return - interface = self._interface( - self.session, entities, event - ) - + interface = self._interface(self.session, entities, event) if interface: return interface - response = self.launch( - self.session, entities, event - ) + response = self.launch(self.session, entities, event) return self._handle_result(response) @@ -196,50 +198,29 @@ class BaseAction(BaseHandler): return result + @staticmethod + def roles_check(settings_roles, user_roles, default=True): + """Compare roles from setting and user's roles. -class ServerAction(BaseAction): - """Action class meant to be used on event server. + Args: + settings_roles(list): List of role names from settings. + user_roles(list): User's lowered role names. + default(bool): If `settings_roles` is empty list. - Unlike the `BaseAction` roles are not checked on register but on discover. - For the same reason register is modified to not filter topics by username. - """ + Returns: + bool: `True` if user has at least one role from settings or + default if `settings_roles` is empty. + """ + if not settings_roles: + return default - def __init__(self, *args, **kwargs): - if not self.role_list: - self.role_list = set() - else: - self.role_list = set( - role_name.lower() - for role_name in self.role_list - ) - super(ServerAction, self).__init__(*args, **kwargs) - - def _register_role_check(self): - # Skip register role check. - return - - def _discover(self, event): - """Check user discover availability.""" - if not self._check_user_discover(event): - return - return super(ServerAction, self)._discover(event) - - def _check_user_discover(self, event): - """Should be action discovered by user trying to show actions.""" - if not self.role_list: - return True - - user_entity = self._get_user_entity(event) - if not user_entity: - return False - - for role in user_entity["user_security_roles"]: - lowered_role = role["security_role"]["name"].lower() - if lowered_role in self.role_list: + for role_name in settings_roles: + if role_name.lower() in user_roles: return True return False - def _get_user_entity(self, event): + @classmethod + def get_user_entity_from_event(cls, session, event): """Query user entity from event.""" not_set = object() @@ -251,17 +232,90 @@ class ServerAction(BaseAction): user_id = user_info.get("id") username = user_info.get("username") if user_id: - user_entity = self.session.query( + user_entity = session.query( "User where id is {}".format(user_id) ).first() if not user_entity and username: - user_entity = self.session.query( + user_entity = session.query( "User where username is {}".format(username) ).first() event["data"]["user_entity"] = user_entity return user_entity + @classmethod + def get_user_roles_from_event(cls, session, event): + """Query user entity from event.""" + not_set = object() + + user_roles = event["data"].get("user_roles", not_set) + if user_roles is not_set: + user_roles = [] + user_entity = cls.get_user_entity_from_event(session, event) + for role in user_entity["user_security_roles"]: + user_roles.append(role["security_role"]["name"].lower()) + event["data"]["user_roles"] = user_roles + return user_roles + + def get_project_name_from_event(self, session, event, entities): + """Load or query and fill project entity from/to event data. + + Project data are stored by ftrack id because in most cases it is + easier to access project id than project name. + + Args: + session (ftrack_api.Session): Current session. + event (ftrack_api.Event): Processed event by session. + entities (list): Ftrack entities of selection. + """ + + # Try to get project entity from event + project_name = event["data"].get("project_name") + if not project_name: + project_entity = self.get_project_from_entity( + entities[0], session + ) + project_name = project_entity["full_name"] + + event["data"]["project_name"] = project_name + return project_name + + def get_ftrack_settings(self, session, event, entities): + project_name = self.get_project_name_from_event( + session, event, entities + ) + project_settings = self.get_project_settings_from_event( + event, project_name + ) + return project_settings["ftrack"] + + def valid_roles(self, session, entities, event): + """Validate user roles by settings. + + Method requires to have set `settings_key` attribute. + """ + ftrack_settings = self.get_ftrack_settings(session, event, entities) + settings = ( + ftrack_settings[self.settings_frack_subkey][self.settings_key] + ) + if not settings.get("enabled", True): + return False + + user_role_list = self.get_user_roles_from_event(session, event) + if not self.roles_check(settings.get("role_list"), user_role_list): + return False + return True + + +class ServerAction(BaseAction): + """Action class meant to be used on event server. + + Unlike the `BaseAction` roles are not checked on register but on discover. + For the same reason register is modified to not filter topics by username. + """ + + settings_frack_subkey = "events" + def register(self): """Register subcription to Ftrack event hub.""" self.session.event_hub.subscribe( diff --git a/pype/modules/ftrack/lib/ftrack_base_handler.py b/pype/modules/ftrack/lib/ftrack_base_handler.py index 72b6272b76..74c31d1c6f 100644 --- a/pype/modules/ftrack/lib/ftrack_base_handler.py +++ b/pype/modules/ftrack/lib/ftrack_base_handler.py @@ -37,14 +37,13 @@ class BaseHandler(object): type = 'No-type' ignore_me = False preactions = [] - role_list = [] @staticmethod def join_query_keys(keys): """Helper to join keys to query.""" return ",".join(["\"{}\"".format(key) for key in keys]) - def __init__(self, session, plugins_presets=None): + def __init__(self, session): '''Expects a ftrack_api.Session instance''' self.log = Logger().get_logger(self.__class__.__name__) if not( @@ -65,31 +64,19 @@ class BaseHandler(object): # Using decorator self.register = self.register_decorator(self.register) self.launch = self.launch_log(self.launch) - if plugins_presets is None: - plugins_presets = {} - self.plugins_presets = plugins_presets # Decorator def register_decorator(self, func): @functools.wraps(func) def wrapper_register(*args, **kwargs): - - presets_data = self.plugins_presets.get(self.__class__.__name__) - if presets_data: - for key, value in presets_data.items(): - if not hasattr(self, key): - continue - setattr(self, key, value) - if self.ignore_me: return - label = self.__class__.__name__ - if hasattr(self, 'label'): - if self.variant is None: - label = self.label - else: - label = '{} {}'.format(self.label, self.variant) + label = getattr(self, "label", self.__class__.__name__) + variant = getattr(self, "variant", None) + if variant: + label = "{} {}".format(label, variant) + try: self._preregister() @@ -126,12 +113,10 @@ class BaseHandler(object): def launch_log(self, func): @functools.wraps(func) def wrapper_launch(*args, **kwargs): - label = self.__class__.__name__ - if hasattr(self, 'label'): - label = self.label - if hasattr(self, 'variant'): - if self.variant is not None: - label = '{} {}'.format(self.label, self.variant) + label = getattr(self, "label", self.__class__.__name__) + variant = getattr(self, "variant", None) + if variant: + label = "{} {}".format(label, variant) self.log.info(('{} "{}": Launched').format(self.type, label)) try: @@ -156,28 +141,7 @@ class BaseHandler(object): def reset_session(self): self.session.reset() - def _register_role_check(self): - if not self.role_list or not isinstance(self.role_list, (list, tuple)): - return - - user_entity = self.session.query( - "User where username is \"{}\"".format(self.session.api_user) - ).one() - available = False - lowercase_rolelist = [ - role_name.lower() - for role_name in self.role_list - ] - for role in user_entity["user_security_roles"]: - if role["security_role"]["name"].lower() in lowercase_rolelist: - available = True - break - if available is False: - raise MissingPermision - def _preregister(self): - self._register_role_check() - # Custom validations result = self.preregister() if result is None: @@ -564,7 +528,7 @@ class BaseHandler(object): "Publishing event: {}" ).format(str(event.__dict__))) - def get_project_from_entity(self, entity): + def get_project_from_entity(self, entity, session=None): low_entity_type = entity.entity_type.lower() if low_entity_type == "project": return entity @@ -585,72 +549,32 @@ class BaseHandler(object): return parent["project"] project_data = entity["link"][0] - return self.session.query( + + if session is None: + session = self.session + return session.query( "Project where id is {}".format(project_data["id"]) ).one() - def get_project_entity_from_event(self, session, event, project_id): - """Load or query and fill project entity from/to event data. - - Project data are stored by ftrack id because in most cases it is - easier to access project id than project name. - - Args: - session (ftrack_api.Session): Current session. - event (ftrack_api.Event): Processed event by session. - project_id (str): Ftrack project id. - """ - if not project_id: - raise ValueError( - "Entered `project_id` is not valid. {} ({})".format( - str(project_id), str(type(project_id)) - ) - ) - # Try to get project entity from event - project_entities = event["data"].get("project_entities") - if not project_entities: - project_entities = {} - event["data"]["project_entities"] = project_entities - - project_entity = project_entities.get(project_id) - if not project_entity: - # Get project entity from task and store to event - project_entity = session.get("Project", project_id) - event["data"]["project_entities"][project_id] = project_entity - return project_entity - - def get_settings_for_project( - self, session, event, project_id=None, project_entity=None - ): + def get_project_settings_from_event(self, event, project_name): """Load or fill pype's project settings from event data. Project data are stored by ftrack id because in most cases it is easier to access project id than project name. Args: - session (ftrack_api.Session): Current session. event (ftrack_api.Event): Processed event by session. - project_id (str): Ftrack project id. Must be entered if - project_entity is not. - project_entity (ftrack_api.Entity): Project entity. Must be entered - if project_id is not. + project_entity (ftrack_api.Entity): Project entity. """ - if not project_entity: - project_entity = self.get_project_entity_from_event( - session, event, project_id - ) - - project_name = project_entity["full_name"] - project_settings_by_id = event["data"].get("project_settings") if not project_settings_by_id: project_settings_by_id = {} event["data"]["project_settings"] = project_settings_by_id - project_settings = project_settings_by_id.get(project_id) + project_settings = project_settings_by_id.get(project_name) if not project_settings: project_settings = get_project_settings(project_name) - event["data"]["project_settings"][project_id] = project_settings + event["data"]["project_settings"][project_name] = project_settings return project_settings @staticmethod diff --git a/pype/modules/ftrack/lib/ftrack_event_handler.py b/pype/modules/ftrack/lib/ftrack_event_handler.py index 770b942844..af565c5421 100644 --- a/pype/modules/ftrack/lib/ftrack_event_handler.py +++ b/pype/modules/ftrack/lib/ftrack_event_handler.py @@ -15,10 +15,6 @@ class BaseEvent(BaseHandler): type = 'Event' - def __init__(self, session, plugins_presets={}): - '''Expects a ftrack_api.Session instance''' - super().__init__(session, plugins_presets) - # Decorator def launch_log(self, func): @functools.wraps(func) @@ -50,3 +46,34 @@ class BaseEvent(BaseHandler): session, ignore=['socialfeed', 'socialnotification'] ) + + def get_project_name_from_event(self, session, event, project_id): + """Load or query and fill project entity from/to event data. + + Project data are stored by ftrack id because in most cases it is + easier to access project id than project name. + + Args: + session (ftrack_api.Session): Current session. + event (ftrack_api.Event): Processed event by session. + project_id (str): Ftrack project id. + """ + if not project_id: + raise ValueError( + "Entered `project_id` is not valid. {} ({})".format( + str(project_id), str(type(project_id)) + ) + ) + # Try to get project entity from event + project_data = event["data"].get("project_data") + if not project_data: + project_data = {} + event["data"]["project_data"] = project_data + + project_name = project_data.get(project_id) + if not project_name: + # Get project entity from task and store to event + project_entity = session.get("Project", project_id) + project_name = project_entity["full_name"] + event["data"]["project_data"][project_id] = project_name + return project_name diff --git a/pype/modules/launcher_action.py b/pype/modules/launcher_action.py new file mode 100644 index 0000000000..9c2120cf9a --- /dev/null +++ b/pype/modules/launcher_action.py @@ -0,0 +1,44 @@ +from . import PypeModule, ITrayAction + + +class LauncherAction(PypeModule, ITrayAction): + label = "Launcher" + name = "launcher_tool" + + def initialize(self, _modules_settings): + # This module is always enabled + self.enabled = True + + # Tray attributes + self.window = None + + def tray_init(self): + self.create_window() + + def tray_start(self): + return + + def connect_with_modules(self, enabled_modules): + # Register actions + if self.tray_initialized: + from pype.tools.launcher import actions + # actions.register_default_actions() + actions.register_config_actions() + actions_paths = self.manager.collect_plugin_paths()["actions"] + actions.register_actions_from_paths(actions_paths) + actions.register_environment_actions() + + def create_window(self): + if self.window: + return + from pype.tools.launcher import LauncherWindow + self.window = LauncherWindow() + + def on_action_trigger(self): + self.show_launcher() + + def show_launcher(self): + if self.window: + self.window.show() + self.window.raise_() + self.window.activateWindow() diff --git a/pype/modules/log_viewer/__init__.py b/pype/modules/log_viewer/__init__.py new file mode 100644 index 0000000000..672f47c015 --- /dev/null +++ b/pype/modules/log_viewer/__init__.py @@ -0,0 +1,6 @@ +from .log_view_module import LogViewModule + + +__all__ = ( + "LogViewModule", +) diff --git a/pype/modules/logging/logging_module.py b/pype/modules/log_viewer/log_view_module.py similarity index 96% rename from pype/modules/logging/logging_module.py rename to pype/modules/log_viewer/log_view_module.py index 06101b51a5..1252eaf888 100644 --- a/pype/modules/logging/logging_module.py +++ b/pype/modules/log_viewer/log_view_module.py @@ -2,7 +2,7 @@ from pype.api import Logger from .. import PypeModule, ITrayModule -class LoggingModule(PypeModule, ITrayModule): +class LogViewModule(PypeModule, ITrayModule): name = "log_viewer" def initialize(self, modules_settings): diff --git a/pype/modules/logging/tray/__init__.py b/pype/modules/log_viewer/tray/__init__.py similarity index 100% rename from pype/modules/logging/tray/__init__.py rename to pype/modules/log_viewer/tray/__init__.py diff --git a/pype/modules/logging/tray/app.py b/pype/modules/log_viewer/tray/app.py similarity index 100% rename from pype/modules/logging/tray/app.py rename to pype/modules/log_viewer/tray/app.py diff --git a/pype/modules/logging/tray/models.py b/pype/modules/log_viewer/tray/models.py similarity index 100% rename from pype/modules/logging/tray/models.py rename to pype/modules/log_viewer/tray/models.py diff --git a/pype/modules/logging/tray/widgets.py b/pype/modules/log_viewer/tray/widgets.py similarity index 100% rename from pype/modules/logging/tray/widgets.py rename to pype/modules/log_viewer/tray/widgets.py diff --git a/pype/modules/logging/__init__.py b/pype/modules/logging/__init__.py deleted file mode 100644 index c87d8b7f43..0000000000 --- a/pype/modules/logging/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .logging_module import LoggingModule - - -__all__ = ( - "LoggingModule", -) diff --git a/pype/modules/settings_module.py b/pype/modules/settings_action.py similarity index 58% rename from pype/modules/settings_module.py rename to pype/modules/settings_action.py index 0651170148..0d56a6c5ae 100644 --- a/pype/modules/settings_module.py +++ b/pype/modules/settings_action.py @@ -1,11 +1,13 @@ -from . import PypeModule, ITrayModule +from . import PypeModule, ITrayAction -class SettingsModule(PypeModule, ITrayModule): +class SettingsAction(PypeModule, ITrayAction): + """Action to show Setttings tool.""" name = "settings" + label = "Settings" def initialize(self, _modules_settings): - # This module is always enabled + # This action is always enabled self.enabled = True # User role @@ -18,13 +20,28 @@ class SettingsModule(PypeModule, ITrayModule): def connect_with_modules(self, *_a, **_kw): return + def tray_init(self): + """Initialization in tray implementation of ITrayAction.""" + self.create_settings_window() + + def on_action_trigger(self): + """Implementation for action trigger of ITrayAction.""" + self.show_settings_window() + def create_settings_window(self): + """Initializa Settings Qt window.""" if self.settings_window: return from pype.tools.settings import MainWidget self.settings_window = MainWidget(self.user_role) def show_settings_window(self): + """Show settings tool window. + + Raises: + AssertionError: Window must be already created. Call + `create_settings_window` before callint this method. + """ if not self.settings_window: raise AssertionError("Window is not initialized.") @@ -33,21 +50,3 @@ class SettingsModule(PypeModule, ITrayModule): # Pull window to the front. self.settings_window.raise_() self.settings_window.activateWindow() - - def tray_init(self): - self.create_settings_window() - - def tray_menu(self, tray_menu): - """Add **change credentials** option to tray menu.""" - from Qt import QtWidgets - - # Actions - action = QtWidgets.QAction("Settings", tray_menu) - action.triggered.connect(self.show_settings_window) - tray_menu.addAction(action) - - def tray_start(self): - return - - def tray_exit(self): - return diff --git a/pype/modules/standalonepublish/__init__.py b/pype/modules/standalonepublish/__init__.py deleted file mode 100644 index 5c40deb6f0..0000000000 --- a/pype/modules/standalonepublish/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .standalonepublish_module import StandAlonePublishModule - -__all__ = ( - "StandAlonePublishModule", -) diff --git a/pype/modules/standalonepublish/standalonepublish_module.py b/pype/modules/standalonepublish_action.py similarity index 68% rename from pype/modules/standalonepublish/standalonepublish_module.py rename to pype/modules/standalonepublish_action.py index 5b0cfe14bf..4bcb5b6018 100644 --- a/pype/modules/standalonepublish/standalonepublish_module.py +++ b/pype/modules/standalonepublish_action.py @@ -1,15 +1,15 @@ import os import sys import subprocess -import pype -from .. import PypeModule, ITrayModule +from . import PypeModule, ITrayAction -class StandAlonePublishModule(PypeModule, ITrayModule): - menu_label = "Publish" +class StandAlonePublishAction(PypeModule, ITrayAction): + label = "Publish" name = "standalonepublish_tool" def initialize(self, modules_settings): + import pype self.enabled = modules_settings[self.name]["enabled"] self.publish_paths = [ os.path.join( @@ -20,17 +20,8 @@ class StandAlonePublishModule(PypeModule, ITrayModule): def tray_init(self): return - def tray_start(self): - return - - def tray_exit(self): - return - - def tray_menu(self, parent_menu): - from Qt import QtWidgets - run_action = QtWidgets.QAction(self.menu_label, parent_menu) - run_action.triggered.connect(self.run_standalone_publisher) - parent_menu.addAction(run_action) + def on_action_trigger(self): + self.run_standalone_publisher() def connect_with_modules(self, enabled_modules): """Collect publish paths from other modules.""" diff --git a/pype/plugins/aftereffects/publish/submit_aftereffects_deadline.py b/pype/plugins/aftereffects/publish/submit_aftereffects_deadline.py index 5e5c00dec1..fcb97e1281 100644 --- a/pype/plugins/aftereffects/publish/submit_aftereffects_deadline.py +++ b/pype/plugins/aftereffects/publish/submit_aftereffects_deadline.py @@ -43,8 +43,10 @@ class AfterEffectsSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline dln_job_info.UserName = context.data.get( "deadlineUser", getpass.getuser()) if self._instance.data["frameEnd"] > self._instance.data["frameStart"]: - frame_range = "{}-{}".format(self._instance.data["frameStart"], - self._instance.data["frameEnd"]) + # Deadline requires integers in frame range + frame_range = "{}-{}".format( + int(round(self._instance.data["frameStart"])), + int(round(self._instance.data["frameEnd"]))) dln_job_info.Frames = frame_range dln_job_info.ChunkSize = self.chunk_size diff --git a/pype/plugins/global/publish/extract_burnin.py b/pype/plugins/global/publish/extract_burnin.py index 501162b6a6..d29af63483 100644 --- a/pype/plugins/global/publish/extract_burnin.py +++ b/pype/plugins/global/publish/extract_burnin.py @@ -6,6 +6,9 @@ import tempfile import pype.api import pyblish +from pype.lib import should_decompress, \ + get_decompress_dir, decompress +import shutil class ExtractBurnin(pype.api.Extractor): @@ -28,7 +31,8 @@ class ExtractBurnin(pype.api.Extractor): "premiere", "standalonepublisher", "harmony", - "fusion" + "fusion", + "aftereffects" ] optional = True @@ -204,6 +208,26 @@ class ExtractBurnin(pype.api.Extractor): # Prepare paths and files for process. self.input_output_paths(new_repre, temp_data, filename_suffix) + decompressed_dir = '' + full_input_path = temp_data["full_input_path"] + do_decompress = should_decompress(full_input_path) + if do_decompress: + decompressed_dir = get_decompress_dir() + + decompress( + decompressed_dir, + full_input_path, + temp_data["frame_start"], + temp_data["frame_end"], + self.log + ) + + # input path changed, 'decompressed' added + input_file = os.path.basename(full_input_path) + temp_data["full_input_path"] = os.path.join( + decompressed_dir, + input_file) + # Data for burnin script script_data = { "input": temp_data["full_input_path"], @@ -263,6 +287,9 @@ class ExtractBurnin(pype.api.Extractor): os.remove(filepath) self.log.debug("Removed: \"{}\"".format(filepath)) + if do_decompress and os.path.exists(decompressed_dir): + shutil.rmtree(decompressed_dir) + def prepare_basic_data(self, instance): """Pick data from instance for processing and for burnin strings. diff --git a/pype/plugins/global/publish/extract_jpeg.py b/pype/plugins/global/publish/extract_jpeg.py index 551e57796a..af90d4366d 100644 --- a/pype/plugins/global/publish/extract_jpeg.py +++ b/pype/plugins/global/publish/extract_jpeg.py @@ -3,6 +3,9 @@ import os import pyblish.api import pype.api import pype.lib +from pype.lib import should_decompress, \ + get_decompress_dir, decompress +import shutil class ExtractJpegEXR(pyblish.api.InstancePlugin): @@ -22,7 +25,8 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): if 'crypto' in instance.data['subset']: return - # ffmpeg doesn't support multipart exrs + do_decompress = False + # ffmpeg doesn't support multipart exrs, use oiiotool if available if instance.data.get("multipartExr") is True: return @@ -36,10 +40,6 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): # filter out mov and img sequences representations_new = representations[:] - if instance.data.get("multipartExr"): - # ffmpeg doesn't support multipart exrs - return - for repre in representations: tags = repre.get("tags", []) self.log.debug(repre) @@ -60,6 +60,19 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): full_input_path = os.path.join(stagingdir, input_file) self.log.info("input {}".format(full_input_path)) + decompressed_dir = '' + do_decompress = should_decompress(full_input_path) + if do_decompress: + decompressed_dir = get_decompress_dir() + + decompress( + decompressed_dir, + full_input_path) + # input path changed, 'decompressed' added + full_input_path = os.path.join( + decompressed_dir, + input_file) + filename = os.path.splitext(input_file)[0] if not filename.endswith('.'): filename += "." @@ -93,7 +106,14 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): # run subprocess self.log.debug("{}".format(subprocess_jpeg)) - pype.api.subprocess(subprocess_jpeg, shell=True) + try: # temporary until oiiotool is supported cross platform + pype.api.subprocess(subprocess_jpeg, shell=True) + except RuntimeError as exp: + if "Compression" in str(exp): + self.log.debug("Unsupported compression on input files. " + + "Skipping!!!") + return + raise if "representations" not in instance.data: instance.data["representations"] = [] @@ -111,4 +131,7 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): self.log.debug("Adding: {}".format(representation)) representations_new.append(representation) + if do_decompress and os.path.exists(decompressed_dir): + shutil.rmtree(decompressed_dir) + instance.data["representations"] = representations_new diff --git a/pype/plugins/global/publish/extract_review.py b/pype/plugins/global/publish/extract_review.py index aa8d8accb5..37fe83bf10 100644 --- a/pype/plugins/global/publish/extract_review.py +++ b/pype/plugins/global/publish/extract_review.py @@ -6,6 +6,8 @@ import pyblish.api import clique import pype.api import pype.lib +from pype.lib import should_decompress, \ + get_decompress_dir, decompress class ExtractReview(pyblish.api.InstancePlugin): @@ -14,7 +16,7 @@ class ExtractReview(pyblish.api.InstancePlugin): Compulsory attribute of representation is tags list with "review", otherwise the representation is ignored. - All new represetnations are created and encoded by ffmpeg following + All new representations are created and encoded by ffmpeg following presets found in `pype-config/presets/plugins/global/ publish.json:ExtractReview:outputs`. """ @@ -188,9 +190,17 @@ class ExtractReview(pyblish.api.InstancePlugin): temp_data = self.prepare_temp_data(instance, repre, output_def) - ffmpeg_args = self._ffmpeg_arguments( - output_def, instance, new_repre, temp_data - ) + try: # temporary until oiiotool is supported cross platform + ffmpeg_args = self._ffmpeg_arguments( + output_def, instance, new_repre, temp_data + ) + except ZeroDivisionError: + if 'exr' in temp_data["origin_repre"]["ext"]: + self.log.debug("Unsupported compression on input " + + "files. Skipping!!!") + return + raise + subprcs_cmd = " ".join(ffmpeg_args) # run subprocess @@ -318,9 +328,9 @@ class ExtractReview(pyblish.api.InstancePlugin): Args: output_def (dict): Currently processed output definition. instance (Instance): Currently processed instance. - new_repre (dict): Reprensetation representing output of this + new_repre (dict): Representation representing output of this process. - temp_data (dict): Base data for successfull process. + temp_data (dict): Base data for successful process. """ # Get FFmpeg arguments from profile presets @@ -331,9 +341,35 @@ class ExtractReview(pyblish.api.InstancePlugin): ffmpeg_video_filters = out_def_ffmpeg_args.get("video_filters") or [] ffmpeg_audio_filters = out_def_ffmpeg_args.get("audio_filters") or [] + if isinstance(new_repre['files'], list): + input_files_urls = [os.path.join(new_repre["stagingDir"], f) for f + in new_repre['files']] + test_path = input_files_urls[0] + else: + test_path = os.path.join( + new_repre["stagingDir"], new_repre['files']) + do_decompress = should_decompress(test_path) + + if do_decompress: + # change stagingDir, decompress first + # calculate all paths with modified directory, used on too many + # places + # will be purged by cleanup.py automatically + orig_staging_dir = new_repre["stagingDir"] + new_repre["stagingDir"] = get_decompress_dir() + # Prepare input and output filepaths self.input_output_paths(new_repre, output_def, temp_data) + if do_decompress: + input_file = temp_data["full_input_path"].\ + replace(new_repre["stagingDir"], orig_staging_dir) + + decompress(new_repre["stagingDir"], input_file, + temp_data["frame_start"], + temp_data["frame_end"], + self.log) + # Set output frames len to 1 when ouput is single image if ( temp_data["output_ext_is_image"] @@ -930,7 +966,7 @@ class ExtractReview(pyblish.api.InstancePlugin): return regexes def validate_value_by_regexes(self, value, in_list): - """Validates in any regexe from list match entered value. + """Validates in any regex from list match entered value. Args: in_list (list): List with regexes. @@ -955,9 +991,9 @@ class ExtractReview(pyblish.api.InstancePlugin): def profile_exclusion(self, matching_profiles): """Find out most matching profile byt host, task and family match. - Profiles are selectivelly filtered. Each profile should have + Profiles are selectively filtered. Each profile should have "__value__" key with list of booleans. Each boolean represents - existence of filter for specific key (host, taks, family). + existence of filter for specific key (host, tasks, family). Profiles are looped in sequence. In each sequence are split into true_list and false_list. For next sequence loop are used profiles in true_list if there are any profiles else false_list is used. @@ -1036,7 +1072,7 @@ class ExtractReview(pyblish.api.InstancePlugin): highest_profile_points = -1 # Each profile get 1 point for each matching filter. Profile with most - # points is returnd. For cases when more than one profile will match + # points is returned. For cases when more than one profile will match # are also stored ordered lists of matching values. for profile in self.profiles: profile_points = 0 @@ -1648,7 +1684,7 @@ class ExtractReview(pyblish.api.InstancePlugin): def add_video_filter_args(self, args, inserting_arg): """ - Fixing video filter argumets to be one long string + Fixing video filter arguments to be one long string Args: args (list): list of string arguments diff --git a/pype/plugins/global/publish/integrate_new.py b/pype/plugins/global/publish/integrate_new.py index 26e5fff699..133b4fc6ef 100644 --- a/pype/plugins/global/publish/integrate_new.py +++ b/pype/plugins/global/publish/integrate_new.py @@ -15,7 +15,7 @@ from avalon import io from avalon.vendor import filelink import pype.api from datetime import datetime -from pype.modules import ModulesManager +# from pype.modules import ModulesManager # this is needed until speedcopy for linux is fixed if sys.platform == "win32": @@ -933,15 +933,15 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): remote_site = None sync_server_presets = None - manager = ModulesManager() - sync_server = manager.modules_by_name["sync_server"] - try: - if sync_server.enabled: - local_site, remote_site = sync_server.get_sites_for_project() - except ValueError: - log.debug(("There are not set presets for SyncServer." - " No credentials provided, no synching possible"). - format(str(sync_server_presets))) + # manager = ModulesManager() + # sync_server = manager.modules_by_name["sync_server"] + # try: + # if sync_server.enabled: + # local_site, remote_site = sync_server.get_sites_for_project() + # except ValueError: + # log.debug(("There are not set presets for SyncServer." + # " No credentials provided, no synching possible"). + # format(str(sync_server_presets))) rec = { "_id": io.ObjectId(), diff --git a/pype/plugins/maya/create/create_render.py b/pype/plugins/maya/create/create_render.py index bdd237a54e..b718079b43 100644 --- a/pype/plugins/maya/create/create_render.py +++ b/pype/plugins/maya/create/create_render.py @@ -193,6 +193,7 @@ class CreateRender(avalon.maya.Creator): self.data["tilesX"] = 2 self.data["tilesY"] = 2 self.data["convertToScanline"] = False + self.data["vrayUseReferencedAovs"] = False # Disable for now as this feature is not working yet # self.data["assScene"] = False diff --git a/pype/plugins/maya/create/create_review.py b/pype/plugins/maya/create/create_review.py index 97731d7950..bfeab33f5b 100644 --- a/pype/plugins/maya/create/create_review.py +++ b/pype/plugins/maya/create/create_review.py @@ -13,6 +13,7 @@ class CreateReview(avalon.maya.Creator): defaults = ['Main'] keepImages = False isolate = False + imagePlane = True def __init__(self, *args, **kwargs): super(CreateReview, self).__init__(*args, **kwargs) @@ -25,5 +26,6 @@ class CreateReview(avalon.maya.Creator): data["isolate"] = self.isolate data["keepImages"] = self.keepImages + data["imagePlane"] = self.imagePlane self.data = data diff --git a/pype/plugins/maya/publish/collect_render.py b/pype/plugins/maya/publish/collect_render.py index 3dde3b1592..0853473120 100644 --- a/pype/plugins/maya/publish/collect_render.py +++ b/pype/plugins/maya/publish/collect_render.py @@ -149,7 +149,7 @@ class CollectMayaRender(pyblish.api.ContextPlugin): # return all expected files for all cameras and aovs in given # frame range - ef = ExpectedFiles() + ef = ExpectedFiles(render_instance) exp_files = ef.get(renderer, layer_name) self.log.info("multipart: {}".format(ef.multipart)) assert exp_files, "no file names were generated, this is bug" @@ -248,7 +248,8 @@ class CollectMayaRender(pyblish.api.ContextPlugin): "tilesX": render_instance.data.get("tilesX") or 2, "tilesY": render_instance.data.get("tilesY") or 2, "priority": render_instance.data.get("priority"), - "convertToScanline": render_instance.data.get("convertToScanline") or False # noqa: E501 + "convertToScanline": render_instance.data.get("convertToScanline") or False, # noqa: E501 + "vrayUseReferencedAovs": render_instance.data.get("vrayUseReferencedAovs") or False # noqa: E501 } if self.sync_workfile_version: diff --git a/pype/plugins/maya/publish/extract_playblast.py b/pype/plugins/maya/publish/extract_playblast.py index 82795ab9df..39b02a5a83 100644 --- a/pype/plugins/maya/publish/extract_playblast.py +++ b/pype/plugins/maya/publish/extract_playblast.py @@ -83,6 +83,13 @@ class ExtractPlayblast(pype.api.Extractor): if instance.data.get("isolate"): preset["isolate"] = instance.data["setMembers"] + # Show/Hide image planes on request. + image_plane = instance.data.get("imagePlane", True) + if "viewport_options" in preset: + preset["viewport_options"]["imagePlane"] = image_plane + else: + preset["viewport_options"] = {"imagePlane": image_plane} + with maintained_time(): filename = preset.get("filename", "%TEMP%") diff --git a/pype/plugins/maya/publish/validate_vray_referenced_aovs.py b/pype/plugins/maya/publish/validate_vray_referenced_aovs.py new file mode 100644 index 0000000000..120677021d --- /dev/null +++ b/pype/plugins/maya/publish/validate_vray_referenced_aovs.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +"""Validate if there are AOVs pulled from references.""" +import pyblish.api +import types +from maya import cmds + +import pype.hosts.maya.action + + +class ValidateVrayReferencedAOVs(pyblish.api.InstancePlugin): + """Validate whether the V-Ray Render Elements (AOVs) include references. + + This will check if there are AOVs pulled from references. If + `Vray Use Referenced Aovs` is checked on render instance, u must add those + manually to Render Elements as Pype will expect them to be rendered. + + """ + + order = pyblish.api.ValidatorOrder + label = 'VRay Referenced AOVs' + hosts = ['maya'] + families = ['renderlayer'] + actions = [pype.api.RepairContextAction] + + def process(self, instance): + """Plugin main entry point.""" + if instance.data.get("renderer") != "vray": + # If not V-Ray ignore.. + return + + ref_aovs = cmds.ls( + type=["VRayRenderElement", "VRayRenderElementSet"], + referencedNodes=True) + ref_aovs_enabled = ValidateVrayReferencedAOVs.maya_is_true( + cmds.getAttr("vraySettings.relements_usereferenced")) + + if not instance.data.get("vrayUseReferencedAovs"): + if ref_aovs_enabled and ref_aovs: + self.log.warning(( + "Referenced AOVs are enabled in Vray " + "Render Settings and are detected in scene, but " + "Pype render instance option for referenced AOVs is " + "disabled. Those AOVs will be rendered but not published " + "by Pype." + )) + self.log.warning(", ".join(ref_aovs)) + else: + if not ref_aovs: + self.log.warning(( + "Use of referenced AOVs enabled but there are none " + "in the scene." + )) + if not ref_aovs_enabled: + self.log.error(( + "'Use referenced' not enabled in Vray Render Settings." + )) + raise AssertionError("Invalid render settings") + + @classmethod + def repair(cls, context): + """Repair action.""" + vray_settings = cmds.ls(type="VRaySettingsNode") + if not vray_settings: + node = cmds.createNode("VRaySettingsNode") + else: + node = vray_settings[0] + + cmds.setAttr("{}.relements_usereferenced".format(node), True) + + @staticmethod + def maya_is_true(attr_val): + """Whether a Maya attr evaluates to True. + + When querying an attribute value from an ambiguous object the + Maya API will return a list of values, which need to be properly + handled to evaluate properly. + + Args: + attr_val (mixed): Maya attribute to be evaluated as bool. + + Returns: + bool: cast Maya attribute to Pythons boolean value. + + """ + if isinstance(attr_val, types.BooleanType): + return attr_val + elif isinstance(attr_val, (types.ListType, types.GeneratorType)): + return any(attr_val) + else: + return bool(attr_val) diff --git a/pype/pype_commands.py b/pype/pype_commands.py index f504728ca1..ecaaf68a47 100644 --- a/pype/pype_commands.py +++ b/pype/pype_commands.py @@ -12,52 +12,17 @@ class PypeCommands: """ @staticmethod def launch_tray(debug=False): - from pype.lib import PypeLogger as Logger - from pype.lib import execute - if debug: - execute([ - sys.executable, - "-m", - "pype.tools.tray" - ]) - return + from pype.tools import tray - detached_process = 0x00000008 # noqa: N806 - - args = [sys.executable, "-m", "pype.tools.tray"] - if sys.platform.startswith('linux'): - subprocess.Popen( - args, - universal_newlines=True, - bufsize=1, - env=os.environ, - stdout=None, - stderr=None, - preexec_fn=os.setpgrp - ) - - if sys.platform == 'win32': - args = ["pythonw", "-m", "pype.tools.tray"] - subprocess.Popen( - args, - universal_newlines=True, - bufsize=1, - cwd=None, - env=os.environ, - stdout=open(Logger.get_file_path(), 'w+'), - stderr=subprocess.STDOUT, - creationflags=detached_process - ) + tray.main() @staticmethod def launch_settings_gui(dev): - from pype.lib import execute + from pype.tools import settings - args = [sys.executable, "-m", "pype.tools.settings"] - if dev: - args.append("--develop") - return_code = execute(args) - return return_code + # TODO change argument options to allow enum of user roles + user_role = "developer" + settings.main(user_role) def launch_eventservercli(self, args): from pype.modules import ftrack diff --git a/pype/settings/defaults/project_settings/ftrack.json b/pype/settings/defaults/project_settings/ftrack.json index 4d617b9f09..a16295f84c 100644 --- a/pype/settings/defaults/project_settings/ftrack.json +++ b/pype/settings/defaults/project_settings/ftrack.json @@ -45,39 +45,37 @@ }, "status_task_to_parent": { "enabled": true, + "parent_object_types": [ + "Shot", + "Asset Build" + ], "parent_status_match_all_task_statuses": { "Completed": [ "Approved", "Omitted" ] }, - "parent_status_by_task_status": { - "In Progress": [ - "in progress", - "change requested", - "retake", - "pending review" - ] - } + "parent_status_by_task_status": [ + { + "new_status": "In Progress", + "task_statuses": [ + "in progress", + "change requested", + "retake", + "pending review" + ] + } + ] }, "status_task_to_version": { "enabled": true, - "mapping": { - "Approved": [ - "Complete" - ] - } + "mapping": {}, + "asset_types_filter": [] }, "status_version_to_task": { "enabled": true, - "mapping": { - "Approved": [ - "Complete" - ] - }, - "asset_types_to_skip": [ - "scene" - ] + "mapping": {}, + "asset_types_to_skip": [] }, "first_version_status": { "enabled": true, @@ -96,13 +94,11 @@ "ignored_statuses": [ "In Progress", "Omitted", - "On hold" + "On hold", + "Approved" ], "status_change": { - "In Progress": [], - "Ready": [ - "Not Ready" - ] + "In Progress": [] } }, "create_update_attributes": { @@ -169,7 +165,8 @@ "sync_to_avalon_local": { "enabled": true, "role_list": [ - "Pypeclub" + "Pypeclub", + "Administrator" ] }, "seed_project": { @@ -190,4 +187,4 @@ "ftrack_custom_attributes": {} } } -} +} \ No newline at end of file diff --git a/pype/settings/defaults/project_settings/maya.json b/pype/settings/defaults/project_settings/maya.json index b8c0dffa26..2307fd8b82 100644 --- a/pype/settings/defaults/project_settings/maya.json +++ b/pype/settings/defaults/project_settings/maya.json @@ -113,7 +113,7 @@ "sync_workfile_version": false }, "ValidateCameraAttributes": { - "enabled": true, + "enabled": false, "optional": true }, "ValidateModelName": { diff --git a/pype/tools/launcher/actions.py b/pype/tools/launcher/actions.py index 6d0c94b676..aefa190768 100644 --- a/pype/tools/launcher/actions.py +++ b/pype/tools/launcher/actions.py @@ -85,29 +85,32 @@ def register_config_actions(): config.register_launcher_actions() -def register_environment_actions(): - """Register actions from AVALON_ACTIONS for Launcher.""" - - paths = os.environ.get("AVALON_ACTIONS") +def register_actions_from_paths(paths): if not paths: return - for path in paths.split(os.pathsep): + for path in paths: + if not path: + continue + + if path.startswith("."): + print(( + "BUG: Relative paths are not allowed for security reasons. {}" + ).format(path)) + continue + + if not os.path.exists(path): + print("Path was not found: {}".format(path)) + continue + api.register_plugin_path(api.Action, path) - # Run "register" if found. - for module in lib.modules_from_path(path): - if "register" not in dir(module): - continue - try: - module.register() - except Exception as e: - print( - "Register method in {0} failed: {1}".format( - module, str(e) - ) - ) +def register_environment_actions(): + """Register actions from AVALON_ACTIONS for Launcher.""" + + paths_str = os.environ.get("AVALON_ACTIONS") or "" + register_actions_from_paths(paths_str.split(os.pathsep)) class ApplicationAction(api.Action): diff --git a/pype/tools/settings/__init__.py b/pype/tools/settings/__init__.py index 88f33ac188..89abd262e8 100644 --- a/pype/tools/settings/__init__.py +++ b/pype/tools/settings/__init__.py @@ -1,7 +1,31 @@ +import sys +from Qt import QtWidgets, QtGui + from .settings import style, MainWidget +def main(user_role=None): + if user_role is None: + user_role = "artist" + else: + user_role_low = user_role.lower() + allowed_roles = ("developer", "manager", "artist") + if user_role_low not in allowed_roles: + raise ValueError("Invalid user role \"{}\". Expected {}".format( + user_role, ", ".join(allowed_roles) + )) + + app = QtWidgets.QApplication(sys.argv) + app.setWindowIcon(QtGui.QIcon(style.app_icon_path())) + + widget = MainWidget(user_role) + widget.show() + + sys.exit(app.exec_()) + + __all__ = ( "style", - "MainWidget" + "MainWidget", + "main" ) diff --git a/pype/tools/settings/__main__.py b/pype/tools/settings/__main__.py index 7e9f80a52c..cf49035c23 100644 --- a/pype/tools/settings/__main__.py +++ b/pype/tools/settings/__main__.py @@ -1,23 +1,7 @@ -import sys - -import settings -from Qt import QtWidgets, QtGui +try: + from . import main +except ImportError: + from settings import main -if __name__ == "__main__": - app = QtWidgets.QApplication(sys.argv) - app.setWindowIcon(QtGui.QIcon(settings.style.app_icon_path())) - - _develop = "-d" in sys.argv or "--develop" in sys.argv - _user = "-m" in sys.argv or "--manager" in sys.argv - if _develop: - user_role = "developer" - elif _user: - user_role = "manager" - else: - user_role = "artist" - - widget = settings.MainWidget(user_role) - widget.show() - - sys.exit(app.exec_()) +main() diff --git a/pype/tools/settings/settings/README.md b/pype/tools/settings/settings/README.md index 53f21aad06..643043b6c8 100644 --- a/pype/tools/settings/settings/README.md +++ b/pype/tools/settings/settings/README.md @@ -269,13 +269,17 @@ ``` ## Inputs for setting value using Pure inputs -- these inputs also have required `"key"` and `"label"` +- these inputs also have required `"key"` +- attribute `"label"` is required in few conditions + - when item is marked `as_group` or when `use_label_wrap` - they use Pure inputs "as widgets" ### list - output is list - items can be added and removed - items in list must be the same type +- to wrap item in collapsible widget with label on top set `use_label_wrap` to `True` + - when this is used `collapsible` and `collapsed` can be set (same as `dict` item does) - type of items is defined with key `"object_type"` - there are 2 possible ways how to set the type: 1.) dictionary with item modifiers (`number` input has `minimum`, `maximum` and `decimals`) in that case item type must be set as value of `"type"` (example below) diff --git a/pype/tools/settings/settings/gui_schemas/projects_schema/schema_main.json b/pype/tools/settings/settings/gui_schemas/projects_schema/schema_main.json index 5724e50cdc..73266a9e79 100644 --- a/pype/tools/settings/settings/gui_schemas/projects_schema/schema_main.json +++ b/pype/tools/settings/settings/gui_schemas/projects_schema/schema_main.json @@ -9,6 +9,7 @@ { "type": "anatomy_roots", "key": "roots", + "label": "Roots", "is_file": true }, { diff --git a/pype/tools/settings/settings/gui_schemas/projects_schema/schema_project_ftrack.json b/pype/tools/settings/settings/gui_schemas/projects_schema/schema_project_ftrack.json index 70f578822a..1554989c55 100644 --- a/pype/tools/settings/settings/gui_schemas/projects_schema/schema_project_ftrack.json +++ b/pype/tools/settings/settings/gui_schemas/projects_schema/schema_project_ftrack.json @@ -157,6 +157,16 @@ "key": "enabled", "label": "Enabled" }, + { + "type": "label", + "label": "List of parent object types where this is triggered (\"Shot\", \"Asset Build\", etc.). Skipped if list is empty." + }, + { + "type": "list", + "object_type": "text", + "key": "parent_object_types", + "label": "Object types" + }, { "key": "parent_status_match_all_task_statuses", "type": "dict-modifiable", @@ -167,12 +177,28 @@ } }, { + "type": "list", "key": "parent_status_by_task_status", - "type": "dict-modifiable", "label": "Change parent status if a single task matches", + "use_label_wrap": true, "object_type": { - "type": "list", - "object_type": "text" + "type": "dict", + "children": [ + { + "type": "text", + "label": "New parent status", + "key": "new_status" + }, + { + "type": "separator" + }, + { + "type": "list", + "label": "Task status", + "key": "task_statuses", + "object_type": "text" + } + ] } } ] @@ -191,10 +217,21 @@ { "type": "dict-modifiable", "key": "mapping", - "object_type": { + "object_type": + { "type": "list", "object_type": "text" } + }, + { + "type": "label", + "label": "Limit status changes to entered asset types. Limitation is ignored if nothing is entered." + }, + { + "type": "list", + "key": "asset_types_filter", + "label": "Asset types (short)", + "object_type": "text" } ] }, diff --git a/pype/tools/settings/settings/gui_schemas/projects_schema/schema_project_global.json b/pype/tools/settings/settings/gui_schemas/projects_schema/schema_project_global.json index d6c413fea3..ab9b56115d 100644 --- a/pype/tools/settings/settings/gui_schemas/projects_schema/schema_project_global.json +++ b/pype/tools/settings/settings/gui_schemas/projects_schema/schema_project_global.json @@ -18,11 +18,11 @@ "type": "collapsible-wrap", "label": "Project Folder Structure", "children": [ - { - "type": "raw-json", - "key": "project_folder_structure", - "label": "" - }] + { + "type": "raw-json", + "key": "project_folder_structure" + } + ] }, { diff --git a/pype/tools/settings/settings/gui_schemas/projects_schema/schemas/schema_anatomy_imageio.json b/pype/tools/settings/settings/gui_schemas/projects_schema/schemas/schema_anatomy_imageio.json index 4f75e1171a..0032e3de06 100644 --- a/pype/tools/settings/settings/gui_schemas/projects_schema/schemas/schema_anatomy_imageio.json +++ b/pype/tools/settings/settings/gui_schemas/projects_schema/schemas/schema_anatomy_imageio.json @@ -92,7 +92,6 @@ { "type": "list", "key": "inputs", - "label": "", "object_type": { "type": "dict", "children": [ @@ -330,7 +329,6 @@ { "type": "list", "key": "inputs", - "label": "", "object_type": { "type": "dict", "children": [ diff --git a/pype/tools/settings/settings/gui_schemas/projects_schema/schemas/schema_global_tools.json b/pype/tools/settings/settings/gui_schemas/projects_schema/schemas/schema_global_tools.json index f221e87aa9..d89477edd1 100644 --- a/pype/tools/settings/settings/gui_schemas/projects_schema/schemas/schema_global_tools.json +++ b/pype/tools/settings/settings/gui_schemas/projects_schema/schemas/schema_global_tools.json @@ -29,41 +29,36 @@ "label": "Workfiles", "children": [ { - "type": "collapsible-wrap", + "type": "list", + "key": "last_workfile_on_startup", "label": "Open last workfiles on launch", - "children": [ - { - "type": "list", - "key": "last_workfile_on_startup", - "label": "", - "is_group": true, - "object_type": { - "type": "dict", - "children": [ - { - "key": "hosts", - "label": "Hosts", - "type": "list", - "object_type": "text" - }, - { - "key": "tasks", - "label": "Tasks", - "type": "list", - "object_type": "text" - }, - { - "type": "splitter" - }, - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - } - ] + "is_group": true, + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "hosts", + "label": "Hosts", + "type": "list", + "object_type": "text" + }, + { + "key": "tasks", + "label": "Tasks", + "type": "list", + "object_type": "text" + }, + { + "type": "splitter" + }, + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" } - } - ] + ] + } }, { "type": "dict-modifiable", diff --git a/pype/tools/settings/settings/widgets/item_types.py b/pype/tools/settings/settings/widgets/item_types.py index 4428d51cef..c5112473e6 100644 --- a/pype/tools/settings/settings/widgets/item_types.py +++ b/pype/tools/settings/settings/widgets/item_types.py @@ -196,6 +196,12 @@ class SettingObject: self.key = self.schema_data["key"] + self.label = self.schema_data.get("label") + if not self.label and self._is_group: + raise ValueError( + "Item is set as `is_group` but has empty `label`." + ) + @property def user_role(self): """Tool is running with any user role. @@ -1323,9 +1329,9 @@ class RawJsonWidget(QtWidgets.QWidget, InputObject): self.setFocusProxy(self.input_field) if not self.as_widget and not label_widget: - label = self.schema_data["label"] - label_widget = QtWidgets.QLabel(label) - layout.addWidget(label_widget, 0, alignment=QtCore.Qt.AlignTop) + if self.label: + label_widget = QtWidgets.QLabel(self.label) + layout.addWidget(label_widget, 0, alignment=QtCore.Qt.AlignTop) self.label_widget = label_widget layout.addWidget(self.input_field, 1, alignment=QtCore.Qt.AlignTop) @@ -1576,6 +1582,25 @@ class ListWidget(QtWidgets.QWidget, InputObject): self.initial_attributes(schema_data, parent, as_widget) + self.use_label_wrap = schema_data.get("use_label_wrap") or False + # Used only if `use_label_wrap` is set to True + self.collapsible = schema_data.get("collapsible") or True + self.collapsed = schema_data.get("collapsed") or False + + self.expand_in_grid = bool(self.use_label_wrap) + + if self.as_widget and self.use_label_wrap: + raise ValueError( + "`ListWidget` can't have set `use_label_wrap` to True and" + " be used as widget at the same time." + ) + + if self.use_label_wrap and not self.label: + raise ValueError( + "`ListWidget` can't have set `use_label_wrap` to True and" + " not have set \"label\" key at the same time." + ) + self.input_fields = [] object_type = schema_data["object_type"] @@ -1585,44 +1610,64 @@ class ListWidget(QtWidgets.QWidget, InputObject): self.item_schema = { "type": object_type } - # Backwards compatibility - input_modifiers = schema_data.get("input_modifiers") or {} - if input_modifiers: - self.log.warning(( - "Used deprecated key `input_modifiers` to define item." - " Rather use `object_type` as dictionary with modifiers." - )) - self.item_schema.update(input_modifiers) def create_ui(self, label_widget=None): - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 5) - layout.setSpacing(5) + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) - if not self.as_widget and not label_widget: - label = self.schema_data.get("label") - if label: - label_widget = QtWidgets.QLabel(label, self) - layout.addWidget(label_widget, alignment=QtCore.Qt.AlignTop) - elif self._is_group: - raise KeyError(( - "Schema item must contain \"label\" if `is_group` is True" - " to be able visualize changes and show actions." - )) + body_widget = None + if self.as_widget: + pass + + elif self.use_label_wrap: + body_widget = ExpandingWidget(self.label, self) + main_layout.addWidget(body_widget) + + label_widget = body_widget.label_widget + + elif not label_widget: + if self.label: + label_widget = QtWidgets.QLabel(self.label, self) + main_layout.addWidget( + label_widget, alignment=QtCore.Qt.AlignTop + ) self.label_widget = label_widget - inputs_widget = QtWidgets.QWidget(self) - inputs_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) - layout.addWidget(inputs_widget) + self.body_widget = body_widget + if body_widget is None: + content_parent_widget = self + else: + content_parent_widget = body_widget + + content_state = "" + + inputs_widget = QtWidgets.QWidget(content_parent_widget) + inputs_widget.setObjectName("ContentWidget") + inputs_widget.setProperty("content_state", content_state) inputs_layout = QtWidgets.QVBoxLayout(inputs_widget) - inputs_layout.setContentsMargins(0, 0, 0, 0) - inputs_layout.setSpacing(3) + inputs_layout.setContentsMargins(CHILD_OFFSET, 5, 0, 5) + if body_widget is None: + main_layout.addWidget(inputs_widget) + else: + body_widget.set_content_widget(inputs_widget) + + self.body_widget = body_widget self.inputs_widget = inputs_widget self.inputs_layout = inputs_layout + if body_widget: + if not self.collapsible: + body_widget.hide_toolbox(hide_content=False) + + elif self.collapsed: + body_widget.toggle_content() + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + self.add_row(is_empty=True) def count(self): @@ -1795,18 +1840,44 @@ class ListWidget(QtWidgets.QWidget, InputObject): return True return False - def update_style(self): + def update_style(self, is_overriden=None): if not self.label_widget: return - state = self._style_state() + child_invalid = self.child_invalid + if self.body_widget: + child_state = self.style_state( + self.child_has_studio_override, + child_invalid, + self.child_overriden, + self.child_modified + ) + if child_state: + child_state = "child-{}".format(child_state) + + if child_state != self._child_state: + self.body_widget.side_line_widget.setProperty( + "state", child_state + ) + self.body_widget.side_line_widget.style().polish( + self.body_widget.side_line_widget + ) + self._child_state = child_state + + state = self.style_state( + self.had_studio_override, + child_invalid, + self.is_overriden, + self.is_modified + ) if self._state == state: return - self._state = state self.label_widget.setProperty("state", state) self.label_widget.style().polish(self.label_widget) + self._state = state + def item_value(self): output = [] for item in self.input_fields: diff --git a/pype/tools/tray/__init__.py b/pype/tools/tray/__init__.py new file mode 100644 index 0000000000..38c59d2a43 --- /dev/null +++ b/pype/tools/tray/__init__.py @@ -0,0 +1,5 @@ +from .pype_tray import main + +__all__ = ( + "main", +) diff --git a/pype/tools/tray/__main__.py b/pype/tools/tray/__main__.py index a997e4302b..830cf45d0e 100644 --- a/pype/tools/tray/__main__.py +++ b/pype/tools/tray/__main__.py @@ -1,13 +1,7 @@ -import os -import sys +try: + from . import pype_tray +except ImportError: + import pype_tray -from . import pype_tray -app = pype_tray.PypeTrayApplication() -if os.name == "nt": - import ctypes - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( - u"pype_tray" - ) - -sys.exit(app.exec_()) +pype_tray.main() diff --git a/pype/tools/tray/pype_tray.py b/pype/tools/tray/pype_tray.py index c8c04d229a..1fec95906d 100644 --- a/pype/tools/tray/pype_tray.py +++ b/pype/tools/tray/pype_tray.py @@ -255,3 +255,15 @@ class PypeTrayApplication(QtWidgets.QApplication): QtCore.Qt.WindowStaysOnTopHint | QtCore.Qt.FramelessWindowHint ) return splash + + +def main(): + app = PypeTrayApplication() + # TODO remove when pype.exe will have an icon + if os.name == "nt": + import ctypes + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( + u"pype_tray" + ) + + sys.exit(app.exec_())