diff --git a/CHANGELOG.md b/CHANGELOG.md index f75f68a5bd..8a41ccb4d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,24 @@ # Changelog -## [3.3.0-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.3.0-nightly.6](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.2.0...HEAD) **π Enhancements** +- Expose stop timer through rest api. [\#1886](https://github.com/pypeclub/OpenPype/pull/1886) +- Allow Multiple Notes to run on tasks. [\#1882](https://github.com/pypeclub/OpenPype/pull/1882) +- Prepare for pyside2 [\#1869](https://github.com/pypeclub/OpenPype/pull/1869) +- Filter hosts in settings host-enum [\#1868](https://github.com/pypeclub/OpenPype/pull/1868) +- Local actions with process identifier [\#1867](https://github.com/pypeclub/OpenPype/pull/1867) +- Workfile tool start at host launch support [\#1865](https://github.com/pypeclub/OpenPype/pull/1865) +- Anatomy schema validation [\#1864](https://github.com/pypeclub/OpenPype/pull/1864) +- Ftrack prepare project structure [\#1861](https://github.com/pypeclub/OpenPype/pull/1861) +- Independent general environments [\#1853](https://github.com/pypeclub/OpenPype/pull/1853) +- TVPaint Start Frame [\#1844](https://github.com/pypeclub/OpenPype/pull/1844) - Ftrack push attributes action adds traceback to job [\#1843](https://github.com/pypeclub/OpenPype/pull/1843) - Prepare project action enhance [\#1838](https://github.com/pypeclub/OpenPype/pull/1838) +- Standalone Publish of textures family [\#1834](https://github.com/pypeclub/OpenPype/pull/1834) - nuke: settings create missing default subsets [\#1829](https://github.com/pypeclub/OpenPype/pull/1829) - Update poetry lock [\#1823](https://github.com/pypeclub/OpenPype/pull/1823) - Settings: settings for plugins [\#1819](https://github.com/pypeclub/OpenPype/pull/1819) @@ -15,12 +26,18 @@ **π Bug fixes** +- Normalize path returned from Workfiles. [\#1880](https://github.com/pypeclub/OpenPype/pull/1880) +- Workfiles tool event arguments fix [\#1862](https://github.com/pypeclub/OpenPype/pull/1862) +- imageio: fix grouping [\#1856](https://github.com/pypeclub/OpenPype/pull/1856) +- publisher: missing version in subset prop [\#1849](https://github.com/pypeclub/OpenPype/pull/1849) +- Ftrack type error fix in sync to avalon event handler [\#1845](https://github.com/pypeclub/OpenPype/pull/1845) - Nuke: updating effects subset fail [\#1841](https://github.com/pypeclub/OpenPype/pull/1841) +- Fix - Standalone Publish better handling of loading multiple versionsβ¦ [\#1837](https://github.com/pypeclub/OpenPype/pull/1837) - nuke: write render node skipped with crop [\#1836](https://github.com/pypeclub/OpenPype/pull/1836) - Project folder structure overrides [\#1813](https://github.com/pypeclub/OpenPype/pull/1813) - Maya: fix yeti settings path in extractor [\#1809](https://github.com/pypeclub/OpenPype/pull/1809) - Failsafe for cross project containers. [\#1806](https://github.com/pypeclub/OpenPype/pull/1806) -- Houdini colector formatting keys fix [\#1802](https://github.com/pypeclub/OpenPype/pull/1802) +- Settings error dialog on show [\#1798](https://github.com/pypeclub/OpenPype/pull/1798) **Merged pull requests:** @@ -47,7 +64,6 @@ - Settings Hosts enum [\#1739](https://github.com/pypeclub/OpenPype/pull/1739) - Validate containers settings [\#1736](https://github.com/pypeclub/OpenPype/pull/1736) - PS - added loader from sequence [\#1726](https://github.com/pypeclub/OpenPype/pull/1726) -- Toggle Ftrack upload in StandalonePublisher [\#1708](https://github.com/pypeclub/OpenPype/pull/1708) **π Bug fixes** @@ -91,40 +107,14 @@ [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.2.0-nightly.2...2.18.3) -**π Bug fixes** - -- Tools names forwards compatibility [\#1727](https://github.com/pypeclub/OpenPype/pull/1727) - -**β οΈ Deprecations** - -- global: removing obsolete ftrack validator plugin [\#1710](https://github.com/pypeclub/OpenPype/pull/1710) - ## [2.18.2](https://github.com/pypeclub/OpenPype/tree/2.18.2) (2021-06-16) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.1.0...2.18.2) -**π Bug fixes** - -- Maya: Extract review hotfix - 2.x backport [\#1713](https://github.com/pypeclub/OpenPype/pull/1713) - -**Merged pull requests:** - -- 1698 Nuke: Prerender Frame Range by default [\#1709](https://github.com/pypeclub/OpenPype/pull/1709) - ## [3.1.0](https://github.com/pypeclub/OpenPype/tree/3.1.0) (2021-06-15) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.1.0-nightly.4...3.1.0) -**π Enhancements** - -- Log Viewer with OpenPype style [\#1703](https://github.com/pypeclub/OpenPype/pull/1703) -- Scrolling in OpenPype info widget [\#1702](https://github.com/pypeclub/OpenPype/pull/1702) - -**π Bug fixes** - -- Nuke: broken publishing rendered frames [\#1707](https://github.com/pypeclub/OpenPype/pull/1707) -- Standalone publisher Thumbnail export args [\#1705](https://github.com/pypeclub/OpenPype/pull/1705) - # Changelog diff --git a/openpype/hooks/pre_copy_template_workfile.py b/openpype/hooks/pre_copy_template_workfile.py index 29a522f933..5c56d721e8 100644 --- a/openpype/hooks/pre_copy_template_workfile.py +++ b/openpype/hooks/pre_copy_template_workfile.py @@ -49,7 +49,7 @@ class CopyTemplateWorkfile(PreLaunchHook): )) return - self.log.info("Last workfile does not exits.") + self.log.info("Last workfile does not exist.") project_name = self.data["project_name"] asset_name = self.data["asset_name"] diff --git a/openpype/hosts/maya/api/commands.py b/openpype/hosts/maya/api/commands.py new file mode 100644 index 0000000000..d4c2b6a225 --- /dev/null +++ b/openpype/hosts/maya/api/commands.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +"""OpenPype script commands to be used directly in Maya.""" + + +class ToolWindows: + + _windows = {} + + @classmethod + def get_window(cls, tool): + """Get widget for specific tool. + + Args: + tool (str): Name of the tool. + + Returns: + Stored widget. + + """ + try: + return cls._windows[tool] + except KeyError: + return None + + @classmethod + def set_window(cls, tool, window): + """Set widget for the tool. + + Args: + tool (str): Name of the tool. + window (QtWidgets.QWidget): Widget + + """ + cls._windows[tool] = window + + +def edit_shader_definitions(): + from avalon.tools import lib + from Qt import QtWidgets + from openpype.hosts.maya.api.shader_definition_editor import ( + ShaderDefinitionsEditor + ) + + top_level_widgets = QtWidgets.QApplication.topLevelWidgets() + main_window = next(widget for widget in top_level_widgets + if widget.objectName() == "MayaWindow") + + with lib.application(): + window = ToolWindows.get_window("shader_definition_editor") + if not window: + window = ShaderDefinitionsEditor(parent=main_window) + ToolWindows.set_window("shader_definition_editor", window) + window.show() diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index 42e5c66e4a..0dced48868 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -6,9 +6,9 @@ from avalon.vendor.Qt import QtWidgets, QtGui from avalon.maya import pipeline from openpype.api import BuildWorkfile import maya.cmds as cmds +from openpype.settings import get_project_settings self = sys.modules[__name__] -self._menu = os.environ.get("AVALON_LABEL") log = logging.getLogger(__name__) @@ -17,8 +17,11 @@ log = logging.getLogger(__name__) def _get_menu(menu_name=None): """Return the menu instance if it currently exists in Maya""" + project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) + _menu = project_settings["maya"]["scriptsmenu"]["name"] + if menu_name is None: - menu_name = self._menu + menu_name = _menu widgets = dict(( w.objectName(), w) for w in QtWidgets.QApplication.allWidgets()) menu = widgets.get(menu_name) @@ -55,35 +58,7 @@ def deferred(): parent=pipeline._parent ) - # Find the pipeline menu - top_menu = _get_menu(pipeline._menu) - - # Try to find workfile tool action in the menu - workfile_action = None - for action in top_menu.actions(): - if action.text() == "Work Files": - workfile_action = action - break - - # Add at the top of menu if "Work Files" action was not found - after_action = "" - if workfile_action: - # Use action's object name for `insertAfter` argument - after_action = workfile_action.objectName() - - # Insert action to menu - cmds.menuItem( - "Work Files", - parent=pipeline._menu, - command=launch_workfiles_app, - insertAfter=after_action - ) - - # Remove replaced action - if workfile_action: - top_menu.removeAction(workfile_action) - - log.info("Attempting to install scripts menu..") + log.info("Attempting to install scripts menu ...") add_build_workfiles_item() add_look_assigner_item() @@ -100,13 +75,18 @@ def deferred(): return # load configuration of custom menu - config_path = os.path.join(os.path.dirname(__file__), "menu.json") - config = scriptsmenu.load_configuration(config_path) + project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) + config = project_settings["maya"]["scriptsmenu"]["definition"] + _menu = project_settings["maya"]["scriptsmenu"]["name"] + + if not config: + log.warning("Skipping studio menu, no definition found.") + return # run the launcher for Maya menu studio_menu = launchformaya.main( - title=self._menu.title(), - objectName=self._menu + title=_menu.title(), + objectName=_menu.title().lower().replace(" ", "_") ) # apply configuration @@ -116,7 +96,7 @@ def deferred(): def uninstall(): menu = _get_menu() if menu: - log.info("Attempting to uninstall..") + log.info("Attempting to uninstall ...") try: menu.deleteLater() @@ -136,9 +116,8 @@ def install(): def popup(): - """Pop-up the existing menu near the mouse cursor""" + """Pop-up the existing menu near the mouse cursor.""" menu = _get_menu() - cursor = QtGui.QCursor() point = cursor.pos() menu.exec_(point) diff --git a/openpype/hosts/maya/api/shader_definition_editor.py b/openpype/hosts/maya/api/shader_definition_editor.py new file mode 100644 index 0000000000..73cc6246ab --- /dev/null +++ b/openpype/hosts/maya/api/shader_definition_editor.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +"""Editor for shader definitions. + +Shader names are stored as simple text file over GridFS in mongodb. + +""" +import os +from Qt import QtWidgets, QtCore, QtGui +from openpype.lib.mongo import OpenPypeMongoConnection +from openpype import resources +import gridfs + + +DEFINITION_FILENAME = "{}/maya/shader_definition.txt".format( + os.getenv("AVALON_PROJECT")) + + +class ShaderDefinitionsEditor(QtWidgets.QWidget): + """Widget serving as simple editor for shader name definitions.""" + + # name of the file used to store definitions + + def __init__(self, parent=None): + super(ShaderDefinitionsEditor, self).__init__(parent) + self._mongo = OpenPypeMongoConnection.get_mongo_client() + self._gridfs = gridfs.GridFS( + self._mongo[os.getenv("OPENPYPE_DATABASE_NAME")]) + self._editor = None + + self._original_content = self._read_definition_file() + + self.setObjectName("shaderDefinitionEditor") + self.setWindowTitle("OpenPype shader name definition editor") + icon = QtGui.QIcon(resources.pype_icon_filepath()) + self.setWindowIcon(icon) + self.setWindowFlags(QtCore.Qt.Window) + self.setParent(parent) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + self.resize(750, 500) + + self._setup_ui() + self._reload() + + def _setup_ui(self): + """Setup UI of Widget.""" + layout = QtWidgets.QVBoxLayout(self) + label = QtWidgets.QLabel() + label.setText("Put shader names here - one name per line:") + layout.addWidget(label) + self._editor = QtWidgets.QPlainTextEdit() + self._editor.setStyleSheet("border: none;") + layout.addWidget(self._editor) + + btn_layout = QtWidgets.QHBoxLayout() + save_btn = QtWidgets.QPushButton("Save") + save_btn.clicked.connect(self._save) + + reload_btn = QtWidgets.QPushButton("Reload") + reload_btn.clicked.connect(self._reload) + + exit_btn = QtWidgets.QPushButton("Exit") + exit_btn.clicked.connect(self._close) + + btn_layout.addWidget(reload_btn) + btn_layout.addWidget(save_btn) + btn_layout.addWidget(exit_btn) + + layout.addLayout(btn_layout) + + def _read_definition_file(self, file=None): + """Read definition file from database. + + Args: + file (gridfs.grid_file.GridOut, Optional): File to read. If not + set, new query will be issued to find it. + + Returns: + str: Content of the file or empty string if file doesn't exist. + + """ + content = "" + if not file: + file = self._gridfs.find_one( + {"filename": DEFINITION_FILENAME}) + if not file: + print(">>> [SNDE]: nothing in database yet") + return content + content = file.read() + file.close() + return content + + def _write_definition_file(self, content, force=False): + """Write content as definition to file in database. + + Before file is writen, check is made if its content has not + changed. If is changed, warning is issued to user if he wants + it to overwrite. Note: GridFs doesn't allow changing file content. + You need to delete existing file and create new one. + + Args: + content (str): Content to write. + + Raises: + ContentException: If file is changed in database while + editor is running. + """ + file = self._gridfs.find_one( + {"filename": DEFINITION_FILENAME}) + if file: + content_check = self._read_definition_file(file) + if content == content_check: + print(">>> [SNDE]: content not changed") + return + if self._original_content != content_check: + if not force: + raise ContentException("Content changed") + print(">>> [SNDE]: overwriting data") + file.close() + self._gridfs.delete(file._id) + + file = self._gridfs.new_file( + filename=DEFINITION_FILENAME, + content_type='text/plain', + encoding='utf-8') + file.write(content) + file.close() + QtCore.QTimer.singleShot(200, self._reset_style) + self._editor.setStyleSheet("border: 1px solid #33AF65;") + self._original_content = content + + def _reset_style(self): + """Reset editor style back. + + Used to visually indicate save. + + """ + self._editor.setStyleSheet("border: none;") + + def _close(self): + self.hide() + + def closeEvent(self, event): + event.ignore() + self.hide() + + def _reload(self): + print(">>> [SNDE]: reloading") + self._set_content(self._read_definition_file()) + + def _save(self): + try: + self._write_definition_file(content=self._editor.toPlainText()) + except ContentException: + # content has changed meanwhile + print(">>> [SNDE]: content has changed") + self._show_overwrite_warning() + + def _set_content(self, content): + self._editor.setPlainText(content) + + def _show_overwrite_warning(self): + reply = QtWidgets.QMessageBox.question( + self, + "Warning", + ("Content you are editing was changed meanwhile in database.\n" + "Please, reload and solve the conflict."), + QtWidgets.QMessageBox.OK) + + if reply == QtWidgets.QMessageBox.OK: + # do nothing + pass + + +class ContentException(Exception): + """This is risen during save if file is changed in database.""" + pass diff --git a/openpype/hosts/maya/plugins/publish/validate_model_name.py b/openpype/hosts/maya/plugins/publish/validate_model_name.py index 98da4d42ba..3757e13a9b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_model_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_model_name.py @@ -1,8 +1,16 @@ +# -*- coding: utf-8 -*- +"""Validate model nodes names.""" from maya import cmds import pyblish.api import openpype.api +import avalon.api import openpype.hosts.maya.api.action +from openpype.hosts.maya.api.shader_definition_editor import ( + DEFINITION_FILENAME) +from openpype.lib.mongo import OpenPypeMongoConnection +import gridfs import re +import os class ValidateModelName(pyblish.api.InstancePlugin): @@ -19,18 +27,18 @@ class ValidateModelName(pyblish.api.InstancePlugin): families = ["model"] label = "Model Name" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] - # path to shader names definitions - # TODO: move it to preset file material_file = None - regex = '(.*)_(\\d)*_(.*)_(GEO)' + database_file = DEFINITION_FILENAME @classmethod def get_invalid(cls, instance): + """Get invalid nodes.""" + use_db = cls.database - # find out if supplied transform is group or not - def is_group(groupName): + def is_group(group_name): + """Find out if supplied transform is group or not.""" try: - children = cmds.listRelatives(groupName, children=True) + children = cmds.listRelatives(group_name, children=True) for child in children: if not cmds.ls(child, transforms=True): return False @@ -44,29 +52,74 @@ class ValidateModelName(pyblish.api.InstancePlugin): cls.log.error("Instance has no nodes!") return True pass + + # validate top level group name + assemblies = cmds.ls(content_instance, assemblies=True, long=True) + if len(assemblies) != 1: + cls.log.error("Must have exactly one top group") + return assemblies or True + top_group = assemblies[0] + regex = cls.top_level_regex + r = re.compile(regex) + m = r.match(top_group) + if m is None: + cls.log.error("invalid name on: {}".format(top_group)) + cls.log.error("name doesn't match regex {}".format(regex)) + invalid.append(top_group) + else: + if "asset" in r.groupindex: + if m.group("asset") != avalon.api.Session["AVALON_ASSET"]: + cls.log.error("Invalid asset name in top level group.") + return top_group + if "subset" in r.groupindex: + if m.group("subset") != instance.data.get("subset"): + cls.log.error("Invalid subset name in top level group.") + return top_group + if "project" in r.groupindex: + if m.group("project") != avalon.api.Session["AVALON_PROJECT"]: + cls.log.error("Invalid project name in top level group.") + return top_group + descendants = cmds.listRelatives(content_instance, allDescendents=True, fullPath=True) or [] descendants = cmds.ls(descendants, noIntermediate=True, long=True) - trns = cmds.ls(descendants, long=False, type=('transform')) + trns = cmds.ls(descendants, long=False, type='transform') # filter out groups - filter = [node for node in trns if not is_group(node)] + filtered = [node for node in trns if not is_group(node)] # load shader list file as utf-8 - if cls.material_file: - shader_file = open(cls.material_file, "r") - shaders = shader_file.readlines() + shaders = [] + if not use_db: + if cls.material_file: + if os.path.isfile(cls.material_file): + shader_file = open(cls.material_file, "r") + shaders = shader_file.readlines() + shader_file.close() + else: + cls.log.error("Missing shader name definition file.") + return True + else: + client = OpenPypeMongoConnection.get_mongo_client() + fs = gridfs.GridFS(client[os.getenv("OPENPYPE_DATABASE_NAME")]) + shader_file = fs.find_one({"filename": cls.database_file}) + if not shader_file: + cls.log.error("Missing shader name definition in database.") + return True + shaders = shader_file.read().splitlines() shader_file.close() # strip line endings from list shaders = map(lambda s: s.rstrip(), shaders) # compile regex for testing names - r = re.compile(cls.regex) + regex = cls.regex + r = re.compile(regex) - for obj in filter: + for obj in filtered: + cls.log.info("testing: {}".format(obj)) m = r.match(obj) if m is None: cls.log.error("invalid name on: {}".format(obj)) @@ -74,7 +127,7 @@ class ValidateModelName(pyblish.api.InstancePlugin): else: # if we have shader files and shader named group is in # regex, test this group against names in shader file - if 'shader' in r.groupindex and shaders: + if "shader" in r.groupindex and shaders: try: if not m.group('shader') in shaders: cls.log.error( @@ -90,8 +143,8 @@ class ValidateModelName(pyblish.api.InstancePlugin): return invalid def process(self, instance): - + """Plugin entry point.""" invalid = self.get_invalid(instance) if invalid: - raise RuntimeError("Model naming is invalid. See log.") + raise RuntimeError("Model naming is invalid. See the log.") diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index eefbcc5d20..7e7cd27f90 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -1660,9 +1660,13 @@ def find_free_space_to_paste_nodes( def launch_workfiles_app(): '''Function letting start workfiles after start of host ''' - # get state from settings - open_at_start = get_current_project_settings()["nuke"].get( - "general", {}).get("open_workfile_at_start") + from openpype.lib import ( + env_value_to_bool + ) + # get all imortant settings + open_at_start = env_value_to_bool( + env_key="OPENPYPE_WORKFILE_TOOL_ON_START", + default=None) # return if none is defined if not open_at_start: @@ -1739,3 +1743,68 @@ def process_workfile_builder(): log.info("Opening last workfile...") # open workfile open_file(last_workfile_path) + + +def recreate_instance(origin_node, avalon_data=None): + """Recreate input instance to different data + + Args: + origin_node (nuke.Node): Nuke node to be recreating from + avalon_data (dict, optional): data to be used in new node avalon_data + + Returns: + nuke.Node: newly created node + """ + knobs_wl = ["render", "publish", "review", "ypos", + "use_limit", "first", "last"] + # get data from avalon knobs + data = anlib.get_avalon_knob_data( + origin_node) + + # add input data to avalon data + if avalon_data: + data.update(avalon_data) + + # capture all node knobs allowed in op_knobs + knobs_data = {k: origin_node[k].value() + for k in origin_node.knobs() + for key in knobs_wl + if key in k} + + # get node dependencies + inputs = origin_node.dependencies() + outputs = origin_node.dependent() + + # remove the node + nuke.delete(origin_node) + + # create new node + # get appropriate plugin class + creator_plugin = None + for Creator in api.discover(api.Creator): + if Creator.__name__ == data["creator"]: + creator_plugin = Creator + break + + # create write node with creator + new_node_name = data["subset"] + new_node = creator_plugin(new_node_name, data["asset"]).process() + + # white listed knobs to the new node + for _k, _v in knobs_data.items(): + try: + print(_k, _v) + new_node[_k].setValue(_v) + except Exception as e: + print(e) + + # connect to original inputs + for i, n in enumerate(inputs): + new_node.setInput(i, n) + + # connect to outputs + if len(outputs) > 0: + for dn in outputs: + dn.setInput(0, new_node) + + return new_node diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_instances.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_instances.py index 60a8cf48fc..3a9a7a3445 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_instances.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_instances.py @@ -182,7 +182,7 @@ class CollectInstances(pyblish.api.InstancePlugin): }) for subset, properities in self.subsets.items(): version = properities.get("version") - if version and version == 0: + if version == 0: properities.pop("version") # adding Review-able instance diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_hierarchy.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_hierarchy.py index ba2aed4bfc..acad98d784 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_hierarchy.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_hierarchy.py @@ -37,7 +37,7 @@ class CollectHierarchyInstance(pyblish.api.ContextPlugin): # return if any if entity_type: - return {"entityType": entity_type, "entityName": value} + return {"entity_type": entity_type, "entity_name": value} def rename_with_hierarchy(self, instance): search_text = "" @@ -76,8 +76,8 @@ class CollectHierarchyInstance(pyblish.api.ContextPlugin): # add current selection context hierarchy from standalonepublisher for entity in reversed(visual_hierarchy): parents.append({ - "entityType": entity["data"]["entityType"], - "entityName": entity["name"] + "entity_type": entity["data"]["entityType"], + "entity_name": entity["name"] }) if self.shot_add_hierarchy: @@ -98,7 +98,7 @@ class CollectHierarchyInstance(pyblish.api.ContextPlugin): # in case SP context is set to the same folder if (_index == 0) and ("folder" in parent_key) \ - and (parents[-1]["entityName"] == parent_filled): + and (parents[-1]["entity_name"] == parent_filled): self.log.debug(f" skiping : {parent_filled}") continue @@ -280,9 +280,9 @@ class CollectHierarchyContext(pyblish.api.ContextPlugin): for parent in reversed(parents): next_dict = {} - parent_name = parent["entityName"] + parent_name = parent["entity_name"] next_dict[parent_name] = {} - next_dict[parent_name]["entity_type"] = parent["entityType"] + next_dict[parent_name]["entity_type"] = parent["entity_type"] next_dict[parent_name]["childs"] = actual actual = next_dict diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py new file mode 100644 index 0000000000..d70a0a75b8 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py @@ -0,0 +1,456 @@ +import os +import re +import pyblish.api +import json + +from avalon.api import format_template_with_optional_keys + +from openpype.lib import prepare_template_data + + +class CollectTextures(pyblish.api.ContextPlugin): + """Collect workfile (and its resource_files) and textures. + + Currently implements use case with Mari and Substance Painter, where + one workfile is main (.mra - Mari) with possible additional workfiles + (.spp - Substance) + + + Provides: + 1 instance per workfile (with 'resources' filled if needed) + (workfile family) + 1 instance per group of textures + (textures family) + """ + + order = pyblish.api.CollectorOrder + label = "Collect Textures" + hosts = ["standalonepublisher"] + families = ["texture_batch"] + actions = [] + + # from presets + main_workfile_extensions = ['mra'] + other_workfile_extensions = ['spp', 'psd'] + texture_extensions = ["exr", "dpx", "jpg", "jpeg", "png", "tiff", "tga", + "gif", "svg"] + + # additional families (ftrack etc.) + workfile_families = [] + textures_families = [] + + color_space = ["linsRGB", "raw", "acesg"] + + # currently implemented placeholders ["color_space"] + # describing patterns in file names splitted by regex groups + input_naming_patterns = { + # workfile: corridorMain_v001.mra > + # texture: corridorMain_aluminiumID_v001_baseColor_linsRGB_1001.exr + "workfile": r'^([^.]+)(_[^_.]*)?_v([0-9]{3,}).+', + "textures": r'^([^_.]+)_([^_.]+)_v([0-9]{3,})_([^_.]+)_({color_space})_(1[0-9]{3}).+', # noqa + } + # matching regex group position to 'input_naming_patterns' + input_naming_groups = { + "workfile": ('asset', 'filler', 'version'), + "textures": ('asset', 'shader', 'version', 'channel', 'color_space', + 'udim') + } + + workfile_subset_template = "textures{Subset}Workfile" + # implemented keys: ["color_space", "channel", "subset", "shader"] + texture_subset_template = "textures{Subset}_{Shader}_{Channel}" + + def process(self, context): + self.context = context + + resource_files = {} + workfile_files = {} + representations = {} + version_data = {} + asset_builds = set() + asset = None + for instance in context: + if not self.input_naming_patterns: + raise ValueError("Naming patterns are not configured. \n" + "Ask admin to provide naming conventions " + "for workfiles and textures.") + + if not asset: + asset = instance.data["asset"] # selected from SP + + parsed_subset = instance.data["subset"].replace( + instance.data["family"], '') + + fill_pairs = { + "subset": parsed_subset + } + + fill_pairs = prepare_template_data(fill_pairs) + workfile_subset = format_template_with_optional_keys( + fill_pairs, self.workfile_subset_template) + + processed_instance = False + for repre in instance.data["representations"]: + ext = repre["ext"].replace('.', '') + asset_build = version = None + + if isinstance(repre["files"], list): + repre_file = repre["files"][0] + else: + repre_file = repre["files"] + + if ext in self.main_workfile_extensions or \ + ext in self.other_workfile_extensions: + + asset_build = self._get_asset_build( + repre_file, + self.input_naming_patterns["workfile"], + self.input_naming_groups["workfile"], + self.color_space + ) + version = self._get_version( + repre_file, + self.input_naming_patterns["workfile"], + self.input_naming_groups["workfile"], + self.color_space + ) + asset_builds.add((asset_build, version, + workfile_subset, 'workfile')) + processed_instance = True + + if not representations.get(workfile_subset): + representations[workfile_subset] = [] + + if ext in self.main_workfile_extensions: + # workfiles can have only single representation + # currently OP is not supporting different extensions in + # representation files + representations[workfile_subset] = [repre] + + workfile_files[asset_build] = repre_file + + if ext in self.other_workfile_extensions: + # add only if not added already from main + if not representations.get(workfile_subset): + representations[workfile_subset] = [repre] + + # only overwrite if not present + if not workfile_files.get(asset_build): + workfile_files[asset_build] = repre_file + + if not resource_files.get(workfile_subset): + resource_files[workfile_subset] = [] + item = { + "files": [os.path.join(repre["stagingDir"], + repre["files"])], + "source": "standalone publisher" + } + resource_files[workfile_subset].append(item) + + if ext in self.texture_extensions: + c_space = self._get_color_space( + repre_file, + self.color_space + ) + + channel = self._get_channel_name( + repre_file, + self.input_naming_patterns["textures"], + self.input_naming_groups["textures"], + self.color_space + ) + + shader = self._get_shader_name( + repre_file, + self.input_naming_patterns["textures"], + self.input_naming_groups["textures"], + self.color_space + ) + + formatting_data = { + "color_space": c_space or '', # None throws exception + "channel": channel or '', + "shader": shader or '', + "subset": parsed_subset or '' + } + + fill_pairs = prepare_template_data(formatting_data) + subset = format_template_with_optional_keys( + fill_pairs, self.texture_subset_template) + + asset_build = self._get_asset_build( + repre_file, + self.input_naming_patterns["textures"], + self.input_naming_groups["textures"], + self.color_space + ) + version = self._get_version( + repre_file, + self.input_naming_patterns["textures"], + self.input_naming_groups["textures"], + self.color_space + ) + if not representations.get(subset): + representations[subset] = [] + representations[subset].append(repre) + + ver_data = { + "color_space": c_space or '', + "channel_name": channel or '', + "shader_name": shader or '' + } + version_data[subset] = ver_data + + asset_builds.add( + (asset_build, version, subset, "textures")) + processed_instance = True + + if processed_instance: + self.context.remove(instance) + + self._create_new_instances(context, + asset, + asset_builds, + resource_files, + representations, + version_data, + workfile_files) + + def _create_new_instances(self, context, asset, asset_builds, + resource_files, representations, + version_data, workfile_files): + """Prepare new instances from collected data. + + Args: + context (ContextPlugin) + asset (string): selected asset from SP + asset_builds (set) of tuples + (asset_build, version, subset, family) + resource_files (list) of resource dicts - to store additional + files to main workfile + representations (list) of dicts - to store workfile info OR + all collected texture files, key is asset_build + version_data (dict) - prepared to store into version doc in DB + workfile_files (dict) - to store workfile to add to textures + key is asset_build + """ + # sort workfile first + asset_builds = sorted(asset_builds, + key=lambda tup: tup[3], reverse=True) + + # workfile must have version, textures might + main_version = None + for asset_build, version, subset, family in asset_builds: + if not main_version: + main_version = version + new_instance = context.create_instance(subset) + new_instance.data.update( + { + "subset": subset, + "asset": asset, + "label": subset, + "name": subset, + "family": family, + "version": int(version or main_version or 1), + "asset_build": asset_build # remove in validator + } + ) + + workfile = workfile_files.get(asset_build) + + if resource_files.get(subset): + # add resources only when workfile is main style + for ext in self.main_workfile_extensions: + if ext in workfile: + new_instance.data.update({ + "resources": resource_files.get(subset) + }) + break + + # store origin + if family == 'workfile': + families = self.workfile_families + + new_instance.data["source"] = "standalone publisher" + else: + families = self.textures_families + + repre = representations.get(subset)[0] + new_instance.context.data["currentFile"] = os.path.join( + repre["stagingDir"], workfile or 'dummy.txt') + + new_instance.data["families"] = families + + # add data for version document + ver_data = version_data.get(subset) + if ver_data: + if workfile: + ver_data['workfile'] = workfile + + new_instance.data.update( + {"versionData": ver_data} + ) + + upd_representations = representations.get(subset) + if upd_representations and family != 'workfile': + upd_representations = self._update_representations( + upd_representations) + + new_instance.data["representations"] = upd_representations + + self.log.debug("new instance - {}:: {}".format( + family, + json.dumps(new_instance.data, indent=4))) + + def _get_asset_build(self, name, + input_naming_patterns, input_naming_groups, + color_spaces): + """Loops through configured workfile patterns to find asset name. + + Asset name used to bind workfile and its textures. + + Args: + name (str): workfile name + input_naming_patterns (list): + [workfile_pattern] or [texture_pattern] + input_naming_groups (list) + ordinal position of regex groups matching to input_naming.. + color_spaces (list) - predefined color spaces + """ + asset_name = "NOT_AVAIL" + + return self._parse(name, input_naming_patterns, input_naming_groups, + color_spaces, 'asset') or asset_name + + def _get_version(self, name, input_naming_patterns, input_naming_groups, + color_spaces): + found = self._parse(name, input_naming_patterns, input_naming_groups, + color_spaces, 'version') + + if found: + return found.replace('v', '') + + self.log.info("No version found in the name {}".format(name)) + + def _get_udim(self, name, input_naming_patterns, input_naming_groups, + color_spaces): + """Parses from 'name' udim value.""" + found = self._parse(name, input_naming_patterns, input_naming_groups, + color_spaces, 'udim') + if found: + return found + + self.log.warning("Didn't find UDIM in {}".format(name)) + + def _get_color_space(self, name, color_spaces): + """Looks for color_space from a list in a file name. + + Color space seems not to be recognizable by regex pattern, set of + known space spaces must be provided. + """ + color_space = None + found = [cs for cs in color_spaces if + re.search("_{}_".format(cs), name)] + + if not found: + self.log.warning("No color space found in {}".format(name)) + else: + if len(found) > 1: + msg = "Multiple color spaces found in {}->{}".format(name, + found) + self.log.warning(msg) + + color_space = found[0] + + return color_space + + def _get_shader_name(self, name, input_naming_patterns, + input_naming_groups, color_spaces): + """Return parsed shader name. + + Shader name is needed for overlapping udims (eg. udims might be + used for different materials, shader needed to not overwrite). + + Unknown format of channel name and color spaces >> cs are known + list - 'color_space' used as a placeholder + """ + found = self._parse(name, input_naming_patterns, input_naming_groups, + color_spaces, 'shader') + if found: + return found + + self.log.warning("Didn't find shader in {}".format(name)) + + def _get_channel_name(self, name, input_naming_patterns, + input_naming_groups, color_spaces): + """Return parsed channel name. + + Unknown format of channel name and color spaces >> cs are known + list - 'color_space' used as a placeholder + """ + found = self._parse(name, input_naming_patterns, input_naming_groups, + color_spaces, 'channel') + if found: + return found + + self.log.warning("Didn't find channel in {}".format(name)) + + def _parse(self, name, input_naming_patterns, input_naming_groups, + color_spaces, key): + """Universal way to parse 'name' with configurable regex groups. + + Args: + name (str): workfile name + input_naming_patterns (list): + [workfile_pattern] or [texture_pattern] + input_naming_groups (list) + ordinal position of regex groups matching to input_naming.. + color_spaces (list) - predefined color spaces + + Raises: + ValueError - if broken 'input_naming_groups' + """ + for input_pattern in input_naming_patterns: + for cs in color_spaces: + pattern = input_pattern.replace('{color_space}', cs) + regex_result = re.findall(pattern, name) + if regex_result: + idx = list(input_naming_groups).index(key) + if idx < 0: + msg = "input_naming_groups must " +\ + "have '{}' key".format(key) + raise ValueError(msg) + + try: + parsed_value = regex_result[0][idx] + return parsed_value + except IndexError: + self.log.warning("Wrong index, probably " + "wrong name {}".format(name)) + + def _update_representations(self, upd_representations): + """Frames dont have sense for textures, add collected udims instead.""" + udims = [] + for repre in upd_representations: + repre.pop("frameStart", None) + repre.pop("frameEnd", None) + repre.pop("fps", None) + + # ignore unique name from SP, use extension instead + # SP enforces unique name, here different subsets >> unique repres + repre["name"] = repre["ext"].replace('.', '') + + files = repre.get("files", []) + if not isinstance(files, list): + files = [files] + + for file_name in files: + udim = self._get_udim(file_name, + self.input_naming_patterns["textures"], + self.input_naming_groups["textures"], + self.color_space) + udims.append(udim) + + repre["udim"] = udims # must be this way, used for filling path + + return upd_representations diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_resources.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_resources.py new file mode 100644 index 0000000000..1183180833 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_resources.py @@ -0,0 +1,42 @@ +import os +import pyblish.api + + +class ExtractResources(pyblish.api.InstancePlugin): + """ + Extracts files from instance.data["resources"]. + + These files are additional (textures etc.), currently not stored in + representations! + + Expects collected 'resourcesDir'. (list of dicts with 'files' key and + list of source urls) + + Provides filled 'transfers' (list of tuples (source_url, target_url)) + """ + + label = "Extract Resources SP" + hosts = ["standalonepublisher"] + order = pyblish.api.ExtractorOrder + + families = ["workfile"] + + def process(self, instance): + if not instance.data.get("resources"): + self.log.info("No resources") + return + + if not instance.data.get("transfers"): + instance.data["transfers"] = [] + + publish_dir = instance.data["resourcesDir"] + + transfers = [] + for resource in instance.data["resources"]: + for file_url in resource.get("files", []): + file_name = os.path.basename(file_url) + dest_url = os.path.join(publish_dir, file_name) + transfers.append((file_url, dest_url)) + + self.log.info("transfers:: {}".format(transfers)) + instance.data["transfers"].extend(transfers) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py index eb613fa951..059ac9603c 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py @@ -60,7 +60,7 @@ class ExtractTrimVideoAudio(openpype.api.Extractor): ] args = [ - ffmpeg_path, + f"\"{ffmpeg_path}\"", "-ss", str(start / fps), "-i", f"\"{video_file_path}\"", "-t", str(dur / fps) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_workfile_location.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_workfile_location.py new file mode 100644 index 0000000000..18bf0394ae --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_workfile_location.py @@ -0,0 +1,43 @@ +import os +import pyblish.api + + +class ExtractWorkfileUrl(pyblish.api.ContextPlugin): + """ + Modifies 'workfile' field to contain link to published workfile. + + Expects that batch contains only single workfile and matching + (multiple) textures. + """ + + label = "Extract Workfile Url SP" + hosts = ["standalonepublisher"] + order = pyblish.api.ExtractorOrder + + families = ["textures"] + + def process(self, context): + filepath = None + + # first loop for workfile + for instance in context: + if instance.data["family"] == 'workfile': + anatomy = context.data['anatomy'] + template_data = instance.data.get("anatomyData") + rep_name = instance.data.get("representations")[0].get("name") + template_data["representation"] = rep_name + template_data["ext"] = rep_name + anatomy_filled = anatomy.format(template_data) + template_filled = anatomy_filled["publish"]["path"] + filepath = os.path.normpath(template_filled) + self.log.info("Using published scene for render {}".format( + filepath)) + + if not filepath: + self.log.info("Texture batch doesn't contain workfile.") + return + + # then apply to all textures + for instance in context: + if instance.data["family"] == 'textures': + instance.data["versionData"]["workfile"] = filepath diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py new file mode 100644 index 0000000000..af200b59e0 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py @@ -0,0 +1,22 @@ +import pyblish.api +import openpype.api + + +class ValidateTextureBatch(pyblish.api.InstancePlugin): + """Validates that some texture files are present.""" + + label = "Validate Texture Presence" + hosts = ["standalonepublisher"] + order = openpype.api.ValidateContentsOrder + families = ["workfile"] + optional = False + + def process(self, instance): + present = False + for instance in instance.context: + if instance.data["family"] == "textures": + self.log.info("Some textures present.") + + return + + assert present, "No textures found in published batch!" diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_has_workfile.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_has_workfile.py new file mode 100644 index 0000000000..7cd540668c --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_has_workfile.py @@ -0,0 +1,20 @@ +import pyblish.api +import openpype.api + + +class ValidateTextureHasWorkfile(pyblish.api.InstancePlugin): + """Validates that textures have appropriate workfile attached. + + Workfile is optional, disable this Validator after Refresh if you are + sure it is not needed. + """ + label = "Validate Texture Has Workfile" + hosts = ["standalonepublisher"] + order = openpype.api.ValidateContentsOrder + families = ["textures"] + optional = True + + def process(self, instance): + wfile = instance.data["versionData"].get("workfile") + + assert wfile, "Textures are missing attached workfile" diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py new file mode 100644 index 0000000000..92f930c3fc --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py @@ -0,0 +1,50 @@ +import pyblish.api +import openpype.api + + +class ValidateTextureBatchNaming(pyblish.api.InstancePlugin): + """Validates that all instances had properly formatted name.""" + + label = "Validate Texture Batch Naming" + hosts = ["standalonepublisher"] + order = openpype.api.ValidateContentsOrder + families = ["workfile", "textures"] + optional = False + + def process(self, instance): + file_name = instance.data["representations"][0]["files"] + if isinstance(file_name, list): + file_name = file_name[0] + + msg = "Couldnt find asset name in '{}'\n".format(file_name) + \ + "File name doesn't follow configured pattern.\n" + \ + "Please rename the file." + assert "NOT_AVAIL" not in instance.data["asset_build"], msg + + instance.data.pop("asset_build") + + if instance.data["family"] == "textures": + file_name = instance.data["representations"][0]["files"][0] + self._check_proper_collected(instance.data["versionData"], + file_name) + + def _check_proper_collected(self, versionData, file_name): + """ + Loop through collected versionData to check if name parsing was OK. + Args: + versionData: (dict) + + Returns: + raises AssertionException + """ + missing_key_values = [] + for key, value in versionData.items(): + if not value: + missing_key_values.append(key) + + msg = "Collected data {} doesn't contain values for {}".format( + versionData, missing_key_values) + "\n" + \ + "Name of the texture file doesn't match expected pattern.\n" + \ + "Please rename file(s) {}".format(file_name) + + assert not missing_key_values, msg diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_versions.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_versions.py new file mode 100644 index 0000000000..90d0e8e512 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_versions.py @@ -0,0 +1,38 @@ +import pyblish.api +import openpype.api + + +class ValidateTextureBatchVersions(pyblish.api.InstancePlugin): + """Validates that versions match in workfile and textures. + + Workfile is optional, so if you are sure, you can disable this + validator after Refresh. + + Validates that only single version is published at a time. + """ + label = "Validate Texture Batch Versions" + hosts = ["standalonepublisher"] + order = openpype.api.ValidateContentsOrder + families = ["textures"] + optional = False + + def process(self, instance): + wfile = instance.data["versionData"].get("workfile") + + version_str = "v{:03d}".format(instance.data["version"]) + + if not wfile: # no matching workfile, do not check versions + self.log.info("No workfile present for textures") + return + + msg = "Not matching version: texture v{:03d} - workfile {}" + assert version_str in wfile, \ + msg.format( + instance.data["version"], wfile + ) + + present_versions = set() + for instance in instance.context: + present_versions.add(instance.data["version"]) + + assert len(present_versions) == 1, "Too many versions in a batch!" diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py new file mode 100644 index 0000000000..aa3aad71db --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py @@ -0,0 +1,29 @@ +import pyblish.api +import openpype.api + + +class ValidateTextureBatchWorkfiles(pyblish.api.InstancePlugin): + """Validates that textures workfile has collected resources (optional). + + Collected recourses means secondary workfiles (in most cases). + """ + + label = "Validate Texture Workfile Has Resources" + hosts = ["standalonepublisher"] + order = openpype.api.ValidateContentsOrder + families = ["workfile"] + optional = True + + # from presets + main_workfile_extensions = ['mra'] + + def process(self, instance): + if instance.data["family"] == "workfile": + ext = instance.data["representations"][0]["ext"] + if ext not in self.main_workfile_extensions: + self.log.warning("Only secondary workfile present!") + return + + msg = "No secondary workfiles present for workfile {}".\ + format(instance.data["name"]) + assert instance.data.get("resources"), msg diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py index d8bb03f541..79cc01740a 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py @@ -155,6 +155,7 @@ class CollectWorkfileData(pyblish.api.ContextPlugin): "sceneMarkInState": mark_in_state == "set", "sceneMarkOut": int(mark_out_frame), "sceneMarkOutState": mark_out_state == "set", + "sceneStartFrame": int(lib.execute_george("tv_startframe")), "sceneBgColor": self._get_bg_color() } self.log.debug( diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py index 536df2adb0..1df7512588 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py @@ -49,6 +49,14 @@ class ExtractSequence(pyblish.api.Extractor): family_lowered = instance.data["family"].lower() mark_in = instance.context.data["sceneMarkIn"] mark_out = instance.context.data["sceneMarkOut"] + + # Scene start frame offsets the output files, so we need to offset the + # marks. + scene_start_frame = instance.context.data["sceneStartFrame"] + difference = scene_start_frame - mark_in + mark_in += difference + mark_out += difference + # Frame start/end may be stored as float frame_start = int(instance.data["frameStart"]) frame_end = int(instance.data["frameEnd"]) @@ -98,7 +106,7 @@ class ExtractSequence(pyblish.api.Extractor): self.log.warning(( "Lowering representation range to {} frames." " Changed frame end {} -> {}" - ).format(output_range + 1, mark_out, new_mark_out)) + ).format(output_range + 1, mark_out, new_output_frame_end)) output_frame_end = new_output_frame_end # ------------------------------------------------------------------- diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py b/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py new file mode 100644 index 0000000000..d769d47736 --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py @@ -0,0 +1,27 @@ +import pyblish.api +from avalon.tvpaint import lib + + +class RepairStartFrame(pyblish.api.Action): + """Repair start frame.""" + + label = "Repair" + icon = "wrench" + on = "failed" + + def process(self, context, plugin): + lib.execute_george("tv_startframe 0") + + +class ValidateStartFrame(pyblish.api.ContextPlugin): + """Validate start frame being at frame 0.""" + + label = "Validate Start Frame" + order = pyblish.api.ValidatorOrder + hosts = ["tvpaint"] + actions = [RepairStartFrame] + optional = True + + def process(self, context): + start_frame = lib.execute_george("tv_startframe") + assert int(start_frame) == 0, "Start frame has to be frame 0." diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index e1b304a351..ada194f15f 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1302,10 +1302,18 @@ def _prepare_last_workfile(data, workdir): ) data["start_last_workfile"] = start_last_workfile + workfile_startup = should_workfile_tool_start( + project_name, app.host_name, task_name + ) + data["workfile_startup"] = workfile_startup + # Store boolean as "0"(False) or "1"(True) data["env"]["AVALON_OPEN_LAST_WORKFILE"] = ( str(int(bool(start_last_workfile))) ) + data["env"]["OPENPYPE_WORKFILE_TOOL_ON_START"] = ( + str(int(bool(workfile_startup))) + ) _sub_msg = "" if start_last_workfile else " not" log.debug( @@ -1344,40 +1352,9 @@ def _prepare_last_workfile(data, workdir): data["last_workfile_path"] = last_workfile_path -def should_start_last_workfile( - project_name, host_name, task_name, default_output=False +def get_option_from_settings( + startup_presets, host_name, task_name, default_output ): - """Define if host should start last version workfile if possible. - - Default output is `False`. Can be overriden with environment variable - `AVALON_OPEN_LAST_WORKFILE`, valid values without case sensitivity are - `"0", "1", "true", "false", "yes", "no"`. - - Args: - project_name (str): Name of project. - host_name (str): Name of host which is launched. In avalon's - application context it's value stored in app definition under - key `"application_dir"`. Is not case sensitive. - task_name (str): Name of task which is used for launching the host. - Task name is not case sensitive. - - Returns: - bool: True if host should start workfile. - - """ - - project_settings = get_project_settings(project_name) - startup_presets = ( - project_settings - ["global"] - ["tools"] - ["Workfiles"] - ["last_workfile_on_startup"] - ) - - if not startup_presets: - return default_output - host_name_lowered = host_name.lower() task_name_lowered = task_name.lower() @@ -1421,6 +1398,82 @@ def should_start_last_workfile( return default_output +def should_start_last_workfile( + project_name, host_name, task_name, default_output=False +): + """Define if host should start last version workfile if possible. + + Default output is `False`. Can be overriden with environment variable + `AVALON_OPEN_LAST_WORKFILE`, valid values without case sensitivity are + `"0", "1", "true", "false", "yes", "no"`. + + Args: + project_name (str): Name of project. + host_name (str): Name of host which is launched. In avalon's + application context it's value stored in app definition under + key `"application_dir"`. Is not case sensitive. + task_name (str): Name of task which is used for launching the host. + Task name is not case sensitive. + + Returns: + bool: True if host should start workfile. + + """ + + project_settings = get_project_settings(project_name) + startup_presets = ( + project_settings + ["global"] + ["tools"] + ["Workfiles"] + ["last_workfile_on_startup"] + ) + + if not startup_presets: + return default_output + + return get_option_from_settings( + startup_presets, host_name, task_name, default_output) + + +def should_workfile_tool_start( + project_name, host_name, task_name, default_output=False +): + """Define if host should start workfile tool at host launch. + + Default output is `False`. Can be overriden with environment variable + `OPENPYPE_WORKFILE_TOOL_ON_START`, valid values without case sensitivity are + `"0", "1", "true", "false", "yes", "no"`. + + Args: + project_name (str): Name of project. + host_name (str): Name of host which is launched. In avalon's + application context it's value stored in app definition under + key `"application_dir"`. Is not case sensitive. + task_name (str): Name of task which is used for launching the host. + Task name is not case sensitive. + + Returns: + bool: True if host should start workfile. + + """ + + project_settings = get_project_settings(project_name) + startup_presets = ( + project_settings + ["global"] + ["tools"] + ["Workfiles"] + ["open_workfile_tool_on_startup"] + ) + + if not startup_presets: + return default_output + + return get_option_from_settings( + startup_presets, host_name, task_name, default_output) + + def compile_list_of_regexes(in_list): """Convert strings in entered list to compiled regex objects.""" regexes = list() diff --git a/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py b/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py index 12d687bbf2..3a96ae3311 100644 --- a/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py +++ b/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py @@ -1,6 +1,8 @@ import json +from avalon.api import AvalonMongoDB from openpype.api import ProjectSettings +from openpype.lib import create_project from openpype.modules.ftrack.lib import ( ServerAction, @@ -21,8 +23,24 @@ class PrepareProjectServer(ServerAction): role_list = ["Pypeclub", "Administrator", "Project Manager"] - # Key to store info about trigerring create folder structure + settings_key = "prepare_project" + item_splitter = {"type": "label", "value": "---"} + _keys_order = ( + "fps", + "frameStart", + "frameEnd", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "resolutionHeight", + "resolutionWidth", + "pixelAspect", + "applications", + "tools_env", + "library_project", + ) def discover(self, session, entities, event): """Show only on project.""" @@ -47,13 +65,7 @@ class PrepareProjectServer(ServerAction): project_entity = entities[0] project_name = project_entity["full_name"] - try: - project_settings = ProjectSettings(project_name) - except ValueError: - return { - "message": "Project is not synchronized yet", - "success": False - } + project_settings = ProjectSettings(project_name) project_anatom_settings = project_settings["project_anatomy"] root_items = self.prepare_root_items(project_anatom_settings) @@ -78,14 +90,13 @@ class PrepareProjectServer(ServerAction): items.extend(ca_items) - # This item will be last (before enumerators) - # - sets value of auto synchronization - auto_sync_name = "avalon_auto_sync" + # This item will be last before enumerators + # Set value of auto synchronization auto_sync_value = project_entity["custom_attributes"].get( CUST_ATTR_AUTO_SYNC, False ) auto_sync_item = { - "name": auto_sync_name, + "name": CUST_ATTR_AUTO_SYNC, "type": "boolean", "value": auto_sync_value, "label": "AutoSync to Avalon" @@ -199,7 +210,18 @@ class PrepareProjectServer(ServerAction): str([key for key in attributes_to_set]) )) - for key, in_data in attributes_to_set.items(): + attribute_keys = set(attributes_to_set.keys()) + keys_order = [] + for key in self._keys_order: + if key in attribute_keys: + keys_order.append(key) + + attribute_keys = attribute_keys - set(keys_order) + for key in sorted(attribute_keys): + keys_order.append(key) + + for key in keys_order: + in_data = attributes_to_set[key] attr = in_data["object"] # initial item definition @@ -225,7 +247,7 @@ class PrepareProjectServer(ServerAction): multiselect_enumerators.append(self.item_splitter) multiselect_enumerators.append({ "type": "label", - "value": in_data["label"] + "value": "
For example:
^.*(?P=<shader>.+)_GEO
For example:
^.*(?P=<shader>.+)_GEO
(?P<asset>.*) for Asset name(?P<subset>.*) for Subset(?P<project>.*) for projectFor example to check for asset in name so *_some_asset_name_GRP is valid, use:.*?_(?P<asset>.*)_GEO"
+ },
+ {
+ "type": "text",
+ "key": "top_level_regex",
+ "label": "Top level group name regex"
}
]
},
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_scriptsmenu.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_scriptsmenu.json
new file mode 100644
index 0000000000..e841d6ba77
--- /dev/null
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_scriptsmenu.json
@@ -0,0 +1,22 @@
+{
+ "type": "dict",
+ "collapsible": true,
+ "key": "scriptsmenu",
+ "label": "Scripts Menu Definition",
+ "children": [
+ {
+ "type": "text",
+ "key": "name",
+ "label": "Menu Name"
+ },
+ {
+ "type": "splitter"
+ },
+ {
+ "type": "raw-json",
+ "key": "definition",
+ "label": "Menu definition",
+ "is_list": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/openpype/settings/entities/schemas/system_schema/example_schema.json b/openpype/settings/entities/schemas/system_schema/example_schema.json
index c3287d7452..8ec97064a1 100644
--- a/openpype/settings/entities/schemas/system_schema/example_schema.json
+++ b/openpype/settings/entities/schemas/system_schema/example_schema.json
@@ -9,6 +9,31 @@
"label": "Color input",
"type": "color"
},
+ {
+ "type": "dict-conditional",
+ "key": "overriden_value",
+ "label": "Overriden value",
+ "enum_key": "overriden",
+ "enum_is_horizontal": true,
+ "enum_children": [
+ {
+ "key": "overriden",
+ "label": "Override value",
+ "children": [
+ {
+ "type": "number",
+ "key": "value",
+ "label": "value"
+ }
+ ]
+ },
+ {
+ "key": "inherit",
+ "label": "Inherit value",
+ "children": []
+ }
+ ]
+ },
{
"type": "dict-conditional",
"use_label_wrap": true,
diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py
index 4a3e66de33..5c2c0dcd94 100644
--- a/openpype/settings/lib.py
+++ b/openpype/settings/lib.py
@@ -315,14 +315,28 @@ class DuplicatedEnvGroups(Exception):
super(DuplicatedEnvGroups, self).__init__(msg)
+def load_openpype_default_settings():
+ """Load openpype default settings."""
+ return load_jsons_from_dir(DEFAULTS_DIR)
+
+
def reset_default_settings():
+ """Reset cache of default settings. Can't be used now."""
global _DEFAULT_SETTINGS
_DEFAULT_SETTINGS = None
def get_default_settings():
+ """Get default settings.
+
+ Todo:
+ Cache loaded defaults.
+
+ Returns:
+ dict: Loaded default settings.
+ """
# TODO add cacher
- return load_jsons_from_dir(DEFAULTS_DIR)
+ return load_openpype_default_settings()
# global _DEFAULT_SETTINGS
# if _DEFAULT_SETTINGS is None:
# _DEFAULT_SETTINGS = load_jsons_from_dir(DEFAULTS_DIR)
@@ -868,6 +882,25 @@ def get_environments():
return find_environments(get_system_settings(False))
+def get_general_environments():
+ """Get general environments.
+
+ Function is implemented to be able load general environments without using
+ `get_default_settings`.
+ """
+ # Use only openpype defaults.
+ # - prevent to use `get_system_settings` where `get_default_settings`
+ # is used
+ default_values = load_openpype_default_settings()
+ studio_overrides = get_studio_system_settings_overrides()
+ result = apply_overrides(default_values, studio_overrides)
+ environments = result["general"]["environment"]
+
+ clear_metadata_from_settings(environments)
+
+ return environments
+
+
def clear_metadata_from_settings(values):
"""Remove all metadata keys from loaded settings."""
if isinstance(values, dict):
diff --git a/openpype/style/style.css b/openpype/style/style.css
index c57b9a8da6..b955bdc2a6 100644
--- a/openpype/style/style.css
+++ b/openpype/style/style.css
@@ -35,6 +35,10 @@ QWidget:disabled {
color: {color:font-disabled};
}
+QLabel {
+ background: transparent;
+}
+
/* Inputs */
QAbstractSpinBox, QLineEdit, QPlainTextEdit, QTextEdit {
border: 1px solid {color:border};
@@ -97,7 +101,7 @@ QToolButton:disabled {
background: {color:bg-buttons-disabled};
}
-QToolButton[popupMode="1"] {
+QToolButton[popupMode="1"], QToolButton[popupMode="MenuButtonPopup"] {
/* make way for the popup button */
padding-right: 20px;
border: 1px solid {color:bg-buttons};
@@ -340,6 +344,11 @@ QAbstractItemView {
selection-background-color: transparent;
}
+QAbstractItemView::item {
+ /* `border: none` hide outline of selected item. */
+ border: none;
+}
+
QAbstractItemView:disabled{
background: {color:bg-view-disabled};
alternate-background-color: {color:bg-view-alternate-disabled};
diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py
index 0aacd590db..8be3eddfa8 100644
--- a/openpype/tools/settings/settings/categories.py
+++ b/openpype/tools/settings/settings/categories.py
@@ -294,6 +294,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget):
msg = "
".join(warnings)
dialog = QtWidgets.QMessageBox(self)
+ dialog.setWindowTitle("Save warnings")
dialog.setText(msg)
dialog.setIcon(QtWidgets.QMessageBox.Warning)
dialog.exec_()
@@ -303,6 +304,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget):
except Exception as exc:
formatted_traceback = traceback.format_exception(*sys.exc_info())
dialog = QtWidgets.QMessageBox(self)
+ dialog.setWindowTitle("Unexpected error")
msg = "Unexpected error happened!\n\nError: {}".format(str(exc))
dialog.setText(msg)
dialog.setDetailedText("\n".join(formatted_traceback))
@@ -392,6 +394,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget):
except Exception as exc:
formatted_traceback = traceback.format_exception(*sys.exc_info())
dialog = QtWidgets.QMessageBox(self)
+ dialog.setWindowTitle("Unexpected error")
msg = "Unexpected error happened!\n\nError: {}".format(str(exc))
dialog.setText(msg)
dialog.setDetailedText("\n".join(formatted_traceback))
diff --git a/openpype/tools/settings/settings/dict_conditional.py b/openpype/tools/settings/settings/dict_conditional.py
index da2f53497e..31a4fa9fab 100644
--- a/openpype/tools/settings/settings/dict_conditional.py
+++ b/openpype/tools/settings/settings/dict_conditional.py
@@ -24,6 +24,7 @@ class DictConditionalWidget(BaseWidget):
self.body_widget = None
self.content_widget = None
self.content_layout = None
+ self.enum_layout = None
label = None
if self.entity.is_dynamic_item:
@@ -40,8 +41,36 @@ class DictConditionalWidget(BaseWidget):
self._enum_key_by_wrapper_id = {}
self._added_wrapper_ids = set()
- self.content_layout.setColumnStretch(0, 0)
- self.content_layout.setColumnStretch(1, 1)
+ enum_layout = QtWidgets.QGridLayout()
+ enum_layout.setContentsMargins(0, 0, 0, 0)
+ enum_layout.setColumnStretch(0, 0)
+ enum_layout.setColumnStretch(1, 1)
+
+ all_children_layout = QtWidgets.QVBoxLayout()
+ all_children_layout.setContentsMargins(0, 0, 0, 0)
+
+ if self.entity.enum_is_horizontal:
+ if self.entity.enum_on_right:
+ self.content_layout.addLayout(all_children_layout, 0, 0)
+ self.content_layout.addLayout(enum_layout, 0, 1)
+ # Stretch combobox to minimum and expand value
+ self.content_layout.setColumnStretch(0, 1)
+ self.content_layout.setColumnStretch(1, 0)
+ else:
+ self.content_layout.addLayout(enum_layout, 0, 0)
+ self.content_layout.addLayout(all_children_layout, 0, 1)
+ # Stretch combobox to minimum and expand value
+ self.content_layout.setColumnStretch(0, 0)
+ self.content_layout.setColumnStretch(1, 1)
+
+ else:
+ # Expand content
+ self.content_layout.setColumnStretch(0, 1)
+ self.content_layout.addLayout(enum_layout, 0, 0)
+ self.content_layout.addLayout(all_children_layout, 1, 0)
+
+ self.enum_layout = enum_layout
+ self.all_children_layout = all_children_layout
# Add enum entity to layout mapping
enum_entity = self.entity.enum_entity
@@ -58,6 +87,8 @@ class DictConditionalWidget(BaseWidget):
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.setSpacing(5)
+ all_children_layout.addWidget(content_widget)
+
self._content_by_enum_value[enum_key] = {
"widget": content_widget,
"layout": content_layout
@@ -80,9 +111,6 @@ class DictConditionalWidget(BaseWidget):
for item_key, children in self.entity.children.items():
content_widget = self._content_by_enum_value[item_key]["widget"]
- row = self.content_layout.rowCount()
- self.content_layout.addWidget(content_widget, row, 0, 1, 2)
-
for child_obj in children:
self.input_fields.append(
self.create_ui_for_entity(
@@ -191,12 +219,25 @@ class DictConditionalWidget(BaseWidget):
else:
map_id = widget.entity.id
- content_widget = self.content_widget
- content_layout = self.content_layout
- if map_id != self.entity.enum_entity.id:
- enum_value = self._enum_key_by_wrapper_id[map_id]
- content_widget = self._content_by_enum_value[enum_value]["widget"]
- content_layout = self._content_by_enum_value[enum_value]["layout"]
+ is_enum_item = map_id == self.entity.enum_entity.id
+ if is_enum_item:
+ content_widget = self.content_widget
+ content_layout = self.enum_layout
+
+ if not label:
+ content_layout.addWidget(widget, 0, 0, 1, 2)
+ return
+
+ label_widget = GridLabelWidget(label, widget)
+ label_widget.input_field = widget
+ widget.label_widget = label_widget
+ content_layout.addWidget(label_widget, 0, 0, 1, 1)
+ content_layout.addWidget(widget, 0, 1, 1, 1)
+ return
+
+ enum_value = self._enum_key_by_wrapper_id[map_id]
+ content_widget = self._content_by_enum_value[enum_value]["widget"]
+ content_layout = self._content_by_enum_value[enum_value]["layout"]
wrapper = self._parent_widget_by_entity_id[map_id]
if wrapper is not content_widget:
diff --git a/openpype/tools/settings/settings/window.py b/openpype/tools/settings/settings/window.py
index a60a2a1d88..4e88301349 100644
--- a/openpype/tools/settings/settings/window.py
+++ b/openpype/tools/settings/settings/window.py
@@ -94,7 +94,8 @@ class MainWidget(QtWidgets.QWidget):
super(MainWidget, self).showEvent(event)
if self._reset_on_show:
self._reset_on_show = False
- self.reset()
+ # Trigger reset with 100ms delay
+ QtCore.QTimer.singleShot(100, self.reset)
def _show_password_dialog(self):
if self._password_dialog:
@@ -107,6 +108,8 @@ class MainWidget(QtWidgets.QWidget):
self._password_dialog = None
if password_passed:
self.reset()
+ if not self.isVisible():
+ self.show()
else:
self.close()
@@ -141,7 +144,10 @@ class MainWidget(QtWidgets.QWidget):
# Don't show dialog if there are not registered slots for
# `trigger_restart` signal.
# - For example when settings are runnin as standalone tool
- if self.receivers(self.trigger_restart) < 1:
+ # - PySide2 and PyQt5 compatible way how to find out
+ method_index = self.metaObject().indexOfMethod("trigger_restart()")
+ method = self.metaObject().method(method_index)
+ if not self.isSignalConnected(method):
return
dialog = RestartDialog(self)
diff --git a/openpype/tools/standalonepublish/widgets/widget_component_item.py b/openpype/tools/standalonepublish/widgets/widget_component_item.py
index 186c8024db..de3cde50cd 100644
--- a/openpype/tools/standalonepublish/widgets/widget_component_item.py
+++ b/openpype/tools/standalonepublish/widgets/widget_component_item.py
@@ -1,7 +1,6 @@
import os
from Qt import QtCore, QtGui, QtWidgets
from .resources import get_resource
-from avalon import style
class ComponentItem(QtWidgets.QFrame):
@@ -61,7 +60,7 @@ class ComponentItem(QtWidgets.QFrame):
name="menu", size=QtCore.QSize(22, 22)
)
- self.action_menu = QtWidgets.QMenu()
+ self.action_menu = QtWidgets.QMenu(self.btn_action_menu)
expanding_sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding
@@ -229,7 +228,6 @@ class ComponentItem(QtWidgets.QFrame):
if not self.btn_action_menu.isVisible():
self.btn_action_menu.setVisible(True)
self.btn_action_menu.clicked.connect(self.show_actions)
- self.action_menu.setStyleSheet(style.load_stylesheet())
def set_repre_name_valid(self, valid):
self.has_valid_repre = valid
diff --git a/openpype/tools/standalonepublish/widgets/widget_drop_frame.py b/openpype/tools/standalonepublish/widgets/widget_drop_frame.py
index 63dcb82e83..7fe43c4203 100644
--- a/openpype/tools/standalonepublish/widgets/widget_drop_frame.py
+++ b/openpype/tools/standalonepublish/widgets/widget_drop_frame.py
@@ -211,7 +211,8 @@ class DropDataFrame(QtWidgets.QFrame):
folder_path = os.path.dirname(collection.head)
if file_base[-1] in ['.', '_']:
file_base = file_base[:-1]
- file_ext = collection.tail
+ file_ext = os.path.splitext(
+ collection.format('{head}{padding}{tail}'))[1]
repr_name = file_ext.replace('.', '')
range = collection.format('{ranges}')
diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py
index d567e26d74..42f0e422ae 100644
--- a/openpype/tools/workfiles/app.py
+++ b/openpype/tools/workfiles/app.py
@@ -693,16 +693,16 @@ class FilesWidget(QtWidgets.QWidget):
)
return
- file_path = os.path.join(self.root, work_file)
+ file_path = os.path.join(os.path.normpath(self.root), work_file)
- pipeline.emit("before.workfile.save", file_path)
+ pipeline.emit("before.workfile.save", [file_path])
self._enter_session() # Make sure we are in the right session
self.host.save_file(file_path)
self.set_asset_task(self._asset, self._task)
- pipeline.emit("after.workfile.save", file_path)
+ pipeline.emit("after.workfile.save", [file_path])
self.workfile_created.emit(file_path)
diff --git a/openpype/vendor/python/common/scriptsmenu/__init__.py b/openpype/vendor/python/common/scriptsmenu/__init__.py
new file mode 100644
index 0000000000..a881f73533
--- /dev/null
+++ b/openpype/vendor/python/common/scriptsmenu/__init__.py
@@ -0,0 +1,5 @@
+from .scriptsmenu import ScriptsMenu
+from . import version
+
+__all__ = ["ScriptsMenu"]
+__version__ = version.version
diff --git a/openpype/vendor/python/common/scriptsmenu/action.py b/openpype/vendor/python/common/scriptsmenu/action.py
new file mode 100644
index 0000000000..dc4d775f6a
--- /dev/null
+++ b/openpype/vendor/python/common/scriptsmenu/action.py
@@ -0,0 +1,207 @@
+import os
+
+from .vendor.Qt import QtWidgets
+
+
+class Action(QtWidgets.QAction):
+ """Custom Action widget"""
+
+ def __init__(self, parent=None):
+
+ QtWidgets.QAction.__init__(self, parent)
+
+ self._root = None
+ self._tags = list()
+ self._command = None
+ self._sourcetype = None
+ self._iconfile = None
+ self._label = None
+
+ self._COMMAND = """import imp
+f, filepath, descr = imp.find_module('{module_name}', ['{dirname}'])
+module = imp.load_module('{module_name}', f, filepath, descr)
+module.{module_name}()"""
+
+ @property
+ def root(self):
+ return self._root
+
+ @root.setter
+ def root(self, value):
+ self._root = value
+
+ @property
+ def tags(self):
+ return self._tags
+
+ @tags.setter
+ def tags(self, value):
+ self._tags = value
+
+ @property
+ def command(self):
+ return self._command
+
+ @command.setter
+ def command(self, value):
+ """
+ Store the command in the QAction
+
+ Args:
+ value (str): the full command which will be executed when clicked
+
+ Return:
+ None
+ """
+ self._command = value
+
+ @property
+ def sourcetype(self):
+ return self._sourcetype
+
+ @sourcetype.setter
+ def sourcetype(self, value):
+ """
+ Set the command type to get the correct execution of the command given
+
+ Args:
+ value (str): the name of the command type
+
+ Returns:
+ None
+
+ """
+ self._sourcetype = value
+
+ @property
+ def iconfile(self):
+ return self._iconfile
+
+ @iconfile.setter
+ def iconfile(self, value):
+ """Store the path to the image file which needs to be displayed
+
+ Args:
+ value (str): the path to the image
+
+ Returns:
+ None
+ """
+ self._iconfile = value
+
+ @property
+ def label(self):
+ return self._label
+
+ @label.setter
+ def label(self, value):
+ """
+ Set the abbreviation which will be used as overlay text in the shelf
+
+ Args:
+ value (str): an abbreviation of the name
+
+ Returns:
+ None
+
+ """
+ self._label = value
+
+ def run_command(self):
+ """
+ Run the command of the instance or copy the command to the active shelf
+ based on the current modifiers.
+
+ If callbacks have been registered with fouind modifier integer the
+ function will trigger all callbacks. When a callback function returns a
+ non zero integer it will not execute the action's command
+
+ """
+
+ # get the current application and its linked keyboard modifiers
+ modifiers = QtWidgets.QApplication.keyboardModifiers()
+
+ # If the menu has a callback registered for the current modifier
+ # we run the callback instead of the action itself.
+ registered = self._root.registered_callbacks
+ callbacks = registered.get(int(modifiers), [])
+ for callback in callbacks:
+ signal = callback(self)
+ if signal != 0:
+ # Exit function on non-zero return code
+ return
+
+ exec(self.process_command())
+
+ def process_command(self):
+ """
+ Check if the command is a file which needs to be launched and if it
+ has a relative path, if so return the full path by expanding
+ environment variables. Wrap any mel command in a executable string
+ for Python and return the string if the source type is
+
+ Add your own source type and required process to ensure callback
+ is stored correctly.
+
+ An example of a process is the sourcetype is MEL
+ (Maya Embedded Language) as Python cannot run it on its own so it
+ needs to be wrapped in a string in which we explicitly import mel and
+ run it as a mel.eval. The string is then parsed to python as
+ exec("command").
+
+ Returns:
+ str: a clean command which can be used
+
+ """
+ if self._sourcetype == "python":
+ return self._command
+
+ if self._sourcetype == "mel":
+ # Escape single quotes
+ conversion = self._command.replace("'", "\\'")
+ return "import maya; maya.mel.eval('{}')".format(conversion)
+
+ if self._sourcetype == "file":
+ if os.path.isabs(self._command):
+ filepath = self._command
+ else:
+ filepath = os.path.normpath(os.path.expandvars(self._command))
+
+ return self._wrap_filepath(filepath)
+
+ def has_tag(self, tag):
+ """Check whether the tag matches with the action's tags.
+
+ A partial match will also return True, for example tag `a` will match
+ correctly with the `ape` tag on the Action.
+
+ Args:
+ tag (str): The tag
+
+ Returns
+ bool: Whether the action is tagged with given tag
+
+ """
+
+ for tagitem in self.tags:
+ if tag not in tagitem:
+ continue
+ return True
+
+ return False
+
+ def _wrap_filepath(self, file_path):
+ """Create a wrapped string for the python command
+
+ Args:
+ file_path (str): the filepath of a script
+
+ Returns:
+ str: the wrapped command
+ """
+
+ dirname = os.path.dirname(r"{}".format(file_path))
+ dirpath = dirname.replace("\\", "/")
+ module_name = os.path.splitext(os.path.basename(file_path))[0]
+
+ return self._COMMAND.format(module_name=module_name, dirname=dirpath)
diff --git a/openpype/vendor/python/common/scriptsmenu/launchformari.py b/openpype/vendor/python/common/scriptsmenu/launchformari.py
new file mode 100644
index 0000000000..25cfc80d96
--- /dev/null
+++ b/openpype/vendor/python/common/scriptsmenu/launchformari.py
@@ -0,0 +1,54 @@
+
+# Import third-party modules
+from vendor.Qt import QtWidgets
+
+# Import local modules
+import scriptsmenu
+
+
+def _mari_main_window():
+ """Get Mari main window.
+
+ Returns:
+ MriMainWindow: Mari's main window.
+
+ """
+ for obj in QtWidgets.QApplication.topLevelWidgets():
+ if obj.metaObject().className() == 'MriMainWindow':
+ return obj
+ raise RuntimeError('Could not find Mari MainWindow instance')
+
+
+def _mari_main_menubar():
+ """Get Mari main menu bar.
+
+ Returns:
+ Retrieve the main menubar of the Mari window.
+
+ """
+ mari_window = _mari_main_window()
+ menubar = [
+ i for i in mari_window.children() if isinstance(i, QtWidgets.QMenuBar)
+ ]
+ assert len(menubar) == 1, "Error, could not find menu bar!"
+ return menubar[0]
+
+
+def main(title="Scripts"):
+ """Build the main scripts menu in Mari.
+
+ Args:
+ title (str): Name of the menu in the application.
+
+ Returns:
+ scriptsmenu.ScriptsMenu: Instance object.
+
+ """
+ mari_main_bar = _mari_main_menubar()
+ for mari_bar in mari_main_bar.children():
+ if isinstance(mari_bar, scriptsmenu.ScriptsMenu):
+ if mari_bar.title() == title:
+ menu = mari_bar
+ return menu
+ menu = scriptsmenu.ScriptsMenu(title=title, parent=mari_main_bar)
+ return menu
diff --git a/openpype/vendor/python/common/scriptsmenu/launchformaya.py b/openpype/vendor/python/common/scriptsmenu/launchformaya.py
new file mode 100644
index 0000000000..7ad66f0ad2
--- /dev/null
+++ b/openpype/vendor/python/common/scriptsmenu/launchformaya.py
@@ -0,0 +1,137 @@
+import logging
+
+import maya.cmds as cmds
+import maya.mel as mel
+
+import scriptsmenu
+from .vendor.Qt import QtCore, QtWidgets
+
+log = logging.getLogger(__name__)
+
+
+def register_repeat_last(action):
+ """Register the action in repeatLast to ensure the RepeatLast hotkey works
+
+ Args:
+ action (action.Action): Action wigdet instance
+
+ Returns:
+ int: 0
+
+ """
+ command = action.process_command()
+ command = command.replace("\n", "; ")
+ # Register command to Maya (mel)
+ cmds.repeatLast(addCommand='python("{}")'.format(command),
+ addCommandLabel=action.label)
+
+ return 0
+
+
+def to_shelf(action):
+ """Copy clicked menu item to the currently active Maya shelf
+ Args:
+ action (action.Action): the action instance which is clicked
+
+ Returns:
+ int: 1
+
+ """
+
+ shelftoplevel = mel.eval("$gShelfTopLevel = $gShelfTopLevel;")
+ current_active_shelf = cmds.tabLayout(shelftoplevel,
+ query=True,
+ selectTab=True)
+
+ cmds.shelfButton(command=action.process_command(),
+ sourceType="python",
+ parent=current_active_shelf,
+ image=action.iconfile or "pythonFamily.png",
+ annotation=action.statusTip(),
+ imageOverlayLabel=action.label or "")
+
+ return 1
+
+
+def _maya_main_window():
+ """Return Maya's main window"""
+ for obj in QtWidgets.QApplication.topLevelWidgets():
+ if obj.objectName() == 'MayaWindow':
+ return obj
+ raise RuntimeError('Could not find MayaWindow instance')
+
+
+def _maya_main_menubar():
+ """Retrieve the main menubar of the Maya window"""
+ mayawindow = _maya_main_window()
+ menubar = [i for i in mayawindow.children()
+ if isinstance(i, QtWidgets.QMenuBar)]
+
+ assert len(menubar) == 1, "Error, could not find menu bar!"
+
+ return menubar[0]
+
+
+def find_scripts_menu(title, parent):
+ """
+ Check if the menu exists with the given title in the parent
+
+ Args:
+ title (str): the title name of the scripts menu
+
+ parent (QtWidgets.QMenuBar): the menubar to check
+
+ Returns:
+ QtWidgets.QMenu or None
+
+ """
+
+ menu = None
+ search = [i for i in parent.children() if
+ isinstance(i, scriptsmenu.ScriptsMenu)
+ and i.title() == title]
+
+ if search:
+ assert len(search) < 2, ("Multiple instances of menu '{}' "
+ "in menu bar".format(title))
+ menu = search[0]
+
+ return menu
+
+
+def main(title="Scripts", parent=None, objectName=None):
+ """Build the main scripts menu in Maya
+
+ Args:
+ title (str): name of the menu in the application
+
+ parent (QtWidgets.QtMenuBar): the parent object for the menu
+
+ objectName (str): custom objectName for scripts menu
+
+ Returns:
+ scriptsmenu.ScriptsMenu instance
+
+ """
+
+ mayamainbar = parent or _maya_main_menubar()
+ try:
+ # check menu already exists
+ menu = find_scripts_menu(title, mayamainbar)
+ if not menu:
+ log.info("Attempting to build menu ...")
+ object_name = objectName or title.lower()
+ menu = scriptsmenu.ScriptsMenu(title=title,
+ parent=mayamainbar,
+ objectName=object_name)
+ except Exception as e:
+ log.error(e)
+ return
+
+ # Register control + shift callback to add to shelf (maya behavior)
+ modifiers = QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier
+ menu.register_callback(int(modifiers), to_shelf)
+
+ menu.register_callback(0, register_repeat_last)
+
+ return menu
diff --git a/openpype/vendor/python/common/scriptsmenu/launchfornuke.py b/openpype/vendor/python/common/scriptsmenu/launchfornuke.py
new file mode 100644
index 0000000000..23e4ed1b4d
--- /dev/null
+++ b/openpype/vendor/python/common/scriptsmenu/launchfornuke.py
@@ -0,0 +1,36 @@
+import scriptsmenu
+from .vendor.Qt import QtWidgets
+
+
+def _nuke_main_window():
+ """Return Nuke's main window"""
+ for obj in QtWidgets.QApplication.topLevelWidgets():
+ if (obj.inherits('QMainWindow') and
+ obj.metaObject().className() == 'Foundry::UI::DockMainWindow'):
+ return obj
+ raise RuntimeError('Could not find Nuke MainWindow instance')
+
+
+def _nuke_main_menubar():
+ """Retrieve the main menubar of the Nuke window"""
+ nuke_window = _nuke_main_window()
+ menubar = [i for i in nuke_window.children()
+ if isinstance(i, QtWidgets.QMenuBar)]
+
+ assert len(menubar) == 1, "Error, could not find menu bar!"
+ return menubar[0]
+
+
+def main(title="Scripts"):
+ # Register control + shift callback to add to shelf (Nuke behavior)
+ # modifiers = QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier
+ # menu.register_callback(modifiers, to_shelf)
+ nuke_main_bar = _nuke_main_menubar()
+ for nuke_bar in nuke_main_bar.children():
+ if isinstance(nuke_bar, scriptsmenu.ScriptsMenu):
+ if nuke_bar.title() == title:
+ menu = nuke_bar
+ return menu
+
+ menu = scriptsmenu.ScriptsMenu(title=title, parent=nuke_main_bar)
+ return menu
\ No newline at end of file
diff --git a/openpype/vendor/python/common/scriptsmenu/scriptsmenu.py b/openpype/vendor/python/common/scriptsmenu/scriptsmenu.py
new file mode 100644
index 0000000000..e2b7ff96c7
--- /dev/null
+++ b/openpype/vendor/python/common/scriptsmenu/scriptsmenu.py
@@ -0,0 +1,316 @@
+import os
+import json
+import logging
+from collections import defaultdict
+
+from .vendor.Qt import QtWidgets, QtCore
+from . import action
+
+log = logging.getLogger(__name__)
+
+
+class ScriptsMenu(QtWidgets.QMenu):
+ """A Qt menu that displays a list of searchable actions"""
+
+ updated = QtCore.Signal(QtWidgets.QMenu)
+
+ def __init__(self, *args, **kwargs):
+ """Initialize Scripts menu
+
+ Args:
+ title (str): the name of the root menu which will be created
+
+ parent (QtWidgets.QObject) : the QObject to parent the menu to
+
+ Returns:
+ None
+
+ """
+ QtWidgets.QMenu.__init__(self, *args, **kwargs)
+
+ self.searchbar = None
+ self.update_action = None
+
+ self._script_actions = []
+ self._callbacks = defaultdict(list)
+
+ # Automatically add it to the parent menu
+ parent = kwargs.get("parent", None)
+ if parent:
+ parent.addMenu(self)
+
+ objectname = kwargs.get("objectName", "scripts")
+ title = kwargs.get("title", "Scripts")
+ self.setObjectName(objectname)
+ self.setTitle(title)
+
+ # add default items in the menu
+ self.create_default_items()
+
+ def on_update(self):
+ self.updated.emit(self)
+
+ @property
+ def registered_callbacks(self):
+ return self._callbacks.copy()
+
+ def create_default_items(self):
+ """Add a search bar to the top of the menu given"""
+
+ # create widget and link function
+ searchbar = QtWidgets.QLineEdit()
+ searchbar.setFixedWidth(120)
+ searchbar.setPlaceholderText("Search ...")
+ searchbar.textChanged.connect(self._update_search)
+ self.searchbar = searchbar
+
+ # create widget holder
+ searchbar_action = QtWidgets.QWidgetAction(self)
+
+ # add widget to widget holder
+ searchbar_action.setDefaultWidget(self.searchbar)
+ searchbar_action.setObjectName("Searchbar")
+
+ # add update button and link function
+ update_action = QtWidgets.QAction(self)
+ update_action.setObjectName("Update Scripts")
+ update_action.setText("Update Scripts")
+ update_action.setVisible(False)
+ update_action.triggered.connect(self.on_update)
+ self.update_action = update_action
+
+ # add action to menu
+ self.addAction(searchbar_action)
+ self.addAction(update_action)
+
+ # add separator object
+ separator = self.addSeparator()
+ separator.setObjectName("separator")
+
+ def add_menu(self, title, parent=None):
+ """Create a sub menu for a parent widget
+
+ Args:
+ parent(QtWidgets.QWidget): the object to parent the menu to
+
+ title(str): the title of the menu
+
+ Returns:
+ QtWidget.QMenu instance
+ """
+
+ if not parent:
+ parent = self
+
+ menu = QtWidgets.QMenu(parent, title)
+ menu.setTitle(title)
+ menu.setObjectName(title)
+ menu.setTearOffEnabled(True)
+ parent.addMenu(menu)
+
+ return menu
+
+ def add_script(self, parent, title, command, sourcetype, icon=None,
+ tags=None, label=None, tooltip=None):
+ """Create an action item which runs a script when clicked
+
+ Args:
+ parent (QtWidget.QWidget): The widget to parent the item to
+
+ title (str): The text which will be displayed in the menu
+
+ command (str): The command which needs to be run when the item is
+ clicked.
+
+ sourcetype (str): The type of command, the way the command is
+ processed is based on the source type.
+
+ icon (str): The file path of an icon to display with the menu item
+
+ tags (list, tuple): Keywords which describe the action
+
+ label (str): A short description of the script which will be displayed
+ when hovering over the menu item
+
+ tooltip (str): A tip for the user about the usage fo the tool
+
+ Returns:
+ QtWidget.QAction instance
+
+ """
+
+ assert tags is None or isinstance(tags, (list, tuple))
+ # Ensure tags is a list
+ tags = list() if tags is None else list(tags)
+ tags.append(title.lower())
+
+ assert icon is None or isinstance(icon, str), (
+ "Invalid data type for icon, supported : None, string")
+
+ # create new action
+ script_action = action.Action(parent)
+ script_action.setText(title)
+ script_action.setObjectName(title)
+ script_action.tags = tags
+
+ # link action to root for callback library
+ script_action.root = self
+
+ # Set up the command
+ script_action.sourcetype = sourcetype
+ script_action.command = command
+
+ try:
+ script_action.process_command()
+ except RuntimeError as e:
+ raise RuntimeError("Script action can't be "
+ "processed: {}".format(e))
+
+ if icon:
+ iconfile = os.path.expandvars(icon)
+ script_action.iconfile = iconfile
+ script_action_icon = QtWidgets.QIcon(iconfile)
+ script_action.setIcon(script_action_icon)
+
+ if label:
+ script_action.label = label
+
+ if tooltip:
+ script_action.setStatusTip(tooltip)
+
+ script_action.triggered.connect(script_action.run_command)
+ parent.addAction(script_action)
+
+ # Add to our searchable actions
+ self._script_actions.append(script_action)
+
+ return script_action
+
+ def build_from_configuration(self, parent, configuration):
+ """Process the configurations and store the configuration
+
+ This creates all submenus from a configuration.json file.
+
+ When the configuration holds the key `main` all scripts under `main` will
+ be added to the main menu first before adding the rest
+
+ Args:
+ parent (ScriptsMenu): script menu instance
+ configuration (list): A ScriptsMenu configuration list
+
+ Returns:
+ None
+
+ """
+
+ for item in configuration:
+ assert isinstance(item, dict), "Configuration is wrong!"
+
+ # skip items which have no `type` key
+ item_type = item.get('type', None)
+ if not item_type:
+ log.warning("Missing 'type' from configuration item")
+ continue
+
+ # add separator
+ # Special behavior for separators
+ if item_type == "separator":
+ parent.addSeparator()
+
+ # add submenu
+ # items should hold a collection of submenu items (dict)
+ elif item_type == "menu":
+ assert "items" in item, "Menu is missing 'items' key"
+ menu = self.add_menu(parent=parent, title=item["title"])
+ self.build_from_configuration(menu, item["items"])
+
+ # add script
+ elif item_type == "action":
+ # filter out `type` from the item dict
+ config = {key: value for key, value in
+ item.items() if key != "type"}
+
+ self.add_script(parent=parent, **config)
+
+ def set_update_visible(self, state):
+ self.update_action.setVisible(state)
+
+ def clear_menu(self):
+ """Clear all menu items which are not default
+
+ Returns:
+ None
+
+ """
+
+ # TODO: Set up a more robust implementation for this
+ # Delete all except the first three actions
+ for _action in self.actions()[3:]:
+ self.removeAction(_action)
+
+ def register_callback(self, modifiers, callback):
+ self._callbacks[modifiers].append(callback)
+
+ def _update_search(self, search):
+ """Hide all the samples which do not match the user's import
+
+ Returns:
+ None
+
+ """
+
+ if not search:
+ for action in self._script_actions:
+ action.setVisible(True)
+ else:
+ for action in self._script_actions:
+ if not action.has_tag(search.lower()):
+ action.setVisible(False)
+
+ # Set visibility for all submenus
+ for action in self.actions():
+ if not action.menu():
+ continue
+
+ menu = action.menu()
+ visible = any(action.isVisible() for action in menu.actions())
+ action.setVisible(visible)
+
+
+def load_configuration(path):
+ """Load the configuration from a file
+
+ Read out the JSON file which will dictate the structure of the scripts menu
+
+ Args:
+ path (str): file path of the .JSON file
+
+ Returns:
+ dict
+
+ """
+
+ if not os.path.isfile(path):
+ raise AttributeError("Given configuration is not "
+ "a file!\n'{}'".format(path))
+
+ extension = os.path.splitext(path)[-1]
+ if extension != ".json":
+ raise AttributeError("Given configuration file has unsupported "
+ "file type, provide a .json file")
+
+ # retrieve and store config
+ with open(path, "r") as f:
+ configuration = json.load(f)
+
+ return configuration
+
+
+def application(configuration, parent):
+ import sys
+ app = QtWidgets.QApplication(sys.argv)
+
+ scriptsmenu = ScriptsMenu(configuration, parent)
+ scriptsmenu.show()
+
+ sys.exit(app.exec_())
diff --git a/openpype/vendor/python/common/scriptsmenu/vendor/Qt.py b/openpype/vendor/python/common/scriptsmenu/vendor/Qt.py
new file mode 100644
index 0000000000..fe4b45f18f
--- /dev/null
+++ b/openpype/vendor/python/common/scriptsmenu/vendor/Qt.py
@@ -0,0 +1,1989 @@
+"""Minimal Python 2 & 3 shim around all Qt bindings
+
+DOCUMENTATION
+ Qt.py was born in the film and visual effects industry to address
+ the growing need for the development of software capable of running
+ with more than one flavour of the Qt bindings for Python - PySide,
+ PySide2, PyQt4 and PyQt5.
+
+ 1. Build for one, run with all
+ 2. Explicit is better than implicit
+ 3. Support co-existence
+
+ Default resolution order:
+ - PySide2
+ - PyQt5
+ - PySide
+ - PyQt4
+
+ Usage:
+ >> import sys
+ >> from Qt import QtWidgets
+ >> app = QtWidgets.QApplication(sys.argv)
+ >> button = QtWidgets.QPushButton("Hello World")
+ >> button.show()
+ >> app.exec_()
+
+ All members of PySide2 are mapped from other bindings, should they exist.
+ If no equivalent member exist, it is excluded from Qt.py and inaccessible.
+ The idea is to highlight members that exist across all supported binding,
+ and guarantee that code that runs on one binding runs on all others.
+
+ For more details, visit https://github.com/mottosso/Qt.py
+
+LICENSE
+
+ See end of file for license (MIT, BSD) information.
+
+"""
+
+import os
+import sys
+import types
+import shutil
+import importlib
+
+
+__version__ = "1.2.3"
+
+# Enable support for `from Qt import *`
+__all__ = []
+
+# Flags from environment variables
+QT_VERBOSE = bool(os.getenv("QT_VERBOSE"))
+QT_PREFERRED_BINDING = os.getenv("QT_PREFERRED_BINDING", "")
+QT_SIP_API_HINT = os.getenv("QT_SIP_API_HINT")
+
+# Reference to Qt.py
+Qt = sys.modules[__name__]
+Qt.QtCompat = types.ModuleType("QtCompat")
+
+try:
+ long
+except NameError:
+ # Python 3 compatibility
+ long = int
+
+
+"""Common members of all bindings
+
+This is where each member of Qt.py is explicitly defined.
+It is based on a "lowest common denominator" of all bindings;
+including members found in each of the 4 bindings.
+
+The "_common_members" dictionary is generated using the
+build_membership.sh script.
+
+"""
+
+_common_members = {
+ "QtCore": [
+ "QAbstractAnimation",
+ "QAbstractEventDispatcher",
+ "QAbstractItemModel",
+ "QAbstractListModel",
+ "QAbstractState",
+ "QAbstractTableModel",
+ "QAbstractTransition",
+ "QAnimationGroup",
+ "QBasicTimer",
+ "QBitArray",
+ "QBuffer",
+ "QByteArray",
+ "QByteArrayMatcher",
+ "QChildEvent",
+ "QCoreApplication",
+ "QCryptographicHash",
+ "QDataStream",
+ "QDate",
+ "QDateTime",
+ "QDir",
+ "QDirIterator",
+ "QDynamicPropertyChangeEvent",
+ "QEasingCurve",
+ "QElapsedTimer",
+ "QEvent",
+ "QEventLoop",
+ "QEventTransition",
+ "QFile",
+ "QFileInfo",
+ "QFileSystemWatcher",
+ "QFinalState",
+ "QGenericArgument",
+ "QGenericReturnArgument",
+ "QHistoryState",
+ "QItemSelectionRange",
+ "QIODevice",
+ "QLibraryInfo",
+ "QLine",
+ "QLineF",
+ "QLocale",
+ "QMargins",
+ "QMetaClassInfo",
+ "QMetaEnum",
+ "QMetaMethod",
+ "QMetaObject",
+ "QMetaProperty",
+ "QMimeData",
+ "QModelIndex",
+ "QMutex",
+ "QMutexLocker",
+ "QObject",
+ "QParallelAnimationGroup",
+ "QPauseAnimation",
+ "QPersistentModelIndex",
+ "QPluginLoader",
+ "QPoint",
+ "QPointF",
+ "QProcess",
+ "QProcessEnvironment",
+ "QPropertyAnimation",
+ "QReadLocker",
+ "QReadWriteLock",
+ "QRect",
+ "QRectF",
+ "QRegExp",
+ "QResource",
+ "QRunnable",
+ "QSemaphore",
+ "QSequentialAnimationGroup",
+ "QSettings",
+ "QSignalMapper",
+ "QSignalTransition",
+ "QSize",
+ "QSizeF",
+ "QSocketNotifier",
+ "QState",
+ "QStateMachine",
+ "QSysInfo",
+ "QSystemSemaphore",
+ "QT_TRANSLATE_NOOP",
+ "QT_TR_NOOP",
+ "QT_TR_NOOP_UTF8",
+ "QTemporaryFile",
+ "QTextBoundaryFinder",
+ "QTextCodec",
+ "QTextDecoder",
+ "QTextEncoder",
+ "QTextStream",
+ "QTextStreamManipulator",
+ "QThread",
+ "QThreadPool",
+ "QTime",
+ "QTimeLine",
+ "QTimer",
+ "QTimerEvent",
+ "QTranslator",
+ "QUrl",
+ "QVariantAnimation",
+ "QWaitCondition",
+ "QWriteLocker",
+ "QXmlStreamAttribute",
+ "QXmlStreamAttributes",
+ "QXmlStreamEntityDeclaration",
+ "QXmlStreamEntityResolver",
+ "QXmlStreamNamespaceDeclaration",
+ "QXmlStreamNotationDeclaration",
+ "QXmlStreamReader",
+ "QXmlStreamWriter",
+ "Qt",
+ "QtCriticalMsg",
+ "QtDebugMsg",
+ "QtFatalMsg",
+ "QtMsgType",
+ "QtSystemMsg",
+ "QtWarningMsg",
+ "qAbs",
+ "qAddPostRoutine",
+ "qChecksum",
+ "qCritical",
+ "qDebug",
+ "qFatal",
+ "qFuzzyCompare",
+ "qIsFinite",
+ "qIsInf",
+ "qIsNaN",
+ "qIsNull",
+ "qRegisterResourceData",
+ "qUnregisterResourceData",
+ "qVersion",
+ "qWarning",
+ "qrand",
+ "qsrand"
+ ],
+ "QtGui": [
+ "QAbstractTextDocumentLayout",
+ "QActionEvent",
+ "QBitmap",
+ "QBrush",
+ "QClipboard",
+ "QCloseEvent",
+ "QColor",
+ "QConicalGradient",
+ "QContextMenuEvent",
+ "QCursor",
+ "QDesktopServices",
+ "QDoubleValidator",
+ "QDrag",
+ "QDragEnterEvent",
+ "QDragLeaveEvent",
+ "QDragMoveEvent",
+ "QDropEvent",
+ "QFileOpenEvent",
+ "QFocusEvent",
+ "QFont",
+ "QFontDatabase",
+ "QFontInfo",
+ "QFontMetrics",
+ "QFontMetricsF",
+ "QGradient",
+ "QHelpEvent",
+ "QHideEvent",
+ "QHoverEvent",
+ "QIcon",
+ "QIconDragEvent",
+ "QIconEngine",
+ "QImage",
+ "QImageIOHandler",
+ "QImageReader",
+ "QImageWriter",
+ "QInputEvent",
+ "QInputMethodEvent",
+ "QIntValidator",
+ "QKeyEvent",
+ "QKeySequence",
+ "QLinearGradient",
+ "QMatrix2x2",
+ "QMatrix2x3",
+ "QMatrix2x4",
+ "QMatrix3x2",
+ "QMatrix3x3",
+ "QMatrix3x4",
+ "QMatrix4x2",
+ "QMatrix4x3",
+ "QMatrix4x4",
+ "QMouseEvent",
+ "QMoveEvent",
+ "QMovie",
+ "QPaintDevice",
+ "QPaintEngine",
+ "QPaintEngineState",
+ "QPaintEvent",
+ "QPainter",
+ "QPainterPath",
+ "QPainterPathStroker",
+ "QPalette",
+ "QPen",
+ "QPicture",
+ "QPictureIO",
+ "QPixmap",
+ "QPixmapCache",
+ "QPolygon",
+ "QPolygonF",
+ "QQuaternion",
+ "QRadialGradient",
+ "QRegExpValidator",
+ "QRegion",
+ "QResizeEvent",
+ "QSessionManager",
+ "QShortcutEvent",
+ "QShowEvent",
+ "QStandardItem",
+ "QStandardItemModel",
+ "QStatusTipEvent",
+ "QSyntaxHighlighter",
+ "QTabletEvent",
+ "QTextBlock",
+ "QTextBlockFormat",
+ "QTextBlockGroup",
+ "QTextBlockUserData",
+ "QTextCharFormat",
+ "QTextCursor",
+ "QTextDocument",
+ "QTextDocumentFragment",
+ "QTextFormat",
+ "QTextFragment",
+ "QTextFrame",
+ "QTextFrameFormat",
+ "QTextImageFormat",
+ "QTextInlineObject",
+ "QTextItem",
+ "QTextLayout",
+ "QTextLength",
+ "QTextLine",
+ "QTextList",
+ "QTextListFormat",
+ "QTextObject",
+ "QTextObjectInterface",
+ "QTextOption",
+ "QTextTable",
+ "QTextTableCell",
+ "QTextTableCellFormat",
+ "QTextTableFormat",
+ "QTouchEvent",
+ "QTransform",
+ "QValidator",
+ "QVector2D",
+ "QVector3D",
+ "QVector4D",
+ "QWhatsThisClickedEvent",
+ "QWheelEvent",
+ "QWindowStateChangeEvent",
+ "qAlpha",
+ "qBlue",
+ "qGray",
+ "qGreen",
+ "qIsGray",
+ "qRed",
+ "qRgb",
+ "qRgba"
+ ],
+ "QtHelp": [
+ "QHelpContentItem",
+ "QHelpContentModel",
+ "QHelpContentWidget",
+ "QHelpEngine",
+ "QHelpEngineCore",
+ "QHelpIndexModel",
+ "QHelpIndexWidget",
+ "QHelpSearchEngine",
+ "QHelpSearchQuery",
+ "QHelpSearchQueryWidget",
+ "QHelpSearchResultWidget"
+ ],
+ "QtMultimedia": [
+ "QAbstractVideoBuffer",
+ "QAbstractVideoSurface",
+ "QAudio",
+ "QAudioDeviceInfo",
+ "QAudioFormat",
+ "QAudioInput",
+ "QAudioOutput",
+ "QVideoFrame",
+ "QVideoSurfaceFormat"
+ ],
+ "QtNetwork": [
+ "QAbstractNetworkCache",
+ "QAbstractSocket",
+ "QAuthenticator",
+ "QHostAddress",
+ "QHostInfo",
+ "QLocalServer",
+ "QLocalSocket",
+ "QNetworkAccessManager",
+ "QNetworkAddressEntry",
+ "QNetworkCacheMetaData",
+ "QNetworkConfiguration",
+ "QNetworkConfigurationManager",
+ "QNetworkCookie",
+ "QNetworkCookieJar",
+ "QNetworkDiskCache",
+ "QNetworkInterface",
+ "QNetworkProxy",
+ "QNetworkProxyFactory",
+ "QNetworkProxyQuery",
+ "QNetworkReply",
+ "QNetworkRequest",
+ "QNetworkSession",
+ "QSsl",
+ "QTcpServer",
+ "QTcpSocket",
+ "QUdpSocket"
+ ],
+ "QtOpenGL": [
+ "QGL",
+ "QGLContext",
+ "QGLFormat",
+ "QGLWidget"
+ ],
+ "QtPrintSupport": [
+ "QAbstractPrintDialog",
+ "QPageSetupDialog",
+ "QPrintDialog",
+ "QPrintEngine",
+ "QPrintPreviewDialog",
+ "QPrintPreviewWidget",
+ "QPrinter",
+ "QPrinterInfo"
+ ],
+ "QtSql": [
+ "QSql",
+ "QSqlDatabase",
+ "QSqlDriver",
+ "QSqlDriverCreatorBase",
+ "QSqlError",
+ "QSqlField",
+ "QSqlIndex",
+ "QSqlQuery",
+ "QSqlQueryModel",
+ "QSqlRecord",
+ "QSqlRelation",
+ "QSqlRelationalDelegate",
+ "QSqlRelationalTableModel",
+ "QSqlResult",
+ "QSqlTableModel"
+ ],
+ "QtSvg": [
+ "QGraphicsSvgItem",
+ "QSvgGenerator",
+ "QSvgRenderer",
+ "QSvgWidget"
+ ],
+ "QtTest": [
+ "QTest"
+ ],
+ "QtWidgets": [
+ "QAbstractButton",
+ "QAbstractGraphicsShapeItem",
+ "QAbstractItemDelegate",
+ "QAbstractItemView",
+ "QAbstractScrollArea",
+ "QAbstractSlider",
+ "QAbstractSpinBox",
+ "QAction",
+ "QActionGroup",
+ "QApplication",
+ "QBoxLayout",
+ "QButtonGroup",
+ "QCalendarWidget",
+ "QCheckBox",
+ "QColorDialog",
+ "QColumnView",
+ "QComboBox",
+ "QCommandLinkButton",
+ "QCommonStyle",
+ "QCompleter",
+ "QDataWidgetMapper",
+ "QDateEdit",
+ "QDateTimeEdit",
+ "QDesktopWidget",
+ "QDial",
+ "QDialog",
+ "QDialogButtonBox",
+ "QDirModel",
+ "QDockWidget",
+ "QDoubleSpinBox",
+ "QErrorMessage",
+ "QFileDialog",
+ "QFileIconProvider",
+ "QFileSystemModel",
+ "QFocusFrame",
+ "QFontComboBox",
+ "QFontDialog",
+ "QFormLayout",
+ "QFrame",
+ "QGesture",
+ "QGestureEvent",
+ "QGestureRecognizer",
+ "QGraphicsAnchor",
+ "QGraphicsAnchorLayout",
+ "QGraphicsBlurEffect",
+ "QGraphicsColorizeEffect",
+ "QGraphicsDropShadowEffect",
+ "QGraphicsEffect",
+ "QGraphicsEllipseItem",
+ "QGraphicsGridLayout",
+ "QGraphicsItem",
+ "QGraphicsItemGroup",
+ "QGraphicsLayout",
+ "QGraphicsLayoutItem",
+ "QGraphicsLineItem",
+ "QGraphicsLinearLayout",
+ "QGraphicsObject",
+ "QGraphicsOpacityEffect",
+ "QGraphicsPathItem",
+ "QGraphicsPixmapItem",
+ "QGraphicsPolygonItem",
+ "QGraphicsProxyWidget",
+ "QGraphicsRectItem",
+ "QGraphicsRotation",
+ "QGraphicsScale",
+ "QGraphicsScene",
+ "QGraphicsSceneContextMenuEvent",
+ "QGraphicsSceneDragDropEvent",
+ "QGraphicsSceneEvent",
+ "QGraphicsSceneHelpEvent",
+ "QGraphicsSceneHoverEvent",
+ "QGraphicsSceneMouseEvent",
+ "QGraphicsSceneMoveEvent",
+ "QGraphicsSceneResizeEvent",
+ "QGraphicsSceneWheelEvent",
+ "QGraphicsSimpleTextItem",
+ "QGraphicsTextItem",
+ "QGraphicsTransform",
+ "QGraphicsView",
+ "QGraphicsWidget",
+ "QGridLayout",
+ "QGroupBox",
+ "QHBoxLayout",
+ "QHeaderView",
+ "QInputDialog",
+ "QItemDelegate",
+ "QItemEditorCreatorBase",
+ "QItemEditorFactory",
+ "QKeyEventTransition",
+ "QLCDNumber",
+ "QLabel",
+ "QLayout",
+ "QLayoutItem",
+ "QLineEdit",
+ "QListView",
+ "QListWidget",
+ "QListWidgetItem",
+ "QMainWindow",
+ "QMdiArea",
+ "QMdiSubWindow",
+ "QMenu",
+ "QMenuBar",
+ "QMessageBox",
+ "QMouseEventTransition",
+ "QPanGesture",
+ "QPinchGesture",
+ "QPlainTextDocumentLayout",
+ "QPlainTextEdit",
+ "QProgressBar",
+ "QProgressDialog",
+ "QPushButton",
+ "QRadioButton",
+ "QRubberBand",
+ "QScrollArea",
+ "QScrollBar",
+ "QShortcut",
+ "QSizeGrip",
+ "QSizePolicy",
+ "QSlider",
+ "QSpacerItem",
+ "QSpinBox",
+ "QSplashScreen",
+ "QSplitter",
+ "QSplitterHandle",
+ "QStackedLayout",
+ "QStackedWidget",
+ "QStatusBar",
+ "QStyle",
+ "QStyleFactory",
+ "QStyleHintReturn",
+ "QStyleHintReturnMask",
+ "QStyleHintReturnVariant",
+ "QStyleOption",
+ "QStyleOptionButton",
+ "QStyleOptionComboBox",
+ "QStyleOptionComplex",
+ "QStyleOptionDockWidget",
+ "QStyleOptionFocusRect",
+ "QStyleOptionFrame",
+ "QStyleOptionGraphicsItem",
+ "QStyleOptionGroupBox",
+ "QStyleOptionHeader",
+ "QStyleOptionMenuItem",
+ "QStyleOptionProgressBar",
+ "QStyleOptionRubberBand",
+ "QStyleOptionSizeGrip",
+ "QStyleOptionSlider",
+ "QStyleOptionSpinBox",
+ "QStyleOptionTab",
+ "QStyleOptionTabBarBase",
+ "QStyleOptionTabWidgetFrame",
+ "QStyleOptionTitleBar",
+ "QStyleOptionToolBar",
+ "QStyleOptionToolBox",
+ "QStyleOptionToolButton",
+ "QStyleOptionViewItem",
+ "QStylePainter",
+ "QStyledItemDelegate",
+ "QSwipeGesture",
+ "QSystemTrayIcon",
+ "QTabBar",
+ "QTabWidget",
+ "QTableView",
+ "QTableWidget",
+ "QTableWidgetItem",
+ "QTableWidgetSelectionRange",
+ "QTapAndHoldGesture",
+ "QTapGesture",
+ "QTextBrowser",
+ "QTextEdit",
+ "QTimeEdit",
+ "QToolBar",
+ "QToolBox",
+ "QToolButton",
+ "QToolTip",
+ "QTreeView",
+ "QTreeWidget",
+ "QTreeWidgetItem",
+ "QTreeWidgetItemIterator",
+ "QUndoCommand",
+ "QUndoGroup",
+ "QUndoStack",
+ "QUndoView",
+ "QVBoxLayout",
+ "QWhatsThis",
+ "QWidget",
+ "QWidgetAction",
+ "QWidgetItem",
+ "QWizard",
+ "QWizardPage"
+ ],
+ "QtX11Extras": [
+ "QX11Info"
+ ],
+ "QtXml": [
+ "QDomAttr",
+ "QDomCDATASection",
+ "QDomCharacterData",
+ "QDomComment",
+ "QDomDocument",
+ "QDomDocumentFragment",
+ "QDomDocumentType",
+ "QDomElement",
+ "QDomEntity",
+ "QDomEntityReference",
+ "QDomImplementation",
+ "QDomNamedNodeMap",
+ "QDomNode",
+ "QDomNodeList",
+ "QDomNotation",
+ "QDomProcessingInstruction",
+ "QDomText",
+ "QXmlAttributes",
+ "QXmlContentHandler",
+ "QXmlDTDHandler",
+ "QXmlDeclHandler",
+ "QXmlDefaultHandler",
+ "QXmlEntityResolver",
+ "QXmlErrorHandler",
+ "QXmlInputSource",
+ "QXmlLexicalHandler",
+ "QXmlLocator",
+ "QXmlNamespaceSupport",
+ "QXmlParseException",
+ "QXmlReader",
+ "QXmlSimpleReader"
+ ],
+ "QtXmlPatterns": [
+ "QAbstractMessageHandler",
+ "QAbstractUriResolver",
+ "QAbstractXmlNodeModel",
+ "QAbstractXmlReceiver",
+ "QSourceLocation",
+ "QXmlFormatter",
+ "QXmlItem",
+ "QXmlName",
+ "QXmlNamePool",
+ "QXmlNodeModelIndex",
+ "QXmlQuery",
+ "QXmlResultItems",
+ "QXmlSchema",
+ "QXmlSchemaValidator",
+ "QXmlSerializer"
+ ]
+}
+
+""" Missing members
+
+This mapping describes members that have been deprecated
+in one or more bindings and have been left out of the
+_common_members mapping.
+
+The member can provide an extra details string to be
+included in exceptions and warnings.
+"""
+
+_missing_members = {
+ "QtGui": {
+ "QMatrix": "Deprecated in PyQt5",
+ },
+}
+
+
+def _qInstallMessageHandler(handler):
+ """Install a message handler that works in all bindings
+
+ Args:
+ handler: A function that takes 3 arguments, or None
+ """
+ def messageOutputHandler(*args):
+ # In Qt4 bindings, message handlers are passed 2 arguments
+ # In Qt5 bindings, message handlers are passed 3 arguments
+ # The first argument is a QtMsgType
+ # The last argument is the message to be printed
+ # The Middle argument (if passed) is a QMessageLogContext
+ if len(args) == 3:
+ msgType, logContext, msg = args
+ elif len(args) == 2:
+ msgType, msg = args
+ logContext = None
+ else:
+ raise TypeError(
+ "handler expected 2 or 3 arguments, got {0}".format(len(args)))
+
+ if isinstance(msg, bytes):
+ # In python 3, some bindings pass a bytestring, which cannot be
+ # used elsewhere. Decoding a python 2 or 3 bytestring object will
+ # consistently return a unicode object.
+ msg = msg.decode()
+
+ handler(msgType, logContext, msg)
+
+ passObject = messageOutputHandler if handler else handler
+ if Qt.IsPySide or Qt.IsPyQt4:
+ return Qt._QtCore.qInstallMsgHandler(passObject)
+ elif Qt.IsPySide2 or Qt.IsPyQt5:
+ return Qt._QtCore.qInstallMessageHandler(passObject)
+
+
+def _getcpppointer(object):
+ if hasattr(Qt, "_shiboken2"):
+ return getattr(Qt, "_shiboken2").getCppPointer(object)[0]
+ elif hasattr(Qt, "_shiboken"):
+ return getattr(Qt, "_shiboken").getCppPointer(object)[0]
+ elif hasattr(Qt, "_sip"):
+ return getattr(Qt, "_sip").unwrapinstance(object)
+ raise AttributeError("'module' has no attribute 'getCppPointer'")
+
+
+def _wrapinstance(ptr, base=None):
+ """Enable implicit cast of pointer to most suitable class
+
+ This behaviour is available in sip per default.
+
+ Based on http://nathanhorne.com/pyqtpyside-wrap-instance
+
+ Usage:
+ This mechanism kicks in under these circumstances.
+ 1. Qt.py is using PySide 1 or 2.
+ 2. A `base` argument is not provided.
+
+ See :func:`QtCompat.wrapInstance()`
+
+ Arguments:
+ ptr (long): Pointer to QObject in memory
+ base (QObject, optional): Base class to wrap with. Defaults to QObject,
+ which should handle anything.
+
+ """
+
+ assert isinstance(ptr, long), "Argument 'ptr' must be of type