From e8066f072e1d32787099879abf0639c0aa45e380 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 24 Jun 2021 13:03:43 +0200 Subject: [PATCH 01/57] add scriptsmenu module and basic Settings --- openpype/hosts/maya/api/menu.py | 4 +- .../defaults/project_settings/maya.json | 12 + .../projects_schema/schema_project_maya.json | 4 + .../schemas/schema_maya_scriptsmenu.json | 22 + .../python/common/scriptsmenu/__init__.py | 5 + .../python/common/scriptsmenu/action.py | 208 ++ .../common/scriptsmenu/launchformari.py | 54 + .../common/scriptsmenu/launchformaya.py | 137 ++ .../common/scriptsmenu/launchfornuke.py | 36 + .../python/common/scriptsmenu/scriptsmenu.py | 316 +++ .../python/common/scriptsmenu/vendor/Qt.py | 1989 +++++++++++++++++ .../common/scriptsmenu/vendor/__init__.py | 0 .../python/common/scriptsmenu/version.py | 9 + 13 files changed, 2794 insertions(+), 2 deletions(-) create mode 100644 openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_scriptsmenu.json create mode 100644 openpype/vendor/python/common/scriptsmenu/__init__.py create mode 100644 openpype/vendor/python/common/scriptsmenu/action.py create mode 100644 openpype/vendor/python/common/scriptsmenu/launchformari.py create mode 100644 openpype/vendor/python/common/scriptsmenu/launchformaya.py create mode 100644 openpype/vendor/python/common/scriptsmenu/launchfornuke.py create mode 100644 openpype/vendor/python/common/scriptsmenu/scriptsmenu.py create mode 100644 openpype/vendor/python/common/scriptsmenu/vendor/Qt.py create mode 100644 openpype/vendor/python/common/scriptsmenu/vendor/__init__.py create mode 100644 openpype/vendor/python/common/scriptsmenu/version.py diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index 42e5c66e4a..5e036b8e0c 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -83,7 +83,7 @@ def deferred(): 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() @@ -116,7 +116,7 @@ def deferred(): def uninstall(): menu = _get_menu() if menu: - log.info("Attempting to uninstall..") + log.info("Attempting to uninstall ...") try: menu.deleteLater() diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 284a1a0040..0375eb42d5 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -7,6 +7,18 @@ "workfile": "ma", "yetiRig": "ma" }, + "scriptsmenu": { + "name": "OpenPype Tools", + "definition": [ + { + "type": "action", + "command": "$OPENPYPE_SCRIPTS\\others\\save_scene_incremental.py", + "sourcetype": "file", + "title": "# Version Up", + "tooltip": "Incremental save with a specific format" + } + ] + }, "create": { "CreateLook": { "enabled": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json index 0a59cab510..c2a8274313 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json @@ -14,6 +14,10 @@ "type": "text" } }, + { + "type": "schema", + "name": "schema_maya_scriptsmenu" + }, { "type": "schema", "name": "schema_maya_create" 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/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..5e68628406 --- /dev/null +++ b/openpype/vendor/python/common/scriptsmenu/action.py @@ -0,0 +1,208 @@ +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 + app = QtWidgets.QApplication.instance() + modifiers = app.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 " + assert (base is None) or issubclass(base, Qt.QtCore.QObject), ( + "Argument 'base' must be of type ") + + if Qt.IsPyQt4 or Qt.IsPyQt5: + func = getattr(Qt, "_sip").wrapinstance + elif Qt.IsPySide2: + func = getattr(Qt, "_shiboken2").wrapInstance + elif Qt.IsPySide: + func = getattr(Qt, "_shiboken").wrapInstance + else: + raise AttributeError("'module' has no attribute 'wrapInstance'") + + if base is None: + q_object = func(long(ptr), Qt.QtCore.QObject) + meta_object = q_object.metaObject() + class_name = meta_object.className() + super_class_name = meta_object.superClass().className() + + if hasattr(Qt.QtWidgets, class_name): + base = getattr(Qt.QtWidgets, class_name) + + elif hasattr(Qt.QtWidgets, super_class_name): + base = getattr(Qt.QtWidgets, super_class_name) + + else: + base = Qt.QtCore.QObject + + return func(long(ptr), base) + + +def _isvalid(object): + """Check if the object is valid to use in Python runtime. + + Usage: + See :func:`QtCompat.isValid()` + + Arguments: + object (QObject): QObject to check the validity of. + + """ + + assert isinstance(object, Qt.QtCore.QObject) + + if hasattr(Qt, "_shiboken2"): + return getattr(Qt, "_shiboken2").isValid(object) + + elif hasattr(Qt, "_shiboken"): + return getattr(Qt, "_shiboken").isValid(object) + + elif hasattr(Qt, "_sip"): + return not getattr(Qt, "_sip").isdeleted(object) + + else: + raise AttributeError("'module' has no attribute isValid") + + +def _translate(context, sourceText, *args): + # In Qt4 bindings, translate can be passed 2 or 3 arguments + # In Qt5 bindings, translate can be passed 2 arguments + # The first argument is disambiguation[str] + # The last argument is n[int] + # The middle argument can be encoding[QtCore.QCoreApplication.Encoding] + if len(args) == 3: + disambiguation, encoding, n = args + elif len(args) == 2: + disambiguation, n = args + encoding = None + else: + raise TypeError( + "Expected 4 or 5 arguments, got {0}.".format(len(args) + 2)) + + if hasattr(Qt.QtCore, "QCoreApplication"): + app = getattr(Qt.QtCore, "QCoreApplication") + else: + raise NotImplementedError( + "Missing QCoreApplication implementation for {binding}".format( + binding=Qt.__binding__, + ) + ) + if Qt.__binding__ in ("PySide2", "PyQt5"): + sanitized_args = [context, sourceText, disambiguation, n] + else: + sanitized_args = [ + context, + sourceText, + disambiguation, + encoding or app.CodecForTr, + n + ] + return app.translate(*sanitized_args) + + +def _loadUi(uifile, baseinstance=None): + """Dynamically load a user interface from the given `uifile` + + This function calls `uic.loadUi` if using PyQt bindings, + else it implements a comparable binding for PySide. + + Documentation: + http://pyqt.sourceforge.net/Docs/PyQt5/designer.html#PyQt5.uic.loadUi + + Arguments: + uifile (str): Absolute path to Qt Designer file. + baseinstance (QWidget): Instantiated QWidget or subclass thereof + + Return: + baseinstance if `baseinstance` is not `None`. Otherwise + return the newly created instance of the user interface. + + """ + if hasattr(Qt, "_uic"): + return Qt._uic.loadUi(uifile, baseinstance) + + elif hasattr(Qt, "_QtUiTools"): + # Implement `PyQt5.uic.loadUi` for PySide(2) + + class _UiLoader(Qt._QtUiTools.QUiLoader): + """Create the user interface in a base instance. + + Unlike `Qt._QtUiTools.QUiLoader` itself this class does not + create a new instance of the top-level widget, but creates the user + interface in an existing instance of the top-level class if needed. + + This mimics the behaviour of `PyQt5.uic.loadUi`. + + """ + + def __init__(self, baseinstance): + super(_UiLoader, self).__init__(baseinstance) + self.baseinstance = baseinstance + self.custom_widgets = {} + + def _loadCustomWidgets(self, etree): + """ + Workaround to pyside-77 bug. + + From QUiLoader doc we should use registerCustomWidget method. + But this causes a segfault on some platforms. + + Instead we fetch from customwidgets DOM node the python class + objects. Then we can directly use them in createWidget method. + """ + + def headerToModule(header): + """ + Translate a header file to python module path + foo/bar.h => foo.bar + """ + # Remove header extension + module = os.path.splitext(header)[0] + + # Replace os separator by python module separator + return module.replace("/", ".").replace("\\", ".") + + custom_widgets = etree.find("customwidgets") + + if custom_widgets is None: + return + + for custom_widget in custom_widgets: + class_name = custom_widget.find("class").text + header = custom_widget.find("header").text + module = importlib.import_module(headerToModule(header)) + self.custom_widgets[class_name] = getattr(module, + class_name) + + def load(self, uifile, *args, **kwargs): + from xml.etree.ElementTree import ElementTree + + # For whatever reason, if this doesn't happen then + # reading an invalid or non-existing .ui file throws + # a RuntimeError. + etree = ElementTree() + etree.parse(uifile) + self._loadCustomWidgets(etree) + + widget = Qt._QtUiTools.QUiLoader.load( + self, uifile, *args, **kwargs) + + # Workaround for PySide 1.0.9, see issue #208 + widget.parentWidget() + + return widget + + def createWidget(self, class_name, parent=None, name=""): + """Called for each widget defined in ui file + + Overridden here to populate `baseinstance` instead. + + """ + + if parent is None and self.baseinstance: + # Supposed to create the top-level widget, + # return the base instance instead + return self.baseinstance + + # For some reason, Line is not in the list of available + # widgets, but works fine, so we have to special case it here. + if class_name in self.availableWidgets() + ["Line"]: + # Create a new widget for child widgets + widget = Qt._QtUiTools.QUiLoader.createWidget(self, + class_name, + parent, + name) + elif class_name in self.custom_widgets: + widget = self.custom_widgets[class_name](parent) + else: + raise Exception("Custom widget '%s' not supported" + % class_name) + + if self.baseinstance: + # Set an attribute for the new child widget on the base + # instance, just like PyQt5.uic.loadUi does. + setattr(self.baseinstance, name, widget) + + return widget + + widget = _UiLoader(baseinstance).load(uifile) + Qt.QtCore.QMetaObject.connectSlotsByName(widget) + + return widget + + else: + raise NotImplementedError("No implementation available for loadUi") + + +"""Misplaced members + +These members from the original submodule are misplaced relative PySide2 + +""" +_misplaced_members = { + "PySide2": { + "QtCore.QStringListModel": "QtCore.QStringListModel", + "QtGui.QStringListModel": "QtCore.QStringListModel", + "QtCore.Property": "QtCore.Property", + "QtCore.Signal": "QtCore.Signal", + "QtCore.Slot": "QtCore.Slot", + "QtCore.QAbstractProxyModel": "QtCore.QAbstractProxyModel", + "QtCore.QSortFilterProxyModel": "QtCore.QSortFilterProxyModel", + "QtCore.QItemSelection": "QtCore.QItemSelection", + "QtCore.QItemSelectionModel": "QtCore.QItemSelectionModel", + "QtCore.QItemSelectionRange": "QtCore.QItemSelectionRange", + "QtUiTools.QUiLoader": ["QtCompat.loadUi", _loadUi], + "shiboken2.wrapInstance": ["QtCompat.wrapInstance", _wrapinstance], + "shiboken2.getCppPointer": ["QtCompat.getCppPointer", _getcpppointer], + "shiboken2.isValid": ["QtCompat.isValid", _isvalid], + "QtWidgets.qApp": "QtWidgets.QApplication.instance()", + "QtCore.QCoreApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtWidgets.QApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtCore.qInstallMessageHandler": [ + "QtCompat.qInstallMessageHandler", _qInstallMessageHandler + ], + "QtWidgets.QStyleOptionViewItem": "QtCompat.QStyleOptionViewItemV4", + }, + "PyQt5": { + "QtCore.pyqtProperty": "QtCore.Property", + "QtCore.pyqtSignal": "QtCore.Signal", + "QtCore.pyqtSlot": "QtCore.Slot", + "QtCore.QAbstractProxyModel": "QtCore.QAbstractProxyModel", + "QtCore.QSortFilterProxyModel": "QtCore.QSortFilterProxyModel", + "QtCore.QStringListModel": "QtCore.QStringListModel", + "QtCore.QItemSelection": "QtCore.QItemSelection", + "QtCore.QItemSelectionModel": "QtCore.QItemSelectionModel", + "QtCore.QItemSelectionRange": "QtCore.QItemSelectionRange", + "uic.loadUi": ["QtCompat.loadUi", _loadUi], + "sip.wrapinstance": ["QtCompat.wrapInstance", _wrapinstance], + "sip.unwrapinstance": ["QtCompat.getCppPointer", _getcpppointer], + "sip.isdeleted": ["QtCompat.isValid", _isvalid], + "QtWidgets.qApp": "QtWidgets.QApplication.instance()", + "QtCore.QCoreApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtWidgets.QApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtCore.qInstallMessageHandler": [ + "QtCompat.qInstallMessageHandler", _qInstallMessageHandler + ], + "QtWidgets.QStyleOptionViewItem": "QtCompat.QStyleOptionViewItemV4", + }, + "PySide": { + "QtGui.QAbstractProxyModel": "QtCore.QAbstractProxyModel", + "QtGui.QSortFilterProxyModel": "QtCore.QSortFilterProxyModel", + "QtGui.QStringListModel": "QtCore.QStringListModel", + "QtGui.QItemSelection": "QtCore.QItemSelection", + "QtGui.QItemSelectionModel": "QtCore.QItemSelectionModel", + "QtCore.Property": "QtCore.Property", + "QtCore.Signal": "QtCore.Signal", + "QtCore.Slot": "QtCore.Slot", + "QtGui.QItemSelectionRange": "QtCore.QItemSelectionRange", + "QtGui.QAbstractPrintDialog": "QtPrintSupport.QAbstractPrintDialog", + "QtGui.QPageSetupDialog": "QtPrintSupport.QPageSetupDialog", + "QtGui.QPrintDialog": "QtPrintSupport.QPrintDialog", + "QtGui.QPrintEngine": "QtPrintSupport.QPrintEngine", + "QtGui.QPrintPreviewDialog": "QtPrintSupport.QPrintPreviewDialog", + "QtGui.QPrintPreviewWidget": "QtPrintSupport.QPrintPreviewWidget", + "QtGui.QPrinter": "QtPrintSupport.QPrinter", + "QtGui.QPrinterInfo": "QtPrintSupport.QPrinterInfo", + "QtUiTools.QUiLoader": ["QtCompat.loadUi", _loadUi], + "shiboken.wrapInstance": ["QtCompat.wrapInstance", _wrapinstance], + "shiboken.unwrapInstance": ["QtCompat.getCppPointer", _getcpppointer], + "shiboken.isValid": ["QtCompat.isValid", _isvalid], + "QtGui.qApp": "QtWidgets.QApplication.instance()", + "QtCore.QCoreApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtGui.QApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtCore.qInstallMsgHandler": [ + "QtCompat.qInstallMessageHandler", _qInstallMessageHandler + ], + "QtGui.QStyleOptionViewItemV4": "QtCompat.QStyleOptionViewItemV4", + }, + "PyQt4": { + "QtGui.QAbstractProxyModel": "QtCore.QAbstractProxyModel", + "QtGui.QSortFilterProxyModel": "QtCore.QSortFilterProxyModel", + "QtGui.QItemSelection": "QtCore.QItemSelection", + "QtGui.QStringListModel": "QtCore.QStringListModel", + "QtGui.QItemSelectionModel": "QtCore.QItemSelectionModel", + "QtCore.pyqtProperty": "QtCore.Property", + "QtCore.pyqtSignal": "QtCore.Signal", + "QtCore.pyqtSlot": "QtCore.Slot", + "QtGui.QItemSelectionRange": "QtCore.QItemSelectionRange", + "QtGui.QAbstractPrintDialog": "QtPrintSupport.QAbstractPrintDialog", + "QtGui.QPageSetupDialog": "QtPrintSupport.QPageSetupDialog", + "QtGui.QPrintDialog": "QtPrintSupport.QPrintDialog", + "QtGui.QPrintEngine": "QtPrintSupport.QPrintEngine", + "QtGui.QPrintPreviewDialog": "QtPrintSupport.QPrintPreviewDialog", + "QtGui.QPrintPreviewWidget": "QtPrintSupport.QPrintPreviewWidget", + "QtGui.QPrinter": "QtPrintSupport.QPrinter", + "QtGui.QPrinterInfo": "QtPrintSupport.QPrinterInfo", + # "QtCore.pyqtSignature": "QtCore.Slot", + "uic.loadUi": ["QtCompat.loadUi", _loadUi], + "sip.wrapinstance": ["QtCompat.wrapInstance", _wrapinstance], + "sip.unwrapinstance": ["QtCompat.getCppPointer", _getcpppointer], + "sip.isdeleted": ["QtCompat.isValid", _isvalid], + "QtCore.QString": "str", + "QtGui.qApp": "QtWidgets.QApplication.instance()", + "QtCore.QCoreApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtGui.QApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtCore.qInstallMsgHandler": [ + "QtCompat.qInstallMessageHandler", _qInstallMessageHandler + ], + "QtGui.QStyleOptionViewItemV4": "QtCompat.QStyleOptionViewItemV4", + } +} + +""" Compatibility Members + +This dictionary is used to build Qt.QtCompat objects that provide a consistent +interface for obsolete members, and differences in binding return values. + +{ + "binding": { + "classname": { + "targetname": "binding_namespace", + } + } +} +""" +_compatibility_members = { + "PySide2": { + "QWidget": { + "grab": "QtWidgets.QWidget.grab", + }, + "QHeaderView": { + "sectionsClickable": "QtWidgets.QHeaderView.sectionsClickable", + "setSectionsClickable": + "QtWidgets.QHeaderView.setSectionsClickable", + "sectionResizeMode": "QtWidgets.QHeaderView.sectionResizeMode", + "setSectionResizeMode": + "QtWidgets.QHeaderView.setSectionResizeMode", + "sectionsMovable": "QtWidgets.QHeaderView.sectionsMovable", + "setSectionsMovable": "QtWidgets.QHeaderView.setSectionsMovable", + }, + "QFileDialog": { + "getOpenFileName": "QtWidgets.QFileDialog.getOpenFileName", + "getOpenFileNames": "QtWidgets.QFileDialog.getOpenFileNames", + "getSaveFileName": "QtWidgets.QFileDialog.getSaveFileName", + }, + }, + "PyQt5": { + "QWidget": { + "grab": "QtWidgets.QWidget.grab", + }, + "QHeaderView": { + "sectionsClickable": "QtWidgets.QHeaderView.sectionsClickable", + "setSectionsClickable": + "QtWidgets.QHeaderView.setSectionsClickable", + "sectionResizeMode": "QtWidgets.QHeaderView.sectionResizeMode", + "setSectionResizeMode": + "QtWidgets.QHeaderView.setSectionResizeMode", + "sectionsMovable": "QtWidgets.QHeaderView.sectionsMovable", + "setSectionsMovable": "QtWidgets.QHeaderView.setSectionsMovable", + }, + "QFileDialog": { + "getOpenFileName": "QtWidgets.QFileDialog.getOpenFileName", + "getOpenFileNames": "QtWidgets.QFileDialog.getOpenFileNames", + "getSaveFileName": "QtWidgets.QFileDialog.getSaveFileName", + }, + }, + "PySide": { + "QWidget": { + "grab": "QtWidgets.QPixmap.grabWidget", + }, + "QHeaderView": { + "sectionsClickable": "QtWidgets.QHeaderView.isClickable", + "setSectionsClickable": "QtWidgets.QHeaderView.setClickable", + "sectionResizeMode": "QtWidgets.QHeaderView.resizeMode", + "setSectionResizeMode": "QtWidgets.QHeaderView.setResizeMode", + "sectionsMovable": "QtWidgets.QHeaderView.isMovable", + "setSectionsMovable": "QtWidgets.QHeaderView.setMovable", + }, + "QFileDialog": { + "getOpenFileName": "QtWidgets.QFileDialog.getOpenFileName", + "getOpenFileNames": "QtWidgets.QFileDialog.getOpenFileNames", + "getSaveFileName": "QtWidgets.QFileDialog.getSaveFileName", + }, + }, + "PyQt4": { + "QWidget": { + "grab": "QtWidgets.QPixmap.grabWidget", + }, + "QHeaderView": { + "sectionsClickable": "QtWidgets.QHeaderView.isClickable", + "setSectionsClickable": "QtWidgets.QHeaderView.setClickable", + "sectionResizeMode": "QtWidgets.QHeaderView.resizeMode", + "setSectionResizeMode": "QtWidgets.QHeaderView.setResizeMode", + "sectionsMovable": "QtWidgets.QHeaderView.isMovable", + "setSectionsMovable": "QtWidgets.QHeaderView.setMovable", + }, + "QFileDialog": { + "getOpenFileName": "QtWidgets.QFileDialog.getOpenFileName", + "getOpenFileNames": "QtWidgets.QFileDialog.getOpenFileNames", + "getSaveFileName": "QtWidgets.QFileDialog.getSaveFileName", + }, + }, +} + + +def _apply_site_config(): + try: + import QtSiteConfig + except ImportError: + # If no QtSiteConfig module found, no modifications + # to _common_members are needed. + pass + else: + # Provide the ability to modify the dicts used to build Qt.py + if hasattr(QtSiteConfig, 'update_members'): + QtSiteConfig.update_members(_common_members) + + if hasattr(QtSiteConfig, 'update_misplaced_members'): + QtSiteConfig.update_misplaced_members(members=_misplaced_members) + + if hasattr(QtSiteConfig, 'update_compatibility_members'): + QtSiteConfig.update_compatibility_members( + members=_compatibility_members) + + +def _new_module(name): + return types.ModuleType(__name__ + "." + name) + + +def _import_sub_module(module, name): + """import_sub_module will mimic the function of importlib.import_module""" + module = __import__(module.__name__ + "." + name) + for level in name.split("."): + module = getattr(module, level) + return module + + +def _setup(module, extras): + """Install common submodules""" + + Qt.__binding__ = module.__name__ + + for name in list(_common_members) + extras: + try: + submodule = _import_sub_module( + module, name) + except ImportError: + try: + # For extra modules like sip and shiboken that may not be + # children of the binding. + submodule = __import__(name) + except ImportError: + continue + + setattr(Qt, "_" + name, submodule) + + if name not in extras: + # Store reference to original binding, + # but don't store speciality modules + # such as uic or QtUiTools + setattr(Qt, name, _new_module(name)) + + +def _reassign_misplaced_members(binding): + """Apply misplaced members from `binding` to Qt.py + + Arguments: + binding (dict): Misplaced members + + """ + + for src, dst in _misplaced_members[binding].items(): + dst_value = None + + src_parts = src.split(".") + src_module = src_parts[0] + src_member = None + if len(src_parts) > 1: + src_member = src_parts[1:] + + if isinstance(dst, (list, tuple)): + dst, dst_value = dst + + dst_parts = dst.split(".") + dst_module = dst_parts[0] + dst_member = None + if len(dst_parts) > 1: + dst_member = dst_parts[1] + + # Get the member we want to store in the namesapce. + if not dst_value: + try: + _part = getattr(Qt, "_" + src_module) + while src_member: + member = src_member.pop(0) + _part = getattr(_part, member) + dst_value = _part + except AttributeError: + # If the member we want to store in the namespace does not + # exist, there is no need to continue. This can happen if a + # request was made to rename a member that didn't exist, for + # example if QtWidgets isn't available on the target platform. + _log("Misplaced member has no source: {0}".format(src)) + continue + + try: + src_object = getattr(Qt, dst_module) + except AttributeError: + if dst_module not in _common_members: + # Only create the Qt parent module if its listed in + # _common_members. Without this check, if you remove QtCore + # from _common_members, the default _misplaced_members will add + # Qt.QtCore so it can add Signal, Slot, etc. + msg = 'Not creating missing member module "{m}" for "{c}"' + _log(msg.format(m=dst_module, c=dst_member)) + continue + # If the dst is valid but the Qt parent module does not exist + # then go ahead and create a new module to contain the member. + setattr(Qt, dst_module, _new_module(dst_module)) + src_object = getattr(Qt, dst_module) + # Enable direct import of the new module + sys.modules[__name__ + "." + dst_module] = src_object + + if not dst_value: + dst_value = getattr(Qt, "_" + src_module) + if src_member: + dst_value = getattr(dst_value, src_member) + + setattr( + src_object, + dst_member or dst_module, + dst_value + ) + + +def _build_compatibility_members(binding, decorators=None): + """Apply `binding` to QtCompat + + Arguments: + binding (str): Top level binding in _compatibility_members. + decorators (dict, optional): Provides the ability to decorate the + original Qt methods when needed by a binding. This can be used + to change the returned value to a standard value. The key should + be the classname, the value is a dict where the keys are the + target method names, and the values are the decorator functions. + + """ + + decorators = decorators or dict() + + # Allow optional site-level customization of the compatibility members. + # This method does not need to be implemented in QtSiteConfig. + try: + import QtSiteConfig + except ImportError: + pass + else: + if hasattr(QtSiteConfig, 'update_compatibility_decorators'): + QtSiteConfig.update_compatibility_decorators(binding, decorators) + + _QtCompat = type("QtCompat", (object,), {}) + + for classname, bindings in _compatibility_members[binding].items(): + attrs = {} + for target, binding in bindings.items(): + namespaces = binding.split('.') + try: + src_object = getattr(Qt, "_" + namespaces[0]) + except AttributeError as e: + _log("QtCompat: AttributeError: %s" % e) + # Skip reassignment of non-existing members. + # This can happen if a request was made to + # rename a member that didn't exist, for example + # if QtWidgets isn't available on the target platform. + continue + + # Walk down any remaining namespace getting the object assuming + # that if the first namespace exists the rest will exist. + for namespace in namespaces[1:]: + src_object = getattr(src_object, namespace) + + # decorate the Qt method if a decorator was provided. + if target in decorators.get(classname, []): + # staticmethod must be called on the decorated method to + # prevent a TypeError being raised when the decorated method + # is called. + src_object = staticmethod( + decorators[classname][target](src_object)) + + attrs[target] = src_object + + # Create the QtCompat class and install it into the namespace + compat_class = type(classname, (_QtCompat,), attrs) + setattr(Qt.QtCompat, classname, compat_class) + + +def _pyside2(): + """Initialise PySide2 + + These functions serve to test the existence of a binding + along with set it up in such a way that it aligns with + the final step; adding members from the original binding + to Qt.py + + """ + + import PySide2 as module + extras = ["QtUiTools"] + try: + try: + # Before merge of PySide and shiboken + import shiboken2 + except ImportError: + # After merge of PySide and shiboken, May 2017 + from PySide2 import shiboken2 + extras.append("shiboken2") + except ImportError: + pass + + _setup(module, extras) + Qt.__binding_version__ = module.__version__ + + if hasattr(Qt, "_shiboken2"): + Qt.QtCompat.wrapInstance = _wrapinstance + Qt.QtCompat.getCppPointer = _getcpppointer + Qt.QtCompat.delete = shiboken2.delete + + if hasattr(Qt, "_QtUiTools"): + Qt.QtCompat.loadUi = _loadUi + + if hasattr(Qt, "_QtCore"): + Qt.__qt_version__ = Qt._QtCore.qVersion() + Qt.QtCompat.dataChanged = ( + lambda self, topleft, bottomright, roles=None: + self.dataChanged.emit(topleft, bottomright, roles or []) + ) + + if hasattr(Qt, "_QtWidgets"): + Qt.QtCompat.setSectionResizeMode = \ + Qt._QtWidgets.QHeaderView.setSectionResizeMode + + _reassign_misplaced_members("PySide2") + _build_compatibility_members("PySide2") + + +def _pyside(): + """Initialise PySide""" + + import PySide as module + extras = ["QtUiTools"] + try: + try: + # Before merge of PySide and shiboken + import shiboken + except ImportError: + # After merge of PySide and shiboken, May 2017 + from PySide import shiboken + extras.append("shiboken") + except ImportError: + pass + + _setup(module, extras) + Qt.__binding_version__ = module.__version__ + + if hasattr(Qt, "_shiboken"): + Qt.QtCompat.wrapInstance = _wrapinstance + Qt.QtCompat.getCppPointer = _getcpppointer + Qt.QtCompat.delete = shiboken.delete + + if hasattr(Qt, "_QtUiTools"): + Qt.QtCompat.loadUi = _loadUi + + if hasattr(Qt, "_QtGui"): + setattr(Qt, "QtWidgets", _new_module("QtWidgets")) + setattr(Qt, "_QtWidgets", Qt._QtGui) + if hasattr(Qt._QtGui, "QX11Info"): + setattr(Qt, "QtX11Extras", _new_module("QtX11Extras")) + Qt.QtX11Extras.QX11Info = Qt._QtGui.QX11Info + + Qt.QtCompat.setSectionResizeMode = Qt._QtGui.QHeaderView.setResizeMode + + if hasattr(Qt, "_QtCore"): + Qt.__qt_version__ = Qt._QtCore.qVersion() + Qt.QtCompat.dataChanged = ( + lambda self, topleft, bottomright, roles=None: + self.dataChanged.emit(topleft, bottomright) + ) + + _reassign_misplaced_members("PySide") + _build_compatibility_members("PySide") + + +def _pyqt5(): + """Initialise PyQt5""" + + import PyQt5 as module + extras = ["uic"] + + try: + import sip + extras += ["sip"] + except ImportError: + + # Relevant to PyQt5 5.11 and above + try: + from PyQt5 import sip + extras += ["sip"] + except ImportError: + sip = None + + _setup(module, extras) + if hasattr(Qt, "_sip"): + Qt.QtCompat.wrapInstance = _wrapinstance + Qt.QtCompat.getCppPointer = _getcpppointer + Qt.QtCompat.delete = sip.delete + + if hasattr(Qt, "_uic"): + Qt.QtCompat.loadUi = _loadUi + + if hasattr(Qt, "_QtCore"): + Qt.__binding_version__ = Qt._QtCore.PYQT_VERSION_STR + Qt.__qt_version__ = Qt._QtCore.QT_VERSION_STR + Qt.QtCompat.dataChanged = ( + lambda self, topleft, bottomright, roles=None: + self.dataChanged.emit(topleft, bottomright, roles or []) + ) + + if hasattr(Qt, "_QtWidgets"): + Qt.QtCompat.setSectionResizeMode = \ + Qt._QtWidgets.QHeaderView.setSectionResizeMode + + _reassign_misplaced_members("PyQt5") + _build_compatibility_members('PyQt5') + + +def _pyqt4(): + """Initialise PyQt4""" + + import sip + + # Validation of envivornment variable. Prevents an error if + # the variable is invalid since it's just a hint. + try: + hint = int(QT_SIP_API_HINT) + except TypeError: + hint = None # Variable was None, i.e. not set. + except ValueError: + raise ImportError("QT_SIP_API_HINT=%s must be a 1 or 2") + + for api in ("QString", + "QVariant", + "QDate", + "QDateTime", + "QTextStream", + "QTime", + "QUrl"): + try: + sip.setapi(api, hint or 2) + except AttributeError: + raise ImportError("PyQt4 < 4.6 isn't supported by Qt.py") + except ValueError: + actual = sip.getapi(api) + if not hint: + raise ImportError("API version already set to %d" % actual) + else: + # Having provided a hint indicates a soft constraint, one + # that doesn't throw an exception. + sys.stderr.write( + "Warning: API '%s' has already been set to %d.\n" + % (api, actual) + ) + + import PyQt4 as module + extras = ["uic"] + try: + import sip + extras.append(sip.__name__) + except ImportError: + sip = None + + _setup(module, extras) + if hasattr(Qt, "_sip"): + Qt.QtCompat.wrapInstance = _wrapinstance + Qt.QtCompat.getCppPointer = _getcpppointer + Qt.QtCompat.delete = sip.delete + + if hasattr(Qt, "_uic"): + Qt.QtCompat.loadUi = _loadUi + + if hasattr(Qt, "_QtGui"): + setattr(Qt, "QtWidgets", _new_module("QtWidgets")) + setattr(Qt, "_QtWidgets", Qt._QtGui) + if hasattr(Qt._QtGui, "QX11Info"): + setattr(Qt, "QtX11Extras", _new_module("QtX11Extras")) + Qt.QtX11Extras.QX11Info = Qt._QtGui.QX11Info + + Qt.QtCompat.setSectionResizeMode = \ + Qt._QtGui.QHeaderView.setResizeMode + + if hasattr(Qt, "_QtCore"): + Qt.__binding_version__ = Qt._QtCore.PYQT_VERSION_STR + Qt.__qt_version__ = Qt._QtCore.QT_VERSION_STR + Qt.QtCompat.dataChanged = ( + lambda self, topleft, bottomright, roles=None: + self.dataChanged.emit(topleft, bottomright) + ) + + _reassign_misplaced_members("PyQt4") + + # QFileDialog QtCompat decorator + def _standardizeQFileDialog(some_function): + """Decorator that makes PyQt4 return conform to other bindings""" + def wrapper(*args, **kwargs): + ret = (some_function(*args, **kwargs)) + + # PyQt4 only returns the selected filename, force it to a + # standard return of the selected filename, and a empty string + # for the selected filter + return ret, '' + + wrapper.__doc__ = some_function.__doc__ + wrapper.__name__ = some_function.__name__ + + return wrapper + + decorators = { + "QFileDialog": { + "getOpenFileName": _standardizeQFileDialog, + "getOpenFileNames": _standardizeQFileDialog, + "getSaveFileName": _standardizeQFileDialog, + } + } + _build_compatibility_members('PyQt4', decorators) + + +def _none(): + """Internal option (used in installer)""" + + Mock = type("Mock", (), {"__getattr__": lambda Qt, attr: None}) + + Qt.__binding__ = "None" + Qt.__qt_version__ = "0.0.0" + Qt.__binding_version__ = "0.0.0" + Qt.QtCompat.loadUi = lambda uifile, baseinstance=None: None + Qt.QtCompat.setSectionResizeMode = lambda *args, **kwargs: None + + for submodule in _common_members.keys(): + setattr(Qt, submodule, Mock()) + setattr(Qt, "_" + submodule, Mock()) + + +def _log(text): + if QT_VERBOSE: + sys.stdout.write(text + "\n") + + +def _convert(lines): + """Convert compiled .ui file from PySide2 to Qt.py + + Arguments: + lines (list): Each line of of .ui file + + Usage: + >> with open("myui.py") as f: + .. lines = _convert(f.readlines()) + + """ + + def parse(line): + line = line.replace("from PySide2 import", "from Qt import QtCompat,") + line = line.replace("QtWidgets.QApplication.translate", + "QtCompat.translate") + if "QtCore.SIGNAL" in line: + raise NotImplementedError("QtCore.SIGNAL is missing from PyQt5 " + "and so Qt.py does not support it: you " + "should avoid defining signals inside " + "your ui files.") + return line + + parsed = list() + for line in lines: + line = parse(line) + parsed.append(line) + + return parsed + + +def _cli(args): + """Qt.py command-line interface""" + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("--convert", + help="Path to compiled Python module, e.g. my_ui.py") + parser.add_argument("--compile", + help="Accept raw .ui file and compile with native " + "PySide2 compiler.") + parser.add_argument("--stdout", + help="Write to stdout instead of file", + action="store_true") + parser.add_argument("--stdin", + help="Read from stdin instead of file", + action="store_true") + + args = parser.parse_args(args) + + if args.stdout: + raise NotImplementedError("--stdout") + + if args.stdin: + raise NotImplementedError("--stdin") + + if args.compile: + raise NotImplementedError("--compile") + + if args.convert: + sys.stdout.write("#\n" + "# WARNING: --convert is an ALPHA feature.\n#\n" + "# See https://github.com/mottosso/Qt.py/pull/132\n" + "# for details.\n" + "#\n") + + # + # ------> Read + # + with open(args.convert) as f: + lines = _convert(f.readlines()) + + backup = "%s_backup%s" % os.path.splitext(args.convert) + sys.stdout.write("Creating \"%s\"..\n" % backup) + shutil.copy(args.convert, backup) + + # + # <------ Write + # + with open(args.convert, "w") as f: + f.write("".join(lines)) + + sys.stdout.write("Successfully converted \"%s\"\n" % args.convert) + + +class MissingMember(object): + """ + A placeholder type for a missing Qt object not + included in Qt.py + + Args: + name (str): The name of the missing type + details (str): An optional custom error message + """ + ERR_TMPL = ("{} is not a common object across PySide2 " + "and the other Qt bindings. It is not included " + "as a common member in the Qt.py layer") + + def __init__(self, name, details=''): + self.__name = name + self.__err = self.ERR_TMPL.format(name) + + if details: + self.__err = "{}: {}".format(self.__err, details) + + def __repr__(self): + return "<{}: {}>".format(self.__class__.__name__, self.__name) + + def __getattr__(self, name): + raise NotImplementedError(self.__err) + + def __call__(self, *a, **kw): + raise NotImplementedError(self.__err) + + +def _install(): + # Default order (customise order and content via QT_PREFERRED_BINDING) + default_order = ("PySide2", "PyQt5", "PySide", "PyQt4") + preferred_order = list( + b for b in QT_PREFERRED_BINDING.split(os.pathsep) if b + ) + + order = preferred_order or default_order + + available = { + "PySide2": _pyside2, + "PyQt5": _pyqt5, + "PySide": _pyside, + "PyQt4": _pyqt4, + "None": _none + } + + _log("Order: '%s'" % "', '".join(order)) + + # Allow site-level customization of the available modules. + _apply_site_config() + + found_binding = False + for name in order: + _log("Trying %s" % name) + + try: + available[name]() + found_binding = True + break + + except ImportError as e: + _log("ImportError: %s" % e) + + except KeyError: + _log("ImportError: Preferred binding '%s' not found." % name) + + if not found_binding: + # If not binding were found, throw this error + raise ImportError("No Qt binding were found.") + + # Install individual members + for name, members in _common_members.items(): + try: + their_submodule = getattr(Qt, "_%s" % name) + except AttributeError: + continue + + our_submodule = getattr(Qt, name) + + # Enable import * + __all__.append(name) + + # Enable direct import of submodule, + # e.g. import Qt.QtCore + sys.modules[__name__ + "." + name] = our_submodule + + for member in members: + # Accept that a submodule may miss certain members. + try: + their_member = getattr(their_submodule, member) + except AttributeError: + _log("'%s.%s' was missing." % (name, member)) + continue + + setattr(our_submodule, member, their_member) + + # Install missing member placeholders + for name, members in _missing_members.items(): + our_submodule = getattr(Qt, name) + + for member in members: + + # If the submodule already has this member installed, + # either by the common members, or the site config, + # then skip installing this one over it. + if hasattr(our_submodule, member): + continue + + placeholder = MissingMember("{}.{}".format(name, member), + details=members[member]) + setattr(our_submodule, member, placeholder) + + # Enable direct import of QtCompat + sys.modules['Qt.QtCompat'] = Qt.QtCompat + + # Backwards compatibility + if hasattr(Qt.QtCompat, 'loadUi'): + Qt.QtCompat.load_ui = Qt.QtCompat.loadUi + + +_install() + +# Setup Binding Enum states +Qt.IsPySide2 = Qt.__binding__ == 'PySide2' +Qt.IsPyQt5 = Qt.__binding__ == 'PyQt5' +Qt.IsPySide = Qt.__binding__ == 'PySide' +Qt.IsPyQt4 = Qt.__binding__ == 'PyQt4' + +"""Augment QtCompat + +QtCompat contains wrappers and added functionality +to the original bindings, such as the CLI interface +and otherwise incompatible members between bindings, +such as `QHeaderView.setSectionResizeMode`. + +""" + +Qt.QtCompat._cli = _cli +Qt.QtCompat._convert = _convert + +# Enable command-line interface +if __name__ == "__main__": + _cli(sys.argv[1:]) + + +# The MIT License (MIT) +# +# Copyright (c) 2016-2017 Marcus Ottosson +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# In PySide(2), loadUi does not exist, so we implement it +# +# `_UiLoader` is adapted from the qtpy project, which was further influenced +# by qt-helpers which was released under a 3-clause BSD license which in turn +# is based on a solution at: +# +# - https://gist.github.com/cpbotha/1b42a20c8f3eb9bb7cb8 +# +# The License for this code is as follows: +# +# qt-helpers - a common front-end to various Qt modules +# +# Copyright (c) 2015, Chris Beaumont and Thomas Robitaille +# +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the +# distribution. +# * Neither the name of the Glue project nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Which itself was based on the solution at +# +# https://gist.github.com/cpbotha/1b42a20c8f3eb9bb7cb8 +# +# which was released under the MIT license: +# +# Copyright (c) 2011 Sebastian Wiesner +# Modifications by Charl Botha +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files +# (the "Software"),to deal in the Software without restriction, +# including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/openpype/vendor/python/common/scriptsmenu/vendor/__init__.py b/openpype/vendor/python/common/scriptsmenu/vendor/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/vendor/python/common/scriptsmenu/version.py b/openpype/vendor/python/common/scriptsmenu/version.py new file mode 100644 index 0000000000..73f9426c2d --- /dev/null +++ b/openpype/vendor/python/common/scriptsmenu/version.py @@ -0,0 +1,9 @@ +VERSION_MAJOR = 1 +VERSION_MINOR = 5 +VERSION_PATCH = 1 + + +version = '{}.{}.{}'.format(VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH) +__version__ = version + +__all__ = ['version', '__version__'] From 7948820108ccfc2bd088d8125e006a3efa4d6377 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 24 Jun 2021 17:01:11 +0200 Subject: [PATCH 02/57] add shader definition item to menu --- openpype/hosts/maya/api/commands.py | 6 ++++ openpype/hosts/maya/api/menu.py | 35 +++---------------- .../defaults/project_settings/maya.json | 14 +++++--- 3 files changed, 19 insertions(+), 36 deletions(-) create mode 100644 openpype/hosts/maya/api/commands.py diff --git a/openpype/hosts/maya/api/commands.py b/openpype/hosts/maya/api/commands.py new file mode 100644 index 0000000000..cbd8ec57f8 --- /dev/null +++ b/openpype/hosts/maya/api/commands.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +"""OpenPype script commands to be used directly in Maya.""" + +def edit_shader_definitions(): + print("Editing shader definitions...") + pass \ No newline at end of file diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index 5e036b8e0c..a8812210a5 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -6,9 +6,11 @@ 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") +project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) +self._menu = project_settings["maya"]["scriptsmenu"]["name"] log = logging.getLogger(__name__) @@ -55,34 +57,6 @@ 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 ...") add_build_workfiles_item() @@ -100,8 +74,7 @@ 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) + config = project_settings["maya"]["scriptsmenu"]["definition"] # run the launcher for Maya menu studio_menu = launchformaya.main( diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 0375eb42d5..e3f0a86c27 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -10,12 +10,16 @@ "scriptsmenu": { "name": "OpenPype Tools", "definition": [ - { + { "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\save_scene_incremental.py", - "sourcetype": "file", - "title": "# Version Up", - "tooltip": "Incremental save with a specific format" + "command": "import openpype.hosts.maya.api.commands as op_cmds; op_cmds.edit_shader_definitions()", + "sourcetype": "python", + "title": "Edit shader name definitions", + "tooltip": "Edit shader name definitions used in validation and renaming.", + "tags": [ + "pipeline", + "shader" + ] } ] }, From 8683ab3c3ff870680e711caa07698cea3ed27f8a Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 24 Jun 2021 19:07:44 +0200 Subject: [PATCH 03/57] first prototype of editor --- openpype/hosts/maya/api/commands.py | 21 ++++- .../maya/api/shader_definition_editor.py | 83 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 openpype/hosts/maya/api/shader_definition_editor.py diff --git a/openpype/hosts/maya/api/commands.py b/openpype/hosts/maya/api/commands.py index cbd8ec57f8..fc0dc90678 100644 --- a/openpype/hosts/maya/api/commands.py +++ b/openpype/hosts/maya/api/commands.py @@ -1,6 +1,25 @@ # -*- coding: utf-8 -*- """OpenPype script commands to be used directly in Maya.""" +import sys + def edit_shader_definitions(): + from avalon.tools import lib + from Qt import QtWidgets, QtCore + from openpype.hosts.maya.api.shader_definition_editor import ShaderDefinitionsEditor + print("Editing shader definitions...") - pass \ No newline at end of file + + module = sys.modules[__name__] + module.window = None + + top_level_widgets = QtWidgets.QApplication.topLevelWidgets() + mainwindow = next(widget for widget in top_level_widgets + if widget.objectName() == "MayaWindow") + + with lib.application(): + window = ShaderDefinitionsEditor(parent=mainwindow) + # window.setStyleSheet(style.load_stylesheet()) + window.show() + + module.window = window 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..88e24e6cac --- /dev/null +++ b/openpype/hosts/maya/api/shader_definition_editor.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +"""Editor for shader definitions.""" +import os +import csv +from Qt import QtWidgets, QtCore, QtGui +from openpype.lib.mongo import OpenPypeMongoConnection +from openpype import resources +import gridfs + + +class ShaderDefinitionsEditor(QtWidgets.QWidget): + + DEFINITION_FILENAME = "maya/shader_definition.csv" + + 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")]) + + # TODO: handle GridIn and GridOut + self._file = self._gridfs.find_one( + {"filename": self.DEFINITION_FILENAME}) + if not self._file: + self._file = self._gridfs.new_file(filename=self.DEFINITION_FILENAME) + + self.setObjectName("shaderDefinitionEditor") + self.setWindowTitle("OpenPype shader 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() + + def setup_ui(self): + layout = QtWidgets.QVBoxLayout(self) + self._editor = QtWidgets.QPlainTextEdit() + layout.addWidget(self._editor) + + btn_layout = QtWidgets.QHBoxLayout() + save_btn = QtWidgets.QPushButton("Save") + save_btn.clicked.connect(self._close) + + 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): + content = [] + with open(self._file, "r") as f: + reader = csv.reader(f) + for row in reader: + content.append(row) + + return content + + def _write_definition_file(self, content): + with open(self._file, "w", newline="") as f: + writer = csv.writer(f) + writer.writerows(content.splitlines()) + + def _close(self): + self.close() + + def _reload(self): + print("reloading") + self._set_content(self._read_definition_file()) + + def _save(self): + pass + + def _set_content(self, content): + self._editor.set_content("\n".join(content)) From 14c79dca2e6bbaab2a47977ff2a287f37962fc15 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 25 Jun 2021 12:49:44 +0200 Subject: [PATCH 04/57] working editor --- .../maya/api/shader_definition_editor.py | 141 ++++++++++++++---- 1 file changed, 116 insertions(+), 25 deletions(-) diff --git a/openpype/hosts/maya/api/shader_definition_editor.py b/openpype/hosts/maya/api/shader_definition_editor.py index 88e24e6cac..79de19069c 100644 --- a/openpype/hosts/maya/api/shader_definition_editor.py +++ b/openpype/hosts/maya/api/shader_definition_editor.py @@ -1,7 +1,10 @@ # -*- coding: utf-8 -*- -"""Editor for shader definitions.""" +"""Editor for shader definitions. + +Shader names are stored as simple text file over GridFS in mongodb. + +""" import os -import csv from Qt import QtWidgets, QtCore, QtGui from openpype.lib.mongo import OpenPypeMongoConnection from openpype import resources @@ -9,22 +12,21 @@ import gridfs class ShaderDefinitionsEditor(QtWidgets.QWidget): + """Widget serving as simple editor for shader name definitions.""" - DEFINITION_FILENAME = "maya/shader_definition.csv" + # name of the file used to store definitions + DEFINITION_FILENAME = "maya/shader_definition.txt" 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 - # TODO: handle GridIn and GridOut - self._file = self._gridfs.find_one( - {"filename": self.DEFINITION_FILENAME}) - if not self._file: - self._file = self._gridfs.new_file(filename=self.DEFINITION_FILENAME) + self._original_content = self._read_definition_file() self.setObjectName("shaderDefinitionEditor") - self.setWindowTitle("OpenPype shader definition editor") + self.setWindowTitle("OpenPype shader name definition editor") icon = QtGui.QIcon(resources.pype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags(QtCore.Qt.Window) @@ -32,16 +34,22 @@ class ShaderDefinitionsEditor(QtWidgets.QWidget): self.setAttribute(QtCore.Qt.WA_DeleteOnClose) self.resize(750, 500) - self.setup_ui() + self._setup_ui() + self._reload() - def setup_ui(self): + 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._close) + save_btn.clicked.connect(self._save) reload_btn = QtWidgets.QPushButton("Reload") reload_btn.clicked.connect(self._reload) @@ -55,29 +63,112 @@ class ShaderDefinitionsEditor(QtWidgets.QWidget): layout.addLayout(btn_layout) - def _read_definition_file(self): - content = [] - with open(self._file, "r") as f: - reader = csv.reader(f) - for row in reader: - content.append(row) + 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": self.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): - with open(self._file, "w", newline="") as f: - writer = csv.writer(f) - writer.writerows(content.splitlines()) + 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": self.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=self.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.close() def _reload(self): - print("reloading") + print(">>> [SNDE]: reloading") self._set_content(self._read_definition_file()) def _save(self): - pass + 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.set_content("\n".join(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" + "Do you want to overwrite it?"), + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) + + if reply == QtWidgets.QMessageBox.Yes: + self._write_definition_file( + content=self._editor.toPlainText(), + force=True + ) + + elif reply == QtWidgets.QMessageBox.No: + # do nothing + pass + + +class ContentException(Exception): + """This is risen during save if file is changed in database.""" + pass From f5b5c944873c8159ceb88b48d4f3a6f4288e1041 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 25 Jun 2021 12:53:44 +0200 Subject: [PATCH 05/57] hound fixes --- openpype/hosts/maya/api/commands.py | 15 +++++++-------- .../hosts/maya/api/shader_definition_editor.py | 3 ++- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/maya/api/commands.py b/openpype/hosts/maya/api/commands.py index fc0dc90678..645e5840fd 100644 --- a/openpype/hosts/maya/api/commands.py +++ b/openpype/hosts/maya/api/commands.py @@ -5,21 +5,20 @@ import sys def edit_shader_definitions(): from avalon.tools import lib - from Qt import QtWidgets, QtCore - from openpype.hosts.maya.api.shader_definition_editor import ShaderDefinitionsEditor - - print("Editing shader definitions...") + from Qt import QtWidgets + from openpype.hosts.maya.api.shader_definition_editor import ( + ShaderDefinitionsEditor + ) module = sys.modules[__name__] module.window = None top_level_widgets = QtWidgets.QApplication.topLevelWidgets() - mainwindow = next(widget for widget in top_level_widgets - if widget.objectName() == "MayaWindow") + main_window = next(widget for widget in top_level_widgets + if widget.objectName() == "MayaWindow") with lib.application(): - window = ShaderDefinitionsEditor(parent=mainwindow) - # window.setStyleSheet(style.load_stylesheet()) + window = ShaderDefinitionsEditor(parent=main_window) window.show() module.window = window diff --git a/openpype/hosts/maya/api/shader_definition_editor.py b/openpype/hosts/maya/api/shader_definition_editor.py index 79de19069c..5585c9ea8e 100644 --- a/openpype/hosts/maya/api/shader_definition_editor.py +++ b/openpype/hosts/maya/api/shader_definition_editor.py @@ -20,7 +20,8 @@ class ShaderDefinitionsEditor(QtWidgets.QWidget): 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._gridfs = gridfs.GridFS( + self._mongo[os.getenv("OPENPYPE_DATABASE_NAME")]) self._editor = None self._original_content = self._read_definition_file() From 5254a53d035d32d049a9b2c40f2887feae6dc5e6 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 25 Jun 2021 19:15:53 +0200 Subject: [PATCH 06/57] validator --- .../plugins/publish/validate_model_name.py | 54 +++++++++++++------ .../defaults/project_settings/maya.json | 3 +- .../schemas/schema_maya_publish.json | 7 ++- 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_model_name.py b/openpype/hosts/maya/plugins/publish/validate_model_name.py index 98da4d42ba..d031a8b76c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_model_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_model_name.py @@ -1,8 +1,13 @@ +# -*- coding: utf-8 -*- +"""Validate model nodes names.""" from maya import cmds import pyblish.api import openpype.api import openpype.hosts.maya.api.action +from openpype.lib.mongo import OpenPypeMongoConnection +import gridfs import re +import os class ValidateModelName(pyblish.api.InstancePlugin): @@ -19,18 +24,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 = "maya/shader_definition.txt" @classmethod def get_invalid(cls, instance): + """Get invalid nodes.""" + use_db = instance.context.data["project_settings"]["maya"]["publish"]["ValidateModelName"]["database"] # noqa: E401 - # 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 @@ -49,24 +54,41 @@ class ValidateModelName(pyblish.api.InstancePlugin): 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 = instance.context.data["project_settings"]["maya"]["publish"]["ValidateModelName"]["regex"] # noqa: E401 + 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 +96,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 +112,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/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index e3f0a86c27..b40ab40c61 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -164,12 +164,13 @@ }, "ValidateModelName": { "enabled": false, + "database": true, "material_file": { "windows": "", "darwin": "", "linux": "" }, - "regex": "(.*)_(\\\\d)*_(.*)_(GEO)" + "regex": "(.*)_(\\d)*_(?P.*)_(GEO)" }, "ValidateTransformNamingSuffix": { "enabled": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 5ca7059ee5..10b80dddfd 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -147,9 +147,14 @@ "key": "enabled", "label": "Enabled" }, + { + "type": "boolean", + "key": "database", + "label": "Use database shader name definitions" + }, { "type": "label", - "label": "Path to material file defining list of material names to check. This is material name per line simple text file.
It will be checked against named group shader in your Validation regex.

For example:
^.*(?P=<shader>.+)_GEO

" + "label": "Path to material file defining list of material names to check. This is material name per line simple text file.
It will be checked against named group shader in your Validation regex.

For example:
^.*(?P=<shader>.+)_GEO

This is used instead of database definitions if they are disabled." }, { "type": "path", From 325835c860826a2e9ab1330cffe4f27d35062a37 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 25 Jun 2021 19:18:35 +0200 Subject: [PATCH 07/57] fix hound --- openpype/hosts/maya/plugins/publish/validate_model_name.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_model_name.py b/openpype/hosts/maya/plugins/publish/validate_model_name.py index d031a8b76c..84242cda23 100644 --- a/openpype/hosts/maya/plugins/publish/validate_model_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_model_name.py @@ -30,7 +30,7 @@ class ValidateModelName(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): """Get invalid nodes.""" - use_db = instance.context.data["project_settings"]["maya"]["publish"]["ValidateModelName"]["database"] # noqa: E401 + use_db = instance.context.data["project_settings"]["maya"]["publish"]["ValidateModelName"]["database"] # noqa: E501 def is_group(group_name): """Find out if supplied transform is group or not.""" @@ -84,7 +84,7 @@ class ValidateModelName(pyblish.api.InstancePlugin): shaders = map(lambda s: s.rstrip(), shaders) # compile regex for testing names - regex = instance.context.data["project_settings"]["maya"]["publish"]["ValidateModelName"]["regex"] # noqa: E401 + regex = instance.context.data["project_settings"]["maya"]["publish"]["ValidateModelName"]["regex"] # noqa: E501 r = re.compile(regex) for obj in filtered: From 6d678c242b3e27075b10e0057b50c83498bc841b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 7 Jul 2021 19:53:36 +0200 Subject: [PATCH 08/57] trigger reset on show with small delay so setting ui is visible --- openpype/tools/settings/settings/window.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/tools/settings/settings/window.py b/openpype/tools/settings/settings/window.py index a60a2a1d88..f4428af6ed 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: From 4648e145816bf6c517c4063e0aa3eccb90a25911 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 7 Jul 2021 19:54:01 +0200 Subject: [PATCH 09/57] make sure on passed password that window is visible --- openpype/tools/settings/settings/window.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/settings/settings/window.py b/openpype/tools/settings/settings/window.py index f4428af6ed..a3591f292a 100644 --- a/openpype/tools/settings/settings/window.py +++ b/openpype/tools/settings/settings/window.py @@ -108,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() From 179328fbe799668e718e3b0f7b4d6a8c41f31c4c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 7 Jul 2021 19:58:45 +0200 Subject: [PATCH 10/57] added few titles to dialogs --- openpype/tools/settings/settings/categories.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index 392c749211..cf57785c25 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -289,6 +289,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_() @@ -298,6 +299,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)) @@ -387,6 +389,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)) From 228829e81a4e27021a4e344345b223821bd597e4 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 21 Jul 2021 17:20:36 +0200 Subject: [PATCH 11/57] various fixes --- openpype/hosts/maya/api/commands.py | 32 +++++++++++++++---- openpype/hosts/maya/api/menu.py | 20 ++++++++---- .../maya/api/shader_definition_editor.py | 29 +++++++++-------- .../plugins/publish/validate_model_name.py | 10 ++++-- .../python/common/scriptsmenu/action.py | 3 +- 5 files changed, 62 insertions(+), 32 deletions(-) diff --git a/openpype/hosts/maya/api/commands.py b/openpype/hosts/maya/api/commands.py index 645e5840fd..4d37288b4e 100644 --- a/openpype/hosts/maya/api/commands.py +++ b/openpype/hosts/maya/api/commands.py @@ -3,6 +3,28 @@ import sys +class ToolWindows: + + _windows = {} + + @classmethod + def get_window(cls, tool, window=None): + # type: (str, QtWidgets.QWidget) -> QtWidgets.QWidget + try: + return cls._windows[tool] + except KeyError: + if window: + cls.set_window(tool, window) + return window + else: + return None + + @classmethod + def set_window(cls, tool, window): + # type: (str, QtWidget.QWidget) -> None + cls._windows[tool] = window + + def edit_shader_definitions(): from avalon.tools import lib from Qt import QtWidgets @@ -10,15 +32,13 @@ def edit_shader_definitions(): ShaderDefinitionsEditor ) - module = sys.modules[__name__] - module.window = None - top_level_widgets = QtWidgets.QApplication.topLevelWidgets() main_window = next(widget for widget in top_level_widgets if widget.objectName() == "MayaWindow") with lib.application(): - window = ShaderDefinitionsEditor(parent=main_window) + window = ToolWindows.get_window("shader_definition_editor") + if not window: + window = ShaderDefinitionsEditor(parent=main_window) + ToolWindows.set_window("shader_definition_editor", window) window.show() - - module.window = window diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index a8812210a5..0dced48868 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -9,8 +9,6 @@ import maya.cmds as cmds from openpype.settings import get_project_settings self = sys.modules[__name__] -project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) -self._menu = project_settings["maya"]["scriptsmenu"]["name"] log = logging.getLogger(__name__) @@ -19,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) @@ -74,12 +75,18 @@ def deferred(): return # load configuration of custom menu + 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 @@ -109,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 index 5585c9ea8e..73cc6246ab 100644 --- a/openpype/hosts/maya/api/shader_definition_editor.py +++ b/openpype/hosts/maya/api/shader_definition_editor.py @@ -11,11 +11,14 @@ 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 - DEFINITION_FILENAME = "maya/shader_definition.txt" def __init__(self, parent=None): super(ShaderDefinitionsEditor, self).__init__(parent) @@ -78,7 +81,7 @@ class ShaderDefinitionsEditor(QtWidgets.QWidget): content = "" if not file: file = self._gridfs.find_one( - {"filename": self.DEFINITION_FILENAME}) + {"filename": DEFINITION_FILENAME}) if not file: print(">>> [SNDE]: nothing in database yet") return content @@ -102,7 +105,7 @@ class ShaderDefinitionsEditor(QtWidgets.QWidget): editor is running. """ file = self._gridfs.find_one( - {"filename": self.DEFINITION_FILENAME}) + {"filename": DEFINITION_FILENAME}) if file: content_check = self._read_definition_file(file) if content == content_check: @@ -116,7 +119,7 @@ class ShaderDefinitionsEditor(QtWidgets.QWidget): self._gridfs.delete(file._id) file = self._gridfs.new_file( - filename=self.DEFINITION_FILENAME, + filename=DEFINITION_FILENAME, content_type='text/plain', encoding='utf-8') file.write(content) @@ -134,7 +137,11 @@ class ShaderDefinitionsEditor(QtWidgets.QWidget): self._editor.setStyleSheet("border: none;") def _close(self): - self.close() + self.hide() + + def closeEvent(self, event): + event.ignore() + self.hide() def _reload(self): print(">>> [SNDE]: reloading") @@ -156,16 +163,10 @@ class ShaderDefinitionsEditor(QtWidgets.QWidget): self, "Warning", ("Content you are editing was changed meanwhile in database.\n" - "Do you want to overwrite it?"), - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) + "Please, reload and solve the conflict."), + QtWidgets.QMessageBox.OK) - if reply == QtWidgets.QMessageBox.Yes: - self._write_definition_file( - content=self._editor.toPlainText(), - force=True - ) - - elif reply == QtWidgets.QMessageBox.No: + if reply == QtWidgets.QMessageBox.OK: # do nothing pass diff --git a/openpype/hosts/maya/plugins/publish/validate_model_name.py b/openpype/hosts/maya/plugins/publish/validate_model_name.py index 84242cda23..42471b7877 100644 --- a/openpype/hosts/maya/plugins/publish/validate_model_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_model_name.py @@ -4,6 +4,8 @@ from maya import cmds import pyblish.api import openpype.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 @@ -25,12 +27,13 @@ class ValidateModelName(pyblish.api.InstancePlugin): label = "Model Name" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] material_file = None - database_file = "maya/shader_definition.txt" + database_file = DEFINITION_FILENAME @classmethod def get_invalid(cls, instance): """Get invalid nodes.""" - use_db = instance.context.data["project_settings"]["maya"]["publish"]["ValidateModelName"]["database"] # noqa: E501 + # use_db = instance.context.data["project_settings"]["maya"]["publish"]["ValidateModelName"]["database"] # noqa: E501 + use_db = cls.database def is_group(group_name): """Find out if supplied transform is group or not.""" @@ -84,7 +87,8 @@ class ValidateModelName(pyblish.api.InstancePlugin): shaders = map(lambda s: s.rstrip(), shaders) # compile regex for testing names - regex = instance.context.data["project_settings"]["maya"]["publish"]["ValidateModelName"]["regex"] # noqa: E501 + # regex = instance.context.data["project_settings"]["maya"]["publish"]["ValidateModelName"]["regex"] # noqa: E501 + regex = cls.regex r = re.compile(regex) for obj in filtered: diff --git a/openpype/vendor/python/common/scriptsmenu/action.py b/openpype/vendor/python/common/scriptsmenu/action.py index 5e68628406..dc4d775f6a 100644 --- a/openpype/vendor/python/common/scriptsmenu/action.py +++ b/openpype/vendor/python/common/scriptsmenu/action.py @@ -119,8 +119,7 @@ module.{module_name}()""" """ # get the current application and its linked keyboard modifiers - app = QtWidgets.QApplication.instance() - modifiers = app.keyboardModifiers() + modifiers = QtWidgets.QApplication.keyboardModifiers() # If the menu has a callback registered for the current modifier # we run the callback instead of the action itself. From 941a4d51ab9cb626b8d7df4dd9c7acbaf3a1a272 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 21 Jul 2021 17:26:44 +0200 Subject: [PATCH 12/57] =?UTF-8?q?=F0=9F=90=A9=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openpype/hosts/maya/api/commands.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/maya/api/commands.py b/openpype/hosts/maya/api/commands.py index 4d37288b4e..d4c2b6a225 100644 --- a/openpype/hosts/maya/api/commands.py +++ b/openpype/hosts/maya/api/commands.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- """OpenPype script commands to be used directly in Maya.""" -import sys class ToolWindows: @@ -8,20 +7,30 @@ class ToolWindows: _windows = {} @classmethod - def get_window(cls, tool, window=None): - # type: (str, QtWidgets.QWidget) -> QtWidgets.QWidget + 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: - if window: - cls.set_window(tool, window) - return window - else: - return None + return None @classmethod def set_window(cls, tool, window): - # type: (str, QtWidget.QWidget) -> None + """Set widget for the tool. + + Args: + tool (str): Name of the tool. + window (QtWidgets.QWidget): Widget + + """ cls._windows[tool] = window From 558e71e8d53298d44a0aa1f47269c2373ac4e6ac Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 21 Jul 2021 17:27:50 +0200 Subject: [PATCH 13/57] minor cleanup --- openpype/hosts/maya/plugins/publish/validate_model_name.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_model_name.py b/openpype/hosts/maya/plugins/publish/validate_model_name.py index 42471b7877..64f06fb1fb 100644 --- a/openpype/hosts/maya/plugins/publish/validate_model_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_model_name.py @@ -32,7 +32,6 @@ class ValidateModelName(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): """Get invalid nodes.""" - # use_db = instance.context.data["project_settings"]["maya"]["publish"]["ValidateModelName"]["database"] # noqa: E501 use_db = cls.database def is_group(group_name): @@ -87,7 +86,6 @@ class ValidateModelName(pyblish.api.InstancePlugin): shaders = map(lambda s: s.rstrip(), shaders) # compile regex for testing names - # regex = instance.context.data["project_settings"]["maya"]["publish"]["ValidateModelName"]["regex"] # noqa: E501 regex = cls.regex r = re.compile(regex) From 5067d18cdafe383b6063e346ecaacfa603ba26b0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 21 Jul 2021 17:55:11 +0200 Subject: [PATCH 14/57] =?UTF-8?q?add=20=F0=9F=A7=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/docs/admin_hosts_maya.md | 25 +++++++++++++++++- .../maya-admin_model_name_validator.png | Bin 0 -> 19794 bytes .../docs/assets/maya-admin_scriptsmenu.png | Bin 0 -> 16565 bytes 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 website/docs/assets/maya-admin_model_name_validator.png create mode 100644 website/docs/assets/maya-admin_scriptsmenu.png diff --git a/website/docs/admin_hosts_maya.md b/website/docs/admin_hosts_maya.md index 83c4121be9..81aa64f9d6 100644 --- a/website/docs/admin_hosts_maya.md +++ b/website/docs/admin_hosts_maya.md @@ -49,4 +49,27 @@ Arnolds Camera (AA) samples to 6. Note that `aiOptions` is not the name of node but rather its type. For renderers there is usually just one instance of this node type but if that is not so, validator will go through all its instances and check the value there. Node type for **VRay** settings is `VRaySettingsNode`, for **Renderman** -it is `rmanGlobals`, for **Redshift** it is `RedshiftOptions`. \ No newline at end of file +it is `rmanGlobals`, for **Redshift** it is `RedshiftOptions`. + +#### Model Name Validator (`ValidateRenderSettings`) +This validator can enforce specific names for model members. It will check them against **Validation Regex**. +There is special group in that regex - **shader**. If present, it will take that part of the name as shader name +and it will compare it with list of shaders defined either in file name specified in **Material File** or from +database file that is per project and can be directly edited from Maya's *OpenPype Tools > Edit Shader name definitions* when +**Use database shader name definitions** is on. This list defines simply as one shader name per line. + +![Settings example](assets/maya-admin_model_name_validator.png) + +For example - you are using default regex `(.*)_(\d)*_(?P.*)_(GEO)` and you have two shaders defined +in either file or database `foo` and `bar`. + +Object named `SomeCube_0001_foo_GEO` will pass but `SomeCube_GEO` will not and `SomeCube_001_xxx_GEO` will not too. + +### Custom Menu +You can add your custom tools menu into Maya by extending definitions in **Maya -> Scripts Menu Definition**. +![Custom menu definition](assets/maya-admin_scriptsmenu.png) + +:::note Work in progress +This is still work in progress. Menu definition will be handled more friendly with widgets and not +raw json. +::: \ No newline at end of file diff --git a/website/docs/assets/maya-admin_model_name_validator.png b/website/docs/assets/maya-admin_model_name_validator.png new file mode 100644 index 0000000000000000000000000000000000000000..39ec2b2d211a27ac7962dadaaae4c2af4241f797 GIT binary patch literal 19794 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 Vy(H!b1HAwM002ovPDHLkV1k)@_e%f( literal 0 HcmV?d00001 diff --git a/website/docs/assets/maya-admin_scriptsmenu.png b/website/docs/assets/maya-admin_scriptsmenu.png new file mode 100644 index 0000000000000000000000000000000000000000..ecfe7e42a74db2fb543d813ee83c842e924d3d87 GIT binary patch literal 16565 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 literal 0 HcmV?d00001 From 295e400c81fca122e9dbda7b1b337697e4482b67 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 26 Jul 2021 11:38:50 +0200 Subject: [PATCH 15/57] BaseAction has identifier id added to end of class identifier --- .../modules/ftrack/lib/ftrack_action_handler.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/openpype/modules/ftrack/lib/ftrack_action_handler.py b/openpype/modules/ftrack/lib/ftrack_action_handler.py index 2bff9d8cb3..6994ecc4dd 100644 --- a/openpype/modules/ftrack/lib/ftrack_action_handler.py +++ b/openpype/modules/ftrack/lib/ftrack_action_handler.py @@ -1,4 +1,5 @@ import os +from uuid import uuid4 from .ftrack_base_handler import BaseHandler @@ -29,6 +30,10 @@ class BaseAction(BaseHandler): icon = None type = 'Action' + # Modified identifier used for local actions + _identifier = None + _identifier_id = str(uuid4()) + settings_frack_subkey = "user_handlers" settings_enabled_key = "enabled" @@ -42,6 +47,14 @@ class BaseAction(BaseHandler): super().__init__(session) + def get_identifier(self): + """Modify identifier to trigger the action only on once machine.""" + if self._identifier is None: + self._identifier = "{}.{}".format( + self.identifier, self._identifier_id + ) + return self._identifier + def register(self): ''' Registers the action, subscribing the the discover and launch topics. @@ -60,7 +73,7 @@ class BaseAction(BaseHandler): ' and data.actionIdentifier={0}' ' and source.user.username={1}' ).format( - self.identifier, + self.get_identifier(), self.session.api_user ) self.session.event_hub.subscribe( @@ -86,7 +99,7 @@ class BaseAction(BaseHandler): 'label': self.label, 'variant': self.variant, 'description': self.description, - 'actionIdentifier': self.identifier, + 'actionIdentifier': self.get_identifier(), 'icon': self.icon, }] } From 607cc6e2ea75fb3a711afc5f3d3665bcb9fb8483 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 26 Jul 2021 11:39:05 +0200 Subject: [PATCH 16/57] override `get_identifier` for server action --- openpype/modules/ftrack/lib/ftrack_action_handler.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/modules/ftrack/lib/ftrack_action_handler.py b/openpype/modules/ftrack/lib/ftrack_action_handler.py index 6994ecc4dd..9d005eb876 100644 --- a/openpype/modules/ftrack/lib/ftrack_action_handler.py +++ b/openpype/modules/ftrack/lib/ftrack_action_handler.py @@ -331,6 +331,12 @@ class ServerAction(BaseAction): settings_frack_subkey = "events" + def get_identifier(self): + """Override default implementation to not add identifier id.""" + if self._identifier is None: + self._identifier = self.identifier + return self._identifier + def register(self): """Register subcription to Ftrack event hub.""" self.session.event_hub.subscribe( @@ -341,5 +347,5 @@ class ServerAction(BaseAction): launch_subscription = ( "topic=ftrack.action.launch and data.actionIdentifier={0}" - ).format(self.identifier) + ).format(self.get_identifier()) self.session.event_hub.subscribe(launch_subscription, self._launch) From e1b10317da98691abc8737e62db9c1baf5d82b84 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 26 Jul 2021 12:00:47 +0200 Subject: [PATCH 17/57] removed all added stuff --- .../ftrack/lib/ftrack_action_handler.py | 24 +++---------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/openpype/modules/ftrack/lib/ftrack_action_handler.py b/openpype/modules/ftrack/lib/ftrack_action_handler.py index 9d005eb876..06152c19f7 100644 --- a/openpype/modules/ftrack/lib/ftrack_action_handler.py +++ b/openpype/modules/ftrack/lib/ftrack_action_handler.py @@ -30,10 +30,6 @@ class BaseAction(BaseHandler): icon = None type = 'Action' - # Modified identifier used for local actions - _identifier = None - _identifier_id = str(uuid4()) - settings_frack_subkey = "user_handlers" settings_enabled_key = "enabled" @@ -47,14 +43,6 @@ class BaseAction(BaseHandler): super().__init__(session) - def get_identifier(self): - """Modify identifier to trigger the action only on once machine.""" - if self._identifier is None: - self._identifier = "{}.{}".format( - self.identifier, self._identifier_id - ) - return self._identifier - def register(self): ''' Registers the action, subscribing the the discover and launch topics. @@ -73,7 +61,7 @@ class BaseAction(BaseHandler): ' and data.actionIdentifier={0}' ' and source.user.username={1}' ).format( - self.get_identifier(), + self.identifier, self.session.api_user ) self.session.event_hub.subscribe( @@ -99,7 +87,7 @@ class BaseAction(BaseHandler): 'label': self.label, 'variant': self.variant, 'description': self.description, - 'actionIdentifier': self.get_identifier(), + 'actionIdentifier': self.identifier, 'icon': self.icon, }] } @@ -331,12 +319,6 @@ class ServerAction(BaseAction): settings_frack_subkey = "events" - def get_identifier(self): - """Override default implementation to not add identifier id.""" - if self._identifier is None: - self._identifier = self.identifier - return self._identifier - def register(self): """Register subcription to Ftrack event hub.""" self.session.event_hub.subscribe( @@ -347,5 +329,5 @@ class ServerAction(BaseAction): launch_subscription = ( "topic=ftrack.action.launch and data.actionIdentifier={0}" - ).format(self.get_identifier()) + ).format(self.identifier) self.session.event_hub.subscribe(launch_subscription, self._launch) From 5c9a7d10486ae543688c4dcee06797dc8323e130 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 26 Jul 2021 12:21:14 +0200 Subject: [PATCH 18/57] added discover and launch identifier properties for actions --- .../action_applications.py | 2 +- .../ftrack/lib/ftrack_action_handler.py | 34 +++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/openpype/modules/ftrack/event_handlers_user/action_applications.py b/openpype/modules/ftrack/event_handlers_user/action_applications.py index 23c96e1b9f..58ea3c5671 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_applications.py +++ b/openpype/modules/ftrack/event_handlers_user/action_applications.py @@ -29,7 +29,7 @@ class AppplicationsAction(BaseAction): icon_url = os.environ.get("OPENPYPE_STATICS_SERVER") def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + super(AppplicationsAction, self).__init__(*args, **kwargs) self.application_manager = ApplicationManager() self.dbcon = AvalonMongoDB() diff --git a/openpype/modules/ftrack/lib/ftrack_action_handler.py b/openpype/modules/ftrack/lib/ftrack_action_handler.py index 06152c19f7..878eac6627 100644 --- a/openpype/modules/ftrack/lib/ftrack_action_handler.py +++ b/openpype/modules/ftrack/lib/ftrack_action_handler.py @@ -30,6 +30,10 @@ class BaseAction(BaseHandler): icon = None type = 'Action' + _identifier_id = str(uuid4()) + _discover_identifier = None + _launch_identifier = None + settings_frack_subkey = "user_handlers" settings_enabled_key = "enabled" @@ -43,6 +47,22 @@ class BaseAction(BaseHandler): super().__init__(session) + @property + def discover_identifier(self): + if self._discover_identifier is None: + self._discover_identifier = "{}.{}".format( + self.identifier, self._identifier_id + ) + return self._discover_identifier + + @property + def launch_identifier(self): + if self._launch_identifier is None: + self._launch_identifier = "{}.{}".format( + self.identifier, self._identifier_id + ) + return self._launch_identifier + def register(self): ''' Registers the action, subscribing the the discover and launch topics. @@ -61,7 +81,7 @@ class BaseAction(BaseHandler): ' and data.actionIdentifier={0}' ' and source.user.username={1}' ).format( - self.identifier, + self.launch_identifier, self.session.api_user ) self.session.event_hub.subscribe( @@ -87,7 +107,7 @@ class BaseAction(BaseHandler): 'label': self.label, 'variant': self.variant, 'description': self.description, - 'actionIdentifier': self.identifier, + 'actionIdentifier': self.discover_identifier, 'icon': self.icon, }] } @@ -319,6 +339,14 @@ class ServerAction(BaseAction): settings_frack_subkey = "events" + @property + def discover_identifier(self): + return self.identifier + + @property + def launch_identifier(self): + return self.identifier + def register(self): """Register subcription to Ftrack event hub.""" self.session.event_hub.subscribe( @@ -329,5 +357,5 @@ class ServerAction(BaseAction): launch_subscription = ( "topic=ftrack.action.launch and data.actionIdentifier={0}" - ).format(self.identifier) + ).format(self.launch_identifier) self.session.event_hub.subscribe(launch_subscription, self._launch) From 281e6645ffcc7af8dc39f57e5f8d49fc6b34ae87 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 27 Jul 2021 12:26:37 +0200 Subject: [PATCH 19/57] emit workfile arguments as list instead of path --- openpype/tools/workfiles/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index d567e26d74..f98085e579 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -695,14 +695,14 @@ class FilesWidget(QtWidgets.QWidget): file_path = os.path.join(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) From ce301f8d0a2907eec5c2f5ad5916cb9a8f1cb7fc Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 27 Jul 2021 14:18:20 +0200 Subject: [PATCH 20/57] add support for RedshiftNormalMap node, fix tx linear space --- .../maya/plugins/publish/collect_look.py | 15 ++++++++++-- .../maya/plugins/publish/extract_look.py | 24 ++++++++++++------- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index bf24b463ac..0dde52447d 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -167,6 +167,8 @@ def get_file_node_path(node): if cmds.nodeType(node) == 'aiImage': return cmds.getAttr('{0}.filename'.format(node)) + if cmds.nodeType(node) == 'RedshiftNormalMap': + return cmds.getAttr('{}.tex0'.format(node)) # otherwise use fileTextureName return cmds.getAttr('{0}.fileTextureName'.format(node)) @@ -357,6 +359,7 @@ class CollectLook(pyblish.api.InstancePlugin): files = cmds.ls(history, type="file", long=True) files.extend(cmds.ls(history, type="aiImage", long=True)) + files.extend(cmds.ls(history, type="RedshiftNormalMap", long=True)) self.log.info("Collected file nodes:\n{}".format(files)) # Collect textures if any file nodes are found @@ -487,7 +490,7 @@ class CollectLook(pyblish.api.InstancePlugin): """ self.log.debug("processing: {}".format(node)) - if cmds.nodeType(node) not in ["file", "aiImage"]: + if cmds.nodeType(node) not in ["file", "aiImage", "RedshiftNormalMap"]: self.log.error( "Unsupported file node: {}".format(cmds.nodeType(node))) raise AssertionError("Unsupported file node") @@ -500,11 +503,19 @@ class CollectLook(pyblish.api.InstancePlugin): self.log.debug("aiImage node") attribute = "{}.filename".format(node) computed_attribute = attribute + elif cmds.nodeType(node) == 'RedshiftNormalMap': + self.log.debug("RedshiftNormalMap node") + attribute = "{}.tex0".format(node) + computed_attribute = attribute source = cmds.getAttr(attribute) self.log.info(" - file source: {}".format(source)) color_space_attr = "{}.colorSpace".format(node) - color_space = cmds.getAttr(color_space_attr) + try: + color_space = cmds.getAttr(color_space_attr) + except ValueError: + # node doesn't have colorspace attribute + color_space = "raw" # Compare with the computed file path, e.g. the one with the # pattern in it, to generate some logging information about this # difference diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index bdd061578e..c823602dc4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -233,11 +233,14 @@ class ExtractLook(openpype.api.Extractor): for filepath in files_metadata: linearize = False - if do_maketx and files_metadata[filepath]["color_space"] == "sRGB": # noqa: E501 + if do_maketx and files_metadata[filepath]["color_space"].lower() == "srgb": # noqa: E501 linearize = True # set its file node to 'raw' as tx will be linearized files_metadata[filepath]["color_space"] = "raw" + if do_maketx: + color_space = "raw" + source, mode, texture_hash = self._process_texture( filepath, do_maketx, @@ -280,15 +283,20 @@ class ExtractLook(openpype.api.Extractor): # This will also trigger in the same order at end of context to # ensure after context it's still the original value. color_space_attr = resource["node"] + ".colorSpace" - color_space = cmds.getAttr(color_space_attr) - if files_metadata[source]["color_space"] == "raw": - # set color space to raw if we linearized it - color_space = "Raw" - # Remap file node filename to destination + try: + color_space = cmds.getAttr(color_space_attr) + except ValueError: + # node doesn't have color space attribute + color_space = "raw" + else: + if files_metadata[source]["color_space"] == "raw": + # set color space to raw if we linearized it + color_space = "raw" + # Remap file node filename to destination + remap[color_space_attr] = color_space attr = resource["attribute"] remap[attr] = destinations[source] - remap[color_space_attr] = color_space - + self.log.info("Finished remapping destinations ...") # Extract in correct render layer From 74c74dc97d53d52536a82c2591102534aafb1d57 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 27 Jul 2021 15:13:31 +0200 Subject: [PATCH 21/57] Settings: adding workfile tool start attribute --- .../defaults/project_settings/global.json | 7 ++++ .../schemas/schema_global_tools.json | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 43053c38c0..636acc0d17 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -260,6 +260,13 @@ "enabled": true } ], + "open_workfile_tool_on_startup": [ + { + "hosts": [], + "tasks": [], + "enabled": false + } + ], "sw_folders": { "compositing": [ "nuke", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json index 8c92a45a56..a9fe27c24b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json @@ -97,6 +97,38 @@ ] } }, + { + "type": "list", + "key": "open_workfile_tool_on_startup", + "label": "Open workfile tool on launch", + "is_group": true, + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "hosts-enum", + "key": "hosts", + "label": "Hosts", + "multiselection": true + }, + { + "key": "tasks", + "label": "Tasks", + "type": "list", + "object_type": "text" + }, + { + "type": "splitter" + }, + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + } + }, { "type": "dict-modifiable", "collapsible": true, From 08b0b3035a02d1a41274a8b0e9b4f64f419b5434 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 27 Jul 2021 15:14:03 +0200 Subject: [PATCH 22/57] global: adding workfile start at launch attribute search func --- openpype/lib/applications.py | 119 +++++++++++++++++++++++++---------- 1 file changed, 86 insertions(+), 33 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index e1b304a351..01bc0cddf8 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"]["WORKFILE_STARTUP"] = ( + 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_preset( + 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_preset( + 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 + `WORKFILE_STARTUP`, 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_preset( + 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() From d23f31da7edf5723d571a774e200f0056bbb0032 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 27 Jul 2021 15:14:22 +0200 Subject: [PATCH 23/57] Nuke: refactory workfile launch callback --- openpype/hosts/nuke/api/lib.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index eefbcc5d20..fce92f08d5 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="WORKFILE_STARTUP", + default=None) # return if none is defined if not open_at_start: From bee136e29a3fbcdd0d24438e3cfda47d3eebf3fd Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 27 Jul 2021 15:22:11 +0200 Subject: [PATCH 24/57] added process identifier to base handler --- openpype/modules/ftrack/lib/ftrack_base_handler.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openpype/modules/ftrack/lib/ftrack_base_handler.py b/openpype/modules/ftrack/lib/ftrack_base_handler.py index 011ce8db9d..b8be287a03 100644 --- a/openpype/modules/ftrack/lib/ftrack_base_handler.py +++ b/openpype/modules/ftrack/lib/ftrack_base_handler.py @@ -2,6 +2,7 @@ import os import tempfile import json import functools +import uuid import datetime import traceback import time @@ -36,6 +37,7 @@ class BaseHandler(object): - a verbose descriptive text for you action - icon in ftrack ''' + _process_id = None # Default priority is 100 priority = 100 # Type is just for logging purpose (e.g.: Action, Event, Application,...) @@ -70,6 +72,13 @@ class BaseHandler(object): self.register = self.register_decorator(self.register) self.launch = self.launch_log(self.launch) + @staticmethod + def process_identifier(): + """Helper property to have """ + if not BaseHandler._process_id: + BaseHandler._process_id = str(uuid.uuid4()) + return BaseHandler._process_id + # Decorator def register_decorator(self, func): @functools.wraps(func) From 7737dbb326d26ae2e25f3b037f7b814d0f2cf6d5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 27 Jul 2021 15:22:58 +0200 Subject: [PATCH 25/57] use process_identifier instead of uuid for each action --- openpype/modules/ftrack/lib/ftrack_action_handler.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/modules/ftrack/lib/ftrack_action_handler.py b/openpype/modules/ftrack/lib/ftrack_action_handler.py index 878eac6627..1c9faec6bf 100644 --- a/openpype/modules/ftrack/lib/ftrack_action_handler.py +++ b/openpype/modules/ftrack/lib/ftrack_action_handler.py @@ -1,5 +1,4 @@ import os -from uuid import uuid4 from .ftrack_base_handler import BaseHandler @@ -30,7 +29,6 @@ class BaseAction(BaseHandler): icon = None type = 'Action' - _identifier_id = str(uuid4()) _discover_identifier = None _launch_identifier = None @@ -51,7 +49,7 @@ class BaseAction(BaseHandler): def discover_identifier(self): if self._discover_identifier is None: self._discover_identifier = "{}.{}".format( - self.identifier, self._identifier_id + self.identifier, self.process_identifier() ) return self._discover_identifier @@ -59,7 +57,7 @@ class BaseAction(BaseHandler): def launch_identifier(self): if self._launch_identifier is None: self._launch_identifier = "{}.{}".format( - self.identifier, self._identifier_id + self.identifier, self.process_identifier() ) return self._launch_identifier From 72a2bdfd04e5d060057ddbb28f5c4458db612b13 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 27 Jul 2021 15:23:43 +0200 Subject: [PATCH 26/57] AppplicationsAction is using process identifier --- .../action_applications.py | 63 ++++++++++++++----- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/openpype/modules/ftrack/event_handlers_user/action_applications.py b/openpype/modules/ftrack/event_handlers_user/action_applications.py index 58ea3c5671..74d14c2fc4 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_applications.py +++ b/openpype/modules/ftrack/event_handlers_user/action_applications.py @@ -11,21 +11,14 @@ from avalon.api import AvalonMongoDB class AppplicationsAction(BaseAction): - """Application Action class. - - Args: - session (ftrack_api.Session): Session where action will be registered. - label (str): A descriptive string identifing your action. - varaint (str, optional): To group actions together, give them the same - label and specify a unique variant per action. - identifier (str): An unique identifier for app. - description (str): A verbose descriptive text for you action. - icon (str): Url path to icon which will be shown in Ftrack web. - """ + """Applications Action class.""" type = "Application" label = "Application action" - identifier = "pype_app.{}.".format(str(uuid4())) + + identifier = "openpype_app" + _launch_identifier_with_id = None + icon_url = os.environ.get("OPENPYPE_STATICS_SERVER") def __init__(self, *args, **kwargs): @@ -34,6 +27,28 @@ class AppplicationsAction(BaseAction): self.application_manager = ApplicationManager() self.dbcon = AvalonMongoDB() + @property + def discover_identifier(self): + if self._discover_identifier is None: + self._discover_identifier = "{}.{}".format( + self.identifier, self.process_identifier() + ) + return self._discover_identifier + + @property + def launch_identifier(self): + if self._launch_identifier is None: + self._launch_identifier = "{}.*".format(self.identifier) + return self._launch_identifier + + @property + def launch_identifier_with_id(self): + if self._launch_identifier_with_id is None: + self._launch_identifier_with_id = "{}.{}".format( + self.identifier, self.process_identifier() + ) + return self._launch_identifier_with_id + def construct_requirements_validations(self): # Override validation as this action does not need them return @@ -56,7 +71,7 @@ class AppplicationsAction(BaseAction): " and data.actionIdentifier={0}" " and source.user.username={1}" ).format( - self.identifier + "*", + self.launch_identifier, self.session.api_user ) self.session.event_hub.subscribe( @@ -136,12 +151,29 @@ class AppplicationsAction(BaseAction): "label": app.group.label, "variant": app.label, "description": None, - "actionIdentifier": self.identifier + app_name, + "actionIdentifier": "{}.{}".format( + self.launch_identifier_with_id, app_name + ), "icon": app_icon }) return items + def _launch(self, event): + event_identifier = event["data"]["actionIdentifier"] + # Check if identifier is same + # - show message that acion may not be triggered on this machine + if event_identifier.startswith(self.launch_identifier_with_id): + return BaseAction._launch(self, event) + + return { + "success": False, + "message": ( + "There are running more OpenPype processes" + " where Application can be launched." + ) + } + def launch(self, session, entities, event): """Callback method for the custom action. @@ -162,7 +194,8 @@ class AppplicationsAction(BaseAction): *event* the unmodified original event """ identifier = event["data"]["actionIdentifier"] - app_name = identifier[len(self.identifier):] + id_identifier_len = len(self.launch_identifier_with_id) + 1 + app_name = identifier[id_identifier_len:] entity = entities[0] From 8ebdbd4f932ad1171257f40a754c9b04d3dc2a7b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 27 Jul 2021 15:25:50 +0200 Subject: [PATCH 27/57] added helper LocalAction as base which tells user that is not launched because was launched in other process --- .../ftrack/lib/ftrack_action_handler.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/openpype/modules/ftrack/lib/ftrack_action_handler.py b/openpype/modules/ftrack/lib/ftrack_action_handler.py index 1c9faec6bf..b24fe5f12a 100644 --- a/openpype/modules/ftrack/lib/ftrack_action_handler.py +++ b/openpype/modules/ftrack/lib/ftrack_action_handler.py @@ -328,6 +328,78 @@ class BaseAction(BaseHandler): return True +class LocalAction(BaseAction): + """Action that warn user when more Processes with same action are running. + + Action is launched all the time but if id does not match id of current + instanace then message is shown to user. + + Handy for actions where matters if is executed on specific machine. + """ + _full_launch_identifier = None + + @property + def discover_identifier(self): + if self._discover_identifier is None: + self._discover_identifier = "{}.{}".format( + self.identifier, self.process_identifier() + ) + return self._discover_identifier + + @property + def launch_identifier(self): + """Catch all topics with same identifier.""" + if self._launch_identifier is None: + self._launch_identifier = "{}.*".format(self.identifier) + return self._launch_identifier + + @property + def full_launch_identifier(self): + """Catch all topics with same identifier.""" + if self._full_launch_identifier is None: + self._full_launch_identifier = "{}.{}".format( + self.identifier, self.process_identifier() + ) + return self._full_launch_identifier + + def _discover(self, event): + entities = self._translate_event(event) + if not entities: + return + + accepts = self.discover(self.session, entities, event) + if not accepts: + return + + self.log.debug("Discovering action with selection: {0}".format( + event["data"].get("selection", []) + )) + + return { + "items": [{ + "label": self.label, + "variant": self.variant, + "description": self.description, + "actionIdentifier": self.discover_identifier, + "icon": self.icon, + }] + } + + def _launch(self, event): + event_identifier = event["data"]["actionIdentifier"] + # Check if identifier is same + # - show message that acion may not be triggered on this machine + if event_identifier != self.full_launch_identifier: + return { + "success": False, + "message": ( + "There are running more OpenPype processes" + " where this action could be launched." + ) + } + return super(LocalAction, self)._launch(event) + + class ServerAction(BaseAction): """Action class meant to be used on event server. From b911f3f0655551a829f5f15ec4efc199f4720e44 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 27 Jul 2021 15:32:04 +0200 Subject: [PATCH 28/57] fix prepare project which triggers different action --- .../ftrack/event_handlers_user/action_prepare_project.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py b/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py index ea0bfa2971..4b42500e8f 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py +++ b/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py @@ -428,9 +428,11 @@ class PrepareProjectLocal(BaseAction): # Trigger create project structure action if create_project_structure_checked: - self.trigger_action( - self.create_project_structure_identifier, event + trigger_identifier = "{}.{}".format( + self.create_project_structure_identifier, + self.process_identifier() ) + self.trigger_action(trigger_identifier, event) return True From a4089715b456c0700aa04dee9d4bd51ff6efedf6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 27 Jul 2021 15:42:12 +0200 Subject: [PATCH 29/57] fix where I run action --- .../ftrack/event_handlers_user/action_where_run_show.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/modules/ftrack/event_handlers_user/action_where_run_show.py b/openpype/modules/ftrack/event_handlers_user/action_where_run_show.py index 4ce1a439a3..b8b49e86cb 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_where_run_show.py +++ b/openpype/modules/ftrack/event_handlers_user/action_where_run_show.py @@ -24,6 +24,10 @@ class ActionShowWhereIRun(BaseAction): return False + @property + def launch_identifier(self): + return self.identifier + def launch(self, session, entities, event): # Don't show info when was launch from this session if session.event_hub.id == event.get("data", {}).get("event_hub_id"): From 6a2bd167b5fdadf6283dbfdbc783cb44c1efbab4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 28 Jul 2021 14:08:41 +0200 Subject: [PATCH 30/57] global: better env var name --- openpype/lib/applications.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 01bc0cddf8..ada194f15f 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1311,7 +1311,7 @@ def _prepare_last_workfile(data, workdir): data["env"]["AVALON_OPEN_LAST_WORKFILE"] = ( str(int(bool(start_last_workfile))) ) - data["env"]["WORKFILE_STARTUP"] = ( + data["env"]["OPENPYPE_WORKFILE_TOOL_ON_START"] = ( str(int(bool(workfile_startup))) ) @@ -1352,7 +1352,7 @@ def _prepare_last_workfile(data, workdir): data["last_workfile_path"] = last_workfile_path -def get_option_from_preset( +def get_option_from_settings( startup_presets, host_name, task_name, default_output ): host_name_lowered = host_name.lower() @@ -1432,7 +1432,7 @@ def should_start_last_workfile( if not startup_presets: return default_output - return get_option_from_preset( + return get_option_from_settings( startup_presets, host_name, task_name, default_output) @@ -1442,7 +1442,7 @@ def should_workfile_tool_start( """Define if host should start workfile tool at host launch. Default output is `False`. Can be overriden with environment variable - `WORKFILE_STARTUP`, valid values without case sensitivity are + `OPENPYPE_WORKFILE_TOOL_ON_START`, valid values without case sensitivity are `"0", "1", "true", "false", "yes", "no"`. Args: @@ -1470,7 +1470,7 @@ def should_workfile_tool_start( if not startup_presets: return default_output - return get_option_from_preset( + return get_option_from_settings( startup_presets, host_name, task_name, default_output) From 95b7ee57ec0952c6bcaa829ec63ab0d36bb6032a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 28 Jul 2021 14:09:11 +0200 Subject: [PATCH 31/57] settings: add filter to host which are supported now --- .../schemas/projects_schema/schemas/schema_global_tools.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json index 211a8d0057..9e39eeb39e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json @@ -125,7 +125,10 @@ "type": "hosts-enum", "key": "hosts", "label": "Hosts", - "multiselection": true + "multiselection": true, + "hosts_filter": [ + "nuke" + ] }, { "key": "tasks", From 9f018cb6fb840551fbb084eb263b3cf96dc00a94 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 28 Jul 2021 14:09:29 +0200 Subject: [PATCH 32/57] nuke: improving env var name --- openpype/hosts/nuke/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 442c0122be..7e7cd27f90 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -1665,7 +1665,7 @@ def launch_workfiles_app(): ) # get all imortant settings open_at_start = env_value_to_bool( - env_key="WORKFILE_STARTUP", + env_key="OPENPYPE_WORKFILE_TOOL_ON_START", default=None) # return if none is defined From e58534b2d6c82673f469f66138a4fe2a68eda049 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 28 Jul 2021 15:38:51 +0200 Subject: [PATCH 33/57] add model top group name validation --- .../plugins/publish/validate_model_name.py | 29 +++++++++++++++++++ .../defaults/project_settings/maya.json | 3 +- .../schemas/schema_maya_publish.json | 9 ++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_model_name.py b/openpype/hosts/maya/plugins/publish/validate_model_name.py index 64f06fb1fb..3757e13a9b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_model_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_model_name.py @@ -3,6 +3,7 @@ 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) @@ -51,6 +52,34 @@ 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 [] diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index b40ab40c61..1db6cdf9f1 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -170,7 +170,8 @@ "darwin": "", "linux": "" }, - "regex": "(.*)_(\\d)*_(?P.*)_(GEO)" + "regex": "(.*)_(\\d)*_(?P.*)_(GEO)", + "top_level_regex": ".*_GRP" }, "ValidateTransformNamingSuffix": { "enabled": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 10b80dddfd..89cd30aed0 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -167,6 +167,15 @@ "type": "text", "key": "regex", "label": "Validation regex" + }, + { + "type": "label", + "label": "Regex for validating name of top level group name.
You can use named capturing groups:
(?P<asset>.*) for Asset name
(?P<subset>.*) for Subset
(?P<project>.*) for project

For 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" } ] }, From 60defafff47266741e51ceaa31e5f83b0b6be984 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 28 Jul 2021 15:59:25 +0200 Subject: [PATCH 34/57] updated documentation --- website/docs/admin_hosts_maya.md | 19 ++++++++++++++++++ .../maya-admin_model_name_validator.png | Bin 19794 -> 34893 bytes 2 files changed, 19 insertions(+) diff --git a/website/docs/admin_hosts_maya.md b/website/docs/admin_hosts_maya.md index 81aa64f9d6..d38ab8d8ad 100644 --- a/website/docs/admin_hosts_maya.md +++ b/website/docs/admin_hosts_maya.md @@ -65,6 +65,25 @@ in either file or database `foo` and `bar`. Object named `SomeCube_0001_foo_GEO` will pass but `SomeCube_GEO` will not and `SomeCube_001_xxx_GEO` will not too. +##### Top level group name +There is a validation for top level group name too. You can specify whatever regex you'd like to use. Default will +pass everything with `_GRP` suffix. You can use *named capturing groups* to validate against specific data. If you +put `(?P.*)` it will try to match everything captured in that group against current asset name. Likewise you can +use it for **subset** and **project** - `(?P.*)` and `(?P.*)`. + +**Example** + +You are working on asset (shot) `0030_OGC_0190`. You have this regex in **Top level group name**: +```regexp +.*?_(?P.*)_GRP +``` + +When you publish your model with top group named like `foo_GRP` it will fail. But with `foo_0030_OGC_0190_GRP` it will pass. + +:::info About regex +All regexes used here are in Python variant. +::: + ### Custom Menu You can add your custom tools menu into Maya by extending definitions in **Maya -> Scripts Menu Definition**. ![Custom menu definition](assets/maya-admin_scriptsmenu.png) diff --git a/website/docs/assets/maya-admin_model_name_validator.png b/website/docs/assets/maya-admin_model_name_validator.png index 39ec2b2d211a27ac7962dadaaae4c2af4241f797..d1b92c5fc3edbc7e3c1cdb0753804a4fa11dcfb8 100644 GIT binary patch literal 34893 zcmb@u1yo$?wlxSLL4rqc3j_)7!Citwa0-fG!L3N)5<-GYaCeu&T|;p90)o2~?odb< z=bU@{_WR%K(em#x7>r_XzWwQ1bIrL{1*s~_Vm~K&j)a7SE%!m{BNEb+Oe7@aYIIb@ zU!JJl!w|oaoj%G+Ae9c2?I1opH5XSBM?$KIz`QkjhWL!(@IluJ2??j;@dvrbzQ7a- zDPUDjN?gMgxWC}#@M=1h?Y8W#U zYrW8A%O)ajFU_&U=y_t)BeeA;J#EJld^xynMig||!}IxO|BOH1h#9OuxOzpA!7E?$esN`1r@Mg@Xl`(qb2X<8!vh&uWY8=(N<-)LG3Yc=A$+ z%dLkhCJ=F*go`sgKCnUVr;4~w-7kom`FKCZ1xq3bai8Ik==+Pm8sPY+0RVo){;kfG zz`Tbc#=%_%y7XajB5`v7t^=&)^0*1+RQi(z#{js3?% zB*@{?RMQXmQNUrBAMVxBVz;s9QKa!a`m2@AyVaV-jq{bIwQj+x`s)MiW)~QN-I52X zwddtZ_T@qV>3eCNoA#)#xx}f1U(=Uf1U~CNXk}r_#fi$E7WXG>fu6hKG0i-I$D~#l z!7-A$m8F!30qJ2cHu2;y5-4`XfJ|E5oaH zg={NjUsngrq`Tb~^wNhT@$=TNLRZIKXa|VJ(r|r#ku+LV1irfKIh+EBaW&k*TtW#{ zal0t*@DA>lCF?Kjccy7vqgX(Kyunm;l%n_f(~&=`T94>w-hHd716~`ts?GWLP%aoh z&FvrTQs-OD&#m=>5ni?1zt3G95Ywp5`&0W8u)2Cm?|^(@*-tR#1(>LN(tT9r&kbFyiY{@Kw(h!s-E}22LO8w8 zj5@-&C!3nBw_$OkmNd+BVC_h;;c9x55@7POF5eek^TE$+v(mh0MT%%t*|P*jOKX-^w*0F?XO{RxH4n1kB=W}(4i4c BI+%$ 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 literal 19794 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 Vy(H!b1HAwM002ovPDHLkV1k)@_e%f( From 490ec264b3c147562ba10ea39b46d70ca47c0029 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 29 Jul 2021 08:43:08 +0100 Subject: [PATCH 35/57] Normalize path returned from Workfiles. --- openpype/tools/workfiles/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index d567e26d74..6b56322140 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -693,7 +693,7 @@ 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) From 03a894ac9503c254eb7fb0c0c082a53c54a10b3f Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 29 Jul 2021 09:24:35 +0100 Subject: [PATCH 36/57] Allow Multiple Notes to run on tasks. --- .../ftrack/event_handlers_user/action_multiple_notes.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/modules/ftrack/event_handlers_user/action_multiple_notes.py b/openpype/modules/ftrack/event_handlers_user/action_multiple_notes.py index 8db65fe39b..666e7efaef 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_multiple_notes.py +++ b/openpype/modules/ftrack/event_handlers_user/action_multiple_notes.py @@ -9,14 +9,15 @@ class MultipleNotes(BaseAction): #: Action label. label = 'Multiple Notes' #: Action description. - description = 'Add same note to multiple Asset Versions' + description = 'Add same note to multiple entities' icon = statics_icon("ftrack", "action_icons", "MultipleNotes.svg") def discover(self, session, entities, event): ''' Validation ''' + valid_entity_types = ['assetversion', 'task'] valid = True for entity in entities: - if entity.entity_type.lower() != 'assetversion': + if entity.entity_type.lower() not in valid_entity_types: valid = False break return valid @@ -58,7 +59,7 @@ class MultipleNotes(BaseAction): splitter = { 'type': 'label', - 'value': '{}'.format(200*"-") + 'value': '{}'.format(200 * "-") } items = [] From 02780730d62fe5d05037bd20ecc1cebb05a9b9e1 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 29 Jul 2021 09:34:30 +0100 Subject: [PATCH 37/57] Check for multiple selection. --- .../ftrack/event_handlers_user/action_multiple_notes.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/openpype/modules/ftrack/event_handlers_user/action_multiple_notes.py b/openpype/modules/ftrack/event_handlers_user/action_multiple_notes.py index 666e7efaef..f5af044de0 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_multiple_notes.py +++ b/openpype/modules/ftrack/event_handlers_user/action_multiple_notes.py @@ -14,12 +14,19 @@ class MultipleNotes(BaseAction): def discover(self, session, entities, event): ''' Validation ''' - valid_entity_types = ['assetversion', 'task'] valid = True + + # Check for multiple selection. + if len(entities) < 2: + valid = False + + # Check for valid entities. + valid_entity_types = ['assetversion', 'task'] for entity in entities: if entity.entity_type.lower() not in valid_entity_types: valid = False break + return valid def interface(self, session, entities, event): From 09fc70c30387cb14e6b96bcff6acc8206fac5501 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Thu, 29 Jul 2021 16:34:00 +0200 Subject: [PATCH 38/57] tweak doc headings --- website/docs/admin_hosts_maya.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/website/docs/admin_hosts_maya.md b/website/docs/admin_hosts_maya.md index d38ab8d8ad..5e0aa15345 100644 --- a/website/docs/admin_hosts_maya.md +++ b/website/docs/admin_hosts_maya.md @@ -4,11 +4,11 @@ title: Maya sidebar_label: Maya --- -## Maya +## Publish Plugins -### Publish Plugins +### Render Settings Validator -#### Render Settings Validator (`ValidateRenderSettings`) +`ValidateRenderSettings` Render Settings Validator is here to make sure artists will submit renders we correct settings. Some of these settings are needed by OpenPype but some @@ -51,7 +51,10 @@ just one instance of this node type but if that is not so, validator will go thr instances and check the value there. Node type for **VRay** settings is `VRaySettingsNode`, for **Renderman** it is `rmanGlobals`, for **Redshift** it is `RedshiftOptions`. -#### Model Name Validator (`ValidateRenderSettings`) +### Model Name Validator + +`ValidateRenderSettings` + This validator can enforce specific names for model members. It will check them against **Validation Regex**. There is special group in that regex - **shader**. If present, it will take that part of the name as shader name and it will compare it with list of shaders defined either in file name specified in **Material File** or from @@ -65,7 +68,7 @@ in either file or database `foo` and `bar`. Object named `SomeCube_0001_foo_GEO` will pass but `SomeCube_GEO` will not and `SomeCube_001_xxx_GEO` will not too. -##### Top level group name +#### Top level group name There is a validation for top level group name too. You can specify whatever regex you'd like to use. Default will pass everything with `_GRP` suffix. You can use *named capturing groups* to validate against specific data. If you put `(?P.*)` it will try to match everything captured in that group against current asset name. Likewise you can @@ -84,7 +87,7 @@ When you publish your model with top group named like `foo_GRP` it will fail. Bu All regexes used here are in Python variant. ::: -### Custom Menu +## Custom Menu You can add your custom tools menu into Maya by extending definitions in **Maya -> Scripts Menu Definition**. ![Custom menu definition](assets/maya-admin_scriptsmenu.png) From 18184a321bc04adcda5e5c5e5cdd0458e6ec7dc0 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Fri, 30 Jul 2021 12:06:15 +0100 Subject: [PATCH 39/57] Increment workfile plugin --- .../publish/increment_workfile_version.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 openpype/hosts/tvpaint/plugins/publish/increment_workfile_version.py diff --git a/openpype/hosts/tvpaint/plugins/publish/increment_workfile_version.py b/openpype/hosts/tvpaint/plugins/publish/increment_workfile_version.py new file mode 100644 index 0000000000..a96a8e3d5d --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/increment_workfile_version.py @@ -0,0 +1,22 @@ +import pyblish.api + +from avalon.tvpaint import workio +from openpype.api import version_up + + +class IncrementWorkfileVersion(pyblish.api.ContextPlugin): + """Increment current workfile version.""" + + order = pyblish.api.IntegratorOrder + 1 + label = "Increment Workfile Version" + optional = True + hosts = ["tvpaint"] + + def process(self, context): + + assert all(result["success"] for result in context.data["results"]), ( + "Publishing not succesfull so version is not increased.") + + path = context.data["currentFile"] + workio.save_file(version_up(path)) + self.log.info('Incrementing workfile version') From 7ec2cf735252c01b912049eec8a58c737651d04d Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Fri, 30 Jul 2021 12:12:56 +0100 Subject: [PATCH 40/57] Expose stop timer through rest api. --- openpype/modules/timers_manager/rest_api.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openpype/modules/timers_manager/rest_api.py b/openpype/modules/timers_manager/rest_api.py index 975c1a91f9..ac8d8b7b74 100644 --- a/openpype/modules/timers_manager/rest_api.py +++ b/openpype/modules/timers_manager/rest_api.py @@ -3,6 +3,7 @@ from openpype.api import Logger log = Logger().get_logger("Event processor") + class TimersManagerModuleRestApi: """ REST API endpoint used for calling from hosts when context change @@ -22,6 +23,11 @@ class TimersManagerModuleRestApi: self.prefix + "/start_timer", self.start_timer ) + self.server_manager.add_route( + "POST", + self.prefix + "/stop_timer", + self.stop_timer + ) async def start_timer(self, request): data = await request.json() @@ -38,3 +44,7 @@ class TimersManagerModuleRestApi: self.module.stop_timers() self.module.start_timer(project_name, asset_name, task_name, hierarchy) return Response(status=200) + + async def stop_timer(self, request): + self.module.stop_timers() + return Response(status=200) From 0df9744f29de5e20f7de1c2c3a72fc87f6968888 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 30 Jul 2021 16:08:54 +0200 Subject: [PATCH 41/57] publisher: editorial plugins fixes --- .../plugins/publish/collect_editorial_instances.py | 2 +- .../plugins/publish/collect_hierarchy.py | 12 ++++++------ .../plugins/publish/extract_trim_video_audio.py | 2 +- .../project_settings/standalonepublisher.json | 2 +- .../schema_project_standalonepublisher.json | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) 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/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/settings/defaults/project_settings/standalonepublisher.json b/openpype/settings/defaults/project_settings/standalonepublisher.json index 7d5cd4d8a1..50c1e34366 100644 --- a/openpype/settings/defaults/project_settings/standalonepublisher.json +++ b/openpype/settings/defaults/project_settings/standalonepublisher.json @@ -254,7 +254,7 @@ }, "shot_add_tasks": {} }, - "shot_add_tasks": { + "CollectInstances": { "custom_start_frame": 0, "timeline_frame_start": 900000, "timeline_frame_offset": 0, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_standalonepublisher.json b/openpype/settings/entities/schemas/projects_schema/schema_project_standalonepublisher.json index 0af32c8287..37fcaac69f 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_standalonepublisher.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_standalonepublisher.json @@ -327,7 +327,7 @@ { "type": "dict", "collapsible": true, - "key": "shot_add_tasks", + "key": "CollectInstances", "label": "Collect Clip Instances", "is_group": true, "children": [ From ace014c777cf08223a11eeee3cc94435289a424d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Jul 2021 16:16:47 +0200 Subject: [PATCH 42/57] fix exceptions --- openpype/settings/entities/dict_conditional.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py index 96065b670e..b61f667f6d 100644 --- a/openpype/settings/entities/dict_conditional.py +++ b/openpype/settings/entities/dict_conditional.py @@ -185,13 +185,13 @@ class DictConditionalEntity(ItemEntity): children_def_keys = [] for children_def in self.enum_children: if not isinstance(children_def, dict): - raise EntitySchemaError(( + raise EntitySchemaError(self, ( "Children definition under key 'enum_children' must" " be a dictionary." )) if "key" not in children_def: - raise EntitySchemaError(( + raise EntitySchemaError(self, ( "Children definition under key 'enum_children' miss" " 'key' definition." )) From 73a13a13cd6a9e2a1711e439e67c4cd9d4538eef Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Jul 2021 16:18:35 +0200 Subject: [PATCH 43/57] added new enum attributes --- openpype/settings/entities/dict_conditional.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py index b61f667f6d..b48c5a1cb0 100644 --- a/openpype/settings/entities/dict_conditional.py +++ b/openpype/settings/entities/dict_conditional.py @@ -144,6 +144,13 @@ class DictConditionalEntity(ItemEntity): self.enum_entity = None + # GUI attributes + self.enum_is_horizontal = self.schema_data.get( + "enum_is_horizontal", False + ) + # `enum_on_right` can be used only if + self.enum_on_right = self.schema_data.get("enum_on_right", False) + self.highlight_content = self.schema_data.get( "highlight_content", False ) From e4c050611d102771c4976db8347032b4cd33b897 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Jul 2021 16:20:55 +0200 Subject: [PATCH 44/57] modified widget to be able show combobox horizontally --- .../settings/settings/dict_conditional.py | 63 +++++++++++++++---- 1 file changed, 52 insertions(+), 11 deletions(-) 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: From 8ad04c84f62840c86634f8cd82cfd97f7b8927bd Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Jul 2021 16:21:10 +0200 Subject: [PATCH 45/57] allow to not set label --- openpype/settings/entities/dict_conditional.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py index b48c5a1cb0..d275d8ac3d 100644 --- a/openpype/settings/entities/dict_conditional.py +++ b/openpype/settings/entities/dict_conditional.py @@ -293,7 +293,7 @@ class DictConditionalEntity(ItemEntity): "multiselection": False, "enum_items": enum_items, "key": enum_key, - "label": self.enum_label or enum_key + "label": self.enum_label } enum_entity = self.create_schema_object(enum_schema, self) From dc93c7a786bdf3808a49ff92865f69e8c11db23b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 30 Jul 2021 16:21:39 +0200 Subject: [PATCH 46/57] global: integrate name missing default template --- openpype/plugins/publish/integrate_new.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 6d2a95f232..bc810e9125 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -303,6 +303,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): key_values = {"families": family, "tasks": task_name} profile = filter_profiles(self.template_name_profiles, key_values, logger=self.log) + + template_name = "publish" if profile: template_name = profile["template_name"] From 860bb00ed5b466d3ec75b9de00433de138049d80 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Jul 2021 16:22:19 +0200 Subject: [PATCH 47/57] added example of `enum_is_horizontal` usage --- .../schemas/system_schema/example_schema.json | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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, From b0d0e41c98e78dffbce2c87fc7cf3a2905904817 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 30 Jul 2021 16:23:12 +0200 Subject: [PATCH 48/57] removing blank line space --- openpype/plugins/publish/integrate_new.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index bc810e9125..3504206fe1 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -303,7 +303,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): key_values = {"families": family, "tasks": task_name} profile = filter_profiles(self.template_name_profiles, key_values, logger=self.log) - + template_name = "publish" if profile: template_name = profile["template_name"] From f34f45c3fbf8fb2568ed69483dd0719c9e9f9520 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Jul 2021 16:25:18 +0200 Subject: [PATCH 49/57] added enum_is_horizontal to readme --- openpype/settings/entities/schemas/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md index 079d16c506..399c4ac1d9 100644 --- a/openpype/settings/entities/schemas/README.md +++ b/openpype/settings/entities/schemas/README.md @@ -204,6 +204,8 @@ - it is possible to add darker background with `"highlight_content"` (Default: `False`) - darker background has limits of maximum applies after 3-4 nested highlighted items there is not difference in the color - output is dictionary `{the "key": children values}` +- for UI porposes was added `enum_is_horizontal` which will make combobox appear next to children inputs instead of on top of them (Default: `False`) + - this has extended ability of `enum_on_right` which will move combobox to right side next to children widgets (Default: `False`) ``` # Example { From 74f57039e4d390f27c1450f390b3a1c1ffd34b7a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 30 Jul 2021 16:37:05 +0200 Subject: [PATCH 50/57] global: better label --- openpype/plugins/publish/validate_editorial_asset_name.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/validate_editorial_asset_name.py b/openpype/plugins/publish/validate_editorial_asset_name.py index ccea42dc37..f13e3b4f38 100644 --- a/openpype/plugins/publish/validate_editorial_asset_name.py +++ b/openpype/plugins/publish/validate_editorial_asset_name.py @@ -11,7 +11,7 @@ class ValidateEditorialAssetName(pyblish.api.ContextPlugin): """ order = pyblish.api.ValidatorOrder - label = "Validate Asset Name" + label = "Validate Editorial Asset Name" def process(self, context): From e8f773efa188f90b26abc47469fe18760431c9a7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 30 Jul 2021 16:37:40 +0200 Subject: [PATCH 51/57] settings: global validators with options --- .../defaults/project_settings/global.json | 8 ++++ .../schemas/schema_global_publish.json | 40 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 636acc0d17..c14486f384 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -1,5 +1,13 @@ { "publish": { + "ValidateEditorialAssetName": { + "enabled": true, + "optional": false + }, + "ValidateVersion": { + "enabled": true, + "optional": false + }, "IntegrateHeroVersion": { "enabled": true, "optional": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json index 4715db4888..a1cbc8639f 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json @@ -4,6 +4,46 @@ "key": "publish", "label": "Publish plugins", "children": [ + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "ValidateEditorialAssetName", + "label": "Validate Editorial Asset Name", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + } + ] + }, + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "ValidateVersion", + "label": "Validate Version", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + } + ] + }, { "type": "dict", "collapsible": true, From 56dfb1b12606e39f568d64d202b53f043beda2ee Mon Sep 17 00:00:00 2001 From: OpenPype Date: Sat, 31 Jul 2021 03:41:18 +0000 Subject: [PATCH 52/57] [Automated] Bump version --- CHANGELOG.md | 27 +++++++++++++-------------- openpype/version.py | 2 +- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd5ccd412..8a41ccb4d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,17 @@ # Changelog -## [3.3.0-nightly.5](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) @@ -20,23 +26,23 @@ **πŸ› 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) -- nuke: fixing wrong name of family folder when `used existing frames` [\#1803](https://github.com/pypeclub/OpenPype/pull/1803) -- 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:** -- Ftrack push attributes action adds traceback to job [\#1842](https://github.com/pypeclub/OpenPype/pull/1842) - Add support for pyenv-win on windows [\#1822](https://github.com/pypeclub/OpenPype/pull/1822) - PS, AE - send actual context when another webserver is running [\#1811](https://github.com/pypeclub/OpenPype/pull/1811) -- Build: don't add Poetry to `PATH` [\#1808](https://github.com/pypeclub/OpenPype/pull/1808) ## [3.2.0](https://github.com/pypeclub/OpenPype/tree/3.2.0) (2021-07-13) @@ -61,6 +67,7 @@ **πŸ› Bug fixes** +- nuke: fixing wrong name of family folder when `used existing frames` [\#1803](https://github.com/pypeclub/OpenPype/pull/1803) - Collect ftrack family bugs [\#1801](https://github.com/pypeclub/OpenPype/pull/1801) - Invitee email can be None which break the Ftrack commit. [\#1788](https://github.com/pypeclub/OpenPype/pull/1788) - Fix: staging and `--use-version` option [\#1786](https://github.com/pypeclub/OpenPype/pull/1786) @@ -82,9 +89,9 @@ **Merged pull requests:** +- Build: don't add Poetry to `PATH` [\#1808](https://github.com/pypeclub/OpenPype/pull/1808) - Bump prismjs from 1.23.0 to 1.24.0 in /website [\#1773](https://github.com/pypeclub/OpenPype/pull/1773) - Bc/fix/docs [\#1771](https://github.com/pypeclub/OpenPype/pull/1771) -- Expose write attributes to config [\#1770](https://github.com/pypeclub/OpenPype/pull/1770) - TVPaint ftrack family [\#1755](https://github.com/pypeclub/OpenPype/pull/1755) ## [2.18.4](https://github.com/pypeclub/OpenPype/tree/2.18.4) (2021-06-24) @@ -100,18 +107,10 @@ [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) - ## [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) - ## [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) diff --git a/openpype/version.py b/openpype/version.py index d7efcf6bd5..ee121051ea 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.3.0-nightly.5" +__version__ = "3.3.0-nightly.6" From 0963f3b776ac703c6d3c2890cf53bd416b115ed8 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 3 Aug 2021 09:07:00 +0200 Subject: [PATCH 53/57] fixed python detection --- tools/create_env.ps1 | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/create_env.ps1 b/tools/create_env.ps1 index 2ab6abe76e..e2ec401bb3 100644 --- a/tools/create_env.ps1 +++ b/tools/create_env.ps1 @@ -62,9 +62,12 @@ function Test-Python() { Write-Host "Detecting host Python ... " -NoNewline $python = "python" if (Get-Command "pyenv" -ErrorAction SilentlyContinue) { - $python = & pyenv which python + $pyenv_python = & pyenv which python + if (Test-Path -PathType Leaf -Path "$($pyenv_python)") { + $python = $pyenv_python + } } - if (-not (Get-Command "python3" -ErrorAction SilentlyContinue)) { + if (-not (Get-Command $python -ErrorAction SilentlyContinue)) { Write-Host "!!! Python not detected" -ForegroundColor red Set-Location -Path $current_dir Exit-WithCode 1 From e5c8814797f464e7863d94c970305c42a200bebe Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 3 Aug 2021 10:50:50 +0200 Subject: [PATCH 54/57] removed unused function --- tools/build.ps1 | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tools/build.ps1 b/tools/build.ps1 index e1962ee933..10da3d0b83 100644 --- a/tools/build.ps1 +++ b/tools/build.ps1 @@ -80,17 +80,6 @@ function Show-PSWarning() { } } -function Install-Poetry() { - Write-Host ">>> " -NoNewline -ForegroundColor Green - Write-Host "Installing Poetry ... " - $python = "python" - if (Get-Command "pyenv" -ErrorAction SilentlyContinue) { - $python = & pyenv which python - } - $env:POETRY_HOME="$openpype_root\.poetry" - (Invoke-WebRequest -Uri https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py -UseBasicParsing).Content | & $($python) - -} - $art = @" . . .. . .. From af45f466d5f81e4d7daca097ff06b9df0008b92a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Tue, 3 Aug 2021 17:13:35 +0200 Subject: [PATCH 55/57] remove whitespace --- openpype/hosts/maya/plugins/publish/extract_look.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index c823602dc4..f09d50d714 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -296,7 +296,7 @@ class ExtractLook(openpype.api.Extractor): remap[color_space_attr] = color_space attr = resource["attribute"] remap[attr] = destinations[source] - + self.log.info("Finished remapping destinations ...") # Extract in correct render layer From 058089429fb5d95a22d55c10bf5b34ffb2175279 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Wed, 4 Aug 2021 03:42:17 +0000 Subject: [PATCH 56/57] [Automated] Bump version --- CHANGELOG.md | 18 ++++++++++-------- openpype/version.py | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a41ccb4d6..5e3f2150c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,15 @@ # Changelog -## [3.3.0-nightly.6](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.3.0-nightly.7](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.2.0...HEAD) **πŸš€ Enhancements** +- Settings: global validators with options [\#1892](https://github.com/pypeclub/OpenPype/pull/1892) +- Settings: Conditional dict enum positioning [\#1891](https://github.com/pypeclub/OpenPype/pull/1891) - Expose stop timer through rest api. [\#1886](https://github.com/pypeclub/OpenPype/pull/1886) +- TVPaint: Increment workfile [\#1885](https://github.com/pypeclub/OpenPype/pull/1885) - 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) @@ -23,24 +26,28 @@ - Update poetry lock [\#1823](https://github.com/pypeclub/OpenPype/pull/1823) - Settings: settings for plugins [\#1819](https://github.com/pypeclub/OpenPype/pull/1819) - Maya: Deadline custom settings [\#1797](https://github.com/pypeclub/OpenPype/pull/1797) +- Maya: Shader name validation [\#1762](https://github.com/pypeclub/OpenPype/pull/1762) **πŸ› Bug fixes** +- Bug: fixed python detection [\#1893](https://github.com/pypeclub/OpenPype/pull/1893) +- global: integrate name missing default template [\#1890](https://github.com/pypeclub/OpenPype/pull/1890) +- publisher: editorial plugins fixes [\#1889](https://github.com/pypeclub/OpenPype/pull/1889) - 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) -- Settings error dialog on show [\#1798](https://github.com/pypeclub/OpenPype/pull/1798) +- Houdini colector formatting keys fix [\#1802](https://github.com/pypeclub/OpenPype/pull/1802) **Merged pull requests:** +- Maya: add support for `RedshiftNormalMap` node, fix `tx` linear space πŸš€ [\#1863](https://github.com/pypeclub/OpenPype/pull/1863) - Add support for pyenv-win on windows [\#1822](https://github.com/pypeclub/OpenPype/pull/1822) - PS, AE - send actual context when another webserver is running [\#1811](https://github.com/pypeclub/OpenPype/pull/1811) @@ -62,8 +69,6 @@ - Deadline: Nuke submission additional attributes [\#1756](https://github.com/pypeclub/OpenPype/pull/1756) - Settings schema without prefill [\#1753](https://github.com/pypeclub/OpenPype/pull/1753) - 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) **πŸ› Bug fixes** @@ -71,7 +76,6 @@ - Collect ftrack family bugs [\#1801](https://github.com/pypeclub/OpenPype/pull/1801) - Invitee email can be None which break the Ftrack commit. [\#1788](https://github.com/pypeclub/OpenPype/pull/1788) - Fix: staging and `--use-version` option [\#1786](https://github.com/pypeclub/OpenPype/pull/1786) -- Otio unrelated error on import [\#1782](https://github.com/pypeclub/OpenPype/pull/1782) - FFprobe streams order [\#1775](https://github.com/pypeclub/OpenPype/pull/1775) - Fix - single file files are str only, cast it to list to count properly [\#1772](https://github.com/pypeclub/OpenPype/pull/1772) - Environments in app executable for MacOS [\#1768](https://github.com/pypeclub/OpenPype/pull/1768) @@ -84,8 +88,6 @@ - Hiero: creator instance error [\#1742](https://github.com/pypeclub/OpenPype/pull/1742) - Nuke: fixing render creator for no selection format failing [\#1741](https://github.com/pypeclub/OpenPype/pull/1741) - StandalonePublisher: failing collector for editorial [\#1738](https://github.com/pypeclub/OpenPype/pull/1738) -- Local settings UI crash on missing defaults [\#1737](https://github.com/pypeclub/OpenPype/pull/1737) -- TVPaint white background on thumbnail [\#1735](https://github.com/pypeclub/OpenPype/pull/1735) **Merged pull requests:** diff --git a/openpype/version.py b/openpype/version.py index ee121051ea..473be3bafc 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.3.0-nightly.6" +__version__ = "3.3.0-nightly.7" From fe4a0ea2a51f4b224d2527f5af84bff6431e78e8 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Wed, 4 Aug 2021 15:08:52 +0100 Subject: [PATCH 57/57] Try formatting paths with current environment. --- openpype/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/__init__.py b/openpype/__init__.py index a86d2bc2be..e7462e14e9 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -98,6 +98,11 @@ def install(): .get(platform_name) ) or [] for path in project_plugins: + try: + path = str(path.format(**os.environ)) + except KeyError: + pass + if not path or not os.path.exists(path): continue