diff --git a/CHANGELOG.md b/CHANGELOG.md
index 68409c4db8..0f0749ba52 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,20 @@
# Changelog
-## [3.6.0-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD)
+## [3.6.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.5.0...HEAD)
+**π New features**
+
+- Flame: a host basic integration [\#2165](https://github.com/pypeclub/OpenPype/pull/2165)
+- Houdini: simple HDA workflow [\#2072](https://github.com/pypeclub/OpenPype/pull/2072)
+
**π Enhancements**
+- Delivery: Check 'frame' key in template for sequence delivery [\#2196](https://github.com/pypeclub/OpenPype/pull/2196)
+- Usage of tools code [\#2185](https://github.com/pypeclub/OpenPype/pull/2185)
+- Settings: Dictionary based on project roots [\#2184](https://github.com/pypeclub/OpenPype/pull/2184)
+- Subset name: Be able to pass asset document to get subset name [\#2179](https://github.com/pypeclub/OpenPype/pull/2179)
- Tools: Experimental tools [\#2167](https://github.com/pypeclub/OpenPype/pull/2167)
- Loader: Refactor and use OpenPype stylesheets [\#2166](https://github.com/pypeclub/OpenPype/pull/2166)
- Add loader for linked smart objects in photoshop [\#2149](https://github.com/pypeclub/OpenPype/pull/2149)
@@ -14,6 +23,8 @@
**π Bug fixes**
+- Project Manager: Fix copying of tasks [\#2191](https://github.com/pypeclub/OpenPype/pull/2191)
+- StandalonePublisher: Source validator don't expect representations [\#2190](https://github.com/pypeclub/OpenPype/pull/2190)
- MacOS: Launching of applications may cause Permissions error [\#2175](https://github.com/pypeclub/OpenPype/pull/2175)
- Blender: Fix 'Deselect All' with object not in 'Object Mode' [\#2163](https://github.com/pypeclub/OpenPype/pull/2163)
- Tools: Stylesheets are applied after tool show [\#2161](https://github.com/pypeclub/OpenPype/pull/2161)
@@ -101,11 +112,6 @@
- Ftrack: Removed ftrack interface [\#2049](https://github.com/pypeclub/OpenPype/pull/2049)
- Settings UI: Deffered set value on entity [\#2044](https://github.com/pypeclub/OpenPype/pull/2044)
- Loader: Families filtering [\#2043](https://github.com/pypeclub/OpenPype/pull/2043)
-- Settings UI: Project view enhancements [\#2042](https://github.com/pypeclub/OpenPype/pull/2042)
-- Settings for Nuke IncrementScriptVersion [\#2039](https://github.com/pypeclub/OpenPype/pull/2039)
-- Loader & Library loader: Use tools from OpenPype [\#2038](https://github.com/pypeclub/OpenPype/pull/2038)
-- Adding predefined project folders creation in PM [\#2030](https://github.com/pypeclub/OpenPype/pull/2030)
-- WebserverModule: Removed interface of webserver module [\#2028](https://github.com/pypeclub/OpenPype/pull/2028)
**π Bug fixes**
@@ -123,19 +129,6 @@
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.4.0-nightly.6...3.4.0)
-**π Enhancements**
-
-- Added possibility to configure of synchronization of workfile version⦠[\#2041](https://github.com/pypeclub/OpenPype/pull/2041)
-- General: Task types in profiles [\#2036](https://github.com/pypeclub/OpenPype/pull/2036)
-
-**π Bug fixes**
-
-- Workfiles tool: Task selection [\#2040](https://github.com/pypeclub/OpenPype/pull/2040)
-- Ftrack: Delete old versions missing settings key [\#2037](https://github.com/pypeclub/OpenPype/pull/2037)
-- Nuke: typo on a button [\#2034](https://github.com/pypeclub/OpenPype/pull/2034)
-- Hiero: Fix "none" named tags [\#2033](https://github.com/pypeclub/OpenPype/pull/2033)
-- FFmpeg: Subprocess arguments as list [\#2032](https://github.com/pypeclub/OpenPype/pull/2032)
-
## [3.3.1](https://github.com/pypeclub/OpenPype/tree/3.3.1) (2021-08-20)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.3.1-nightly.1...3.3.1)
diff --git a/openpype/hosts/blender/plugins/publish/extract_blend.py b/openpype/hosts/blender/plugins/publish/extract_blend.py
index e880b1bde0..565e2fe425 100644
--- a/openpype/hosts/blender/plugins/publish/extract_blend.py
+++ b/openpype/hosts/blender/plugins/publish/extract_blend.py
@@ -37,7 +37,8 @@ class ExtractBlend(openpype.api.Extractor):
if tree.type == 'SHADER':
for node in tree.nodes:
if node.bl_idname == 'ShaderNodeTexImage':
- node.image.pack()
+ if node.image:
+ node.image.pack()
bpy.data.libraries.write(filepath, data_blocks)
diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py
index efdaa60084..63d9bba470 100644
--- a/openpype/hosts/houdini/api/plugin.py
+++ b/openpype/hosts/houdini/api/plugin.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
"""Houdini specific Avalon/Pyblish plugin definitions."""
import sys
+from avalon.api import CreatorError
from avalon import houdini
import six
@@ -8,7 +9,7 @@ import hou
from openpype.api import PypeCreatorMixin
-class OpenPypeCreatorError(Exception):
+class OpenPypeCreatorError(CreatorError):
pass
diff --git a/openpype/hosts/houdini/api/usd.py b/openpype/hosts/houdini/api/usd.py
index 850ffb60e5..6f808779ea 100644
--- a/openpype/hosts/houdini/api/usd.py
+++ b/openpype/hosts/houdini/api/usd.py
@@ -4,8 +4,8 @@ import contextlib
import logging
from Qt import QtCore, QtGui
-from avalon.tools.widgets import AssetWidget
-from avalon import style
+from openpype.tools.utils.widgets import AssetWidget
+from avalon import style, io
from pxr import Sdf
@@ -31,7 +31,7 @@ def pick_asset(node):
# Construct the AssetWidget as a frameless popup so it automatically
# closes when clicked outside of it.
global tool
- tool = AssetWidget(silo_creatable=False)
+ tool = AssetWidget(io)
tool.setContentsMargins(5, 5, 5, 5)
tool.setWindowTitle("Pick Asset")
tool.setStyleSheet(style.load_stylesheet())
@@ -41,8 +41,6 @@ def pick_asset(node):
# Select the current asset if there is any
name = parm.eval()
if name:
- from avalon import io
-
db_asset = io.find_one({"name": name, "type": "asset"})
if db_asset:
silo = db_asset.get("silo")
diff --git a/openpype/hosts/houdini/plugins/create/create_hda.py b/openpype/hosts/houdini/plugins/create/create_hda.py
new file mode 100644
index 0000000000..2af1e4a257
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/create/create_hda.py
@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*-
+from openpype.hosts.houdini.api import plugin
+from avalon.houdini import lib
+from avalon import io
+import hou
+
+
+class CreateHDA(plugin.Creator):
+ """Publish Houdini Digital Asset file."""
+
+ name = "hda"
+ label = "Houdini Digital Asset (Hda)"
+ family = "hda"
+ icon = "gears"
+ maintain_selection = False
+
+ def __init__(self, *args, **kwargs):
+ super(CreateHDA, self).__init__(*args, **kwargs)
+ self.data.pop("active", None)
+
+ def _check_existing(self, subset_name):
+ # type: (str) -> bool
+ """Check if existing subset name versions already exists."""
+ # Get all subsets of the current asset
+ asset_id = io.find_one({"name": self.data["asset"], "type": "asset"},
+ projection={"_id": True})['_id']
+ subset_docs = io.find(
+ {
+ "type": "subset",
+ "parent": asset_id
+ }, {"name": 1}
+ )
+ existing_subset_names = set(subset_docs.distinct("name"))
+ existing_subset_names_low = {
+ _name.lower() for _name in existing_subset_names
+ }
+ return subset_name.lower() in existing_subset_names_low
+
+ def _process(self, instance):
+ subset_name = self.data["subset"]
+ # get selected nodes
+ out = hou.node("/obj")
+ self.nodes = hou.selectedNodes()
+
+ if (self.options or {}).get("useSelection") and self.nodes:
+ # if we have `use selection` enabled and we have some
+ # selected nodes ...
+ to_hda = self.nodes[0]
+ if len(self.nodes) > 1:
+ # if there is more then one node, create subnet first
+ subnet = out.createNode(
+ "subnet", node_name="{}_subnet".format(self.name))
+ to_hda = subnet
+ else:
+ # in case of no selection, just create subnet node
+ subnet = out.createNode(
+ "subnet", node_name="{}_subnet".format(self.name))
+ subnet.moveToGoodPosition()
+ to_hda = subnet
+
+ if not to_hda.type().definition():
+ # if node type has not its definition, it is not user
+ # created hda. We test if hda can be created from the node.
+ if not to_hda.canCreateDigitalAsset():
+ raise Exception(
+ "cannot create hda from node {}".format(to_hda))
+
+ hda_node = to_hda.createDigitalAsset(
+ name=subset_name,
+ hda_file_name="$HIP/{}.hda".format(subset_name)
+ )
+ hou.moveNodesTo(self.nodes, hda_node)
+ hda_node.layoutChildren()
+ else:
+ if self._check_existing(subset_name):
+ raise plugin.OpenPypeCreatorError(
+ ("subset {} is already published with different HDA"
+ "definition.").format(subset_name))
+ hda_node = to_hda
+
+ hda_node.setName(subset_name)
+
+ # delete node created by Avalon in /out
+ # this needs to be addressed in future Houdini workflow refactor.
+
+ hou.node("/out/{}".format(subset_name)).destroy()
+
+ try:
+ lib.imprint(hda_node, self.data)
+ except hou.OperationFailed:
+ raise plugin.OpenPypeCreatorError(
+ ("Cannot set metadata on asset. Might be that it already is "
+ "OpenPype asset.")
+ )
+
+ return hda_node
diff --git a/openpype/hosts/houdini/plugins/load/load_hda.py b/openpype/hosts/houdini/plugins/load/load_hda.py
new file mode 100644
index 0000000000..6610d5e513
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/load/load_hda.py
@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+from avalon import api
+
+from avalon.houdini import pipeline
+
+
+class HdaLoader(api.Loader):
+ """Load Houdini Digital Asset file."""
+
+ families = ["hda"]
+ label = "Load Hda"
+ representations = ["hda"]
+ order = -10
+ icon = "code-fork"
+ color = "orange"
+
+ def load(self, context, name=None, namespace=None, data=None):
+ import os
+ import hou
+
+ # Format file name, Houdini only wants forward slashes
+ file_path = os.path.normpath(self.fname)
+ file_path = file_path.replace("\\", "/")
+
+ # Get the root node
+ obj = hou.node("/obj")
+
+ # Create a unique name
+ counter = 1
+ namespace = namespace or context["asset"]["name"]
+ formatted = "{}_{}".format(namespace, name) if namespace else name
+ node_name = "{0}_{1:03d}".format(formatted, counter)
+
+ hou.hda.installFile(file_path)
+ hda_node = obj.createNode(name, node_name)
+
+ self[:] = [hda_node]
+
+ return pipeline.containerise(
+ node_name,
+ namespace,
+ [hda_node],
+ context,
+ self.__class__.__name__,
+ suffix="",
+ )
+
+ def update(self, container, representation):
+ import hou
+
+ hda_node = container["node"]
+ file_path = api.get_representation_path(representation)
+ file_path = file_path.replace("\\", "/")
+ hou.hda.installFile(file_path)
+ defs = hda_node.type().allInstalledDefinitions()
+ def_paths = [d.libraryFilePath() for d in defs]
+ new = def_paths.index(file_path)
+ defs[new].setIsPreferred(True)
+
+ def remove(self, container):
+ node = container["node"]
+ node.destroy()
diff --git a/openpype/hosts/houdini/plugins/publish/collect_active_state.py b/openpype/hosts/houdini/plugins/publish/collect_active_state.py
index 1193f0cd19..862d5720e1 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_active_state.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_active_state.py
@@ -23,8 +23,10 @@ class CollectInstanceActiveState(pyblish.api.InstancePlugin):
return
# Check bypass state and reverse
+ active = True
node = instance[0]
- active = not node.isBypassed()
+ if hasattr(node, "isBypassed"):
+ active = not node.isBypassed()
# Set instance active state
instance.data.update(
diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances.py b/openpype/hosts/houdini/plugins/publish/collect_instances.py
index 1b36526783..ac081ac297 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_instances.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_instances.py
@@ -31,6 +31,7 @@ class CollectInstances(pyblish.api.ContextPlugin):
def process(self, context):
nodes = hou.node("/out").children()
+ nodes += hou.node("/obj").children()
# Include instances in USD stage only when it exists so it
# remains backwards compatible with version before houdini 18
@@ -49,9 +50,12 @@ class CollectInstances(pyblish.api.ContextPlugin):
has_family = node.evalParm("family")
assert has_family, "'%s' is missing 'family'" % node.name()
+ self.log.info("processing {}".format(node))
+
data = lib.read(node)
# Check bypass state and reverse
- data.update({"active": not node.isBypassed()})
+ if hasattr(node, "isBypassed"):
+ data.update({"active": not node.isBypassed()})
# temporarily translation of `active` to `publish` till issue has
# been resolved, https://github.com/pyblish/pyblish-base/issues/307
diff --git a/openpype/hosts/houdini/plugins/publish/extract_hda.py b/openpype/hosts/houdini/plugins/publish/extract_hda.py
new file mode 100644
index 0000000000..301dd4e297
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/publish/extract_hda.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+import os
+
+from pprint import pformat
+
+import pyblish.api
+import openpype.api
+
+
+class ExtractHDA(openpype.api.Extractor):
+
+ order = pyblish.api.ExtractorOrder
+ label = "Extract HDA"
+ hosts = ["houdini"]
+ families = ["hda"]
+
+ def process(self, instance):
+ self.log.info(pformat(instance.data))
+ hda_node = instance[0]
+ hda_def = hda_node.type().definition()
+ hda_options = hda_def.options()
+ hda_options.setSaveInitialParmsAndContents(True)
+
+ next_version = instance.data["anatomyData"]["version"]
+ self.log.info("setting version: {}".format(next_version))
+ hda_def.setVersion(str(next_version))
+ hda_def.setOptions(hda_options)
+ hda_def.save(hda_def.libraryFilePath(), hda_node, hda_options)
+
+ if "representations" not in instance.data:
+ instance.data["representations"] = []
+
+ file = os.path.basename(hda_def.libraryFilePath())
+ staging_dir = os.path.dirname(hda_def.libraryFilePath())
+ self.log.info("Using HDA from {}".format(hda_def.libraryFilePath()))
+
+ representation = {
+ 'name': 'hda',
+ 'ext': 'hda',
+ 'files': file,
+ "stagingDir": staging_dir,
+ }
+ instance.data["representations"].append(representation)
diff --git a/openpype/hosts/houdini/plugins/publish/validate_bypass.py b/openpype/hosts/houdini/plugins/publish/validate_bypass.py
index 79c67c3008..fc4e18f701 100644
--- a/openpype/hosts/houdini/plugins/publish/validate_bypass.py
+++ b/openpype/hosts/houdini/plugins/publish/validate_bypass.py
@@ -35,5 +35,5 @@ class ValidateBypassed(pyblish.api.InstancePlugin):
def get_invalid(cls, instance):
rop = instance[0]
- if rop.isBypassed():
+ if hasattr(rop, "isBypassed") and rop.isBypassed():
return [rop]
diff --git a/openpype/hosts/maya/api/__init__.py b/openpype/hosts/maya/api/__init__.py
index d1c13b04d5..0a8370eafc 100644
--- a/openpype/hosts/maya/api/__init__.py
+++ b/openpype/hosts/maya/api/__init__.py
@@ -275,8 +275,7 @@ def on_open(_):
# Show outdated pop-up
def _on_show_inventory():
- import avalon.tools.sceneinventory as tool
- tool.show(parent=parent)
+ host_tools.show_scene_inventory(parent=parent)
dialog = popup.Popup(parent=parent)
dialog.setWindowTitle("Maya scene has outdated content")
diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py
index b16774a8d6..88c0525f3b 100644
--- a/openpype/hosts/maya/api/lib.py
+++ b/openpype/hosts/maya/api/lib.py
@@ -2,6 +2,7 @@
import re
import os
+import platform
import uuid
import math
@@ -22,6 +23,7 @@ import avalon.maya.lib
import avalon.maya.interactive
from openpype import lib
+from openpype.api import get_anatomy_settings
log = logging.getLogger(__name__)
@@ -1822,7 +1824,7 @@ def set_scene_fps(fps, update=True):
cmds.file(modified=True)
-def set_scene_resolution(width, height):
+def set_scene_resolution(width, height, pixelAspect):
"""Set the render resolution
Args:
@@ -1850,6 +1852,36 @@ def set_scene_resolution(width, height):
cmds.setAttr("%s.width" % control_node, width)
cmds.setAttr("%s.height" % control_node, height)
+ deviceAspectRatio = ((float(width) / float(height)) * float(pixelAspect))
+ cmds.setAttr("%s.deviceAspectRatio" % control_node, deviceAspectRatio)
+ cmds.setAttr("%s.pixelAspect" % control_node, pixelAspect)
+
+
+def reset_scene_resolution():
+ """Apply the scene resolution from the project definition
+
+ scene resolution can be overwritten by an asset if the asset.data contains
+ any information regarding scene resolution .
+
+ Returns:
+ None
+ """
+
+ project_doc = io.find_one({"type": "project"})
+ project_data = project_doc["data"]
+ asset_data = lib.get_asset()["data"]
+
+ # Set project resolution
+ width_key = "resolutionWidth"
+ height_key = "resolutionHeight"
+ pixelAspect_key = "pixelAspect"
+
+ width = asset_data.get(width_key, project_data.get(width_key, 1920))
+ height = asset_data.get(height_key, project_data.get(height_key, 1080))
+ pixelAspect = asset_data.get(pixelAspect_key,
+ project_data.get(pixelAspect_key, 1))
+
+ set_scene_resolution(width, height, pixelAspect)
def set_context_settings():
"""Apply the project settings from the project definition
@@ -1876,18 +1908,14 @@ def set_context_settings():
api.Session["AVALON_FPS"] = str(fps)
set_scene_fps(fps)
- # Set project resolution
- width_key = "resolutionWidth"
- height_key = "resolutionHeight"
-
- width = asset_data.get(width_key, project_data.get(width_key, 1920))
- height = asset_data.get(height_key, project_data.get(height_key, 1080))
-
- set_scene_resolution(width, height)
+ reset_scene_resolution()
# Set frame range.
avalon.maya.interactive.reset_frame_range()
+ # Set colorspace
+ set_colorspace()
+
# Valid FPS
def validate_fps():
@@ -2743,3 +2771,49 @@ def iter_shader_edits(relationships, shader_nodes, nodes_by_id, label=None):
"uuid": data["uuid"],
"nodes": nodes,
"attributes": attr_value}
+
+
+def set_colorspace():
+ """Set Colorspace from project configuration
+ """
+ project_name = os.getenv("AVALON_PROJECT")
+ imageio = get_anatomy_settings(project_name)["imageio"]["maya"]
+ root_dict = imageio["colorManagementPreference"]
+
+ if not isinstance(root_dict, dict):
+ msg = "set_colorspace(): argument should be dictionary"
+ log.error(msg)
+
+ log.debug(">> root_dict: {}".format(root_dict))
+
+ # first enable color management
+ cmds.colorManagementPrefs(e=True, cmEnabled=True)
+ cmds.colorManagementPrefs(e=True, ocioRulesEnabled=True)
+
+ # second set config path
+ if root_dict.get("configFilePath"):
+ unresolved_path = root_dict["configFilePath"]
+ ocio_paths = unresolved_path[platform.system().lower()]
+
+ resolved_path = None
+ for ocio_p in ocio_paths:
+ resolved_path = str(ocio_p).format(**os.environ)
+ if not os.path.exists(resolved_path):
+ continue
+
+ if resolved_path:
+ filepath = str(resolved_path).replace("\\", "/")
+ cmds.colorManagementPrefs(e=True, configFilePath=filepath)
+ cmds.colorManagementPrefs(e=True, cmConfigFileEnabled=True)
+ log.debug("maya '{}' changed to: {}".format(
+ "configFilePath", resolved_path))
+ root_dict.pop("configFilePath")
+ else:
+ cmds.colorManagementPrefs(e=True, cmConfigFileEnabled=False)
+ cmds.colorManagementPrefs(e=True, configFilePath="" )
+
+ # third set rendering space and view transform
+ renderSpace = root_dict["renderSpace"]
+ cmds.colorManagementPrefs(e=True, renderingSpaceName=renderSpace)
+ viewTransform = root_dict["viewTransform"]
+ cmds.colorManagementPrefs(e=True, viewTransformName=viewTransform)
diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py
index 4e69e41dca..df5058dfd5 100644
--- a/openpype/hosts/maya/api/menu.py
+++ b/openpype/hosts/maya/api/menu.py
@@ -11,6 +11,7 @@ from avalon.maya import pipeline
from openpype.api import BuildWorkfile
from openpype.settings import get_project_settings
from openpype.tools.utils import host_tools
+from openpype.hosts.maya.api import lib
log = logging.getLogger(__name__)
@@ -110,6 +111,35 @@ def deferred():
if workfile_action:
top_menu.removeAction(workfile_action)
+ def modify_resolution():
+ # Find the pipeline menu
+ top_menu = _get_menu()
+
+ # Try to find resolution tool action in the menu
+ resolution_action = None
+ for action in top_menu.actions():
+ if action.text() == "Reset Resolution":
+ resolution_action = action
+ break
+
+ # Add at the top of menu if "Work Files" action was not found
+ after_action = ""
+ if resolution_action:
+ # Use action's object name for `insertAfter` argument
+ after_action = resolution_action.objectName()
+
+ # Insert action to menu
+ cmds.menuItem(
+ "Reset Resolution",
+ parent=pipeline._menu,
+ command=lambda *args: lib.reset_scene_resolution(),
+ insertAfter=after_action
+ )
+
+ # Remove replaced action
+ if resolution_action:
+ top_menu.removeAction(resolution_action)
+
def remove_project_manager():
top_menu = _get_menu()
@@ -134,6 +164,31 @@ def deferred():
if project_manager_action is not None:
system_menu.menu().removeAction(project_manager_action)
+ def add_colorspace():
+ # Find the pipeline menu
+ top_menu = _get_menu()
+
+ # Try to find workfile tool action in the menu
+ workfile_action = None
+ for action in top_menu.actions():
+ if action.text() == "Reset Resolution":
+ 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(
+ "Set Colorspace",
+ parent=pipeline._menu,
+ command=lambda *args: lib.set_colorspace(),
+ insertAfter=after_action
+ )
+
log.info("Attempting to install scripts menu ...")
# add_scripts_menu()
@@ -141,7 +196,9 @@ def deferred():
add_look_assigner_item()
add_experimental_item()
modify_workfiles()
+ modify_resolution()
remove_project_manager()
+ add_colorspace()
add_scripts_menu()
diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py b/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py
new file mode 100644
index 0000000000..e8cc019b52
--- /dev/null
+++ b/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py
@@ -0,0 +1,53 @@
+from maya import cmds
+
+import pyblish.api
+import openpype.api
+import openpype.hosts.maya.api.action
+from avalon import maya
+from openpype.hosts.maya.api import lib
+
+
+def polyConstraint(objects, *args, **kwargs):
+ kwargs.pop('mode', None)
+
+ with lib.no_undo(flush=False):
+ with maya.maintained_selection():
+ with lib.reset_polySelectConstraint():
+ cmds.select(objects, r=1, noExpand=True)
+ # Acting as 'polyCleanupArgList' for n-sided polygon selection
+ cmds.polySelectConstraint(*args, mode=3, **kwargs)
+ result = cmds.ls(selection=True)
+ cmds.select(clear=True)
+
+ return result
+
+
+class ValidateMeshNgons(pyblish.api.Validator):
+ """Ensure that meshes don't have ngons
+
+ Ngon are faces with more than 4 sides.
+
+ To debug the problem on the meshes you can use Maya's modeling
+ tool: "Mesh > Cleanup..."
+
+ """
+
+ order = openpype.api.ValidateContentsOrder
+ hosts = ["maya"]
+ families = ["model"]
+ label = "Mesh ngons"
+ actions = [openpype.hosts.maya.api.action.SelectInvalidAction]
+
+ @staticmethod
+ def get_invalid(instance):
+
+ meshes = cmds.ls(instance, type='mesh')
+ return polyConstraint(meshes, type=8, size=3)
+
+ def process(self, instance):
+ """Process all the nodes in the instance "objectSet"""
+
+ invalid = self.get_invalid(instance)
+ if invalid:
+ raise ValueError("Meshes found with n-gons"
+ "values: {0}".format(invalid))
diff --git a/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py
index 9bb8e90350..12f9fa5ab5 100644
--- a/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py
+++ b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py
@@ -106,12 +106,12 @@ class CollectRemoteInstances(pyblish.api.ContextPlugin):
for mapping in self.color_code_mapping:
if mapping["color_code"] and \
layer.color_code not in mapping["color_code"]:
- break
+ continue
if mapping["layer_name_regex"] and \
not any(re.search(pattern, layer.name)
for pattern in mapping["layer_name_regex"]):
- break
+ continue
family_list.append(mapping["family"])
subset_name_list.append(mapping["subset_template_name"])
@@ -127,7 +127,6 @@ class CollectRemoteInstances(pyblish.api.ContextPlugin):
format(layer.name))
self.log.warning("Only first family used!")
family_list[:] = family_list[0]
-
if subset_name_list:
resolved_subset_template = subset_name_list.pop()
if family_list:
diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py
index a5177335b3..9f075d66cf 100644
--- a/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py
+++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py
@@ -3,7 +3,7 @@ import json
import pyblish.api
from avalon import io
-from openpype.lib import get_subset_name
+from openpype.lib import get_subset_name_with_asset_doc
class CollectBulkMovInstances(pyblish.api.InstancePlugin):
@@ -26,16 +26,10 @@ class CollectBulkMovInstances(pyblish.api.InstancePlugin):
context = instance.context
asset_name = instance.data["asset"]
- asset_doc = io.find_one(
- {
- "type": "asset",
- "name": asset_name
- },
- {
- "_id": 1,
- "data.tasks": 1
- }
- )
+ asset_doc = io.find_one({
+ "type": "asset",
+ "name": asset_name
+ })
if not asset_doc:
raise AssertionError((
"Couldn't find Asset document with name \"{}\""
@@ -53,11 +47,11 @@ class CollectBulkMovInstances(pyblish.api.InstancePlugin):
task_name = available_task_names[_task_name_low]
break
- subset_name = get_subset_name(
+ subset_name = get_subset_name_with_asset_doc(
self.new_instance_family,
self.subset_name_variant,
task_name,
- asset_doc["_id"],
+ asset_doc,
io.Session["AVALON_PROJECT"]
)
instance_name = f"{asset_name}_{subset_name}"
diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py
index da424cfb45..eec675e97f 100644
--- a/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py
+++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py
@@ -22,15 +22,15 @@ class ValidateSources(pyblish.api.InstancePlugin):
def process(self, instance):
self.log.info("instance {}".format(instance.data))
- for repr in instance.data["representations"]:
+ for repre in instance.data.get("representations") or []:
files = []
- if isinstance(repr["files"], str):
- files.append(repr["files"])
+ if isinstance(repre["files"], str):
+ files.append(repre["files"])
else:
- files = list(repr["files"])
+ files = list(repre["files"])
for file_name in files:
- source_file = os.path.join(repr["stagingDir"],
+ source_file = os.path.join(repre["stagingDir"],
file_name)
if not os.path.exists(source_file):
diff --git a/openpype/hosts/testhost/README.md b/openpype/hosts/testhost/README.md
new file mode 100644
index 0000000000..f69e02a3b3
--- /dev/null
+++ b/openpype/hosts/testhost/README.md
@@ -0,0 +1,16 @@
+# What is `testhost`
+Host `testhost` was created to fake running host for testing of publisher.
+
+Does not have any proper launch mechanism at the moment. There is python script `./run_publish.py` which will show publisher window. The script requires to set few variables to run. Execution will register host `testhost`, register global publish plugins and register creator and publish plugins from `./plugins`.
+
+## Data
+Created instances and context data are stored into json files inside `./api` folder. Can be easily modified to save them to a different place.
+
+## Plugins
+Test host has few plugins to be able test publishing.
+
+### Creators
+They are just example plugins using functions from `api` to create/remove/update data. One of them is auto creator which means that is triggered on each reset of create context. Others are manual creators both creating the same family.
+
+### Publishers
+Collectors are example plugin to use `get_attribute_defs` to define attributes for specific families or for context. Validators are to test `PublishValidationError`.
diff --git a/openpype/hosts/testhost/__init__.py b/openpype/hosts/testhost/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openpype/hosts/testhost/api/__init__.py b/openpype/hosts/testhost/api/__init__.py
new file mode 100644
index 0000000000..7840b25892
--- /dev/null
+++ b/openpype/hosts/testhost/api/__init__.py
@@ -0,0 +1,43 @@
+import os
+import logging
+import pyblish.api
+import avalon.api
+from openpype.pipeline import BaseCreator
+
+from .pipeline import (
+ ls,
+ list_instances,
+ update_instances,
+ remove_instances,
+ get_context_data,
+ update_context_data,
+ get_context_title
+)
+
+
+HOST_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+PLUGINS_DIR = os.path.join(HOST_DIR, "plugins")
+PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish")
+CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
+
+log = logging.getLogger(__name__)
+
+
+def install():
+ log.info("OpenPype - Installing TestHost integration")
+ pyblish.api.register_host("testhost")
+ pyblish.api.register_plugin_path(PUBLISH_PATH)
+ avalon.api.register_plugin_path(BaseCreator, CREATE_PATH)
+
+
+__all__ = (
+ "ls",
+ "list_instances",
+ "update_instances",
+ "remove_instances",
+ "get_context_data",
+ "update_context_data",
+ "get_context_title",
+
+ "install"
+)
diff --git a/openpype/hosts/testhost/api/context.json b/openpype/hosts/testhost/api/context.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/openpype/hosts/testhost/api/context.json
@@ -0,0 +1 @@
+{}
diff --git a/openpype/hosts/testhost/api/instances.json b/openpype/hosts/testhost/api/instances.json
new file mode 100644
index 0000000000..84021eff91
--- /dev/null
+++ b/openpype/hosts/testhost/api/instances.json
@@ -0,0 +1,108 @@
+[
+ {
+ "id": "pyblish.avalon.instance",
+ "active": true,
+ "family": "test",
+ "subset": "testMyVariant",
+ "version": 1,
+ "asset": "sq01_sh0010",
+ "task": "Compositing",
+ "variant": "myVariant",
+ "uuid": "a485f148-9121-46a5-8157-aa64df0fb449",
+ "creator_attributes": {
+ "number_key": 10,
+ "ha": 10
+ },
+ "publish_attributes": {
+ "CollectFtrackApi": {
+ "add_ftrack_family": false
+ }
+ },
+ "creator_identifier": "test_one"
+ },
+ {
+ "id": "pyblish.avalon.instance",
+ "active": true,
+ "family": "test",
+ "subset": "testMyVariant2",
+ "version": 1,
+ "asset": "sq01_sh0010",
+ "task": "Compositing",
+ "variant": "myVariant2",
+ "uuid": "a485f148-9121-46a5-8157-aa64df0fb444",
+ "creator_attributes": {},
+ "publish_attributes": {
+ "CollectFtrackApi": {
+ "add_ftrack_family": true
+ }
+ },
+ "creator_identifier": "test_one"
+ },
+ {
+ "id": "pyblish.avalon.instance",
+ "active": true,
+ "family": "test",
+ "subset": "testMain",
+ "version": 1,
+ "asset": "sq01_sh0010",
+ "task": "Compositing",
+ "variant": "Main",
+ "uuid": "3607bc95-75f6-4648-a58d-e699f413d09f",
+ "creator_attributes": {},
+ "publish_attributes": {
+ "CollectFtrackApi": {
+ "add_ftrack_family": true
+ }
+ },
+ "creator_identifier": "test_two"
+ },
+ {
+ "id": "pyblish.avalon.instance",
+ "active": true,
+ "family": "test",
+ "subset": "testMain2",
+ "version": 1,
+ "asset": "sq01_sh0020",
+ "task": "Compositing",
+ "variant": "Main2",
+ "uuid": "4ccf56f6-9982-4837-967c-a49695dbe8eb",
+ "creator_attributes": {},
+ "publish_attributes": {
+ "CollectFtrackApi": {
+ "add_ftrack_family": true
+ }
+ },
+ "creator_identifier": "test_two"
+ },
+ {
+ "id": "pyblish.avalon.instance",
+ "family": "test_three",
+ "subset": "test_threeMain2",
+ "active": true,
+ "version": 1,
+ "asset": "sq01_sh0020",
+ "task": "Compositing",
+ "variant": "Main2",
+ "uuid": "4ccf56f6-9982-4837-967c-a49695dbe8ec",
+ "creator_attributes": {},
+ "publish_attributes": {
+ "CollectFtrackApi": {
+ "add_ftrack_family": true
+ }
+ }
+ },
+ {
+ "id": "pyblish.avalon.instance",
+ "family": "workfile",
+ "subset": "workfileMain",
+ "active": true,
+ "creator_identifier": "workfile",
+ "version": 1,
+ "asset": "Alpaca_01",
+ "task": "modeling",
+ "variant": "Main",
+ "uuid": "7c9ddfc7-9f9c-4c1c-b233-38c966735fb6",
+ "creator_attributes": {},
+ "publish_attributes": {}
+ }
+]
\ No newline at end of file
diff --git a/openpype/hosts/testhost/api/pipeline.py b/openpype/hosts/testhost/api/pipeline.py
new file mode 100644
index 0000000000..49f1d3f33d
--- /dev/null
+++ b/openpype/hosts/testhost/api/pipeline.py
@@ -0,0 +1,156 @@
+import os
+import json
+
+
+class HostContext:
+ instances_json_path = None
+ context_json_path = None
+
+ @classmethod
+ def get_context_title(cls):
+ project_name = os.environ.get("AVALON_PROJECT")
+ if not project_name:
+ return "TestHost"
+
+ asset_name = os.environ.get("AVALON_ASSET")
+ if not asset_name:
+ return project_name
+
+ from avalon import io
+
+ asset_doc = io.find_one(
+ {"type": "asset", "name": asset_name},
+ {"data.parents": 1}
+ )
+ parents = asset_doc.get("data", {}).get("parents") or []
+
+ hierarchy = [project_name]
+ hierarchy.extend(parents)
+ hierarchy.append("{}".format(asset_name))
+ task_name = os.environ.get("AVALON_TASK")
+ if task_name:
+ hierarchy.append(task_name)
+
+ return "/".join(hierarchy)
+
+ @classmethod
+ def get_current_dir_filepath(cls, filename):
+ return os.path.join(
+ os.path.dirname(os.path.abspath(__file__)),
+ filename
+ )
+
+ @classmethod
+ def get_instances_json_path(cls):
+ if cls.instances_json_path is None:
+ cls.instances_json_path = cls.get_current_dir_filepath(
+ "instances.json"
+ )
+ return cls.instances_json_path
+
+ @classmethod
+ def get_context_json_path(cls):
+ if cls.context_json_path is None:
+ cls.context_json_path = cls.get_current_dir_filepath(
+ "context.json"
+ )
+ return cls.context_json_path
+
+ @classmethod
+ def add_instance(cls, instance):
+ instances = cls.get_instances()
+ instances.append(instance)
+ cls.save_instances(instances)
+
+ @classmethod
+ def save_instances(cls, instances):
+ json_path = cls.get_instances_json_path()
+ with open(json_path, "w") as json_stream:
+ json.dump(instances, json_stream, indent=4)
+
+ @classmethod
+ def get_instances(cls):
+ json_path = cls.get_instances_json_path()
+ if not os.path.exists(json_path):
+ instances = []
+ with open(json_path, "w") as json_stream:
+ json.dump(json_stream, instances)
+ else:
+ with open(json_path, "r") as json_stream:
+ instances = json.load(json_stream)
+ return instances
+
+ @classmethod
+ def get_context_data(cls):
+ json_path = cls.get_context_json_path()
+ if not os.path.exists(json_path):
+ data = {}
+ with open(json_path, "w") as json_stream:
+ json.dump(data, json_stream)
+ else:
+ with open(json_path, "r") as json_stream:
+ data = json.load(json_stream)
+ return data
+
+ @classmethod
+ def save_context_data(cls, data):
+ json_path = cls.get_context_json_path()
+ with open(json_path, "w") as json_stream:
+ json.dump(data, json_stream, indent=4)
+
+
+def ls():
+ return []
+
+
+def list_instances():
+ return HostContext.get_instances()
+
+
+def update_instances(update_list):
+ updated_instances = {}
+ for instance, _changes in update_list:
+ updated_instances[instance.id] = instance.data_to_store()
+
+ instances = HostContext.get_instances()
+ for instance_data in instances:
+ instance_id = instance_data["uuid"]
+ if instance_id in updated_instances:
+ new_instance_data = updated_instances[instance_id]
+ old_keys = set(instance_data.keys())
+ new_keys = set(new_instance_data.keys())
+ instance_data.update(new_instance_data)
+ for key in (old_keys - new_keys):
+ instance_data.pop(key)
+
+ HostContext.save_instances(instances)
+
+
+def remove_instances(instances):
+ if not isinstance(instances, (tuple, list)):
+ instances = [instances]
+
+ current_instances = HostContext.get_instances()
+ for instance in instances:
+ instance_id = instance.data["uuid"]
+ found_idx = None
+ for idx, _instance in enumerate(current_instances):
+ if instance_id == _instance["uuid"]:
+ found_idx = idx
+ break
+
+ if found_idx is not None:
+ current_instances.pop(found_idx)
+ HostContext.save_instances(current_instances)
+
+
+def get_context_data():
+ return HostContext.get_context_data()
+
+
+def update_context_data(data, changes):
+ HostContext.save_context_data(data)
+
+
+def get_context_title():
+ return HostContext.get_context_title()
diff --git a/openpype/hosts/testhost/plugins/create/auto_creator.py b/openpype/hosts/testhost/plugins/create/auto_creator.py
new file mode 100644
index 0000000000..0690164ae5
--- /dev/null
+++ b/openpype/hosts/testhost/plugins/create/auto_creator.py
@@ -0,0 +1,74 @@
+from openpype.hosts.testhost.api import pipeline
+from openpype.pipeline import (
+ AutoCreator,
+ CreatedInstance,
+ lib
+)
+from avalon import io
+
+
+class MyAutoCreator(AutoCreator):
+ identifier = "workfile"
+ family = "workfile"
+
+ def get_attribute_defs(self):
+ output = [
+ lib.NumberDef("number_key", label="Number")
+ ]
+ return output
+
+ def collect_instances(self):
+ for instance_data in pipeline.list_instances():
+ creator_id = instance_data.get("creator_identifier")
+ if creator_id == self.identifier:
+ subset_name = instance_data["subset"]
+ instance = CreatedInstance(
+ self.family, subset_name, instance_data, self
+ )
+ self._add_instance_to_context(instance)
+
+ def update_instances(self, update_list):
+ pipeline.update_instances(update_list)
+
+ def create(self, options=None):
+ existing_instance = None
+ for instance in self.create_context.instances:
+ if instance.family == self.family:
+ existing_instance = instance
+ break
+
+ variant = "Main"
+ project_name = io.Session["AVALON_PROJECT"]
+ asset_name = io.Session["AVALON_ASSET"]
+ task_name = io.Session["AVALON_TASK"]
+ host_name = io.Session["AVALON_APP"]
+
+ if existing_instance is None:
+ asset_doc = io.find_one({"type": "asset", "name": asset_name})
+ subset_name = self.get_subset_name(
+ variant, task_name, asset_doc, project_name, host_name
+ )
+ data = {
+ "asset": asset_name,
+ "task": task_name,
+ "variant": variant
+ }
+ data.update(self.get_dynamic_data(
+ variant, task_name, asset_doc, project_name, host_name
+ ))
+
+ new_instance = CreatedInstance(
+ self.family, subset_name, data, self
+ )
+ self._add_instance_to_context(new_instance)
+
+ elif (
+ existing_instance["asset"] != asset_name
+ or existing_instance["task"] != task_name
+ ):
+ asset_doc = io.find_one({"type": "asset", "name": asset_name})
+ subset_name = self.get_subset_name(
+ variant, task_name, asset_doc, project_name, host_name
+ )
+ existing_instance["asset"] = asset_name
+ existing_instance["task"] = task_name
diff --git a/openpype/hosts/testhost/plugins/create/test_creator_1.py b/openpype/hosts/testhost/plugins/create/test_creator_1.py
new file mode 100644
index 0000000000..6ec4d16467
--- /dev/null
+++ b/openpype/hosts/testhost/plugins/create/test_creator_1.py
@@ -0,0 +1,70 @@
+from openpype import resources
+from openpype.hosts.testhost.api import pipeline
+from openpype.pipeline import (
+ Creator,
+ CreatedInstance,
+ lib
+)
+
+
+class TestCreatorOne(Creator):
+ identifier = "test_one"
+ label = "test"
+ family = "test"
+ description = "Testing creator of testhost"
+
+ def get_icon(self):
+ return resources.get_openpype_splash_filepath()
+
+ def collect_instances(self):
+ for instance_data in pipeline.list_instances():
+ creator_id = instance_data.get("creator_identifier")
+ if creator_id == self.identifier:
+ instance = CreatedInstance.from_existing(
+ instance_data, self
+ )
+ self._add_instance_to_context(instance)
+
+ def update_instances(self, update_list):
+ pipeline.update_instances(update_list)
+
+ def remove_instances(self, instances):
+ pipeline.remove_instances(instances)
+ for instance in instances:
+ self._remove_instance_from_context(instance)
+
+ def create(self, subset_name, data, options=None):
+ new_instance = CreatedInstance(self.family, subset_name, data, self)
+ pipeline.HostContext.add_instance(new_instance.data_to_store())
+ self.log.info(new_instance.data)
+ self._add_instance_to_context(new_instance)
+
+ def get_default_variants(self):
+ return [
+ "myVariant",
+ "variantTwo",
+ "different_variant"
+ ]
+
+ def get_attribute_defs(self):
+ output = [
+ lib.NumberDef("number_key", label="Number")
+ ]
+ return output
+
+ def get_detail_description(self):
+ return """# Relictus funes est Nyseides currusque nunc oblita
+
+## Causa sed
+
+Lorem markdownum posito consumptis, *plebe Amorque*, abstitimus rogatus fictaque
+gladium Circe, nos? Bos aeternum quae. Utque me, si aliquem cladis, et vestigia
+arbor, sic mea ferre lacrimae agantur prospiciens hactenus. Amanti dentes pete,
+vos quid laudemque rastrorumque terras in gratantibus **radix** erat cedemus?
+
+Pudor tu ponderibus verbaque illa; ire ergo iam Venus patris certe longae
+cruentum lecta, et quaeque. Sit doce nox. Anteit ad tempora magni plenaque et
+videres mersit sibique auctor in tendunt mittit cunctos ventisque gravitate
+volucris quemquam Aeneaden. Pectore Mensis somnus; pectora
+[ferunt](http://www.mox.org/oculosbracchia)? Fertilitatis bella dulce et suum?
+ """
diff --git a/openpype/hosts/testhost/plugins/create/test_creator_2.py b/openpype/hosts/testhost/plugins/create/test_creator_2.py
new file mode 100644
index 0000000000..4b1430a6a2
--- /dev/null
+++ b/openpype/hosts/testhost/plugins/create/test_creator_2.py
@@ -0,0 +1,74 @@
+from openpype.hosts.testhost.api import pipeline
+from openpype.pipeline import (
+ Creator,
+ CreatedInstance,
+ lib
+)
+
+
+class TestCreatorTwo(Creator):
+ identifier = "test_two"
+ label = "test"
+ family = "test"
+ description = "A second testing creator"
+
+ def get_icon(self):
+ return "cube"
+
+ def create(self, subset_name, data, options=None):
+ new_instance = CreatedInstance(self.family, subset_name, data, self)
+ pipeline.HostContext.add_instance(new_instance.data_to_store())
+ self.log.info(new_instance.data)
+ self._add_instance_to_context(new_instance)
+
+ def collect_instances(self):
+ for instance_data in pipeline.list_instances():
+ creator_id = instance_data.get("creator_identifier")
+ if creator_id == self.identifier:
+ instance = CreatedInstance.from_existing(
+ instance_data, self
+ )
+ self._add_instance_to_context(instance)
+
+ def update_instances(self, update_list):
+ pipeline.update_instances(update_list)
+
+ def remove_instances(self, instances):
+ pipeline.remove_instances(instances)
+ for instance in instances:
+ self._remove_instance_from_context(instance)
+
+ def get_attribute_defs(self):
+ output = [
+ lib.NumberDef("number_key"),
+ lib.TextDef("text_key")
+ ]
+ return output
+
+ def get_detail_description(self):
+ return """# Lorem ipsum, dolor sit amet. [](https://github.com/sindresorhus/awesome)
+
+> A curated list of awesome lorem ipsum generators.
+
+Inspired by the [awesome](https://github.com/sindresorhus/awesome) list thing.
+
+
+## Table of Contents
+
+- [Legend](#legend)
+- [Practical](#briefcase-practical)
+- [Whimsical](#roller_coaster-whimsical)
+ - [Animals](#rabbit-animals)
+ - [Eras](#tophat-eras)
+ - [Famous Individuals](#sunglasses-famous-individuals)
+ - [Music](#microphone-music)
+ - [Food and Drink](#pizza-food-and-drink)
+ - [Geographic and Dialects](#earth_africa-geographic-and-dialects)
+ - [Literature](#books-literature)
+ - [Miscellaneous](#cyclone-miscellaneous)
+ - [Sports and Fitness](#bicyclist-sports-and-fitness)
+ - [TV and Film](#movie_camera-tv-and-film)
+- [Tools, Apps, and Extensions](#wrench-tools-apps-and-extensions)
+- [Contribute](#contribute)
+- [TODO](#todo)
+"""
diff --git a/openpype/hosts/testhost/plugins/publish/collect_context.py b/openpype/hosts/testhost/plugins/publish/collect_context.py
new file mode 100644
index 0000000000..0ab98fb84b
--- /dev/null
+++ b/openpype/hosts/testhost/plugins/publish/collect_context.py
@@ -0,0 +1,34 @@
+import pyblish.api
+
+from openpype.pipeline import (
+ OpenPypePyblishPluginMixin,
+ attribute_definitions
+)
+
+
+class CollectContextDataTestHost(
+ pyblish.api.ContextPlugin, OpenPypePyblishPluginMixin
+):
+ """
+ Collecting temp json data sent from a host context
+ and path for returning json data back to hostself.
+ """
+
+ label = "Collect Source - Test Host"
+ order = pyblish.api.CollectorOrder - 0.4
+ hosts = ["testhost"]
+
+ @classmethod
+ def get_attribute_defs(cls):
+ return [
+ attribute_definitions.BoolDef(
+ "test_bool",
+ True,
+ label="Bool input"
+ )
+ ]
+
+ def process(self, context):
+ # get json paths from os and load them
+ for instance in context:
+ instance.data["source"] = "testhost"
diff --git a/openpype/hosts/testhost/plugins/publish/collect_instance_1.py b/openpype/hosts/testhost/plugins/publish/collect_instance_1.py
new file mode 100644
index 0000000000..3c035eccb6
--- /dev/null
+++ b/openpype/hosts/testhost/plugins/publish/collect_instance_1.py
@@ -0,0 +1,54 @@
+import json
+import pyblish.api
+
+from openpype.pipeline import (
+ OpenPypePyblishPluginMixin,
+ attribute_definitions
+)
+
+
+class CollectInstanceOneTestHost(
+ pyblish.api.InstancePlugin, OpenPypePyblishPluginMixin
+):
+ """
+ Collecting temp json data sent from a host context
+ and path for returning json data back to hostself.
+ """
+
+ label = "Collect Instance 1 - Test Host"
+ order = pyblish.api.CollectorOrder - 0.3
+ hosts = ["testhost"]
+
+ @classmethod
+ def get_attribute_defs(cls):
+ return [
+ attribute_definitions.NumberDef(
+ "version",
+ default=1,
+ minimum=1,
+ maximum=999,
+ decimals=0,
+ label="Version"
+ )
+ ]
+
+ def process(self, instance):
+ self._debug_log(instance)
+
+ publish_attributes = instance.data.get("publish_attributes")
+ if not publish_attributes:
+ return
+
+ values = publish_attributes.get(self.__class__.__name__)
+ if not values:
+ return
+
+ instance.data["version"] = values["version"]
+
+ def _debug_log(self, instance):
+ def _default_json(value):
+ return str(value)
+
+ self.log.info(
+ json.dumps(instance.data, indent=4, default=_default_json)
+ )
diff --git a/openpype/hosts/testhost/plugins/publish/validate_context_with_error.py b/openpype/hosts/testhost/plugins/publish/validate_context_with_error.py
new file mode 100644
index 0000000000..46e996a569
--- /dev/null
+++ b/openpype/hosts/testhost/plugins/publish/validate_context_with_error.py
@@ -0,0 +1,57 @@
+import pyblish.api
+from openpype.pipeline import PublishValidationError
+
+
+class ValidateInstanceAssetRepair(pyblish.api.Action):
+ """Repair the instance asset."""
+
+ label = "Repair"
+ icon = "wrench"
+ on = "failed"
+
+ def process(self, context, plugin):
+ pass
+
+
+description = """
+## Publish plugins
+
+### Validate Scene Settings
+
+#### Skip Resolution Check for Tasks
+
+Set regex pattern(s) to look for in a Task name to skip resolution check against values from DB.
+
+#### Skip Timeline Check for Tasks
+
+Set regex pattern(s) to look for in a Task name to skip `frameStart`, `frameEnd` check against values from DB.
+
+### AfterEffects Submit to Deadline
+
+* `Use Published scene` - Set to True (green) when Deadline should take published scene as a source instead of uploaded local one.
+* `Priority` - priority of job on farm
+* `Primary Pool` - here is list of pool fetched from server you can select from.
+* `Secondary Pool`
+* `Frames Per Task` - number of sequence division between individual tasks (chunks)
+making one job on farm.
+"""
+
+
+class ValidateContextWithError(pyblish.api.ContextPlugin):
+ """Validate the instance asset is the current selected context asset.
+
+ As it might happen that multiple worfiles are opened, switching
+ between them would mess with selected context.
+ In that case outputs might be output under wrong asset!
+
+ Repair action will use Context asset value (from Workfiles or Launcher)
+ Closing and reopening with Workfiles will refresh Context value.
+ """
+
+ label = "Validate Context With Error"
+ hosts = ["testhost"]
+ actions = [ValidateInstanceAssetRepair]
+ order = pyblish.api.ValidatorOrder
+
+ def process(self, context):
+ raise PublishValidationError("Crashing", "Context error", description)
diff --git a/openpype/hosts/testhost/plugins/publish/validate_with_error.py b/openpype/hosts/testhost/plugins/publish/validate_with_error.py
new file mode 100644
index 0000000000..5a2888a8b0
--- /dev/null
+++ b/openpype/hosts/testhost/plugins/publish/validate_with_error.py
@@ -0,0 +1,57 @@
+import pyblish.api
+from openpype.pipeline import PublishValidationError
+
+
+class ValidateInstanceAssetRepair(pyblish.api.Action):
+ """Repair the instance asset."""
+
+ label = "Repair"
+ icon = "wrench"
+ on = "failed"
+
+ def process(self, context, plugin):
+ pass
+
+
+description = """
+## Publish plugins
+
+### Validate Scene Settings
+
+#### Skip Resolution Check for Tasks
+
+Set regex pattern(s) to look for in a Task name to skip resolution check against values from DB.
+
+#### Skip Timeline Check for Tasks
+
+Set regex pattern(s) to look for in a Task name to skip `frameStart`, `frameEnd` check against values from DB.
+
+### AfterEffects Submit to Deadline
+
+* `Use Published scene` - Set to True (green) when Deadline should take published scene as a source instead of uploaded local one.
+* `Priority` - priority of job on farm
+* `Primary Pool` - here is list of pool fetched from server you can select from.
+* `Secondary Pool`
+* `Frames Per Task` - number of sequence division between individual tasks (chunks)
+making one job on farm.
+"""
+
+
+class ValidateWithError(pyblish.api.InstancePlugin):
+ """Validate the instance asset is the current selected context asset.
+
+ As it might happen that multiple worfiles are opened, switching
+ between them would mess with selected context.
+ In that case outputs might be output under wrong asset!
+
+ Repair action will use Context asset value (from Workfiles or Launcher)
+ Closing and reopening with Workfiles will refresh Context value.
+ """
+
+ label = "Validate With Error"
+ hosts = ["testhost"]
+ actions = [ValidateInstanceAssetRepair]
+ order = pyblish.api.ValidatorOrder
+
+ def process(self, instance):
+ raise PublishValidationError("Crashing", "Instance error", description)
diff --git a/openpype/hosts/testhost/run_publish.py b/openpype/hosts/testhost/run_publish.py
new file mode 100644
index 0000000000..44860a30e4
--- /dev/null
+++ b/openpype/hosts/testhost/run_publish.py
@@ -0,0 +1,70 @@
+import os
+import sys
+
+mongo_url = ""
+project_name = ""
+asset_name = ""
+task_name = ""
+ftrack_url = ""
+ftrack_username = ""
+ftrack_api_key = ""
+
+
+def multi_dirname(path, times=1):
+ for _ in range(times):
+ path = os.path.dirname(path)
+ return path
+
+
+host_name = "testhost"
+current_file = os.path.abspath(__file__)
+openpype_dir = multi_dirname(current_file, 4)
+
+os.environ["OPENPYPE_MONGO"] = mongo_url
+os.environ["OPENPYPE_ROOT"] = openpype_dir
+os.environ["AVALON_MONGO"] = mongo_url
+os.environ["AVALON_PROJECT"] = project_name
+os.environ["AVALON_ASSET"] = asset_name
+os.environ["AVALON_TASK"] = task_name
+os.environ["AVALON_APP"] = host_name
+os.environ["OPENPYPE_DATABASE_NAME"] = "openpype"
+os.environ["AVALON_CONFIG"] = "openpype"
+os.environ["AVALON_TIMEOUT"] = "1000"
+os.environ["AVALON_DB"] = "avalon"
+os.environ["FTRACK_SERVER"] = ftrack_url
+os.environ["FTRACK_API_USER"] = ftrack_username
+os.environ["FTRACK_API_KEY"] = ftrack_api_key
+for path in [
+ openpype_dir,
+ r"{}\repos\avalon-core".format(openpype_dir),
+ r"{}\.venv\Lib\site-packages".format(openpype_dir)
+]:
+ sys.path.append(path)
+
+from Qt import QtWidgets, QtCore
+
+from openpype.tools.publisher.window import PublisherWindow
+
+
+def main():
+ """Main function for testing purposes."""
+ import avalon.api
+ import pyblish.api
+ from openpype.modules import ModulesManager
+ from openpype.hosts.testhost import api as testhost
+
+ manager = ModulesManager()
+ for plugin_path in manager.collect_plugin_paths()["publish"]:
+ pyblish.api.register_plugin_path(plugin_path)
+
+ avalon.api.install(testhost)
+
+ QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling)
+ app = QtWidgets.QApplication([])
+ window = PublisherWindow()
+ window.show()
+ app.exec_()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py
index dfa8f17ee9..1d7a48e389 100644
--- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py
+++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py
@@ -4,7 +4,7 @@ import copy
import pyblish.api
from avalon import io
-from openpype.lib import get_subset_name
+from openpype.lib import get_subset_name_with_asset_doc
class CollectInstances(pyblish.api.ContextPlugin):
@@ -70,16 +70,10 @@ class CollectInstances(pyblish.api.ContextPlugin):
# - not sure if it's good idea to require asset id in
# get_subset_name?
asset_name = context.data["workfile_context"]["asset"]
- asset_doc = io.find_one(
- {
- "type": "asset",
- "name": asset_name
- },
- {"_id": 1}
- )
- asset_id = None
- if asset_doc:
- asset_id = asset_doc["_id"]
+ asset_doc = io.find_one({
+ "type": "asset",
+ "name": asset_name
+ })
# Project name from workfile context
project_name = context.data["workfile_context"]["project"]
@@ -88,11 +82,11 @@ class CollectInstances(pyblish.api.ContextPlugin):
# Use empty variant value
variant = ""
task_name = io.Session["AVALON_TASK"]
- new_subset_name = get_subset_name(
+ new_subset_name = get_subset_name_with_asset_doc(
family,
variant,
task_name,
- asset_id,
+ asset_doc,
project_name,
host_name
)
diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py
index 65e38ea258..68ba350a85 100644
--- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py
+++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py
@@ -3,7 +3,7 @@ import json
import pyblish.api
from avalon import io
-from openpype.lib import get_subset_name
+from openpype.lib import get_subset_name_with_asset_doc
class CollectWorkfile(pyblish.api.ContextPlugin):
@@ -28,16 +28,10 @@ class CollectWorkfile(pyblish.api.ContextPlugin):
# get_subset_name?
family = "workfile"
asset_name = context.data["workfile_context"]["asset"]
- asset_doc = io.find_one(
- {
- "type": "asset",
- "name": asset_name
- },
- {"_id": 1}
- )
- asset_id = None
- if asset_doc:
- asset_id = asset_doc["_id"]
+ asset_doc = io.find_one({
+ "type": "asset",
+ "name": asset_name
+ })
# Project name from workfile context
project_name = context.data["workfile_context"]["project"]
@@ -46,11 +40,11 @@ class CollectWorkfile(pyblish.api.ContextPlugin):
# Use empty variant value
variant = ""
task_name = io.Session["AVALON_TASK"]
- subset_name = get_subset_name(
+ subset_name = get_subset_name_with_asset_doc(
family,
variant,
task_name,
- asset_id,
+ asset_doc,
project_name,
host_name
)
diff --git a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py
index 0014d1b344..920ed042dc 100644
--- a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py
+++ b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py
@@ -11,6 +11,7 @@ from avalon.api import AvalonMongoDB
from openpype.lib import OpenPypeMongoConnection
from openpype_modules.avalon_apps.rest_api import _RestApiEndpoint
+from openpype.lib.plugin_tools import parse_json
from openpype.lib import PypeLogger
@@ -175,6 +176,9 @@ class TaskNode(Node):
class WebpublisherBatchPublishEndpoint(_RestApiEndpoint):
"""Triggers headless publishing of batch."""
async def post(self, request) -> Response:
+ # for postprocessing in host, currently only PS
+ host_map = {"photoshop": [".psd", ".psb"]}
+
output = {}
log.info("WebpublisherBatchPublishEndpoint called")
content = await request.json()
@@ -182,10 +186,44 @@ class WebpublisherBatchPublishEndpoint(_RestApiEndpoint):
batch_path = os.path.join(self.resource.upload_dir,
content["batch"])
+ add_args = {
+ "host": "webpublisher",
+ "project": content["project_name"],
+ "user": content["user"]
+ }
+
+ command = "remotepublish"
+
+ if content.get("studio_processing"):
+ log.info("Post processing called")
+
+ batch_data = parse_json(os.path.join(batch_path, "manifest.json"))
+ if not batch_data:
+ raise ValueError(
+ "Cannot parse batch meta in {} folder".format(batch_path))
+ task_dir_name = batch_data["tasks"][0]
+ task_data = parse_json(os.path.join(batch_path, task_dir_name,
+ "manifest.json"))
+ if not task_data:
+ raise ValueError(
+ "Cannot parse batch meta in {} folder".format(task_data))
+
+ command = "remotepublishfromapp"
+ for host, extensions in host_map.items():
+ for ext in extensions:
+ for file_name in task_data["files"]:
+ if ext in file_name:
+ add_args["host"] = host
+ break
+
+ if not add_args.get("host"):
+ raise ValueError(
+ "Couldn't discern host from {}".format(task_data["files"]))
+
openpype_app = self.resource.executable
args = [
openpype_app,
- 'remotepublish',
+ command,
batch_path
]
@@ -193,12 +231,6 @@ class WebpublisherBatchPublishEndpoint(_RestApiEndpoint):
msg = "Non existent OpenPype executable {}".format(openpype_app)
raise RuntimeError(msg)
- add_args = {
- "host": "webpublisher",
- "project": content["project_name"],
- "user": content["user"]
- }
-
for key, value in add_args.items():
args.append("--{}".format(key))
args.append(value)
diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py
index 74004a1239..ee4821b80d 100644
--- a/openpype/lib/__init__.py
+++ b/openpype/lib/__init__.py
@@ -130,6 +130,7 @@ from .applications import (
from .plugin_tools import (
TaskNotSetError,
get_subset_name,
+ get_subset_name_with_asset_doc,
prepare_template_data,
filter_pyblish_plugins,
set_plugin_attributes_from_settings,
@@ -249,6 +250,7 @@ __all__ = [
"TaskNotSetError",
"get_subset_name",
+ "get_subset_name_with_asset_doc",
"filter_pyblish_plugins",
"set_plugin_attributes_from_settings",
"source_hash",
diff --git a/openpype/lib/delivery.py b/openpype/lib/delivery.py
index 5735cbc99d..c89e2e7ae0 100644
--- a/openpype/lib/delivery.py
+++ b/openpype/lib/delivery.py
@@ -245,6 +245,27 @@ def process_sequence(
report_items["Source file was not found"].append(msg)
return report_items, 0
+ delivery_templates = anatomy.templates.get("delivery") or {}
+ delivery_template = delivery_templates.get(template_name)
+ if delivery_template is None:
+ msg = (
+ "Delivery template \"{}\" in anatomy of project \"{}\""
+ " was not found"
+ ).format(template_name, anatomy.project_name)
+ report_items[""].append(msg)
+ return report_items, 0
+
+ # Check if 'frame' key is available in template which is required
+ # for sequence delivery
+ if "{frame" not in delivery_template:
+ msg = (
+ "Delivery template \"{}\" in anatomy of project \"{}\""
+ "does not contain '{{frame}}' key to fill. Delivery of sequence"
+ " can't be processed."
+ ).format(template_name, anatomy.project_name)
+ report_items[""].append(msg)
+ return report_items, 0
+
dir_path, file_name = os.path.split(str(src_path))
context = repre["context"]
diff --git a/openpype/lib/plugin_tools.py b/openpype/lib/plugin_tools.py
index 47e6641731..aa9e0c9b57 100644
--- a/openpype/lib/plugin_tools.py
+++ b/openpype/lib/plugin_tools.py
@@ -28,17 +28,44 @@ class TaskNotSetError(KeyError):
super(TaskNotSetError, self).__init__(msg)
-def get_subset_name(
+def get_subset_name_with_asset_doc(
family,
variant,
task_name,
- asset_id,
+ asset_doc,
project_name=None,
host_name=None,
default_template=None,
- dynamic_data=None,
- dbcon=None
+ dynamic_data=None
):
+ """Calculate subset name based on passed context and OpenPype settings.
+
+ Subst name templates are defined in `project_settings/global/tools/creator
+ /subset_name_profiles` where are profiles with host name, family, task name
+ and task type filters. If context does not match any profile then
+ `DEFAULT_SUBSET_TEMPLATE` is used as default template.
+
+ That's main reason why so many arguments are required to calculate subset
+ name.
+
+ Args:
+ family (str): Instance family.
+ variant (str): In most of cases it is user input during creation.
+ task_name (str): Task name on which context is instance created.
+ asset_doc (dict): Queried asset document with it's tasks in data.
+ Used to get task type.
+ project_name (str): Name of project on which is instance created.
+ Important for project settings that are loaded.
+ host_name (str): One of filtering criteria for template profile
+ filters.
+ default_template (str): Default template if any profile does not match
+ passed context. Constant 'DEFAULT_SUBSET_TEMPLATE' is used if
+ is not passed.
+ dynamic_data (dict): Dynamic data specific for a creator which creates
+ instance.
+ dbcon (AvalonMongoDB): Mongo connection to be able query asset document
+ if 'asset_doc' is not passed.
+ """
if not family:
return ""
@@ -53,25 +80,6 @@ def get_subset_name(
project_name = avalon.api.Session["AVALON_PROJECT"]
- # Function should expect asset document instead of asset id
- # - that way `dbcon` is not needed
- if dbcon is None:
- from avalon.api import AvalonMongoDB
-
- dbcon = AvalonMongoDB()
- dbcon.Session["AVALON_PROJECT"] = project_name
-
- dbcon.install()
-
- asset_doc = dbcon.find_one(
- {
- "type": "asset",
- "_id": asset_id
- },
- {
- "data.tasks": True
- }
- )
asset_tasks = asset_doc.get("data", {}).get("tasks") or {}
task_info = asset_tasks.get(task_name) or {}
task_type = task_info.get("type")
@@ -113,6 +121,49 @@ def get_subset_name(
return template.format(**prepare_template_data(fill_pairs))
+def get_subset_name(
+ family,
+ variant,
+ task_name,
+ asset_id,
+ project_name=None,
+ host_name=None,
+ default_template=None,
+ dynamic_data=None,
+ dbcon=None
+):
+ """Calculate subset name using OpenPype settings.
+
+ This variant of function expects asset id as argument.
+
+ This is legacy function should be replaced with
+ `get_subset_name_with_asset_doc` where asset document is expected.
+ """
+ if dbcon is None:
+ from avalon.api import AvalonMongoDB
+
+ dbcon = AvalonMongoDB()
+ dbcon.Session["AVALON_PROJECT"] = project_name
+
+ dbcon.install()
+
+ asset_doc = dbcon.find_one(
+ {"_id": asset_id},
+ {"data.tasks": True}
+ ) or {}
+
+ return get_subset_name_with_asset_doc(
+ family,
+ variant,
+ task_name,
+ asset_doc,
+ project_name,
+ host_name,
+ default_template,
+ dynamic_data
+ )
+
+
def prepare_template_data(fill_pairs):
"""
Prepares formatted data for filling template.
diff --git a/openpype/lib/python_2_comp.py b/openpype/lib/python_2_comp.py
new file mode 100644
index 0000000000..d7137dbe9c
--- /dev/null
+++ b/openpype/lib/python_2_comp.py
@@ -0,0 +1,41 @@
+import weakref
+
+
+class _weak_callable:
+ def __init__(self, obj, func):
+ self.im_self = obj
+ self.im_func = func
+
+ def __call__(self, *args, **kws):
+ if self.im_self is None:
+ return self.im_func(*args, **kws)
+ else:
+ return self.im_func(self.im_self, *args, **kws)
+
+
+class WeakMethod:
+ """ Wraps a function or, more importantly, a bound method in
+ a way that allows a bound method's object to be GCed, while
+ providing the same interface as a normal weak reference. """
+
+ def __init__(self, fn):
+ try:
+ self._obj = weakref.ref(fn.im_self)
+ self._meth = fn.im_func
+ except AttributeError:
+ # It's not a bound method
+ self._obj = None
+ self._meth = fn
+
+ def __call__(self):
+ if self._dead():
+ return None
+ return _weak_callable(self._getobj(), self._meth)
+
+ def _dead(self):
+ return self._obj is not None and self._obj() is None
+
+ def _getobj(self):
+ if self._obj is None:
+ return None
+ return self._obj()
diff --git a/openpype/lib/remote_publish.py b/openpype/lib/remote_publish.py
index 4946e1bd53..51007cfad2 100644
--- a/openpype/lib/remote_publish.py
+++ b/openpype/lib/remote_publish.py
@@ -100,6 +100,55 @@ def publish_and_log(dbcon, _id, log, close_plugin_name=None):
)
+def fail_batch(_id, batches_in_progress, dbcon):
+ """Set current batch as failed as there are some stuck batches."""
+ running_batches = [str(batch["_id"])
+ for batch in batches_in_progress
+ if batch["_id"] != _id]
+ msg = "There are still running batches {}\n". \
+ format("\n".join(running_batches))
+ msg += "Ask admin to check them and reprocess current batch"
+ dbcon.update_one(
+ {"_id": _id},
+ {"$set":
+ {
+ "finish_date": datetime.now(),
+ "status": "error",
+ "log": msg
+
+ }}
+ )
+ raise ValueError(msg)
+
+
+def find_variant_key(application_manager, host):
+ """Searches for latest installed variant for 'host'
+
+ Args:
+ application_manager (ApplicationManager)
+ host (str)
+ Returns
+ (string) (optional)
+ Raises:
+ (ValueError) if no variant found
+ """
+ app_group = application_manager.app_groups.get(host)
+ if not app_group or not app_group.enabled:
+ raise ValueError("No application {} configured".format(host))
+
+ found_variant_key = None
+ # finds most up-to-date variant if any installed
+ for variant_key, variant in app_group.variants.items():
+ for executable in variant.executables:
+ if executable.exists():
+ found_variant_key = variant_key
+
+ if not found_variant_key:
+ raise ValueError("No executable for {} found".format(host))
+
+ return found_variant_key
+
+
def _get_close_plugin(close_plugin_name, log):
if close_plugin_name:
plugins = pyblish.api.discover()
diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_to_avalon.py
index 93a0404c0b..178dfc74c7 100644
--- a/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_to_avalon.py
+++ b/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_to_avalon.py
@@ -1,8 +1,6 @@
-import os
import collections
import copy
import json
-import queue
import time
import datetime
import atexit
@@ -193,7 +191,9 @@ class SyncToAvalonEvent(BaseEvent):
self._avalon_ents_by_ftrack_id = {}
proj, ents = self.avalon_entities
if proj:
- ftrack_id = proj["data"]["ftrackId"]
+ ftrack_id = proj["data"].get("ftrackId")
+ if ftrack_id is None:
+ ftrack_id = self._update_project_ftrack_id()
self._avalon_ents_by_ftrack_id[ftrack_id] = proj
for ent in ents:
ftrack_id = ent["data"].get("ftrackId")
@@ -202,6 +202,16 @@ class SyncToAvalonEvent(BaseEvent):
self._avalon_ents_by_ftrack_id[ftrack_id] = ent
return self._avalon_ents_by_ftrack_id
+ def _update_project_ftrack_id(self):
+ ftrack_id = self.cur_project["id"]
+
+ self.dbcon.update_one(
+ {"type": "project"},
+ {"$set": {"data.ftrackId": ftrack_id}}
+ )
+
+ return ftrack_id
+
@property
def avalon_subsets_by_parents(self):
if self._avalon_subsets_by_parents is None:
@@ -340,13 +350,13 @@ class SyncToAvalonEvent(BaseEvent):
self._avalon_archived_by_id[mongo_id] = entity
def _bubble_changeability(self, unchangeable_ids):
- unchangeable_queue = queue.Queue()
+ unchangeable_queue = collections.deque()
for entity_id in unchangeable_ids:
- unchangeable_queue.put((entity_id, False))
+ unchangeable_queue.append((entity_id, False))
processed_parents_ids = []
- while not unchangeable_queue.empty():
- entity_id, child_is_archived = unchangeable_queue.get()
+ while unchangeable_queue:
+ entity_id, child_is_archived = unchangeable_queue.popleft()
# skip if already processed
if entity_id in processed_parents_ids:
continue
@@ -388,7 +398,7 @@ class SyncToAvalonEvent(BaseEvent):
parent_id = entity["data"]["visualParent"]
if parent_id is None:
continue
- unchangeable_queue.put((parent_id, child_is_archived))
+ unchangeable_queue.append((parent_id, child_is_archived))
def reset_variables(self):
"""Reset variables so each event callback has clear env."""
@@ -1050,7 +1060,7 @@ class SyncToAvalonEvent(BaseEvent):
key=(lambda entity: len(entity["link"]))
)
- children_queue = queue.Queue()
+ children_queue = collections.deque()
for entity in synchronizable_ents:
parent_avalon_ent = self.avalon_ents_by_ftrack_id[
entity["parent_id"]
@@ -1060,10 +1070,10 @@ class SyncToAvalonEvent(BaseEvent):
for child in entity["children"]:
if child.entity_type.lower() == "task":
continue
- children_queue.put(child)
+ children_queue.append(child)
- while not children_queue.empty():
- entity = children_queue.get()
+ while children_queue:
+ entity = children_queue.popleft()
ftrack_id = entity["id"]
name = entity["name"]
ent_by_ftrack_id = self.avalon_ents_by_ftrack_id.get(ftrack_id)
@@ -1093,7 +1103,7 @@ class SyncToAvalonEvent(BaseEvent):
for child in entity["children"]:
if child.entity_type.lower() == "task":
continue
- children_queue.put(child)
+ children_queue.append(child)
def create_entity_in_avalon(self, ftrack_ent, parent_avalon):
proj, ents = self.avalon_entities
@@ -1278,7 +1288,7 @@ class SyncToAvalonEvent(BaseEvent):
"Processing renamed entities: {}".format(str(ent_infos))
)
- changeable_queue = queue.Queue()
+ changeable_queue = collections.deque()
for ftrack_id, ent_info in ent_infos.items():
entity_type = ent_info["entity_type"]
if entity_type == "Task":
@@ -1306,7 +1316,7 @@ class SyncToAvalonEvent(BaseEvent):
mongo_id = avalon_ent["_id"]
if self.changeability_by_mongo_id[mongo_id]:
- changeable_queue.put((ftrack_id, avalon_ent, new_name))
+ changeable_queue.append((ftrack_id, avalon_ent, new_name))
else:
ftrack_ent = self.ftrack_ents_by_id[ftrack_id]
ftrack_ent["name"] = avalon_ent["name"]
@@ -1348,8 +1358,8 @@ class SyncToAvalonEvent(BaseEvent):
old_names = []
# Process renaming in Avalon DB
- while not changeable_queue.empty():
- ftrack_id, avalon_ent, new_name = changeable_queue.get()
+ while changeable_queue:
+ ftrack_id, avalon_ent, new_name = changeable_queue.popleft()
mongo_id = avalon_ent["_id"]
old_name = avalon_ent["name"]
@@ -1390,13 +1400,13 @@ class SyncToAvalonEvent(BaseEvent):
# - it's name may be changed in next iteration
same_name_ftrack_id = same_name_avalon_ent["data"]["ftrackId"]
same_is_unprocessed = False
- for item in list(changeable_queue.queue):
+ for item in changeable_queue:
if same_name_ftrack_id == item[0]:
same_is_unprocessed = True
break
if same_is_unprocessed:
- changeable_queue.put((ftrack_id, avalon_ent, new_name))
+ changeable_queue.append((ftrack_id, avalon_ent, new_name))
continue
self.duplicated.append(ftrack_id)
@@ -2008,12 +2018,12 @@ class SyncToAvalonEvent(BaseEvent):
# ftrack_parenting = collections.defaultdict(list)
entities_dict = collections.defaultdict(dict)
- children_queue = queue.Queue()
- parent_queue = queue.Queue()
+ children_queue = collections.deque()
+ parent_queue = collections.deque()
for mongo_id in hier_cust_attrs_ids:
avalon_ent = self.avalon_ents_by_id[mongo_id]
- parent_queue.put(avalon_ent)
+ parent_queue.append(avalon_ent)
ftrack_id = avalon_ent["data"]["ftrackId"]
if ftrack_id not in entities_dict:
entities_dict[ftrack_id] = {
@@ -2040,10 +2050,10 @@ class SyncToAvalonEvent(BaseEvent):
entities_dict[_ftrack_id]["parent_id"] = ftrack_id
if _ftrack_id not in entities_dict[ftrack_id]["children"]:
entities_dict[ftrack_id]["children"].append(_ftrack_id)
- children_queue.put(children_ent)
+ children_queue.append(children_ent)
- while not children_queue.empty():
- avalon_ent = children_queue.get()
+ while children_queue:
+ avalon_ent = children_queue.popleft()
mongo_id = avalon_ent["_id"]
ftrack_id = avalon_ent["data"]["ftrackId"]
if ftrack_id in cust_attrs_ftrack_ids:
@@ -2066,10 +2076,10 @@ class SyncToAvalonEvent(BaseEvent):
entities_dict[_ftrack_id]["parent_id"] = ftrack_id
if _ftrack_id not in entities_dict[ftrack_id]["children"]:
entities_dict[ftrack_id]["children"].append(_ftrack_id)
- children_queue.put(children_ent)
+ children_queue.append(children_ent)
- while not parent_queue.empty():
- avalon_ent = parent_queue.get()
+ while parent_queue:
+ avalon_ent = parent_queue.popleft()
if avalon_ent["type"].lower() == "project":
continue
@@ -2100,7 +2110,7 @@ class SyncToAvalonEvent(BaseEvent):
# if ftrack_id not in ftrack_parenting[parent_ftrack_id]:
# ftrack_parenting[parent_ftrack_id].append(ftrack_id)
- parent_queue.put(parent_ent)
+ parent_queue.append(parent_ent)
# Prepare values to query
configuration_ids = set()
@@ -2174,11 +2184,13 @@ class SyncToAvalonEvent(BaseEvent):
if value is not None:
project_values[key] = value
- hier_down_queue = queue.Queue()
- hier_down_queue.put((project_values, ftrack_project_id))
+ hier_down_queue = collections.deque()
+ hier_down_queue.append(
+ (project_values, ftrack_project_id)
+ )
- while not hier_down_queue.empty():
- hier_values, parent_id = hier_down_queue.get()
+ while hier_down_queue:
+ hier_values, parent_id = hier_down_queue.popleft()
for child_id in entities_dict[parent_id]["children"]:
_hier_values = hier_values.copy()
for name in hier_cust_attrs_keys:
@@ -2187,7 +2199,7 @@ class SyncToAvalonEvent(BaseEvent):
_hier_values[name] = value
entities_dict[child_id]["hier_attrs"].update(_hier_values)
- hier_down_queue.put((_hier_values, child_id))
+ hier_down_queue.append((_hier_values, child_id))
ftrack_mongo_mapping = {}
for mongo_id, ftrack_id in mongo_ftrack_mapping.items():
@@ -2302,11 +2314,12 @@ class SyncToAvalonEvent(BaseEvent):
"""
mongo_changes_bulk = []
for mongo_id, changes in self.updates.items():
- filter = {"_id": mongo_id}
avalon_ent = self.avalon_ents_by_id[mongo_id]
is_project = avalon_ent["type"] == "project"
change_data = avalon_sync.from_dict_to_set(changes, is_project)
- mongo_changes_bulk.append(UpdateOne(filter, change_data))
+ mongo_changes_bulk.append(
+ UpdateOne({"_id": mongo_id}, change_data)
+ )
if not mongo_changes_bulk:
return
diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py
index f860065b26..d3cc0ad971 100644
--- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py
+++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py
@@ -1,7 +1,6 @@
import collections
import uuid
from datetime import datetime
-from queue import Queue
from bson.objectid import ObjectId
from openpype_modules.ftrack.lib import BaseAction, statics_icon
@@ -473,12 +472,12 @@ class DeleteAssetSubset(BaseAction):
continue
ftrack_ids_to_delete.append(ftrack_id)
- children_queue = Queue()
+ children_queue = collections.deque()
for mongo_id in assets_to_delete:
- children_queue.put(mongo_id)
+ children_queue.append(mongo_id)
- while not children_queue.empty():
- mongo_id = children_queue.get()
+ while children_queue:
+ mongo_id = children_queue.popleft()
if mongo_id in asset_ids_to_archive:
continue
@@ -494,7 +493,7 @@ class DeleteAssetSubset(BaseAction):
for child in children:
child_id = child["_id"]
if child_id not in asset_ids_to_archive:
- children_queue.put(child_id)
+ children_queue.append(child_id)
# Prepare names of assets in ftrack and ids of subsets in mongo
asset_names_to_delete = []
diff --git a/openpype/modules/default_modules/ftrack/lib/avalon_sync.py b/openpype/modules/default_modules/ftrack/lib/avalon_sync.py
index 2458308af5..1667031f29 100644
--- a/openpype/modules/default_modules/ftrack/lib/avalon_sync.py
+++ b/openpype/modules/default_modules/ftrack/lib/avalon_sync.py
@@ -6,11 +6,6 @@ import copy
import six
-if six.PY3:
- from queue import Queue
-else:
- from Queue import Queue
-
from avalon.api import AvalonMongoDB
import avalon
@@ -146,11 +141,11 @@ def from_dict_to_set(data, is_project):
data.pop("data")
result = {"$set": {}}
- dict_queue = Queue()
- dict_queue.put((None, data))
+ dict_queue = collections.deque()
+ dict_queue.append((None, data))
- while not dict_queue.empty():
- _key, _data = dict_queue.get()
+ while dict_queue:
+ _key, _data = dict_queue.popleft()
for key, value in _data.items():
new_key = key
if _key is not None:
@@ -160,7 +155,7 @@ def from_dict_to_set(data, is_project):
(isinstance(value, dict) and not bool(value)): # empty dic
result["$set"][new_key] = value
continue
- dict_queue.put((new_key, value))
+ dict_queue.append((new_key, value))
if task_changes is not not_set and task_changes_key:
result["$set"][task_changes_key] = task_changes
@@ -714,7 +709,7 @@ class SyncEntitiesFactory:
self.filter_by_duplicate_regex()
def filter_by_duplicate_regex(self):
- filter_queue = Queue()
+ filter_queue = collections.deque()
failed_regex_msg = "{} - Entity has invalid symbols in the name"
duplicate_msg = "There are multiple entities with the name: \"{}\":"
@@ -722,18 +717,18 @@ class SyncEntitiesFactory:
for id in ids:
ent_path = self.get_ent_path(id)
self.log.warning(failed_regex_msg.format(ent_path))
- filter_queue.put(id)
+ filter_queue.append(id)
for name, ids in self.duplicates.items():
self.log.warning(duplicate_msg.format(name))
for id in ids:
ent_path = self.get_ent_path(id)
self.log.warning(ent_path)
- filter_queue.put(id)
+ filter_queue.append(id)
filtered_ids = []
- while not filter_queue.empty():
- ftrack_id = filter_queue.get()
+ while filter_queue:
+ ftrack_id = filter_queue.popleft()
if ftrack_id in filtered_ids:
continue
@@ -749,7 +744,7 @@ class SyncEntitiesFactory:
filtered_ids.append(ftrack_id)
for child_id in entity_dict.get("children", []):
- filter_queue.put(child_id)
+ filter_queue.append(child_id)
for name, ids in self.tasks_failed_regex.items():
for id in ids:
@@ -768,10 +763,10 @@ class SyncEntitiesFactory:
) == "_notset_":
return
- self.filter_queue = Queue()
- self.filter_queue.put((self.ft_project_id, False))
- while not self.filter_queue.empty():
- parent_id, remove = self.filter_queue.get()
+ filter_queue = collections.deque()
+ filter_queue.append((self.ft_project_id, False))
+ while filter_queue:
+ parent_id, remove = filter_queue.popleft()
if remove:
parent_dict = self.entities_dict.pop(parent_id, {})
self.all_filtered_entities[parent_id] = parent_dict
@@ -790,7 +785,7 @@ class SyncEntitiesFactory:
child_id
)
_remove = True
- self.filter_queue.put((child_id, _remove))
+ filter_queue.append((child_id, _remove))
def filter_by_selection(self, event):
# BUGGY!!!! cause that entities are in deleted list
@@ -805,47 +800,51 @@ class SyncEntitiesFactory:
selected_ids.append(entity["entityId"])
sync_ids = [self.ft_project_id]
- parents_queue = Queue()
- children_queue = Queue()
- for id in selected_ids:
+ parents_queue = collections.deque()
+ children_queue = collections.deque()
+ for selected_id in selected_ids:
# skip if already filtered with ignore sync custom attribute
- if id in self.filtered_ids:
+ if selected_id in self.filtered_ids:
continue
- parents_queue.put(id)
- children_queue.put(id)
+ parents_queue.append(selected_id)
+ children_queue.append(selected_id)
- while not parents_queue.empty():
- id = parents_queue.get()
+ while parents_queue:
+ ftrack_id = parents_queue.popleft()
while True:
# Stops when parent is in sync_ids
- if id in self.filtered_ids or id in sync_ids or id is None:
+ if (
+ ftrack_id in self.filtered_ids
+ or ftrack_id in sync_ids
+ or ftrack_id is None
+ ):
break
- sync_ids.append(id)
- id = self.entities_dict[id]["parent_id"]
+ sync_ids.append(ftrack_id)
+ ftrack_id = self.entities_dict[ftrack_id]["parent_id"]
- while not children_queue.empty():
- parent_id = children_queue.get()
+ while children_queue:
+ parent_id = children_queue.popleft()
for child_id in self.entities_dict[parent_id]["children"]:
if child_id in sync_ids or child_id in self.filtered_ids:
continue
sync_ids.append(child_id)
- children_queue.put(child_id)
+ children_queue.append(child_id)
# separate not selected and to process entities
for key, value in self.entities_dict.items():
if key not in sync_ids:
self.not_selected_ids.append(key)
- for id in self.not_selected_ids:
+ for ftrack_id in self.not_selected_ids:
# pop from entities
- value = self.entities_dict.pop(id)
+ value = self.entities_dict.pop(ftrack_id)
# remove entity from parent's children
parent_id = value["parent_id"]
if parent_id not in sync_ids:
continue
- self.entities_dict[parent_id]["children"].remove(id)
+ self.entities_dict[parent_id]["children"].remove(ftrack_id)
def _query_custom_attributes(self, session, conf_ids, entity_ids):
output = []
@@ -1117,11 +1116,11 @@ class SyncEntitiesFactory:
if value is not None:
project_values[key] = value
- hier_down_queue = Queue()
- hier_down_queue.put((project_values, top_id))
+ hier_down_queue = collections.deque()
+ hier_down_queue.append((project_values, top_id))
- while not hier_down_queue.empty():
- hier_values, parent_id = hier_down_queue.get()
+ while hier_down_queue:
+ hier_values, parent_id = hier_down_queue.popleft()
for child_id in self.entities_dict[parent_id]["children"]:
_hier_values = copy.deepcopy(hier_values)
for key in attributes_by_key.keys():
@@ -1134,7 +1133,7 @@ class SyncEntitiesFactory:
_hier_values[key] = value
self.entities_dict[child_id]["hier_attrs"].update(_hier_values)
- hier_down_queue.put((_hier_values, child_id))
+ hier_down_queue.append((_hier_values, child_id))
def remove_from_archived(self, mongo_id):
entity = self.avalon_archived_by_id.pop(mongo_id, None)
@@ -1303,15 +1302,15 @@ class SyncEntitiesFactory:
create_ftrack_ids.append(self.ft_project_id)
# make it go hierarchically
- prepare_queue = Queue()
+ prepare_queue = collections.deque()
for child_id in self.entities_dict[self.ft_project_id]["children"]:
- prepare_queue.put(child_id)
+ prepare_queue.append(child_id)
- while not prepare_queue.empty():
- ftrack_id = prepare_queue.get()
+ while prepare_queue:
+ ftrack_id = prepare_queue.popleft()
for child_id in self.entities_dict[ftrack_id]["children"]:
- prepare_queue.put(child_id)
+ prepare_queue.append(child_id)
entity_dict = self.entities_dict[ftrack_id]
ent_path = self.get_ent_path(ftrack_id)
@@ -1426,25 +1425,25 @@ class SyncEntitiesFactory:
parent_id = ent_dict["parent_id"]
self.entities_dict[parent_id]["children"].remove(ftrack_id)
- children_queue = Queue()
- children_queue.put(ftrack_id)
- while not children_queue.empty():
- _ftrack_id = children_queue.get()
+ children_queue = collections.deque()
+ children_queue.append(ftrack_id)
+ while children_queue:
+ _ftrack_id = children_queue.popleft()
entity_dict = self.entities_dict.pop(_ftrack_id, {"children": []})
for child_id in entity_dict["children"]:
- children_queue.put(child_id)
+ children_queue.append(child_id)
def prepare_changes(self):
self.log.debug("* Preparing changes for avalon/ftrack")
hierarchy_changing_ids = []
ignore_keys = collections.defaultdict(list)
- update_queue = Queue()
+ update_queue = collections.deque()
for ftrack_id in self.update_ftrack_ids:
- update_queue.put(ftrack_id)
+ update_queue.append(ftrack_id)
- while not update_queue.empty():
- ftrack_id = update_queue.get()
+ while update_queue:
+ ftrack_id = update_queue.popleft()
if ftrack_id == self.ft_project_id:
changes = self.prepare_project_changes()
if changes:
@@ -1720,7 +1719,7 @@ class SyncEntitiesFactory:
new_entity_id = self.create_ftrack_ent_from_avalon_ent(
av_entity, parent_id
)
- update_queue.put(new_entity_id)
+ update_queue.append(new_entity_id)
if new_entity_id:
ftrack_ent_dict["entity"]["parent_id"] = new_entity_id
@@ -2024,14 +2023,14 @@ class SyncEntitiesFactory:
entity["custom_attributes"][CUST_ATTR_ID_KEY] = str(new_id)
def _bubble_changeability(self, unchangeable_ids):
- unchangeable_queue = Queue()
+ unchangeable_queue = collections.deque()
for entity_id in unchangeable_ids:
- unchangeable_queue.put((entity_id, False))
+ unchangeable_queue.append((entity_id, False))
processed_parents_ids = []
subsets_to_remove = []
- while not unchangeable_queue.empty():
- entity_id, child_is_archived = unchangeable_queue.get()
+ while unchangeable_queue:
+ entity_id, child_is_archived = unchangeable_queue.popleft()
# skip if already processed
if entity_id in processed_parents_ids:
continue
@@ -2067,7 +2066,9 @@ class SyncEntitiesFactory:
parent_id = entity["data"]["visualParent"]
if parent_id is None:
continue
- unchangeable_queue.put((str(parent_id), child_is_archived))
+ unchangeable_queue.append(
+ (str(parent_id), child_is_archived)
+ )
self._delete_subsets_without_asset(subsets_to_remove)
@@ -2150,16 +2151,18 @@ class SyncEntitiesFactory:
self.dbcon.bulk_write(mongo_changes_bulk)
def reload_parents(self, hierarchy_changing_ids):
- parents_queue = Queue()
- parents_queue.put((self.ft_project_id, [], False))
- while not parents_queue.empty():
- ftrack_id, parent_parents, changed = parents_queue.get()
+ parents_queue = collections.deque()
+ parents_queue.append((self.ft_project_id, [], False))
+ while parents_queue:
+ ftrack_id, parent_parents, changed = parents_queue.popleft()
_parents = copy.deepcopy(parent_parents)
if ftrack_id not in hierarchy_changing_ids and not changed:
if ftrack_id != self.ft_project_id:
_parents.append(self.entities_dict[ftrack_id]["name"])
for child_id in self.entities_dict[ftrack_id]["children"]:
- parents_queue.put((child_id, _parents, changed))
+ parents_queue.append(
+ (child_id, _parents, changed)
+ )
continue
changed = True
@@ -2170,7 +2173,9 @@ class SyncEntitiesFactory:
_parents.append(self.entities_dict[ftrack_id]["name"])
for child_id in self.entities_dict[ftrack_id]["children"]:
- parents_queue.put((child_id, _parents, changed))
+ parents_queue.append(
+ (child_id, _parents, changed)
+ )
if ftrack_id in self.create_ftrack_ids:
mongo_id = self.ftrack_avalon_mapper[ftrack_id]
diff --git a/openpype/modules/default_modules/sync_server/providers/abstract_provider.py b/openpype/modules/default_modules/sync_server/providers/abstract_provider.py
index 8ae0ceed79..688a17f14f 100644
--- a/openpype/modules/default_modules/sync_server/providers/abstract_provider.py
+++ b/openpype/modules/default_modules/sync_server/providers/abstract_provider.py
@@ -201,5 +201,9 @@ class AbstractProvider:
msg = "Error in resolving local root from anatomy"
log.error(msg)
raise ValueError(msg)
+ except IndexError:
+ msg = "Path {} contains unfillable placeholder"
+ log.error(msg)
+ raise ValueError(msg)
return path
diff --git a/openpype/modules/default_modules/sync_server/resources/refresh.png b/openpype/modules/default_modules/sync_server/resources/refresh.png
new file mode 100644
index 0000000000..5ddd181fe6
Binary files /dev/null and b/openpype/modules/default_modules/sync_server/resources/refresh.png differ
diff --git a/openpype/modules/default_modules/sync_server/sync_server.py b/openpype/modules/default_modules/sync_server/sync_server.py
index 48df5aad1b..66d4e46db7 100644
--- a/openpype/modules/default_modules/sync_server/sync_server.py
+++ b/openpype/modules/default_modules/sync_server/sync_server.py
@@ -422,6 +422,12 @@ class SyncServerThread(threading.Thread):
periodically.
"""
while self.is_running:
+ if self.module.long_running_tasks:
+ task = self.module.long_running_tasks.pop()
+ log.info("starting long running")
+ await self.loop.run_in_executor(None, task["func"])
+ log.info("finished long running")
+ self.module.projects_processed.remove(task["project_name"])
await asyncio.sleep(0.5)
tasks = [task for task in asyncio.all_tasks() if
task is not asyncio.current_task()]
diff --git a/openpype/modules/default_modules/sync_server/sync_server_module.py b/openpype/modules/default_modules/sync_server/sync_server_module.py
index 281491eedf..243162a905 100644
--- a/openpype/modules/default_modules/sync_server/sync_server_module.py
+++ b/openpype/modules/default_modules/sync_server/sync_server_module.py
@@ -4,6 +4,7 @@ from datetime import datetime
import threading
import platform
import copy
+from collections import deque
from avalon.api import AvalonMongoDB
@@ -120,6 +121,11 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
self._connection = None
+ # list of long blocking tasks
+ self.long_running_tasks = deque()
+ # projects that long tasks are running on
+ self.projects_processed = set()
+
""" Start of Public API """
def add_site(self, collection, representation_id, site_name=None,
force=False):
@@ -197,6 +203,105 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
for repre in representations:
self.remove_site(collection, repre.get("_id"), site_name, True)
+ def create_validate_project_task(self, collection, site_name):
+ """Adds metadata about project files validation on a queue.
+
+ This process will loop through all representation and check if
+ their files actually exist on an active site.
+
+ This might be useful for edge cases when artists is switching
+ between sites, remote site is actually physically mounted and
+ active site has same file urls etc.
+
+ Task will run on a asyncio loop, shouldn't be blocking.
+ """
+ task = {
+ "type": "validate",
+ "project_name": collection,
+ "func": lambda: self.validate_project(collection, site_name)
+ }
+ self.projects_processed.add(collection)
+ self.long_running_tasks.append(task)
+
+ def validate_project(self, collection, site_name, remove_missing=False):
+ """
+ Validate 'collection' of 'site_name' and its local files
+
+ If file present and not marked with a 'site_name' in DB, DB is
+ updated with site name and file modified date.
+
+ Args:
+ module (SyncServerModule)
+ collection (string): project name
+ site_name (string): active site name
+ remove_missing (bool): if True remove sites in DB if missing
+ physically
+ """
+ self.log.debug("Validation of {} for {} started".format(collection,
+ site_name))
+ query = {
+ "type": "representation"
+ }
+
+ representations = list(
+ self.connection.database[collection].find(query))
+ if not representations:
+ self.log.debug("No repre found")
+ return
+
+ sites_added = 0
+ sites_removed = 0
+ for repre in representations:
+ repre_id = repre["_id"]
+ for repre_file in repre.get("files", []):
+ try:
+ has_site = site_name in [site["name"]
+ for site in repre_file["sites"]]
+ except TypeError:
+ self.log.debug("Structure error in {}".format(repre_id))
+ continue
+
+ if has_site and not remove_missing:
+ continue
+
+ file_path = repre_file.get("path", "")
+ local_file_path = self.get_local_file_path(collection,
+ site_name,
+ file_path)
+
+ if local_file_path and os.path.exists(local_file_path):
+ self.log.debug("Adding site {} for {}".format(site_name,
+ repre_id))
+ if not has_site:
+ query = {
+ "_id": repre_id
+ }
+ created_dt = datetime.fromtimestamp(
+ os.path.getmtime(local_file_path))
+ elem = {"name": site_name,
+ "created_dt": created_dt}
+ self._add_site(collection, query, [repre], elem,
+ site_name=site_name,
+ file_id=repre_file["_id"])
+ sites_added += 1
+ else:
+ if has_site and remove_missing:
+ self.log.debug("Removing site {} for {}".
+ format(site_name, repre_id))
+ self.reset_provider_for_file(collection,
+ repre_id,
+ file_id=repre_file["_id"],
+ remove=True)
+ sites_removed += 1
+
+ if sites_added % 100 == 0:
+ self.log.debug("Sites added {}".format(sites_added))
+
+ self.log.debug("Validation of {} for {} ended".format(collection,
+ site_name))
+ self.log.info("Sites added {}, sites removed {}".format(sites_added,
+ sites_removed))
+
def pause_representation(self, collection, representation_id, site_name):
"""
Sets 'representation_id' as paused, eg. no syncing should be
@@ -719,19 +824,8 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
self.lock = threading.Lock()
- try:
- self.sync_server_thread = SyncServerThread(self)
+ self.sync_server_thread = SyncServerThread(self)
- except ValueError:
- log.info("No system setting for sync. Not syncing.", exc_info=True)
- self.enabled = False
- except KeyError:
- log.info((
- "There are not set presets for SyncServer OR "
- "Credentials provided are invalid, "
- "no syncing possible").
- format(str(self.sync_project_settings)), exc_info=True)
- self.enabled = False
def tray_start(self):
"""
@@ -1359,7 +1453,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
found = False
for repre_file in representation.pop().get("files"):
for site in repre_file.get("sites"):
- if site["name"] == site_name:
+ if site.get("name") == site_name:
found = True
break
if not found:
@@ -1410,13 +1504,20 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
self._update_site(collection, query, update, arr_filter)
def _add_site(self, collection, query, representation, elem, site_name,
- force=False):
+ force=False, file_id=None):
"""
Adds 'site_name' to 'representation' on 'collection'
+ Args:
+ representation (list of 1 dict)
+ file_id (ObjectId)
+
Use 'force' to remove existing or raises ValueError
"""
for repre_file in representation.pop().get("files"):
+ if file_id and file_id != repre_file["_id"]:
+ continue
+
for site in repre_file.get("sites"):
if site["name"] == site_name:
if force:
@@ -1429,11 +1530,19 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
log.info(msg)
raise ValueError(msg)
- update = {
- "$push": {"files.$[].sites": elem}
- }
+ if not file_id:
+ update = {
+ "$push": {"files.$[].sites": elem}
+ }
- arr_filter = []
+ arr_filter = []
+ else:
+ update = {
+ "$push": {"files.$[f].sites": elem}
+ }
+ arr_filter = [
+ {'f._id': file_id}
+ ]
self._update_site(collection, query, update, arr_filter)
@@ -1508,7 +1617,24 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
return int(ld)
def show_widget(self):
- """Show dialog to enter credentials"""
+ """Show dialog for Sync Queue"""
+ no_errors = False
+ try:
+ from .tray.app import SyncServerWindow
+ self.widget = SyncServerWindow(self)
+ no_errors = True
+ except ValueError:
+ log.info("No system setting for sync. Not syncing.", exc_info=True)
+ except KeyError:
+ log.info((
+ "There are not set presets for SyncServer OR "
+ "Credentials provided are invalid, "
+ "no syncing possible").
+ format(str(self.sync_project_settings)), exc_info=True)
+ except:
+ log.error("Uncaught exception durin start of SyncServer",
+ exc_info=True)
+ self.enabled = no_errors
self.widget.show()
def _get_success_dict(self, new_file_id):
diff --git a/openpype/modules/default_modules/sync_server/tray/models.py b/openpype/modules/default_modules/sync_server/tray/models.py
index 5642c5b34a..713e167a6a 100644
--- a/openpype/modules/default_modules/sync_server/tray/models.py
+++ b/openpype/modules/default_modules/sync_server/tray/models.py
@@ -124,7 +124,8 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel):
if not representations:
self.query = self.get_query(load_records)
- representations = self.dbcon.aggregate(self.query)
+ representations = self.dbcon.aggregate(pipeline=self.query,
+ allowDiskUse=True)
self.add_page_records(self.active_site, self.remote_site,
representations)
@@ -159,7 +160,8 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel):
items_to_fetch = min(self._total_records - self._rec_loaded,
self.PAGE_SIZE)
self.query = self.get_query(self._rec_loaded)
- representations = self.dbcon.aggregate(self.query)
+ representations = self.dbcon.aggregate(pipeline=self.query,
+ allowDiskUse=True)
self.beginInsertRows(index,
self._rec_loaded,
self._rec_loaded + items_to_fetch - 1)
@@ -192,16 +194,16 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel):
else:
order = -1
- backup_sort = dict(self.sort)
+ backup_sort = dict(self.sort_criteria)
- self.sort = {self.SORT_BY_COLUMN[index]: order} # reset
+ self.sort_criteria = {self.SORT_BY_COLUMN[index]: order} # reset
# add last one
for key, val in backup_sort.items():
if key != '_id' and key != self.SORT_BY_COLUMN[index]:
- self.sort[key] = val
+ self.sort_criteria[key] = val
break
# add default one
- self.sort['_id'] = 1
+ self.sort_criteria['_id'] = 1
self.query = self.get_query()
# import json
@@ -209,7 +211,8 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel):
# replace('False', 'false').\
# replace('True', 'true').replace('None', 'null'))
- representations = self.dbcon.aggregate(self.query)
+ representations = self.dbcon.aggregate(pipeline=self.query,
+ allowDiskUse=True)
self.refresh(representations)
def set_word_filter(self, word_filter):
@@ -440,12 +443,13 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
self.active_site = self.sync_server.get_active_site(self.project)
self.remote_site = self.sync_server.get_remote_site(self.project)
- self.sort = self.DEFAULT_SORT
+ self.sort_criteria = self.DEFAULT_SORT
self.query = self.get_query()
self.default_query = list(self.get_query())
- representations = self.dbcon.aggregate(self.query)
+ representations = self.dbcon.aggregate(pipeline=self.query,
+ allowDiskUse=True)
self.refresh(representations)
self.timer = QtCore.QTimer()
@@ -732,7 +736,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
)
aggr.extend(
- [{"$sort": self.sort},
+ [{"$sort": self.sort_criteria},
{
'$facet': {
'paginatedResults': [{'$skip': self._rec_loaded},
@@ -970,10 +974,11 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel):
self.active_site = self.sync_server.get_active_site(self.project)
self.remote_site = self.sync_server.get_remote_site(self.project)
- self.sort = self.DEFAULT_SORT
+ self.sort_criteria = self.DEFAULT_SORT
self.query = self.get_query()
- representations = self.dbcon.aggregate(self.query)
+ representations = self.dbcon.aggregate(pipeline=self.query,
+ allowDiskUse=True)
self.refresh(representations)
self.timer = QtCore.QTimer()
@@ -1235,7 +1240,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel):
print(self.column_filtering)
aggr.extend([
- {"$sort": self.sort},
+ {"$sort": self.sort_criteria},
{
'$facet': {
'paginatedResults': [{'$skip': self._rec_loaded},
diff --git a/openpype/modules/default_modules/sync_server/tray/widgets.py b/openpype/modules/default_modules/sync_server/tray/widgets.py
index b401411db5..01cc0d46d2 100644
--- a/openpype/modules/default_modules/sync_server/tray/widgets.py
+++ b/openpype/modules/default_modules/sync_server/tray/widgets.py
@@ -32,6 +32,8 @@ class SyncProjectListWidget(QtWidgets.QWidget):
project_changed = QtCore.Signal()
message_generated = QtCore.Signal(str)
+ refresh_msec = 10000
+
def __init__(self, sync_server, parent):
super(SyncProjectListWidget, self).__init__(parent)
self.setObjectName("ProjectListWidget")
@@ -56,8 +58,8 @@ class SyncProjectListWidget(QtWidgets.QWidget):
layout.addWidget(project_list, 1)
project_list.customContextMenuRequested.connect(self._on_context_menu)
- project_list.selectionModel().currentChanged.connect(
- self._on_index_change
+ project_list.selectionModel().selectionChanged.connect(
+ self._on_selection_changed
)
self.project_model = project_model
@@ -69,17 +71,43 @@ class SyncProjectListWidget(QtWidgets.QWidget):
self.remote_site = None
self.icons = {}
- def _on_index_change(self, new_idx, _old_idx):
- project_name = new_idx.data(QtCore.Qt.DisplayRole)
+ self._selection_changed = False
+ self._model_reset = False
+ timer = QtCore.QTimer()
+ timer.setInterval(self.refresh_msec)
+ timer.timeout.connect(self.refresh)
+ timer.start()
+
+ self.timer = timer
+
+ def _on_selection_changed(self, new_selection, _old_selection):
+ # block involuntary selection changes
+ if self._selection_changed or self._model_reset:
+ return
+
+ indexes = new_selection.indexes()
+ if not indexes:
+ return
+
+ project_name = indexes[0].data(QtCore.Qt.DisplayRole)
+
+ if self.current_project == project_name:
+ return
+ self._selection_changed = True
self.current_project = project_name
self.project_changed.emit()
+ self.refresh()
+ self._selection_changed = False
def refresh(self):
+ selected_index = None
model = self.project_model
+ self._model_reset = True
model.clear()
+ self._model_reset = False
- project_name = None
+ selected_item = None
for project_name in self.sync_server.sync_project_settings.\
keys():
if self.sync_server.is_paused() or \
@@ -88,20 +116,38 @@ class SyncProjectListWidget(QtWidgets.QWidget):
else:
icon = self._get_icon("synced")
- model.appendRow(QtGui.QStandardItem(icon, project_name))
+ if project_name in self.sync_server.projects_processed:
+ icon = self._get_icon("refresh")
+
+ item = QtGui.QStandardItem(icon, project_name)
+ model.appendRow(item)
+
+ if self.current_project == project_name:
+ selected_item = item
+
+ if selected_item:
+ selected_index = model.indexFromItem(selected_item)
if len(self.sync_server.sync_project_settings.keys()) == 0:
model.appendRow(QtGui.QStandardItem(lib.DUMMY_PROJECT))
- self.current_project = self.project_list.currentIndex().data(
- QtCore.Qt.DisplayRole
- )
if not self.current_project:
self.current_project = model.item(0).data(QtCore.Qt.DisplayRole)
- if project_name:
- self.local_site = self.sync_server.get_active_site(project_name)
- self.remote_site = self.sync_server.get_remote_site(project_name)
+ self.project_model = model
+
+ if selected_index and \
+ selected_index.isValid() and \
+ not self._selection_changed:
+ mode = QtCore.QItemSelectionModel.Select | \
+ QtCore.QItemSelectionModel.Rows
+ self.project_list.selectionModel().select(selected_index, mode)
+
+ if self.current_project:
+ self.local_site = self.sync_server.get_active_site(
+ self.current_project)
+ self.remote_site = self.sync_server.get_remote_site(
+ self.current_project)
def _can_edit(self):
"""Returns true if some site is user local site, eg. could edit"""
@@ -143,6 +189,11 @@ class SyncProjectListWidget(QtWidgets.QWidget):
actions_mapping[action] = self._clear_project
menu.addAction(action)
+ if self.project_name not in self.sync_server.projects_processed:
+ action = QtWidgets.QAction("Validate files on active site")
+ actions_mapping[action] = self._validate_site
+ menu.addAction(action)
+
result = menu.exec_(QtGui.QCursor.pos())
if result:
to_run = actions_mapping[result]
@@ -167,6 +218,13 @@ class SyncProjectListWidget(QtWidgets.QWidget):
self.project_name = None
self.refresh()
+ def _validate_site(self):
+ if self.project_name:
+ self.sync_server.create_validate_project_task(self.project_name,
+ self.local_site)
+ self.project_name = None
+ self.refresh()
+
class _SyncRepresentationWidget(QtWidgets.QWidget):
"""
diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py
new file mode 100644
index 0000000000..e968df4011
--- /dev/null
+++ b/openpype/pipeline/__init__.py
@@ -0,0 +1,28 @@
+from .lib import attribute_definitions
+
+from .create import (
+ BaseCreator,
+ Creator,
+ AutoCreator,
+ CreatedInstance
+)
+
+from .publish import (
+ PublishValidationError,
+ KnownPublishError,
+ OpenPypePyblishPluginMixin
+)
+
+
+__all__ = (
+ "attribute_definitions",
+
+ "BaseCreator",
+ "Creator",
+ "AutoCreator",
+ "CreatedInstance",
+
+ "PublishValidationError",
+ "KnownPublishError",
+ "OpenPypePyblishPluginMixin"
+)
diff --git a/openpype/pipeline/create/README.md b/openpype/pipeline/create/README.md
new file mode 100644
index 0000000000..9eef7c72a7
--- /dev/null
+++ b/openpype/pipeline/create/README.md
@@ -0,0 +1,78 @@
+# Create
+Creation is process defying what and how will be published. May work in a different way based on host implementation.
+
+## CreateContext
+Entry point of creation. All data and metadata are handled through create context. Context hold all global data and instances. Is responsible for loading of plugins (create, publish), triggering creator methods, validation of host implementation and emitting changes to creators and host.
+
+Discovers Creator plugins to be able create new instances and convert existing instances. Creators may have defined attributes that are specific for their instances. Attributes definition can enhance behavior of instance during publishing.
+
+Publish plugins are loaded because they can also define attributes definitions. These are less family specific To be able define attributes Publish plugin must inherit from `OpenPypePyblishPluginMixin` and must override `get_attribute_defs` class method which must return list of attribute definitions. Values of publish plugin definitions are stored per plugin name under `publish_attributes`. Also can override `convert_attribute_values` class method which gives ability to modify values on instance before are used in CreatedInstance. Method `convert_attribute_values` can be also used without `get_attribute_defs` to modify values when changing compatibility (remove metadata from instance because are irrelevant).
+
+Possible attribute definitions can be found in `openpype/pipeline/lib/attribute_definitions.py`.
+
+Except creating and removing instances are all changes not automatically propagated to host context (scene/workfile/...) to propagate changes call `save_changes` which trigger update of all instances in context using Creators implementation.
+
+
+## CreatedInstance
+Product of creation is "instance" which holds basic data defying it. Core data are `creator_identifier`, `family` and `subset`. Other data can be keys used to fill subset name or metadata modifying publishing process of the instance (more described later). All instances have `id` which holds constant `pyblish.avalon.instance` and `uuid` which is identifier of the instance.
+Family tells how should be instance processed and subset what name will published item have.
+- There are cases when subset is not fully filled during creation and may change during publishing. That is in most of cases caused because instance is related to other instance or instance data do not represent final product.
+
+`CreatedInstance` is entity holding the data which are stored and used.
+
+```python
+{
+ # Immutable data after creation
+ ## Identifier that this data represents instance for publishing (automatically assigned)
+ "id": "pyblish.avalon.instance",
+ ## Identifier of this specific instance (automatically assigned)
+ "uuid": ,
+ ## Instance family (used from Creator)
+ "family": ,
+
+ # Mutable data
+ ## Subset name based on subset name template - may change overtime (on context change)
+ "subset": ,
+ ## Instance is active and will be published
+ "active": True,
+ ## Version of instance
+ "version": 1,
+ # Identifier of creator (is unique)
+ "creator_identifier": "",
+ ## Creator specific attributes (defined by Creator)
+ "creator_attributes": {...},
+ ## Publish plugin specific plugins (defined by Publish plugin)
+ "publish_attributes": {
+ # Attribute values are stored by publish plugin name
+ # - Duplicated plugin names can cause clashes!
+ : {...},
+ ...
+ },
+ ## Additional data related to instance (`asset`, `task`, etc.)
+ ...
+}
+```
+
+## Creator
+To be able create, update, remove or collect existing instances there must be defined a creator. Creator must have unique identifier and can represents a family. There can be multiple Creators for single family. Identifier of creator should contain family (advise).
+
+Creator has abstract methods to handle instances. For new instance creation is used `create` which should create metadata in host context and add new instance object to `CreateContext`. To collect existing instances is used `collect_instances` which should find all existing instances related to creator and add them to `CreateContext`. To update data of instance is used `update_instances` which is called from `CreateContext` on `save_changes`. To remove instance use `remove_instances` which should remove metadata from host context and remove instance from `CreateContext`.
+
+Creator has access to `CreateContext` which created object of the creator. All new instances or removed instances must be told to context. To do so use methods `_add_instance_to_context` and `_remove_instance_from_context` where `CreatedInstance` is passed. They should be called from `create` if new instance was created and from `remove_instances` if instance was removed.
+
+Creators don't have strictly defined how are instances handled but it is good practice to define a way which is host specific. It is not strict because there are cases when host implementation just can't handle all requirements of all creators.
+
+### AutoCreator
+Auto-creators are automatically executed when `CreateContext` is reset. They can be used to create instances that should be always available and may not require artist's manual creation (e.g. `workfile`). Should not create duplicated instance and validate existence before creates a new. Method `remove_instances` is implemented to do nothing.
+
+## Host
+Host implementation must have available global context metadata handler functions. One to get current context data and second to update them. Currently are to context data stored only context publish plugin attribute values.
+
+### Get global context data (`get_context_data`)
+There are data that are not specific for any instance but are specific for whole context (e.g. Context plugins values).
+
+### Update global context data (`update_context_data`)
+Update global context data.
+
+### Optional title of context
+It is recommended to implement `get_context_title` function. String returned from this function will be shown in UI as context in which artist is.
diff --git a/openpype/pipeline/create/__init__.py b/openpype/pipeline/create/__init__.py
new file mode 100644
index 0000000000..610ef6d8e2
--- /dev/null
+++ b/openpype/pipeline/create/__init__.py
@@ -0,0 +1,24 @@
+from .creator_plugins import (
+ CreatorError,
+
+ BaseCreator,
+ Creator,
+ AutoCreator
+)
+
+from .context import (
+ CreatedInstance,
+ CreateContext
+)
+
+
+__all__ = (
+ "CreatorError",
+
+ "BaseCreator",
+ "Creator",
+ "AutoCreator",
+
+ "CreatedInstance",
+ "CreateContext"
+)
diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py
new file mode 100644
index 0000000000..7b0f50b1dc
--- /dev/null
+++ b/openpype/pipeline/create/context.py
@@ -0,0 +1,1142 @@
+import os
+import copy
+import logging
+import collections
+import inspect
+from uuid import uuid4
+from contextlib import contextmanager
+
+from ..lib import UnknownDef
+from .creator_plugins import (
+ BaseCreator,
+ Creator,
+ AutoCreator
+)
+
+from openpype.api import (
+ get_system_settings,
+ get_project_settings
+)
+
+
+class ImmutableKeyError(TypeError):
+ """Accessed key is immutable so does not allow changes or removements."""
+ def __init__(self, key, msg=None):
+ self.immutable_key = key
+ if not msg:
+ msg = "Key \"{}\" is immutable and does not allow changes.".format(
+ key
+ )
+ super(ImmutableKeyError, self).__init__(msg)
+
+
+class HostMissRequiredMethod(Exception):
+ """Host does not have implemented required functions for creation."""
+ def __init__(self, host, missing_methods):
+ self.missing_methods = missing_methods
+ self.host = host
+ joined_methods = ", ".join(
+ ['"{}"'.format(name) for name in missing_methods]
+ )
+ dirpath = os.path.dirname(
+ os.path.normpath(inspect.getsourcefile(host))
+ )
+ dirpath_parts = dirpath.split(os.path.sep)
+ host_name = dirpath_parts.pop(-1)
+ if host_name == "api":
+ host_name = dirpath_parts.pop(-1)
+
+ msg = "Host \"{}\" does not have implemented method/s {}".format(
+ host_name, joined_methods
+ )
+ super(HostMissRequiredMethod, self).__init__(msg)
+
+
+class InstanceMember:
+ """Representation of instance member.
+
+ TODO:
+ Implement and use!
+ """
+ def __init__(self, instance, name):
+ self.instance = instance
+
+ instance.add_members(self)
+
+ self.name = name
+ self._actions = []
+
+ def add_action(self, label, callback):
+ self._actions.append({
+ "label": label,
+ "callback": callback
+ })
+
+
+class AttributeValues:
+ """Container which keep values of Attribute definitions.
+
+ Goal is to have one object which hold values of attribute definitions for
+ single instance.
+
+ Has dictionary like methods. Not all of them are allowed all the time.
+
+ Args:
+ attr_defs(AbtractAttrDef): Defintions of value type and properties.
+ values(dict): Values after possible conversion.
+ origin_data(dict): Values loaded from host before conversion.
+ """
+ def __init__(self, attr_defs, values, origin_data=None):
+ if origin_data is None:
+ origin_data = copy.deepcopy(values)
+ self._origin_data = origin_data
+
+ attr_defs_by_key = {
+ attr_def.key: attr_def
+ for attr_def in attr_defs
+ }
+ for key, value in values.items():
+ if key not in attr_defs_by_key:
+ new_def = UnknownDef(key, label=key, default=value)
+ attr_defs.append(new_def)
+ attr_defs_by_key[key] = new_def
+
+ self._attr_defs = attr_defs
+ self._attr_defs_by_key = attr_defs_by_key
+
+ self._data = {}
+ for attr_def in attr_defs:
+ value = values.get(attr_def.key)
+ if value is not None:
+ self._data[attr_def.key] = value
+
+ def __setitem__(self, key, value):
+ if key not in self._attr_defs_by_key:
+ raise KeyError("Key \"{}\" was not found.".format(key))
+
+ old_value = self._data.get(key)
+ if old_value == value:
+ return
+ self._data[key] = value
+
+ def __getitem__(self, key):
+ if key not in self._attr_defs_by_key:
+ return self._data[key]
+ return self._data.get(key, self._attr_defs_by_key[key].default)
+
+ def __contains__(self, key):
+ return key in self._attr_defs_by_key
+
+ def get(self, key, default=None):
+ if key in self._attr_defs_by_key:
+ return self[key]
+ return default
+
+ def keys(self):
+ return self._attr_defs_by_key.keys()
+
+ def values(self):
+ for key in self._attr_defs_by_key.keys():
+ yield self._data.get(key)
+
+ def items(self):
+ for key in self._attr_defs_by_key.keys():
+ yield key, self._data.get(key)
+
+ def update(self, value):
+ for _key, _value in dict(value):
+ self[_key] = _value
+
+ def pop(self, key, default=None):
+ return self._data.pop(key, default)
+
+ def reset_values(self):
+ self._data = []
+
+ @property
+ def attr_defs(self):
+ """Pointer to attribute definitions."""
+ return self._attr_defs
+
+ def data_to_store(self):
+ """Create new dictionary with data to store."""
+ output = {}
+ for key in self._data:
+ output[key] = self[key]
+ return output
+
+ @staticmethod
+ def calculate_changes(new_data, old_data):
+ """Calculate changes of 2 dictionary objects."""
+ changes = {}
+ for key, new_value in new_data.items():
+ old_value = old_data.get(key)
+ if old_value != new_value:
+ changes[key] = (old_value, new_value)
+ return changes
+
+ def changes(self):
+ return self.calculate_changes(self._data, self._origin_data)
+
+
+class CreatorAttributeValues(AttributeValues):
+ """Creator specific attribute values of an instance.
+
+ Args:
+ instance (CreatedInstance): Instance for which are values hold.
+ """
+ def __init__(self, instance, *args, **kwargs):
+ self.instance = instance
+ super(CreatorAttributeValues, self).__init__(*args, **kwargs)
+
+
+class PublishAttributeValues(AttributeValues):
+ """Publish plugin specific attribute values.
+
+ Values are for single plugin which can be on `CreatedInstance`
+ or context values stored on `CreateContext`.
+
+ Args:
+ publish_attributes(PublishAttributes): Wrapper for multiple publish
+ attributes is used as parent object.
+ """
+ def __init__(self, publish_attributes, *args, **kwargs):
+ self.publish_attributes = publish_attributes
+ super(PublishAttributeValues, self).__init__(*args, **kwargs)
+
+ @property
+ def parent(self):
+ self.publish_attributes.parent
+
+
+class PublishAttributes:
+ """Wrapper for publish plugin attribute definitions.
+
+ Cares about handling attribute definitions of multiple publish plugins.
+
+ Args:
+ parent(CreatedInstance, CreateContext): Parent for which will be
+ data stored and from which are data loaded.
+ origin_data(dict): Loaded data by plugin class name.
+ attr_plugins(list): List of publish plugins that may have defined
+ attribute definitions.
+ """
+ def __init__(self, parent, origin_data, attr_plugins=None):
+ self.parent = parent
+ self._origin_data = copy.deepcopy(origin_data)
+
+ attr_plugins = attr_plugins or []
+ self.attr_plugins = attr_plugins
+
+ self._data = copy.deepcopy(origin_data)
+ self._plugin_names_order = []
+ self._missing_plugins = []
+
+ self.set_publish_plugins(attr_plugins)
+
+ def __getitem__(self, key):
+ return self._data[key]
+
+ def __contains__(self, key):
+ return key in self._data
+
+ def keys(self):
+ return self._data.keys()
+
+ def values(self):
+ return self._data.values()
+
+ def items(self):
+ return self._data.items()
+
+ def pop(self, key, default=None):
+ """Remove or reset value for plugin.
+
+ Plugin values are reset to defaults if plugin is available but
+ data of plugin which was not found are removed.
+
+ Args:
+ key(str): Plugin name.
+ default: Default value if plugin was not found.
+ """
+ if key not in self._data:
+ return default
+
+ if key in self._missing_plugins:
+ self._missing_plugins.remove(key)
+ removed_item = self._data.pop(key)
+ return removed_item.data_to_store()
+
+ value_item = self._data[key]
+ # Prepare value to return
+ output = value_item.data_to_store()
+ # Reset values
+ value_item.reset_values()
+ return output
+
+ def plugin_names_order(self):
+ """Plugin names order by their 'order' attribute."""
+ for name in self._plugin_names_order:
+ yield name
+
+ def data_to_store(self):
+ """Convert attribute values to "data to store"."""
+ output = {}
+ for key, attr_value in self._data.items():
+ output[key] = attr_value.data_to_store()
+ return output
+
+ def changes(self):
+ """Return changes per each key."""
+ changes = {}
+ for key, attr_val in self._data.items():
+ attr_changes = attr_val.changes()
+ if attr_changes:
+ if key not in changes:
+ changes[key] = {}
+ changes[key].update(attr_val)
+
+ for key, value in self._origin_data.items():
+ if key not in self._data:
+ changes[key] = (value, None)
+ return changes
+
+ def set_publish_plugins(self, attr_plugins):
+ """Set publish plugins attribute definitions."""
+ self._plugin_names_order = []
+ self._missing_plugins = []
+ self.attr_plugins = attr_plugins or []
+ if not attr_plugins:
+ return
+
+ origin_data = self._origin_data
+ data = self._data
+ self._data = {}
+ added_keys = set()
+ for plugin in attr_plugins:
+ output = plugin.convert_attribute_values(data)
+ if output is not None:
+ data = output
+ attr_defs = plugin.get_attribute_defs()
+ if not attr_defs:
+ continue
+
+ key = plugin.__name__
+ added_keys.add(key)
+ self._plugin_names_order.append(key)
+
+ value = data.get(key) or {}
+ orig_value = copy.deepcopy(origin_data.get(key) or {})
+ self._data[key] = PublishAttributeValues(
+ self, attr_defs, value, orig_value
+ )
+
+ for key, value in data.items():
+ if key not in added_keys:
+ self._missing_plugins.append(key)
+ self._data[key] = PublishAttributeValues(
+ self, [], value, value
+ )
+
+
+class CreatedInstance:
+ """Instance entity with data that will be stored to workfile.
+
+ I think `data` must be required argument containing all minimum information
+ about instance like "asset" and "task" and all data used for filling subset
+ name as creators may have custom data for subset name filling.
+
+ Args:
+ family(str): Name of family that will be created.
+ subset_name(str): Name of subset that will be created.
+ data(dict): Data used for filling subset name or override data from
+ already existing instance.
+ creator(BaseCreator): Creator responsible for instance.
+ host(ModuleType): Host implementation loaded with
+ `avalon.api.registered_host`.
+ new(bool): Is instance new.
+ """
+ # Keys that can't be changed or removed from data after loading using
+ # creator.
+ # - 'creator_attributes' and 'publish_attributes' can change values of
+ # their individual children but not on their own
+ __immutable_keys = (
+ "id",
+ "uuid",
+ "family",
+ "creator_identifier",
+ "creator_attributes",
+ "publish_attributes"
+ )
+
+ def __init__(
+ self, family, subset_name, data, creator, new=True
+ ):
+ self.creator = creator
+
+ # Instance members may have actions on them
+ self._members = []
+
+ # Create a copy of passed data to avoid changing them on the fly
+ data = copy.deepcopy(data or {})
+ # Store original value of passed data
+ self._orig_data = copy.deepcopy(data)
+
+ # Pop family and subset to prevent unexpected changes
+ data.pop("family", None)
+ data.pop("subset", None)
+
+ # Pop dictionary values that will be converted to objects to be able
+ # catch changes
+ orig_creator_attributes = data.pop("creator_attributes", None) or {}
+ orig_publish_attributes = data.pop("publish_attributes", None) or {}
+
+ # QUESTION Does it make sense to have data stored as ordered dict?
+ self._data = collections.OrderedDict()
+ # QUESTION Do we need this "id" information on instance?
+ self._data["id"] = "pyblish.avalon.instance"
+ self._data["family"] = family
+ self._data["subset"] = subset_name
+ self._data["active"] = data.get("active", True)
+ self._data["creator_identifier"] = creator.identifier
+
+ # QUESTION handle version of instance here or in creator?
+ version = None
+ if not new:
+ version = data.get("version")
+
+ if version is None:
+ version = 1
+ self._data["version"] = version
+
+ # Pop from source data all keys that are defined in `_data` before
+ # this moment and through their values away
+ # - they should be the same and if are not then should not change
+ # already set values
+ for key in self._data.keys():
+ if key in data:
+ data.pop(key)
+
+ # Stored creator specific attribute values
+ # {key: value}
+ creator_values = copy.deepcopy(orig_creator_attributes)
+ creator_attr_defs = creator.get_attribute_defs()
+
+ self._data["creator_attributes"] = CreatorAttributeValues(
+ self, creator_attr_defs, creator_values, orig_creator_attributes
+ )
+
+ # Stored publish specific attribute values
+ # {: {key: value}}
+ # - must be set using 'set_publish_plugins'
+ self._data["publish_attributes"] = PublishAttributes(
+ self, orig_publish_attributes, None
+ )
+ if data:
+ self._data.update(data)
+
+ if not self._data.get("uuid"):
+ self._data["uuid"] = str(uuid4())
+
+ self._asset_is_valid = self.has_set_asset
+ self._task_is_valid = self.has_set_task
+
+ def __str__(self):
+ return (
+ ""
+ " {data}"
+ ).format(
+ subset=str(self._data),
+ creator_identifier=self.creator_identifier,
+ family=self.family,
+ data=str(self._data)
+ )
+
+ # --- Dictionary like methods ---
+ def __getitem__(self, key):
+ return self._data[key]
+
+ def __contains__(self, key):
+ return key in self._data
+
+ def __setitem__(self, key, value):
+ # Validate immutable keys
+ if key not in self.__immutable_keys:
+ self._data[key] = value
+
+ elif value != self._data.get(key):
+ # Raise exception if key is immutable and value has changed
+ raise ImmutableKeyError(key)
+
+ def get(self, key, default=None):
+ return self._data.get(key, default)
+
+ def pop(self, key, *args, **kwargs):
+ # Raise exception if is trying to pop key which is immutable
+ if key in self.__immutable_keys:
+ raise ImmutableKeyError(key)
+
+ self._data.pop(key, *args, **kwargs)
+
+ def keys(self):
+ return self._data.keys()
+
+ def values(self):
+ return self._data.values()
+
+ def items(self):
+ return self._data.items()
+ # ------
+
+ @property
+ def family(self):
+ return self._data["family"]
+
+ @property
+ def subset_name(self):
+ return self._data["subset"]
+
+ @property
+ def creator_identifier(self):
+ return self.creator.identifier
+
+ @property
+ def creator_label(self):
+ return self.creator.label or self.creator_identifier
+
+ @property
+ def create_context(self):
+ return self.creator.create_context
+
+ @property
+ def host(self):
+ return self.create_context.host
+
+ @property
+ def has_set_asset(self):
+ """Asset name is set in data."""
+ return "asset" in self._data
+
+ @property
+ def has_set_task(self):
+ """Task name is set in data."""
+ return "task" in self._data
+
+ @property
+ def has_valid_context(self):
+ """Context data are valid for publishing."""
+ return self.has_valid_asset and self.has_valid_task
+
+ @property
+ def has_valid_asset(self):
+ """Asset set in context exists in project."""
+ if not self.has_set_asset:
+ return False
+ return self._asset_is_valid
+
+ @property
+ def has_valid_task(self):
+ """Task set in context exists in project."""
+ if not self.has_set_task:
+ return False
+ return self._task_is_valid
+
+ def set_asset_invalid(self, invalid):
+ # TODO replace with `set_asset_name`
+ self._asset_is_valid = not invalid
+
+ def set_task_invalid(self, invalid):
+ # TODO replace with `set_task_name`
+ self._task_is_valid = not invalid
+
+ @property
+ def id(self):
+ """Instance identifier."""
+ return self._data["uuid"]
+
+ @property
+ def data(self):
+ """Legacy access to data.
+
+ Access to data is needed to modify values.
+ """
+ return self
+
+ def changes(self):
+ """Calculate and return changes."""
+ changes = {}
+ new_keys = set()
+ for key, new_value in self._data.items():
+ new_keys.add(key)
+ if key in ("creator_attributes", "publish_attributes"):
+ continue
+
+ old_value = self._orig_data.get(key)
+ if old_value != new_value:
+ changes[key] = (old_value, new_value)
+
+ creator_attr_changes = self.creator_attributes.changes()
+ if creator_attr_changes:
+ changes["creator_attributes"] = creator_attr_changes
+
+ publish_attr_changes = self.publish_attributes.changes()
+ if publish_attr_changes:
+ changes["publish_attributes"] = publish_attr_changes
+
+ for key, old_value in self._orig_data.items():
+ if key not in new_keys:
+ changes[key] = (old_value, None)
+ return changes
+
+ @property
+ def creator_attributes(self):
+ return self._data["creator_attributes"]
+
+ @property
+ def creator_attribute_defs(self):
+ return self.creator_attributes.attr_defs
+
+ @property
+ def publish_attributes(self):
+ return self._data["publish_attributes"]
+
+ def data_to_store(self):
+ output = collections.OrderedDict()
+ for key, value in self._data.items():
+ if key in ("creator_attributes", "publish_attributes"):
+ continue
+ output[key] = value
+
+ output["creator_attributes"] = self.creator_attributes.data_to_store()
+ output["publish_attributes"] = self.publish_attributes.data_to_store()
+
+ return output
+
+ @classmethod
+ def from_existing(cls, instance_data, creator):
+ """Convert instance data from workfile to CreatedInstance."""
+ instance_data = copy.deepcopy(instance_data)
+
+ family = instance_data.get("family", None)
+ if family is None:
+ family = creator.family
+ subset_name = instance_data.get("subset", None)
+
+ return cls(
+ family, subset_name, instance_data, creator, new=False
+ )
+
+ def set_publish_plugins(self, attr_plugins):
+ self.publish_attributes.set_publish_plugins(attr_plugins)
+
+ def add_members(self, members):
+ """Currently unused method."""
+ for member in members:
+ if member not in self._members:
+ self._members.append(member)
+
+
+class CreateContext:
+ """Context of instance creation.
+
+ Context itself also can store data related to whole creation (workfile).
+ - those are mainly for Context publish plugins
+
+ Args:
+ host(ModuleType): Host implementation which handles implementation and
+ global metadata.
+ dbcon(AvalonMongoDB): Connection to mongo with context (at least
+ project).
+ headless(bool): Context is created out of UI (Current not used).
+ reset(bool): Reset context on initialization.
+ discover_publish_plugins(bool): Discover publish plugins during reset
+ phase.
+ """
+ # Methods required in host implementaion to be able create instances
+ # or change context data.
+ required_methods = (
+ "get_context_data",
+ "update_context_data"
+ )
+
+ def __init__(
+ self, host, dbcon=None, headless=False, reset=True,
+ discover_publish_plugins=True
+ ):
+ # Create conncetion if is not passed
+ if dbcon is None:
+ import avalon.api
+
+ session = avalon.api.session_data_from_environment(True)
+ dbcon = avalon.api.AvalonMongoDB(session)
+ dbcon.install()
+
+ self.dbcon = dbcon
+ self.host = host
+
+ # Prepare attribute for logger (Created on demand in `log` property)
+ self._log = None
+
+ # Publish context plugins attributes and it's values
+ self._publish_attributes = PublishAttributes(self, {})
+ self._original_context_data = {}
+
+ # Validate host implementation
+ # - defines if context is capable of handling context data
+ host_is_valid = True
+ missing_methods = self.get_host_misssing_methods(host)
+ if missing_methods:
+ host_is_valid = False
+ joined_methods = ", ".join(
+ ['"{}"'.format(name) for name in missing_methods]
+ )
+ self.log.warning((
+ "Host miss required methods to be able use creation."
+ " Missing methods: {}"
+ ).format(joined_methods))
+
+ self._host_is_valid = host_is_valid
+ # Currently unused variable
+ self.headless = headless
+
+ # Instances by their ID
+ self._instances_by_id = {}
+
+ # Discovered creators
+ self.creators = {}
+ # Prepare categories of creators
+ self.autocreators = {}
+ # Manual creators
+ self.manual_creators = {}
+
+ self.publish_discover_result = None
+ self.publish_plugins = []
+ self.plugins_with_defs = []
+ self._attr_plugins_by_family = {}
+
+ # Helpers for validating context of collected instances
+ # - they can be validation for multiple instances at one time
+ # using context manager which will trigger validation
+ # after leaving of last context manager scope
+ self._bulk_counter = 0
+ self._bulk_instances_to_process = []
+
+ # Trigger reset if was enabled
+ if reset:
+ self.reset(discover_publish_plugins)
+
+ @property
+ def instances(self):
+ return self._instances_by_id.values()
+
+ @property
+ def publish_attributes(self):
+ """Access to global publish attributes."""
+ return self._publish_attributes
+
+ @classmethod
+ def get_host_misssing_methods(cls, host):
+ """Collect missing methods from host.
+
+ Args:
+ host(ModuleType): Host implementaion.
+ """
+ missing = set()
+ for attr_name in cls.required_methods:
+ if not hasattr(host, attr_name):
+ missing.add(attr_name)
+ return missing
+
+ @property
+ def host_is_valid(self):
+ """Is host valid for creation."""
+ return self._host_is_valid
+
+ @property
+ def log(self):
+ """Dynamic access to logger."""
+ if self._log is None:
+ self._log = logging.getLogger(self.__class__.__name__)
+ return self._log
+
+ def reset(self, discover_publish_plugins=True):
+ """Reset context with all plugins and instances.
+
+ All changes will be lost if were not saved explicitely.
+ """
+ self.reset_avalon_context()
+ self.reset_plugins(discover_publish_plugins)
+ self.reset_context_data()
+
+ with self.bulk_instances_collection():
+ self.reset_instances()
+ self.execute_autocreators()
+
+ def reset_avalon_context(self):
+ """Give ability to reset avalon context.
+
+ Reset is based on optional host implementation of `get_current_context`
+ function or using `avalon.api.Session`.
+
+ Some hosts have ability to change context file without using workfiles
+ tool but that change is not propagated to
+ """
+ import avalon.api
+
+ project_name = asset_name = task_name = None
+ if hasattr(self.host, "get_current_context"):
+ host_context = self.host.get_current_context()
+ if host_context:
+ project_name = host_context.get("project_name")
+ asset_name = host_context.get("asset_name")
+ task_name = host_context.get("task_name")
+
+ if not project_name:
+ project_name = avalon.api.Session.get("AVALON_PROJECT")
+ if not asset_name:
+ asset_name = avalon.api.Session.get("AVALON_ASSET")
+ if not task_name:
+ task_name = avalon.api.Session.get("AVALON_TASK")
+
+ if project_name:
+ self.dbcon.Session["AVALON_PROJECT"] = project_name
+ if asset_name:
+ self.dbcon.Session["AVALON_ASSET"] = asset_name
+ if task_name:
+ self.dbcon.Session["AVALON_TASK"] = task_name
+
+ def reset_plugins(self, discover_publish_plugins=True):
+ """Reload plugins.
+
+ Reloads creators from preregistered paths and can load publish plugins
+ if it's enabled on context.
+ """
+ import avalon.api
+ import pyblish.logic
+
+ from openpype.pipeline import OpenPypePyblishPluginMixin
+ from openpype.pipeline.publish import (
+ publish_plugins_discover,
+ DiscoverResult
+ )
+
+ # Reset publish plugins
+ self._attr_plugins_by_family = {}
+
+ discover_result = DiscoverResult()
+ plugins_with_defs = []
+ plugins_by_targets = []
+ if discover_publish_plugins:
+ discover_result = publish_plugins_discover()
+ publish_plugins = discover_result.plugins
+
+ targets = pyblish.logic.registered_targets() or ["default"]
+ plugins_by_targets = pyblish.logic.plugins_by_targets(
+ publish_plugins, targets
+ )
+ # Collect plugins that can have attribute definitions
+ for plugin in publish_plugins:
+ if OpenPypePyblishPluginMixin in inspect.getmro(plugin):
+ plugins_with_defs.append(plugin)
+
+ self.publish_discover_result = discover_result
+ self.publish_plugins = plugins_by_targets
+ self.plugins_with_defs = plugins_with_defs
+
+ # Prepare settings
+ project_name = self.dbcon.Session["AVALON_PROJECT"]
+ system_settings = get_system_settings()
+ project_settings = get_project_settings(project_name)
+
+ # Discover and prepare creators
+ creators = {}
+ autocreators = {}
+ manual_creators = {}
+ for creator_class in avalon.api.discover(BaseCreator):
+ if inspect.isabstract(creator_class):
+ self.log.info(
+ "Skipping abstract Creator {}".format(str(creator_class))
+ )
+ continue
+
+ creator_identifier = creator_class.identifier
+ if creator_identifier in creators:
+ self.log.warning((
+ "Duplicated Creator identifier. "
+ "Using first and skipping following"
+ ))
+ continue
+ creator = creator_class(
+ self,
+ system_settings,
+ project_settings,
+ self.headless
+ )
+ creators[creator_identifier] = creator
+ if isinstance(creator, AutoCreator):
+ autocreators[creator_identifier] = creator
+ elif isinstance(creator, Creator):
+ manual_creators[creator_identifier] = creator
+
+ self.autocreators = autocreators
+ self.manual_creators = manual_creators
+
+ self.creators = creators
+
+ def reset_context_data(self):
+ """Reload context data using host implementation.
+
+ These data are not related to any instance but may be needed for whole
+ publishing.
+ """
+ if not self.host_is_valid:
+ self._original_context_data = {}
+ self._publish_attributes = PublishAttributes(self, {})
+ return
+
+ original_data = self.host.get_context_data() or {}
+ self._original_context_data = copy.deepcopy(original_data)
+
+ publish_attributes = original_data.get("publish_attributes") or {}
+
+ attr_plugins = self._get_publish_plugins_with_attr_for_context()
+ self._publish_attributes = PublishAttributes(
+ self, publish_attributes, attr_plugins
+ )
+
+ def context_data_to_store(self):
+ """Data that should be stored by host function.
+
+ The same data should be returned on loading.
+ """
+ return {
+ "publish_attributes": self._publish_attributes.data_to_store()
+ }
+
+ def context_data_changes(self):
+ """Changes of attributes."""
+ changes = {}
+ publish_attribute_changes = self._publish_attributes.changes()
+ if publish_attribute_changes:
+ changes["publish_attributes"] = publish_attribute_changes
+ return changes
+
+ def creator_adds_instance(self, instance):
+ """Creator adds new instance to context.
+
+ Instances should be added only from creators.
+
+ Args:
+ instance(CreatedInstance): Instance with prepared data from
+ creator.
+
+ TODO: Rename method to more suit.
+ """
+ # Add instance to instances list
+ if instance.id in self._instances_by_id:
+ self.log.warning((
+ "Instance with id {} is already added to context."
+ ).format(instance.id))
+ return
+
+ self._instances_by_id[instance.id] = instance
+ # Prepare publish plugin attributes and set it on instance
+ attr_plugins = self._get_publish_plugins_with_attr_for_family(
+ instance.creator.family
+ )
+ instance.set_publish_plugins(attr_plugins)
+
+ # Add instance to be validated inside 'bulk_instances_collection'
+ # context manager if is inside bulk
+ with self.bulk_instances_collection():
+ self._bulk_instances_to_process.append(instance)
+
+ def creator_removed_instance(self, instance):
+ self._instances_by_id.pop(instance.id, None)
+
+ @contextmanager
+ def bulk_instances_collection(self):
+ """Validate context of instances in bulk.
+
+ This can be used for single instance or for adding multiple instances
+ which is helpfull on reset.
+
+ Should not be executed from multiple threads.
+ """
+ self._bulk_counter += 1
+ try:
+ yield
+ finally:
+ self._bulk_counter -= 1
+
+ # Trigger validation if there is no more context manager for bulk
+ # instance validation
+ if self._bulk_counter == 0:
+ (
+ self._bulk_instances_to_process,
+ instances_to_validate
+ ) = (
+ [],
+ self._bulk_instances_to_process
+ )
+ self.validate_instances_context(instances_to_validate)
+
+ def reset_instances(self):
+ """Reload instances"""
+ self._instances_by_id = {}
+
+ # Collect instances
+ for creator in self.creators.values():
+ creator.collect_instances()
+
+ def execute_autocreators(self):
+ """Execute discovered AutoCreator plugins.
+
+ Reset instances if any autocreator executed properly.
+ """
+ for identifier, creator in self.autocreators.items():
+ try:
+ creator.create()
+
+ except Exception:
+ # TODO raise report exception if any crashed
+ msg = (
+ "Failed to run AutoCreator with identifier \"{}\" ({})."
+ ).format(identifier, inspect.getfile(creator.__class__))
+ self.log.warning(msg, exc_info=True)
+
+ def validate_instances_context(self, instances=None):
+ """Validate 'asset' and 'task' instance context."""
+ # Use all instances from context if 'instances' are not passed
+ if instances is None:
+ instances = tuple(self._instances_by_id.values())
+
+ # Skip if instances are empty
+ if not instances:
+ return
+
+ task_names_by_asset_name = collections.defaultdict(set)
+ for instance in instances:
+ task_name = instance.get("task")
+ asset_name = instance.get("asset")
+ if asset_name and task_name:
+ task_names_by_asset_name[asset_name].add(task_name)
+
+ asset_names = [
+ asset_name
+ for asset_name in task_names_by_asset_name.keys()
+ if asset_name is not None
+ ]
+ asset_docs = list(self.dbcon.find(
+ {
+ "type": "asset",
+ "name": {"$in": asset_names}
+ },
+ {
+ "name": True,
+ "data.tasks": True
+ }
+ ))
+
+ task_names_by_asset_name = {}
+ for asset_doc in asset_docs:
+ asset_name = asset_doc["name"]
+ tasks = asset_doc.get("data", {}).get("tasks") or {}
+ task_names_by_asset_name[asset_name] = set(tasks.keys())
+
+ for instance in instances:
+ if not instance.has_valid_asset or not instance.has_valid_task:
+ continue
+
+ asset_name = instance["asset"]
+ if asset_name not in task_names_by_asset_name:
+ instance.set_asset_invalid(True)
+ continue
+
+ task_name = instance["task"]
+ if not task_name:
+ continue
+
+ if task_name not in task_names_by_asset_name[asset_name]:
+ instance.set_task_invalid(True)
+
+ def save_changes(self):
+ """Save changes. Update all changed values."""
+ if not self.host_is_valid:
+ missing_methods = self.get_host_misssing_methods(self.host)
+ raise HostMissRequiredMethod(self.host, missing_methods)
+
+ self._save_context_changes()
+ self._save_instance_changes()
+
+ def _save_context_changes(self):
+ """Save global context values."""
+ changes = self.context_data_changes()
+ if changes:
+ data = self.context_data_to_store()
+ self.host.update_context_data(data, changes)
+
+ def _save_instance_changes(self):
+ """Save instance specific values."""
+ instances_by_identifier = collections.defaultdict(list)
+ for instance in self._instances_by_id.values():
+ identifier = instance.creator_identifier
+ instances_by_identifier[identifier].append(instance)
+
+ for identifier, cretor_instances in instances_by_identifier.items():
+ update_list = []
+ for instance in cretor_instances:
+ instance_changes = instance.changes()
+ if instance_changes:
+ update_list.append((instance, instance_changes))
+
+ creator = self.creators[identifier]
+ if update_list:
+ creator.update_instances(update_list)
+
+ def remove_instances(self, instances):
+ """Remove instances from context.
+
+ Args:
+ instances(list): Instances that should be removed
+ from context.
+ """
+ instances_by_identifier = collections.defaultdict(list)
+ for instance in instances:
+ identifier = instance.creator_identifier
+ instances_by_identifier[identifier].append(instance)
+
+ for identifier, creator_instances in instances_by_identifier.items():
+ creator = self.creators.get(identifier)
+ creator.remove_instances(creator_instances)
+
+ def _get_publish_plugins_with_attr_for_family(self, family):
+ """Publish plugin attributes for passed family.
+
+ Attribute definitions for specific family are cached.
+
+ Args:
+ family(str): Instance family for which should be attribute
+ definitions returned.
+ """
+ if family not in self._attr_plugins_by_family:
+ import pyblish.logic
+
+ filtered_plugins = pyblish.logic.plugins_by_families(
+ self.plugins_with_defs, [family]
+ )
+ plugins = []
+ for plugin in filtered_plugins:
+ if plugin.__instanceEnabled__:
+ plugins.append(plugin)
+ self._attr_plugins_by_family[family] = plugins
+
+ return self._attr_plugins_by_family[family]
+
+ def _get_publish_plugins_with_attr_for_context(self):
+ """Publish plugins attributes for Context plugins."""
+ plugins = []
+ for plugin in self.plugins_with_defs:
+ if not plugin.__instanceEnabled__:
+ plugins.append(plugin)
+ return plugins
diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py
new file mode 100644
index 0000000000..aa2e3333ce
--- /dev/null
+++ b/openpype/pipeline/create/creator_plugins.py
@@ -0,0 +1,269 @@
+import copy
+import logging
+
+from abc import (
+ ABCMeta,
+ abstractmethod,
+ abstractproperty
+)
+import six
+
+from openpype.lib import get_subset_name_with_asset_doc
+
+
+class CreatorError(Exception):
+ """Should be raised when creator failed because of known issue.
+
+ Message of error should be user readable.
+ """
+
+ def __init__(self, message):
+ super(CreatorError, self).__init__(message)
+
+
+@six.add_metaclass(ABCMeta)
+class BaseCreator:
+ """Plugin that create and modify instance data before publishing process.
+
+ We should maybe find better name as creation is only one part of it's logic
+ and to avoid expectations that it is the same as `avalon.api.Creator`.
+
+ Single object should be used for multiple instances instead of single
+ instance per one creator object. Do not store temp data or mid-process data
+ to `self` if it's not Plugin specific.
+ """
+
+ # Label shown in UI
+ label = None
+
+ # Variable to store logger
+ _log = None
+
+ # Creator is enabled (Probably does not have reason of existence?)
+ enabled = True
+
+ # Creator (and family) icon
+ # - may not be used if `get_icon` is reimplemented
+ icon = None
+
+ def __init__(
+ self, create_context, system_settings, project_settings, headless=False
+ ):
+ # Reference to CreateContext
+ self.create_context = create_context
+
+ # Creator is running in headless mode (without UI elemets)
+ # - we may use UI inside processing this attribute should be checked
+ self.headless = headless
+
+ @abstractproperty
+ def identifier(self):
+ """Identifier of creator (must be unique)."""
+ pass
+
+ @abstractproperty
+ def family(self):
+ """Family that plugin represents."""
+ pass
+
+ @property
+ def log(self):
+ if self._log is None:
+ self._log = logging.getLogger(self.__class__.__name__)
+ return self._log
+
+ def _add_instance_to_context(self, instance):
+ """Helper method to ad d"""
+ self.create_context.creator_adds_instance(instance)
+
+ def _remove_instance_from_context(self, instance):
+ self.create_context.creator_removed_instance(instance)
+
+ @abstractmethod
+ def create(self, options=None):
+ """Create new instance.
+
+ Replacement of `process` method from avalon implementation.
+ - must expect all data that were passed to init in previous
+ implementation
+ """
+ pass
+
+ @abstractmethod
+ def collect_instances(self, attr_plugins=None):
+ pass
+
+ @abstractmethod
+ def update_instances(self, update_list):
+ pass
+
+ @abstractmethod
+ def remove_instances(self, instances):
+ """Method called on instance removement.
+
+ Can also remove instance metadata from context but should return
+ 'True' if did so.
+
+ Args:
+ instance(list): Instance objects which should be
+ removed.
+ """
+ pass
+
+ def get_icon(self):
+ """Icon of creator (family).
+
+ Can return path to image file or awesome icon name.
+ """
+ return self.icon
+
+ def get_dynamic_data(
+ self, variant, task_name, asset_doc, project_name, host_name
+ ):
+ """Dynamic data for subset name filling.
+
+ These may be get dynamically created based on current context of
+ workfile.
+ """
+ return {}
+
+ def get_subset_name(
+ self, variant, task_name, asset_doc, project_name, host_name=None
+ ):
+ """Return subset name for passed context.
+
+ CHANGES:
+ Argument `asset_id` was replaced with `asset_doc`. It is easier to
+ query asset before. In some cases would this method be called multiple
+ times and it would be too slow to query asset document on each
+ callback.
+
+ NOTE:
+ Asset document is not used yet but is required if would like to use
+ task type in subset templates.
+
+ Args:
+ variant(str): Subset name variant. In most of cases user input.
+ task_name(str): For which task subset is created.
+ asset_doc(dict): Asset document for which subset is created.
+ project_name(str): Project name.
+ host_name(str): Which host creates subset.
+ """
+ dynamic_data = self.get_dynamic_data(
+ variant, task_name, asset_doc, project_name, host_name
+ )
+
+ return get_subset_name_with_asset_doc(
+ self.family,
+ variant,
+ task_name,
+ asset_doc,
+ project_name,
+ host_name,
+ dynamic_data=dynamic_data
+ )
+
+ def get_attribute_defs(self):
+ """Plugin attribute definitions.
+
+ Attribute definitions of plugin that hold data about created instance
+ and values are stored to metadata for future usage and for publishing
+ purposes.
+
+ NOTE:
+ Convert method should be implemented which should care about updating
+ keys/values when plugin attributes change.
+
+ Returns:
+ list: Attribute definitions that can be tweaked for
+ created instance.
+ """
+ return []
+
+
+class Creator(BaseCreator):
+ """Creator that has more information for artist to show in UI.
+
+ Creation requires prepared subset name and instance data.
+ """
+
+ # GUI Purposes
+ # - default_variants may not be used if `get_default_variants` is overriden
+ default_variants = []
+
+ # Short description of family
+ # - may not be used if `get_description` is overriden
+ description = None
+
+ # Detailed description of family for artists
+ # - may not be used if `get_detail_description` is overriden
+ detailed_description = None
+
+ @abstractmethod
+ def create(self, subset_name, instance_data, options=None):
+ """Create new instance and store it.
+
+ Ideally should be stored to workfile using host implementation.
+
+ Args:
+ subset_name(str): Subset name of created instance.
+ instance_data(dict):
+ """
+
+ # instance = CreatedInstance(
+ # self.family, subset_name, instance_data
+ # )
+ pass
+
+ def get_description(self):
+ """Short description of family and plugin.
+
+ Returns:
+ str: Short description of family.
+ """
+ return self.description
+
+ def get_detail_description(self):
+ """Description of family and plugin.
+
+ Can be detailed with markdown or html tags.
+
+ Returns:
+ str: Detailed description of family for artist.
+ """
+ return self.detailed_description
+
+ def get_default_variants(self):
+ """Default variant values for UI tooltips.
+
+ Replacement of `defatults` attribute. Using method gives ability to
+ have some "logic" other than attribute values.
+
+ By default returns `default_variants` value.
+
+ Returns:
+ list: Whisper variants for user input.
+ """
+ return copy.deepcopy(self.default_variants)
+
+ def get_default_variant(self):
+ """Default variant value that will be used to prefill variant input.
+
+ This is for user input and value may not be content of result from
+ `get_default_variants`.
+
+ Can return `None`. In that case first element from
+ `get_default_variants` should be used.
+ """
+
+ return None
+
+
+class AutoCreator(BaseCreator):
+ """Creator which is automatically triggered without user interaction.
+
+ Can be used e.g. for `workfile`.
+ """
+ def remove_instances(self, instances):
+ """Skip removement."""
+ pass
diff --git a/openpype/pipeline/lib/__init__.py b/openpype/pipeline/lib/__init__.py
new file mode 100644
index 0000000000..1bb65be79b
--- /dev/null
+++ b/openpype/pipeline/lib/__init__.py
@@ -0,0 +1,18 @@
+from .attribute_definitions import (
+ AbtractAttrDef,
+ UnknownDef,
+ NumberDef,
+ TextDef,
+ EnumDef,
+ BoolDef
+)
+
+
+__all__ = (
+ "AbtractAttrDef",
+ "UnknownDef",
+ "NumberDef",
+ "TextDef",
+ "EnumDef",
+ "BoolDef"
+)
diff --git a/openpype/pipeline/lib/attribute_definitions.py b/openpype/pipeline/lib/attribute_definitions.py
new file mode 100644
index 0000000000..2b34e15bc4
--- /dev/null
+++ b/openpype/pipeline/lib/attribute_definitions.py
@@ -0,0 +1,263 @@
+import re
+import collections
+import uuid
+from abc import ABCMeta, abstractmethod
+import six
+
+
+class AbstractAttrDefMeta(ABCMeta):
+ """Meta class to validate existence of 'key' attribute.
+
+ Each object of `AbtractAttrDef` mus have defined 'key' attribute.
+ """
+ def __call__(self, *args, **kwargs):
+ obj = super(AbstractAttrDefMeta, self).__call__(*args, **kwargs)
+ init_class = getattr(obj, "__init__class__", None)
+ if init_class is not AbtractAttrDef:
+ raise TypeError("{} super was not called in __init__.".format(
+ type(obj)
+ ))
+ return obj
+
+
+@six.add_metaclass(AbstractAttrDefMeta)
+class AbtractAttrDef:
+ """Abstraction of attribute definiton.
+
+ Each attribute definition must have implemented validation and
+ conversion method.
+
+ Attribute definition should have ability to return "default" value. That
+ can be based on passed data into `__init__` so is not abstracted to
+ attribute.
+
+ QUESTION:
+ How to force to set `key` attribute?
+
+ Args:
+ key(str): Under which key will be attribute value stored.
+ label(str): Attribute label.
+ tooltip(str): Attribute tooltip.
+ """
+
+ def __init__(self, key, default, label=None, tooltip=None):
+ self.key = key
+ self.label = label
+ self.tooltip = tooltip
+ self.default = default
+ self._id = uuid.uuid4()
+
+ self.__init__class__ = AbtractAttrDef
+
+ @property
+ def id(self):
+ return self._id
+
+ def __eq__(self, other):
+ if not isinstance(other, self.__class__):
+ return False
+ return self.key == other.key
+
+ @abstractmethod
+ def convert_value(self, value):
+ """Convert value to a valid one.
+
+ Convert passed value to a valid type. Use default if value can't be
+ converted.
+ """
+ pass
+
+
+class UnknownDef(AbtractAttrDef):
+ """Definition is not known because definition is not available."""
+ def __init__(self, key, default=None, **kwargs):
+ kwargs["default"] = default
+ super(UnknownDef, self).__init__(key, **kwargs)
+
+ def convert_value(self, value):
+ return value
+
+
+class NumberDef(AbtractAttrDef):
+ """Number definition.
+
+ Number can have defined minimum/maximum value and decimal points. Value
+ is integer if decimals are 0.
+
+ Args:
+ minimum(int, float): Minimum possible value.
+ maximum(int, float): Maximum possible value.
+ decimals(int): Maximum decimal points of value.
+ default(int, float): Default value for conversion.
+ """
+
+ def __init__(
+ self, key, minimum=None, maximum=None, decimals=None, default=None,
+ **kwargs
+ ):
+ minimum = 0 if minimum is None else minimum
+ maximum = 999999 if maximum is None else maximum
+ # Swap min/max when are passed in opposited order
+ if minimum > maximum:
+ maximum, minimum = minimum, maximum
+
+ if default is None:
+ default = 0
+
+ elif not isinstance(default, (int, float)):
+ raise TypeError((
+ "'default' argument must be 'int' or 'float', not '{}'"
+ ).format(type(default)))
+
+ # Fix default value by mim/max values
+ if default < minimum:
+ default = minimum
+
+ elif default > maximum:
+ default = maximum
+
+ super(NumberDef, self).__init__(key, default=default, **kwargs)
+
+ self.minimum = minimum
+ self.maximum = maximum
+ self.decimals = 0 if decimals is None else decimals
+
+ def __eq__(self, other):
+ if not super(NumberDef, self).__eq__(other):
+ return False
+
+ return (
+ self.decimals == other.decimals
+ and self.maximum == other.maximum
+ and self.maximum == other.maximum
+ )
+
+ def convert_value(self, value):
+ if isinstance(value, six.string_types):
+ try:
+ value = float(value)
+ except Exception:
+ pass
+
+ if not isinstance(value, (int, float)):
+ return self.default
+
+ if self.decimals == 0:
+ return int(value)
+ return round(float(value), self.decimals)
+
+
+class TextDef(AbtractAttrDef):
+ """Text definition.
+
+ Text can have multiline option so endline characters are allowed regex
+ validation can be applied placeholder for UI purposes and default value.
+
+ Regex validation is not part of attribute implemntentation.
+
+ Args:
+ multiline(bool): Text has single or multiline support.
+ regex(str, re.Pattern): Regex validation.
+ placeholder(str): UI placeholder for attribute.
+ default(str, None): Default value. Empty string used when not defined.
+ """
+ def __init__(
+ self, key, multiline=None, regex=None, placeholder=None, default=None,
+ **kwargs
+ ):
+ if default is None:
+ default = ""
+
+ super(TextDef, self).__init__(key, default=default, **kwargs)
+
+ if multiline is None:
+ multiline = False
+
+ elif not isinstance(default, six.string_types):
+ raise TypeError((
+ "'default' argument must be a {}, not '{}'"
+ ).format(six.string_types, type(default)))
+
+ if isinstance(regex, six.string_types):
+ regex = re.compile(regex)
+
+ self.multiline = multiline
+ self.placeholder = placeholder
+ self.regex = regex
+
+ def __eq__(self, other):
+ if not super(TextDef, self).__eq__(other):
+ return False
+
+ return (
+ self.multiline == other.multiline
+ and self.regex == other.regex
+ )
+
+ def convert_value(self, value):
+ if isinstance(value, six.string_types):
+ return value
+ return self.default
+
+
+class EnumDef(AbtractAttrDef):
+ """Enumeration of single item from items.
+
+ Args:
+ items: Items definition that can be coverted to
+ `collections.OrderedDict`. Dictionary represent {value: label}
+ relation.
+ default: Default value. Must be one key(value) from passed items.
+ """
+
+ def __init__(self, key, items, default=None, **kwargs):
+ if not items:
+ raise ValueError((
+ "Empty 'items' value. {} must have"
+ " defined values on initialization."
+ ).format(self.__class__.__name__))
+
+ items = collections.OrderedDict(items)
+ if default not in items:
+ for _key in items.keys():
+ default = _key
+ break
+
+ super(EnumDef, self).__init__(key, default=default, **kwargs)
+
+ self.items = items
+
+ def __eq__(self, other):
+ if not super(EnumDef, self).__eq__(other):
+ return False
+
+ if set(self.items.keys()) != set(other.items.keys()):
+ return False
+
+ for key, label in self.items.items():
+ if other.items[key] != label:
+ return False
+ return True
+
+ def convert_value(self, value):
+ if value in self.items:
+ return value
+ return self.default
+
+
+class BoolDef(AbtractAttrDef):
+ """Boolean representation.
+
+ Args:
+ default(bool): Default value. Set to `False` if not defined.
+ """
+
+ def __init__(self, key, default=None, **kwargs):
+ if default is None:
+ default = False
+ super(BoolDef, self).__init__(key, default=default, **kwargs)
+
+ def convert_value(self, value):
+ if isinstance(value, bool):
+ return value
+ return self.default
diff --git a/openpype/pipeline/publish/README.md b/openpype/pipeline/publish/README.md
new file mode 100644
index 0000000000..870d29314d
--- /dev/null
+++ b/openpype/pipeline/publish/README.md
@@ -0,0 +1,38 @@
+# Publish
+OpenPype is using `pyblish` for publishing process which is a little bit extented and modified mainly for UI purposes. OpenPype's (new) publish UI does not allow to enable/disable instances or plugins that can be done during creation part. Also does support actions only for validators after validation exception.
+
+## Exceptions
+OpenPype define few specific exceptions that should be used in publish plugins.
+
+### Validation exception
+Validation plugins should raise `PublishValidationError` to show to an artist what's wrong and give him actions to fix it. The exception says that error happened in plugin can be fixed by artist himself (with or without action on plugin). Any other errors will stop publishing immediately. Exception `PublishValidationError` raised after validation order has same effect as any other exception.
+
+Exception `PublishValidationError` 3 arguments:
+- **message** Which is not used in UI but for headless publishing.
+- **title** Short description of error (2-5 words). Title is used for grouping of exceptions per plugin.
+- **description** Detailed description of happened issue where markdown and html can be used.
+
+
+### Known errors
+When there is a known error that can't be fixed by user (e.g. can't connect to deadline service, etc.) `KnownPublishError` should be raise. The only difference is that it's message is shown in UI to artist otherwise a neutral message without context is shown.
+
+## Plugin extension
+Publish plugins can be extended by additional logic when inherits from `OpenPypePyblishPluginMixin` which can be used as mixin (additional inheritance of class).
+
+```python
+import pyblish.api
+from openpype.pipeline import OpenPypePyblishPluginMixin
+
+
+# Example context plugin
+class MyExtendedPlugin(
+ pyblish.api.ContextPlugin, OpenPypePyblishPluginMixin
+):
+ pass
+
+```
+
+### Extensions
+Currently only extension is ability to define attributes for instances during creation. Method `get_attribute_defs` returns attribute definitions for families defined in plugin's `families` attribute if it's instance plugin or for whole context if it's context plugin. To convert existing values (or to remove legacy values) can be implemented `convert_attribute_values`. Values of publish attributes from created instance are never removed automatically so implementing of this method is best way to remove legacy data or convert them to new data structure.
+
+Possible attribute definitions can be found in `openpype/pipeline/lib/attribute_definitions.py`.
diff --git a/openpype/pipeline/publish/__init__.py b/openpype/pipeline/publish/__init__.py
new file mode 100644
index 0000000000..ca958816fe
--- /dev/null
+++ b/openpype/pipeline/publish/__init__.py
@@ -0,0 +1,20 @@
+from .publish_plugins import (
+ PublishValidationError,
+ KnownPublishError,
+ OpenPypePyblishPluginMixin
+)
+
+from .lib import (
+ DiscoverResult,
+ publish_plugins_discover
+)
+
+
+__all__ = (
+ "PublishValidationError",
+ "KnownPublishError",
+ "OpenPypePyblishPluginMixin",
+
+ "DiscoverResult",
+ "publish_plugins_discover"
+)
diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py
new file mode 100644
index 0000000000..0fa712a301
--- /dev/null
+++ b/openpype/pipeline/publish/lib.py
@@ -0,0 +1,126 @@
+import os
+import sys
+import types
+
+import six
+import pyblish.plugin
+
+
+class DiscoverResult:
+ """Hold result of publish plugins discovery.
+
+ Stores discovered plugins duplicated plugins and file paths which
+ crashed on execution of file.
+ """
+ def __init__(self):
+ self.plugins = []
+ self.crashed_file_paths = {}
+ self.duplicated_plugins = []
+
+ def __iter__(self):
+ for plugin in self.plugins:
+ yield plugin
+
+ def __getitem__(self, item):
+ return self.plugins[item]
+
+ def __setitem__(self, item, value):
+ self.plugins[item] = value
+
+
+def publish_plugins_discover(paths=None):
+ """Find and return available pyblish plug-ins
+
+ Overriden function from `pyblish` module to be able collect crashed files
+ and reason of their crash.
+
+ Arguments:
+ paths (list, optional): Paths to discover plug-ins from.
+ If no paths are provided, all paths are searched.
+
+ """
+
+ # The only difference with `pyblish.api.discover`
+ result = DiscoverResult()
+
+ plugins = dict()
+ plugin_names = []
+
+ allow_duplicates = pyblish.plugin.ALLOW_DUPLICATES
+ log = pyblish.plugin.log
+
+ # Include plug-ins from registered paths
+ if not paths:
+ paths = pyblish.plugin.plugin_paths()
+
+ for path in paths:
+ path = os.path.normpath(path)
+ if not os.path.isdir(path):
+ continue
+
+ for fname in os.listdir(path):
+ if fname.startswith("_"):
+ continue
+
+ abspath = os.path.join(path, fname)
+
+ if not os.path.isfile(abspath):
+ continue
+
+ mod_name, mod_ext = os.path.splitext(fname)
+
+ if not mod_ext == ".py":
+ continue
+
+ module = types.ModuleType(mod_name)
+ module.__file__ = abspath
+
+ try:
+ with open(abspath, "rb") as f:
+ six.exec_(f.read(), module.__dict__)
+
+ # Store reference to original module, to avoid
+ # garbage collection from collecting it's global
+ # imports, such as `import os`.
+ sys.modules[abspath] = module
+
+ except Exception as err:
+ result.crashed_file_paths[abspath] = sys.exc_info()
+
+ log.debug("Skipped: \"%s\" (%s)", mod_name, err)
+ continue
+
+ for plugin in pyblish.plugin.plugins_from_module(module):
+ if not allow_duplicates and plugin.__name__ in plugin_names:
+ result.duplicated_plugins.append(plugin)
+ log.debug("Duplicate plug-in found: %s", plugin)
+ continue
+
+ plugin_names.append(plugin.__name__)
+
+ plugin.__module__ = module.__file__
+ key = "{0}.{1}".format(plugin.__module__, plugin.__name__)
+ plugins[key] = plugin
+
+ # Include plug-ins from registration.
+ # Directly registered plug-ins take precedence.
+ for plugin in pyblish.plugin.registered_plugins():
+ if not allow_duplicates and plugin.__name__ in plugin_names:
+ result.duplicated_plugins.append(plugin)
+ log.debug("Duplicate plug-in found: %s", plugin)
+ continue
+
+ plugin_names.append(plugin.__name__)
+
+ plugins[plugin.__name__] = plugin
+
+ plugins = list(plugins.values())
+ pyblish.plugin.sort(plugins) # In-place
+
+ # In-place user-defined filter
+ for filter_ in pyblish.plugin._registered_plugin_filters:
+ filter_(plugins)
+
+ result.plugins = plugins
+
+ return result
diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py
new file mode 100644
index 0000000000..b60b9f43a7
--- /dev/null
+++ b/openpype/pipeline/publish/publish_plugins.py
@@ -0,0 +1,86 @@
+class PublishValidationError(Exception):
+ """Validation error happened during publishing.
+
+ This exception should be used when validation publishing failed.
+
+ Has additional UI specific attributes that may be handy for artist.
+
+ Args:
+ message(str): Message of error. Short explanation an issue.
+ title(str): Title showed in UI. All instances are grouped under
+ single title.
+ description(str): Detailed description of an error. It is possible
+ to use Markdown syntax.
+ """
+ def __init__(self, message, title=None, description=None):
+ self.message = message
+ self.title = title or "< Missing title >"
+ self.description = description or message
+ super(PublishValidationError, self).__init__(message)
+
+
+class KnownPublishError(Exception):
+ """Publishing crashed because of known error.
+
+ Message will be shown in UI for artist.
+ """
+ pass
+
+
+class OpenPypePyblishPluginMixin:
+ # TODO
+ # executable_in_thread = False
+ #
+ # state_message = None
+ # state_percent = None
+ # _state_change_callbacks = []
+ #
+ # def set_state(self, percent=None, message=None):
+ # """Inner callback of plugin that would help to show in UI state.
+ #
+ # Plugin have registered callbacks on state change which could trigger
+ # update message and percent in UI and repaint the change.
+ #
+ # This part must be optional and should not be used to display errors
+ # or for logging.
+ #
+ # Message should be short without details.
+ #
+ # Args:
+ # percent(int): Percent of processing in range <1-100>.
+ # message(str): Message which will be shown to user (if in UI).
+ # """
+ # if percent is not None:
+ # self.state_percent = percent
+ #
+ # if message:
+ # self.state_message = message
+ #
+ # for callback in self._state_change_callbacks:
+ # callback(self)
+
+ @classmethod
+ def get_attribute_defs(cls):
+ """Publish attribute definitions.
+
+ Attributes available for all families in plugin's `families` attribute.
+ Returns:
+ list: Attribute definitions for plugin.
+ """
+ return []
+
+ @classmethod
+ def convert_attribute_values(cls, attribute_values):
+ if cls.__name__ not in attribute_values:
+ return attribute_values
+
+ plugin_values = attribute_values[cls.__name__]
+
+ attr_defs = cls.get_attribute_defs()
+ for attr_def in attr_defs:
+ key = attr_def.key
+ if key in plugin_values:
+ plugin_values[key] = attr_def.convert_value(
+ plugin_values[key]
+ )
+ return attribute_values
diff --git a/openpype/plugins/publish/collect_from_create_context.py b/openpype/plugins/publish/collect_from_create_context.py
new file mode 100644
index 0000000000..16e3f669c3
--- /dev/null
+++ b/openpype/plugins/publish/collect_from_create_context.py
@@ -0,0 +1,57 @@
+"""Create instances based on CreateContext.
+
+"""
+import os
+import pyblish.api
+import avalon.api
+
+
+class CollectFromCreateContext(pyblish.api.ContextPlugin):
+ """Collect instances and data from CreateContext from new publishing."""
+
+ label = "Collect From Create Context"
+ order = pyblish.api.CollectorOrder - 0.5
+
+ def process(self, context):
+ create_context = context.data.pop("create_context", None)
+ # Skip if create context is not available
+ if not create_context:
+ return
+
+ for created_instance in create_context.instances:
+ instance_data = created_instance.data_to_store()
+ if instance_data["active"]:
+ self.create_instance(context, instance_data)
+
+ # Update global data to context
+ context.data.update(create_context.context_data_to_store())
+
+ # Update context data
+ for key in ("AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK"):
+ value = create_context.dbcon.Session.get(key)
+ if value is not None:
+ avalon.api.Session[key] = value
+ os.environ[key] = value
+
+ def create_instance(self, context, in_data):
+ subset = in_data["subset"]
+ # If instance data already contain families then use it
+ instance_families = in_data.get("families") or []
+
+ instance = context.create_instance(subset)
+ instance.data.update({
+ "subset": subset,
+ "asset": in_data["asset"],
+ "task": in_data["task"],
+ "label": subset,
+ "name": subset,
+ "family": in_data["family"],
+ "families": instance_families
+ })
+ for key, value in in_data.items():
+ if key not in instance.data:
+ instance.data[key] = value
+ self.log.info("collected instance: {}".format(instance.data))
+ self.log.info("parsing data: {}".format(in_data))
+
+ instance.data["representations"] = list()
diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py
index fe780480c2..39ed5ccb28 100644
--- a/openpype/plugins/publish/integrate_new.py
+++ b/openpype/plugins/publish/integrate_new.py
@@ -99,7 +99,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
"camerarig",
"redshiftproxy",
"effect",
- "xgen"
+ "xgen",
+ "hda"
]
exclude_families = ["clip"]
db_representation_context_keys = [
diff --git a/openpype/plugins/publish/validate_containers.py b/openpype/plugins/publish/validate_containers.py
index 784221c3b6..ce91bd3396 100644
--- a/openpype/plugins/publish/validate_containers.py
+++ b/openpype/plugins/publish/validate_containers.py
@@ -9,9 +9,9 @@ class ShowInventory(pyblish.api.Action):
on = "failed"
def process(self, context, plugin):
- from avalon.tools import sceneinventory
+ from openpype.tools.utils import host_tools
- sceneinventory.show()
+ host_tools.show_scene_inventory()
class ValidateContainers(pyblish.api.ContextPlugin):
diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py
index 2ccb4c8a0b..54b4d7b8a0 100644
--- a/openpype/pype_commands.py
+++ b/openpype/pype_commands.py
@@ -11,7 +11,9 @@ from openpype.lib.plugin_tools import parse_json, get_batch_asset_task_info
from openpype.lib.remote_publish import (
get_webpublish_conn,
start_webpublish_log,
- publish_and_log
+ publish_and_log,
+ fail_batch,
+ find_variant_key
)
@@ -124,10 +126,17 @@ class PypeCommands:
wants to process uploaded .psd file and publish collected layers
from there.
+ Checks if no other batches are running (status =='in_progress). If
+ so, it sleeps for SLEEP (this is separate process),
+ waits for WAIT_FOR seconds altogether.
+
Requires installed host application on the machine.
Runs publish process as user would, in automatic fashion.
"""
+ SLEEP = 5 # seconds for another loop check for concurrently runs
+ WAIT_FOR = 300 # seconds to wait for conc. runs
+
from openpype import install, uninstall
from openpype.api import Logger
@@ -140,25 +149,12 @@ class PypeCommands:
from openpype.lib import ApplicationManager
application_manager = ApplicationManager()
- app_group = application_manager.app_groups.get(host)
- if not app_group or not app_group.enabled:
- raise ValueError("No application {} configured".format(host))
-
- found_variant_key = None
- # finds most up-to-date variant if any installed
- for variant_key, variant in app_group.variants.items():
- for executable in variant.executables:
- if executable.exists():
- found_variant_key = variant_key
-
- if not found_variant_key:
- raise ValueError("No executable for {} found".format(host))
+ found_variant_key = find_variant_key(application_manager, host)
app_name = "{}/{}".format(host, found_variant_key)
batch_data = None
if batch_dir and os.path.exists(batch_dir):
- # TODO check if batch manifest is same as tasks manifests
batch_data = parse_json(os.path.join(batch_dir, "manifest.json"))
if not batch_data:
@@ -168,11 +164,38 @@ class PypeCommands:
asset, task_name, _task_type = get_batch_asset_task_info(
batch_data["context"])
+ # processing from app expects JUST ONE task in batch and 1 workfile
+ task_dir_name = batch_data["tasks"][0]
+ task_data = parse_json(os.path.join(batch_dir, task_dir_name,
+ "manifest.json"))
+
workfile_path = os.path.join(batch_dir,
- batch_data["task"],
- batch_data["files"][0])
+ task_dir_name,
+ task_data["files"][0])
+
print("workfile_path {}".format(workfile_path))
+ _, batch_id = os.path.split(batch_dir)
+ dbcon = get_webpublish_conn()
+ # safer to start logging here, launch might be broken altogether
+ _id = start_webpublish_log(dbcon, batch_id, user)
+
+ in_progress = True
+ slept_times = 0
+ while in_progress:
+ batches_in_progress = list(dbcon.find({
+ "status": "in_progress"
+ }))
+ if len(batches_in_progress) > 1:
+ if slept_times * SLEEP >= WAIT_FOR:
+ fail_batch(_id, batches_in_progress, dbcon)
+
+ print("Another batch running, sleeping for a bit")
+ time.sleep(SLEEP)
+ slept_times += 1
+ else:
+ in_progress = False
+
# must have for proper launch of app
env = get_app_environments_for_context(
project,
@@ -182,11 +205,6 @@ class PypeCommands:
)
os.environ.update(env)
- _, batch_id = os.path.split(batch_dir)
- dbcon = get_webpublish_conn()
- # safer to start logging here, launch might be broken altogether
- _id = start_webpublish_log(dbcon, batch_id, user)
-
os.environ["OPENPYPE_PUBLISH_DATA"] = batch_dir
os.environ["IS_HEADLESS"] = "true"
# must pass identifier to update log lines for a batch
diff --git a/openpype/resources/__init__.py b/openpype/resources/__init__.py
index c6886fea73..f463933525 100644
--- a/openpype/resources/__init__.py
+++ b/openpype/resources/__init__.py
@@ -50,3 +50,11 @@ def get_openpype_splash_filepath(staging=None):
else:
splash_file_name = "openpype_splash.png"
return get_resource("icons", splash_file_name)
+
+
+def pype_icon_filepath(staging=None):
+ return get_openpype_icon_filepath(staging)
+
+
+def pype_splash_filepath(staging=None):
+ return get_openpype_splash_filepath(staging)
diff --git a/openpype/settings/defaults/project_anatomy/imageio.json b/openpype/settings/defaults/project_anatomy/imageio.json
index 25608f67c6..fc34ef6813 100644
--- a/openpype/settings/defaults/project_anatomy/imageio.json
+++ b/openpype/settings/defaults/project_anatomy/imageio.json
@@ -172,5 +172,16 @@
}
]
}
+ },
+ "maya": {
+ "colorManagementPreference": {
+ "configFilePath": {
+ "windows": [],
+ "darwin": [],
+ "linux": []
+ },
+ "renderSpace": "scene-linear Rec 709/sRGB",
+ "viewTransform": "sRGB gamma"
+ }
}
}
\ No newline at end of file
diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json
index f8f3432d0f..705917a9f4 100644
--- a/openpype/settings/defaults/project_settings/maya.json
+++ b/openpype/settings/defaults/project_settings/maya.json
@@ -255,6 +255,11 @@
"optional": true,
"active": true
},
+ "ValidateMeshNgons": {
+ "enabled": false,
+ "optional": true,
+ "active": true
+ },
"ValidateMeshNonManifold": {
"enabled": false,
"optional": true,
diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json
index 79711f3067..cc80a94d3f 100644
--- a/openpype/settings/defaults/system_settings/applications.json
+++ b/openpype/settings/defaults/system_settings/applications.json
@@ -1009,8 +1009,6 @@
},
"variants": {
"2020": {
- "enabled": true,
- "variant_label": "2020",
"executables": {
"windows": [
"C:\\Program Files\\Adobe\\Adobe Photoshop 2020\\Photoshop.exe"
@@ -1026,8 +1024,6 @@
"environment": {}
},
"2021": {
- "enabled": true,
- "variant_label": "2021",
"executables": {
"windows": [
"C:\\Program Files\\Adobe\\Adobe Photoshop 2021\\Photoshop.exe"
@@ -1041,6 +1037,21 @@
"linux": []
},
"environment": {}
+ },
+ "2022": {
+ "executables": {
+ "windows": [
+ "C:\\Program Files\\Adobe\\Adobe Photoshop 2022\\Photoshop.exe"
+ ],
+ "darwin": [],
+ "linux": []
+ },
+ "arguments": {
+ "windows": [],
+ "darwin": [],
+ "linux": []
+ },
+ "environment": {}
}
}
},
diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py
index aae2d1fa89..775bf40ac4 100644
--- a/openpype/settings/entities/__init__.py
+++ b/openpype/settings/entities/__init__.py
@@ -110,7 +110,10 @@ from .enum_entity import (
)
from .list_entity import ListEntity
-from .dict_immutable_keys_entity import DictImmutableKeysEntity
+from .dict_immutable_keys_entity import (
+ DictImmutableKeysEntity,
+ RootsDictEntity
+)
from .dict_mutable_keys_entity import DictMutableKeysEntity
from .dict_conditional import (
DictConditionalEntity,
@@ -169,6 +172,7 @@ __all__ = (
"ListEntity",
"DictImmutableKeysEntity",
+ "RootsDictEntity",
"DictMutableKeysEntity",
diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py
index 0e8274d374..341968bd75 100644
--- a/openpype/settings/entities/base_entity.py
+++ b/openpype/settings/entities/base_entity.py
@@ -510,7 +510,7 @@ class BaseItemEntity(BaseEntity):
pass
@abstractmethod
- def _item_initalization(self):
+ def _item_initialization(self):
"""Entity specific initialization process."""
pass
@@ -920,7 +920,7 @@ class ItemEntity(BaseItemEntity):
_default_label_wrap["collapsed"]
)
- self._item_initalization()
+ self._item_initialization()
def save(self):
"""Call save on root item."""
diff --git a/openpype/settings/entities/color_entity.py b/openpype/settings/entities/color_entity.py
index dfaa75e761..3becf2d865 100644
--- a/openpype/settings/entities/color_entity.py
+++ b/openpype/settings/entities/color_entity.py
@@ -9,7 +9,7 @@ from .exceptions import (
class ColorEntity(InputEntity):
schema_types = ["color"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.valid_value_types = (list, )
self.value_on_not_set = [0, 0, 0, 255]
self.use_alpha = self.schema_data.get("use_alpha", True)
diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py
index 6f27760570..0cb8827991 100644
--- a/openpype/settings/entities/dict_conditional.py
+++ b/openpype/settings/entities/dict_conditional.py
@@ -107,7 +107,7 @@ class DictConditionalEntity(ItemEntity):
for _key, _value in new_value.items():
self.non_gui_children[self.current_enum][_key].set(_value)
- def _item_initalization(self):
+ def _item_initialization(self):
self._default_metadata = NOT_SET
self._studio_override_metadata = NOT_SET
self._project_override_metadata = NOT_SET
diff --git a/openpype/settings/entities/dict_immutable_keys_entity.py b/openpype/settings/entities/dict_immutable_keys_entity.py
index 57e21ff5f3..d0cd41d11c 100644
--- a/openpype/settings/entities/dict_immutable_keys_entity.py
+++ b/openpype/settings/entities/dict_immutable_keys_entity.py
@@ -4,7 +4,8 @@ import collections
from .lib import (
WRAPPER_TYPES,
OverrideState,
- NOT_SET
+ NOT_SET,
+ STRING_TYPE
)
from openpype.settings.constants import (
METADATA_KEYS,
@@ -18,6 +19,7 @@ from . import (
GUIEntity
)
from .exceptions import (
+ DefaultsNotDefined,
SchemaDuplicatedKeys,
EntitySchemaError,
InvalidKeySymbols
@@ -172,7 +174,7 @@ class DictImmutableKeysEntity(ItemEntity):
for child_obj in added_children:
self.gui_layout.append(child_obj)
- def _item_initalization(self):
+ def _item_initialization(self):
self._default_metadata = NOT_SET
self._studio_override_metadata = NOT_SET
self._project_override_metadata = NOT_SET
@@ -547,3 +549,178 @@ class DictImmutableKeysEntity(ItemEntity):
super(DictImmutableKeysEntity, self).reset_callbacks()
for child_entity in self.children:
child_entity.reset_callbacks()
+
+
+class RootsDictEntity(DictImmutableKeysEntity):
+ """Entity that adds ability to fill value for roots of current project.
+
+ Value schema is defined by `object_type`.
+
+ It is not possible to change override state (Studio values will always
+ contain studio overrides and same for project). That is because roots can
+ be totally different for each project.
+ """
+ _origin_schema_data = None
+ schema_types = ["dict-roots"]
+
+ def _item_initialization(self):
+ origin_schema_data = self.schema_data
+
+ self.separate_items = origin_schema_data.get("separate_items", True)
+ object_type = origin_schema_data.get("object_type")
+ if isinstance(object_type, STRING_TYPE):
+ object_type = {"type": object_type}
+ self.object_type = object_type
+
+ if not self.is_group:
+ self.is_group = True
+
+ schema_data = copy.deepcopy(self.schema_data)
+ schema_data["children"] = []
+
+ self.schema_data = schema_data
+ self._origin_schema_data = origin_schema_data
+
+ self._default_value = NOT_SET
+ self._studio_value = NOT_SET
+ self._project_value = NOT_SET
+
+ super(RootsDictEntity, self)._item_initialization()
+
+ def schema_validations(self):
+ if self.object_type is None:
+ reason = (
+ "Missing children definitions for root values"
+ " ('object_type' not filled)."
+ )
+ raise EntitySchemaError(self, reason)
+
+ if not isinstance(self.object_type, dict):
+ reason = (
+ "Children definitions for root values must be dictionary"
+ " ('object_type' is \"{}\")."
+ ).format(str(type(self.object_type)))
+ raise EntitySchemaError(self, reason)
+
+ super(RootsDictEntity, self).schema_validations()
+
+ def set_override_state(self, state, ignore_missing_defaults):
+ self.children = []
+ self.non_gui_children = {}
+ self.gui_layout = []
+
+ roots_entity = self.get_entity_from_path(
+ "project_anatomy/roots"
+ )
+ children = []
+ first = True
+ for key in roots_entity.keys():
+ if first:
+ first = False
+ elif self.separate_items:
+ children.append({"type": "separator"})
+ child = copy.deepcopy(self.object_type)
+ child["key"] = key
+ child["label"] = key
+ children.append(child)
+
+ schema_data = copy.deepcopy(self.schema_data)
+ schema_data["children"] = children
+
+ self._add_children(schema_data)
+
+ self._set_children_values(state)
+
+ super(RootsDictEntity, self).set_override_state(
+ state, True
+ )
+
+ if state == OverrideState.STUDIO:
+ self.add_to_studio_default()
+
+ elif state == OverrideState.PROJECT:
+ self.add_to_project_override()
+
+ def on_child_change(self, child_obj):
+ if self._override_state is OverrideState.STUDIO:
+ if not child_obj.has_studio_override:
+ self.add_to_studio_default()
+
+ elif self._override_state is OverrideState.PROJECT:
+ if not child_obj.has_project_override:
+ self.add_to_project_override()
+
+ return super(RootsDictEntity, self).on_child_change(child_obj)
+
+ def _set_children_values(self, state):
+ if state >= OverrideState.DEFAULTS:
+ default_value = self._default_value
+ if default_value is NOT_SET:
+ if state > OverrideState.DEFAULTS:
+ raise DefaultsNotDefined(self)
+ else:
+ default_value = {}
+
+ for key, child_obj in self.non_gui_children.items():
+ child_value = default_value.get(key, NOT_SET)
+ child_obj.update_default_value(child_value)
+
+ if state >= OverrideState.STUDIO:
+ value = self._studio_value
+ if value is NOT_SET:
+ value = {}
+
+ for key, child_obj in self.non_gui_children.items():
+ child_value = value.get(key, NOT_SET)
+ child_obj.update_studio_value(child_value)
+
+ if state >= OverrideState.PROJECT:
+ value = self._project_value
+ if value is NOT_SET:
+ value = {}
+
+ for key, child_obj in self.non_gui_children.items():
+ child_value = value.get(key, NOT_SET)
+ child_obj.update_project_value(child_value)
+
+ def _update_current_metadata(self):
+ """Override this method as this entity should not have metadata."""
+ self._metadata_are_modified = False
+ self._current_metadata = {}
+
+ def update_default_value(self, value):
+ """Update default values.
+
+ Not an api method, should be called by parent.
+ """
+ value = self._check_update_value(value, "default")
+ value, _ = self._prepare_value(value)
+
+ self._default_value = value
+ self._default_metadata = {}
+ self.has_default_value = value is not NOT_SET
+
+ def update_studio_value(self, value):
+ """Update studio override values.
+
+ Not an api method, should be called by parent.
+ """
+ value = self._check_update_value(value, "studio override")
+ value, _ = self._prepare_value(value)
+
+ self._studio_value = value
+ self._studio_override_metadata = {}
+ self.had_studio_override = value is not NOT_SET
+
+ def update_project_value(self, value):
+ """Update project override values.
+
+ Not an api method, should be called by parent.
+ """
+
+ value = self._check_update_value(value, "project override")
+ value, _metadata = self._prepare_value(value)
+
+ self._project_value = value
+ self._project_override_metadata = {}
+ self.had_project_override = value is not NOT_SET
diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py
index f75fb23d82..cff346e9ea 100644
--- a/openpype/settings/entities/dict_mutable_keys_entity.py
+++ b/openpype/settings/entities/dict_mutable_keys_entity.py
@@ -191,7 +191,7 @@ class DictMutableKeysEntity(EndpointEntity):
child_entity = self.children_by_key[key]
self.set_child_label(child_entity, label)
- def _item_initalization(self):
+ def _item_initialization(self):
self._default_metadata = {}
self._studio_override_metadata = {}
self._project_override_metadata = {}
diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py
index f5d832f918..ab3cebbd42 100644
--- a/openpype/settings/entities/enum_entity.py
+++ b/openpype/settings/entities/enum_entity.py
@@ -8,7 +8,7 @@ from .lib import (
class BaseEnumEntity(InputEntity):
- def _item_initalization(self):
+ def _item_initialization(self):
self.multiselection = True
self.value_on_not_set = None
self.enum_items = None
@@ -70,7 +70,7 @@ class BaseEnumEntity(InputEntity):
class EnumEntity(BaseEnumEntity):
schema_types = ["enum"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.multiselection = self.schema_data.get("multiselection", False)
self.enum_items = self.schema_data.get("enum_items")
# Default is optional and non breaking attribute
@@ -157,7 +157,7 @@ class HostsEnumEntity(BaseEnumEntity):
"standalonepublisher"
]
- def _item_initalization(self):
+ def _item_initialization(self):
self.multiselection = self.schema_data.get("multiselection", True)
use_empty_value = False
if not self.multiselection:
@@ -250,7 +250,7 @@ class HostsEnumEntity(BaseEnumEntity):
class AppsEnumEntity(BaseEnumEntity):
schema_types = ["apps-enum"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.multiselection = True
self.value_on_not_set = []
self.enum_items = []
@@ -317,7 +317,7 @@ class AppsEnumEntity(BaseEnumEntity):
class ToolsEnumEntity(BaseEnumEntity):
schema_types = ["tools-enum"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.multiselection = True
self.value_on_not_set = []
self.enum_items = []
@@ -376,7 +376,7 @@ class ToolsEnumEntity(BaseEnumEntity):
class TaskTypeEnumEntity(BaseEnumEntity):
schema_types = ["task-types-enum"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.multiselection = self.schema_data.get("multiselection", True)
if self.multiselection:
self.valid_value_types = (list, )
@@ -452,7 +452,7 @@ class TaskTypeEnumEntity(BaseEnumEntity):
class DeadlineUrlEnumEntity(BaseEnumEntity):
schema_types = ["deadline_url-enum"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.multiselection = self.schema_data.get("multiselection", True)
self.enum_items = []
@@ -503,7 +503,7 @@ class DeadlineUrlEnumEntity(BaseEnumEntity):
class AnatomyTemplatesEnumEntity(BaseEnumEntity):
schema_types = ["anatomy-templates-enum"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.multiselection = False
self.enum_items = []
diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py
index 0ded3ab7e5..a0598d405e 100644
--- a/openpype/settings/entities/input_entities.py
+++ b/openpype/settings/entities/input_entities.py
@@ -362,7 +362,7 @@ class NumberEntity(InputEntity):
float_number_regex = re.compile(r"^\d+\.\d+$")
int_number_regex = re.compile(r"^\d+$")
- def _item_initalization(self):
+ def _item_initialization(self):
self.minimum = self.schema_data.get("minimum", -99999)
self.maximum = self.schema_data.get("maximum", 99999)
self.decimal = self.schema_data.get("decimal", 0)
@@ -420,7 +420,7 @@ class NumberEntity(InputEntity):
class BoolEntity(InputEntity):
schema_types = ["boolean"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.valid_value_types = (bool, )
value_on_not_set = self.convert_to_valid_type(
self.schema_data.get("default", True)
@@ -431,7 +431,7 @@ class BoolEntity(InputEntity):
class TextEntity(InputEntity):
schema_types = ["text"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.valid_value_types = (STRING_TYPE, )
self.value_on_not_set = ""
@@ -449,7 +449,7 @@ class TextEntity(InputEntity):
class PathInput(InputEntity):
schema_types = ["path-input"]
- def _item_initalization(self):
+ def _item_initialization(self):
self.valid_value_types = (STRING_TYPE, )
self.value_on_not_set = ""
@@ -460,7 +460,7 @@ class PathInput(InputEntity):
class RawJsonEntity(InputEntity):
schema_types = ["raw-json"]
- def _item_initalization(self):
+ def _item_initialization(self):
# Schema must define if valid value is dict or list
store_as_string = self.schema_data.get("store_as_string", False)
is_list = self.schema_data.get("is_list", False)
diff --git a/openpype/settings/entities/item_entities.py b/openpype/settings/entities/item_entities.py
index c7c9c3097e..ff0a982900 100644
--- a/openpype/settings/entities/item_entities.py
+++ b/openpype/settings/entities/item_entities.py
@@ -48,7 +48,7 @@ class PathEntity(ItemEntity):
raise AttributeError(self.attribute_error_msg.format("items"))
return self.child_obj.items()
- def _item_initalization(self):
+ def _item_initialization(self):
if self.group_item is None and not self.is_group:
self.is_group = True
@@ -216,7 +216,7 @@ class ListStrictEntity(ItemEntity):
return self.children[idx]
return default
- def _item_initalization(self):
+ def _item_initialization(self):
self.valid_value_types = (list, )
self.require_key = True
diff --git a/openpype/settings/entities/list_entity.py b/openpype/settings/entities/list_entity.py
index b06f4d7a2e..5d89a81351 100644
--- a/openpype/settings/entities/list_entity.py
+++ b/openpype/settings/entities/list_entity.py
@@ -149,7 +149,7 @@ class ListEntity(EndpointEntity):
return list(value)
return NOT_SET
- def _item_initalization(self):
+ def _item_initialization(self):
self.valid_value_types = (list, )
self.children = []
self.value_on_not_set = []
diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py
index 05d20ee60b..b8baed8a93 100644
--- a/openpype/settings/entities/root_entities.py
+++ b/openpype/settings/entities/root_entities.py
@@ -65,7 +65,7 @@ class RootEntity(BaseItemEntity):
super(RootEntity, self).__init__(schema_data)
self._require_restart_callbacks = []
self._item_ids_require_restart = set()
- self._item_initalization()
+ self._item_initialization()
if reset:
self.reset()
@@ -176,7 +176,7 @@ class RootEntity(BaseItemEntity):
for child_obj in added_children:
self.gui_layout.append(child_obj)
- def _item_initalization(self):
+ def _item_initialization(self):
# Store `self` to `root_item` for children entities
self.root_item = self
diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md
index 5258fef9ec..4e8dcc36ce 100644
--- a/openpype/settings/entities/schemas/README.md
+++ b/openpype/settings/entities/schemas/README.md
@@ -208,6 +208,25 @@
}
```
+## dict-roots
+- entity can be used only in Project settings
+- keys of dictionary are based on current project roots
+- they are not updated "live" it is required to save root changes and then
+ modify values on this entity
+ # TODO do live updates
+```
+{
+ "type": "dict-roots",
+ "key": "roots",
+ "label": "Roots",
+ "object_type": {
+ "type": "path",
+ "multiplatform": true,
+ "multipath": false
+ }
+}
+```
+
## dict-conditional
- is similar to `dict` but has always available one enum entity
- the enum entity has single selection and it's value define other children entities
diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json
index 8e4b4d0646..f00bf78fe4 100644
--- a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json
+++ b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json
@@ -81,7 +81,12 @@
{
"key": "family",
"label": "Resulting family",
- "type": "text"
+ "type": "enum",
+ "enum_items": [
+ {
+ "image": "image"
+ }
+ ]
},
{
"type": "text",
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json
index 3c589f9492..7423d6fd3e 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json
@@ -358,6 +358,38 @@
]
}
]
+ },
+ {
+ "key": "maya",
+ "type": "dict",
+ "label": "Maya",
+ "children": [
+ {
+ "key": "colorManagementPreference",
+ "type": "dict",
+ "label": "Color Managment Preference",
+ "collapsible": false,
+ "children": [
+ {
+ "type": "path",
+ "key": "configFilePath",
+ "label": "OCIO Config File Path",
+ "multiplatform": true,
+ "multipath": true
+ },
+ {
+ "type": "text",
+ "key": "renderSpace",
+ "label": "Rendering Space"
+ },
+ {
+ "type": "text",
+ "key": "viewTransform",
+ "label": "Viewer Transform"
+ }
+ ]
+ }
+ ]
}
]
}
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 cbacd12efa..58fe4cc4b1 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
@@ -274,6 +274,10 @@
"key": "ValidateMeshLaminaFaces",
"label": "ValidateMeshLaminaFaces"
},
+ {
+ "key": "ValidateMeshNgons",
+ "label": "ValidateMeshNgons"
+ },
{
"key": "ValidateMeshNonManifold",
"label": "ValidateMeshNonManifold"
diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json
index 7bcd89c650..0687b9699b 100644
--- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json
+++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json
@@ -20,26 +20,21 @@
"type": "raw-json"
},
{
- "type": "dict",
+ "type": "dict-modifiable",
"key": "variants",
- "children": [
- {
- "type": "schema_template",
- "name": "template_host_variant",
- "template_data": [
- {
- "app_variant_label": "2020",
- "app_variant": "2020",
- "variant_skip_paths": ["use_python_2"]
- },
- {
- "app_variant_label": "2021",
- "app_variant": "2021",
- "variant_skip_paths": ["use_python_2"]
- }
- ]
- }
- ]
+ "collapsible_key": true,
+ "use_label_wrap": false,
+ "object_type": {
+ "type": "dict",
+ "collapsible": true,
+ "children": [
+ {
+ "type": "schema_template",
+ "name": "template_host_variant_items",
+ "skip_paths": ["use_python_2"]
+ }
+ ]
+ }
}
]
}
diff --git a/openpype/style/data.json b/openpype/style/data.json
index c33c2eaa5e..26f6743d72 100644
--- a/openpype/style/data.json
+++ b/openpype/style/data.json
@@ -58,6 +58,19 @@
"hover": "rgba(168, 175, 189, 0.3)",
"selected-hover": "rgba(168, 175, 189, 0.7)"
}
+ },
+ "publisher": {
+ "error": "#AA5050",
+ "success": "#458056",
+ "warning": "#ffc671",
+ "list-view-group": {
+ "bg": "#434a56",
+ "bg-hover": "rgba(168, 175, 189, 0.3)",
+ "bg-selected-hover": "rgba(92, 173, 214, 0.4)",
+ "bg-expander": "#2C313A",
+ "bg-expander-hover": "#2d6c9f",
+ "bg-expander-selected-hover": "#3784c5"
+ }
}
}
}
diff --git a/openpype/style/style.css b/openpype/style/style.css
index d6f2460a27..0dd7b27fe4 100644
--- a/openpype/style/style.css
+++ b/openpype/style/style.css
@@ -57,10 +57,15 @@ QAbstractSpinBox:focus, QLineEdit:focus, QPlainTextEdit:focus, QTextEdit:focus{
border-color: {color:border-focus};
}
+/* Checkbox */
+QCheckBox {
+ background: transparent;
+}
+
/* Buttons */
QPushButton {
text-align:center center;
- border: 1px solid transparent;
+ border: 0px solid transparent;
border-radius: 0.2em;
padding: 3px 5px 3px 5px;
background: {color:bg-buttons};
@@ -86,15 +91,15 @@ QPushButton::menu-indicator {
}
QToolButton {
- border: none;
- background: transparent;
+ border: 0px solid transparent;
+ background: {color:bg-buttons};
border-radius: 0.2em;
padding: 2px;
}
QToolButton:hover {
- background: #333840;
- border-color: {color:border-hover};
+ background: {color:bg-button-hover};
+ color: {color:font-hover};
}
QToolButton:disabled {
@@ -104,14 +109,15 @@ QToolButton:disabled {
QToolButton[popupMode="1"], QToolButton[popupMode="MenuButtonPopup"] {
/* make way for the popup button */
padding-right: 20px;
- border: 1px solid {color:bg-buttons};
}
QToolButton::menu-button {
width: 16px;
- /* Set border only of left side. */
+ background: transparent;
border: 1px solid transparent;
- border-left: 1px solid {color:bg-buttons};
+ border-left: 1px solid qlineargradient(x1:0, y1:0, x2:0, y2:1, stop: 0 transparent, stop:0.2 {color:font}, stop:0.8 {color:font}, stop: 1 transparent);
+ padding: 3px 0px 3px 0px;
+ border-radius: 0;
}
QToolButton::menu-arrow {
@@ -571,7 +577,9 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
background: {color:bg-menu-separator};
}
-#IconBtn {}
+#IconButton {
+ padding: 4px 4px 4px 4px;
+}
/* Password dialog*/
#PasswordBtn {
@@ -595,6 +603,13 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
padding-right: 3px;
}
+#InfoText {
+ padding-left: 30px;
+ padding-top: 20px;
+ background: transparent;
+ border: 1px solid {color:border};
+}
+
#TypeEditor, #ToolEditor, #NameEditor, #NumberEditor {
background: transparent;
border-radius: 0.3em;
@@ -671,3 +686,169 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
#OptionalActionBody[state="hover"], #OptionalActionOption[state="hover"] {
background: {color:bg-view-hover};
}
+
+/* New Create/Publish UI */
+#PublishLogConsole {
+ font-family: "Roboto Mono";
+}
+
+#VariantInput[state="new"], #VariantInput[state="new"]:focus, #VariantInput[state="new"]:hover {
+ border-color: {color:publisher:success};
+}
+#VariantInput[state="invalid"], #VariantInput[state="invalid"]:focus, #VariantInput[state="invalid"]:hover {
+ border-color: {color:publisher:error};
+}
+
+#VariantInput[state="empty"], #VariantInput[state="empty"]:focus, #VariantInput[state="empty"]:hover {
+ border-color: {color:bg-inputs};
+}
+
+#VariantInput[state="exists"], #VariantInput[state="exists"]:focus, #VariantInput[state="exists"]:hover {
+ border-color: #4E76BB;
+}
+
+#MultipleItemView {
+ background: transparent;
+ border: none;
+}
+
+#MultipleItemView:item {
+ background: {color:bg-view-selection};
+ border-radius: 0.4em;
+}
+
+#InstanceListView::item {
+ border-radius: 0.3em;
+ margin: 1px;
+}
+#InstanceListGroupWidget {
+ border: none;
+ background: transparent;
+}
+
+#CardViewWidget {
+ background: {color:bg-buttons};
+ border-radius: 0.2em;
+}
+#CardViewWidget:hover {
+ background: {color:bg-button-hover};
+}
+#CardViewWidget[state="selected"] {
+ background: {color:bg-view-selection};
+}
+
+#ListViewSubsetName[state="invalid"] {
+ color: {color:publisher:error};
+}
+
+#PublishFrame {
+ background: rgba(0, 0, 0, 127);
+}
+#PublishFrame[state="1"] {
+ background: rgb(22, 25, 29);
+}
+#PublishFrame[state="2"] {
+ background: {color:bg};
+}
+
+#PublishInfoFrame {
+ background: {color:bg};
+ border: 2px solid black;
+ border-radius: 0.3em;
+}
+
+#PublishInfoFrame[state="-1"] {
+ background: rgb(194, 226, 236);
+}
+
+#PublishInfoFrame[state="0"] {
+ background: {color:publisher:error};
+}
+
+#PublishInfoFrame[state="1"] {
+ background: {color:publisher:success};
+}
+
+#PublishInfoFrame[state="2"] {
+ background: {color:publisher:warning};
+}
+
+#PublishInfoFrame QLabel {
+ color: black;
+ font-style: bold;
+}
+
+#PublishInfoMainLabel {
+ font-size: 12pt;
+}
+
+#PublishContextLabel {
+ font-size: 13pt;
+}
+
+#ValidationActionButton {
+ border-radius: 0.2em;
+ padding: 4px 6px 4px 6px;
+ background: {color:bg-buttons};
+}
+
+#ValidationActionButton:hover {
+ background: {color:bg-button-hover};
+ color: {color:font-hover};
+}
+
+#ValidationActionButton:disabled {
+ background: {color:bg-buttons-disabled};
+}
+
+#ValidationErrorTitleFrame {
+ background: {color:bg-inputs};
+ border-left: 4px solid transparent;
+}
+
+#ValidationErrorTitleFrame:hover {
+ border-left-color: {color:border};
+}
+
+#ValidationErrorTitleFrame[selected="1"] {
+ background: {color:bg};
+ border-left-color: {palette:blue-light};
+}
+
+#ValidationErrorInstanceList {
+ border-radius: 0;
+}
+
+#ValidationErrorInstanceList::item {
+ border-bottom: 1px solid {color:border};
+ border-left: 1px solid {color:border};
+}
+
+#TasksCombobox[state="invalid"], #AssetNameInput[state="invalid"] {
+ border-color: {color:publisher:error};
+}
+
+#PublishProgressBar[state="0"]::chunk {
+ background: {color:bg-buttons};
+}
+
+#PublishDetailViews {
+ background: transparent;
+}
+#PublishDetailViews::item {
+ margin: 1px 0px 1px 0px;
+}
+#PublishCommentInput {
+ padding: 0.2em;
+}
+#FamilyIconLabel {
+ font-size: 14pt;
+}
+#ArrowBtn, #ArrowBtn:disabled, #ArrowBtn:hover {
+ background: transparent;
+}
+
+#NiceCheckbox {
+ /* Default size hint of NiceCheckbox is defined by font size. */
+ font-size: 7pt;
+}
diff --git a/openpype/tests/test_mongo_performance.py b/openpype/tests/mongo_performance.py
similarity index 82%
rename from openpype/tests/test_mongo_performance.py
rename to openpype/tests/mongo_performance.py
index cd606d6483..9220c6c730 100644
--- a/openpype/tests/test_mongo_performance.py
+++ b/openpype/tests/mongo_performance.py
@@ -80,7 +80,7 @@ class TestPerformance():
file_id3 = bson.objectid.ObjectId()
self.inserted_ids.extend([file_id, file_id2, file_id3])
- version_str = "v{0:03}".format(i + 1)
+ version_str = "v{:03d}".format(i + 1)
file_name = "test_Cylinder_workfileLookdev_{}.mb".\
format(version_str)
@@ -95,7 +95,7 @@ class TestPerformance():
"family": "workfile",
"hierarchy": "Assets",
"project": {"code": "test", "name": "Test"},
- "version": 1,
+ "version": i + 1,
"asset": "Cylinder",
"representation": "mb",
"root": self.ROOT_DIR
@@ -104,8 +104,8 @@ class TestPerformance():
"name": "mb",
"parent": {"oid": '{}'.format(id)},
"data": {
- "path": "C:\\projects\\Test\\Assets\\Cylinder\\publish\\workfile\\workfileLookdev\\{}\\{}".format(version_str, file_name),
- "template": "{root}\\{project[name]}\\{hierarchy}\\{asset}\\publish\\{family}\\{subset}\\v{version:0>3}\\{project[code]}_{asset}_{subset}_v{version:0>3}<_{output}><.{frame:0>4}>.{representation}"
+ "path": "C:\\projects\\test_performance\\Assets\\Cylinder\\publish\\workfile\\workfileLookdev\\{}\\{}".format(version_str, file_name), # noqa
+ "template": "{root[work]}\\{project[name]}\\{hierarchy}\\{asset}\\publish\\{family}\\{subset}\\v{version:0>3}\\{project[code]}_{asset}_{subset}_v{version:0>3}<_{output}><.{frame:0>4}>.{representation}" # noqa
},
"type": "representation",
"schema": "openpype:representation-2.0"
@@ -188,30 +188,21 @@ class TestPerformance():
create_files=False):
ret = [
{
- "path": "{root}" + "/Test/Assets/Cylinder/publish/workfile/" +
- "workfileLookdev/v{0:03}/" +
- "test_Cylinder_A_workfileLookdev_v{0:03}.dat"
- .format(i, i),
+ "path": "{root[work]}" + "{root[work]}/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/v{:03d}/test_Cylinder_A_workfileLookdev_v{:03d}.dat".format(i, i), #noqa
"_id": '{}'.format(file_id),
"hash": "temphash",
"sites": self.get_sites(self.MAX_NUMBER_OF_SITES),
"size": random.randint(0, self.MAX_FILE_SIZE_B)
},
{
- "path": "{root}" + "/Test/Assets/Cylinder/publish/workfile/" +
- "workfileLookdev/v{0:03}/" +
- "test_Cylinder_B_workfileLookdev_v{0:03}.dat"
- .format(i, i),
+ "path": "{root[work]}" + "/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/v{:03d}/test_Cylinder_B_workfileLookdev_v{:03d}.dat".format(i, i), #noqa
"_id": '{}'.format(file_id2),
"hash": "temphash",
"sites": self.get_sites(self.MAX_NUMBER_OF_SITES),
"size": random.randint(0, self.MAX_FILE_SIZE_B)
},
{
- "path": "{root}" + "/Test/Assets/Cylinder/publish/workfile/" +
- "workfileLookdev/v{0:03}/" +
- "test_Cylinder_C_workfileLookdev_v{0:03}.dat"
- .format(i, i),
+ "path": "{root[work]}" + "/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/v{:03d}/test_Cylinder_C_workfileLookdev_v{:03d}.dat".format(i, i), #noqa
"_id": '{}'.format(file_id3),
"hash": "temphash",
"sites": self.get_sites(self.MAX_NUMBER_OF_SITES),
@@ -221,7 +212,7 @@ class TestPerformance():
]
if create_files:
for f in ret:
- path = f.get("path").replace("{root}", self.ROOT_DIR)
+ path = f.get("path").replace("{root[work]}", self.ROOT_DIR)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'wb') as fp:
fp.write(os.urandom(f.get("size")))
@@ -231,26 +222,26 @@ class TestPerformance():
def get_files_doc(self, i, file_id, file_id2, file_id3):
ret = {}
ret['{}'.format(file_id)] = {
- "path": "{root}" +
- "/Test/Assets/Cylinder/publish/workfile/workfileLookdev/"
- "v001/test_CylinderA_workfileLookdev_v{0:03}.mb".format(i),
+ "path": "{root[work]}" +
+ "/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/" #noqa
+ "v{:03d}/test_CylinderA_workfileLookdev_v{:03d}.mb".format(i, i), # noqa
"hash": "temphash",
"sites": ["studio"],
"size": 87236
}
ret['{}'.format(file_id2)] = {
- "path": "{root}" +
- "/Test/Assets/Cylinder/publish/workfile/workfileLookdev/"
- "v001/test_CylinderB_workfileLookdev_v{0:03}.mb".format(i),
+ "path": "{root[work]}" +
+ "/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/" #noqa
+ "v{:03d}/test_CylinderB_workfileLookdev_v{:03d}.mb".format(i, i), # noqa
"hash": "temphash",
"sites": ["studio"],
"size": 87236
}
ret['{}'.format(file_id3)] = {
- "path": "{root}" +
- "/Test/Assets/Cylinder/publish/workfile/workfileLookdev/"
- "v001/test_CylinderC_workfileLookdev_v{0:03}.mb".format(i),
+ "path": "{root[work]}" +
+ "/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/" #noqa
+ "v{:03d}/test_CylinderC_workfileLookdev_v{:03d}.mb".format(i, i), # noqa
"hash": "temphash",
"sites": ["studio"],
"size": 87236
@@ -287,7 +278,7 @@ class TestPerformance():
if __name__ == '__main__':
tp = TestPerformance('array')
- tp.prepare(no_of_records=10, create_files=True) # enable to prepare data
+ tp.prepare(no_of_records=10000, create_files=True)
# tp.run(10, 3)
# print('-'*50)
diff --git a/openpype/tools/experimental_tools/dialog.py b/openpype/tools/experimental_tools/dialog.py
index 0fd170b31e..ad65caa8e3 100644
--- a/openpype/tools/experimental_tools/dialog.py
+++ b/openpype/tools/experimental_tools/dialog.py
@@ -29,6 +29,7 @@ class ExperimentalToolsDialog(QtWidgets.QDialog):
self.setWindowTitle("OpenPype Experimental tools")
icon = QtGui.QIcon(app_icon_path())
self.setWindowIcon(icon)
+ self.setStyleSheet(load_stylesheet())
# Widgets for cases there are not available experimental tools
empty_widget = QtWidgets.QWidget(self)
@@ -80,7 +81,9 @@ class ExperimentalToolsDialog(QtWidgets.QDialog):
tool_btns_layout.addWidget(separator_widget, 0)
tool_btns_layout.addWidget(tool_btns_label, 0)
- experimental_tools = ExperimentalTools()
+ experimental_tools = ExperimentalTools(
+ parent=parent, filter_hosts=True
+ )
# Main layout
layout = QtWidgets.QVBoxLayout(self)
diff --git a/openpype/tools/experimental_tools/tools_def.py b/openpype/tools/experimental_tools/tools_def.py
index 254f542c4d..991eb5e4a3 100644
--- a/openpype/tools/experimental_tools/tools_def.py
+++ b/openpype/tools/experimental_tools/tools_def.py
@@ -63,7 +63,14 @@ class ExperimentalTools:
"""
def __init__(self, parent=None, host_name=None, filter_hosts=None):
# Definition of experimental tools
- experimental_tools = []
+ experimental_tools = [
+ ExperimentalTool(
+ "publisher",
+ "New publisher",
+ self._show_publisher,
+ "Combined creation and publishing into one tool."
+ )
+ ]
# --- Example tool (callback will just print on click) ---
# def example_callback(*args):
@@ -110,6 +117,8 @@ class ExperimentalTools:
self._tools = experimental_tools
self._parent_widget = parent
+ self._publisher_tool = None
+
@property
def tools(self):
"""Tools in list.
@@ -140,3 +149,13 @@ class ExperimentalTools:
for identifier, eperimental_tool in self.tools_by_identifier.items():
enabled = experimental_settings.get(identifier, False)
eperimental_tool.set_enabled(enabled)
+
+ def _show_publisher(self):
+ if self._publisher_tool is None:
+ from openpype.tools import publisher
+
+ self._publisher_tool = publisher.PublisherWindow(
+ parent=self._parent_widget
+ )
+
+ self._publisher_tool.show()
diff --git a/openpype/tools/launcher/flickcharm.py b/openpype/tools/flickcharm.py
similarity index 100%
rename from openpype/tools/launcher/flickcharm.py
rename to openpype/tools/flickcharm.py
diff --git a/openpype/tools/launcher/widgets.py b/openpype/tools/launcher/widgets.py
index 35c7d98be1..5e01488ae6 100644
--- a/openpype/tools/launcher/widgets.py
+++ b/openpype/tools/launcher/widgets.py
@@ -6,8 +6,8 @@ from avalon.vendor import qtawesome
from .delegates import ActionDelegate
from . import lib
-from .models import TaskModel, ActionModel, ProjectModel
-from .flickcharm import FlickCharm
+from .models import TaskModel, ActionModel
+from openpype.tools.flickcharm import FlickCharm
from .constants import (
ACTION_ROLE,
GROUP_ROLE,
diff --git a/openpype/tools/launcher/window.py b/openpype/tools/launcher/window.py
index 9b839fb2bc..454445824e 100644
--- a/openpype/tools/launcher/window.py
+++ b/openpype/tools/launcher/window.py
@@ -8,8 +8,7 @@ from avalon.api import AvalonMongoDB
from openpype import style
from openpype.api import resources
-from avalon.tools import lib as tools_lib
-from avalon.tools.widgets import AssetWidget
+from openpype.tools.utils.widgets import AssetWidget
from avalon.vendor import qtawesome
from .models import ProjectModel
from .lib import get_action_label, ProjectHandler
@@ -21,7 +20,7 @@ from .widgets import (
SlidePageWidget
)
-from .flickcharm import FlickCharm
+from openpype.tools.flickcharm import FlickCharm
class ProjectIconView(QtWidgets.QListView):
diff --git a/openpype/tools/mayalookassigner/app.py b/openpype/tools/mayalookassigner/app.py
index 0f5a930902..d723387f2d 100644
--- a/openpype/tools/mayalookassigner/app.py
+++ b/openpype/tools/mayalookassigner/app.py
@@ -7,7 +7,7 @@ from Qt import QtWidgets, QtCore
from openpype.hosts.maya.api.lib import assign_look_by_version
from avalon import style, io
-from avalon.tools import lib
+from openpype.tools.utils.lib import qt_app_context
from maya import cmds
# old api for MFileIO
@@ -258,7 +258,7 @@ def show():
mainwindow = next(widget for widget in top_level_widgets
if widget.objectName() == "MayaWindow")
- with lib.application():
+ with qt_app_context():
window = App(parent=mainwindow)
window.setStyleSheet(style.load_stylesheet())
window.show()
diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py
index 5b6ed78b50..b7ab9e40d0 100644
--- a/openpype/tools/project_manager/project_manager/model.py
+++ b/openpype/tools/project_manager/project_manager/model.py
@@ -1456,7 +1456,11 @@ class HierarchyModel(QtCore.QAbstractItemModel):
return
raw_data = mime_data.data("application/copy_task")
- encoded_data = QtCore.QByteArray.fromRawData(raw_data)
+ if isinstance(raw_data, QtCore.QByteArray):
+ # Raw data are already QByteArrat and we don't have to load them
+ encoded_data = raw_data
+ else:
+ encoded_data = QtCore.QByteArray.fromRawData(raw_data)
stream = QtCore.QDataStream(encoded_data, QtCore.QIODevice.ReadOnly)
text = stream.readQString()
try:
diff --git a/openpype/tools/publisher/__init__.py b/openpype/tools/publisher/__init__.py
new file mode 100644
index 0000000000..a7b597eece
--- /dev/null
+++ b/openpype/tools/publisher/__init__.py
@@ -0,0 +1,7 @@
+from .app import show
+from .window import PublisherWindow
+
+__all__ = (
+ "show",
+ "PublisherWindow"
+)
diff --git a/openpype/tools/publisher/app.py b/openpype/tools/publisher/app.py
new file mode 100644
index 0000000000..bc1bd7cfbd
--- /dev/null
+++ b/openpype/tools/publisher/app.py
@@ -0,0 +1,17 @@
+from .window import PublisherWindow
+
+
+class _WindowCache:
+ window = None
+
+
+def show(parent=None):
+ window = _WindowCache.window
+ if window is None:
+ window = PublisherWindow(parent)
+ _WindowCache.window = window
+
+ window.show()
+ window.activateWindow()
+
+ return window
diff --git a/openpype/tools/publisher/constants.py b/openpype/tools/publisher/constants.py
new file mode 100644
index 0000000000..cf0850bde8
--- /dev/null
+++ b/openpype/tools/publisher/constants.py
@@ -0,0 +1,34 @@
+from Qt import QtCore
+
+# ID of context item in instance view
+CONTEXT_ID = "context"
+CONTEXT_LABEL = "Options"
+
+# Allowed symbols for subset name (and variant)
+# - characters, numbers, unsercore and dash
+SUBSET_NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_."
+VARIANT_TOOLTIP = (
+ "Variant may contain alphabetical characters (a-Z)"
+ "\nnumerical characters (0-9) dot (\".\") or underscore (\"_\")."
+)
+
+# Roles for instance views
+INSTANCE_ID_ROLE = QtCore.Qt.UserRole + 1
+SORT_VALUE_ROLE = QtCore.Qt.UserRole + 2
+IS_GROUP_ROLE = QtCore.Qt.UserRole + 3
+CREATOR_IDENTIFIER_ROLE = QtCore.Qt.UserRole + 4
+FAMILY_ROLE = QtCore.Qt.UserRole + 5
+
+
+__all__ = (
+ "CONTEXT_ID",
+
+ "SUBSET_NAME_ALLOWED_SYMBOLS",
+ "VARIANT_TOOLTIP",
+
+ "INSTANCE_ID_ROLE",
+ "SORT_VALUE_ROLE",
+ "IS_GROUP_ROLE",
+ "CREATOR_IDENTIFIER_ROLE",
+ "FAMILY_ROLE"
+)
diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py
new file mode 100644
index 0000000000..24ec9dcb0e
--- /dev/null
+++ b/openpype/tools/publisher/control.py
@@ -0,0 +1,991 @@
+import os
+import copy
+import inspect
+import logging
+import traceback
+import collections
+
+import weakref
+try:
+ from weakref import WeakMethod
+except Exception:
+ from openpype.lib.python_2_comp import WeakMethod
+
+import avalon.api
+import pyblish.api
+
+from openpype.pipeline import PublishValidationError
+from openpype.pipeline.create import CreateContext
+
+from Qt import QtCore
+
+# Define constant for plugin orders offset
+PLUGIN_ORDER_OFFSET = 0.5
+
+
+class MainThreadItem:
+ """Callback with args and kwargs."""
+ def __init__(self, callback, *args, **kwargs):
+ self.callback = callback
+ self.args = args
+ self.kwargs = kwargs
+
+ def process(self):
+ self.callback(*self.args, **self.kwargs)
+
+
+class MainThreadProcess(QtCore.QObject):
+ """Qt based main thread process executor.
+
+ Has timer which controls each 50ms if there is new item to process.
+
+ This approach gives ability to update UI meanwhile plugin is in progress.
+ """
+ def __init__(self):
+ super(MainThreadProcess, self).__init__()
+ self._items_to_process = collections.deque()
+
+ timer = QtCore.QTimer()
+ timer.setInterval(50)
+
+ timer.timeout.connect(self._execute)
+
+ self._timer = timer
+
+ def add_item(self, item):
+ self._items_to_process.append(item)
+
+ def _execute(self):
+ if not self._items_to_process:
+ return
+
+ item = self._items_to_process.popleft()
+ item.process()
+
+ def start(self):
+ if not self._timer.isActive():
+ self._timer.start()
+
+ def stop(self):
+ if self._timer.isActive():
+ self._timer.stop()
+
+ def clear(self):
+ if self._timer.isActive():
+ self._timer.stop()
+ self._items_to_process = collections.deque()
+
+
+class AssetDocsCache:
+ """Cache asset documents for creation part."""
+ projection = {
+ "_id": True,
+ "name": True,
+ "data.visualParent": True,
+ "data.tasks": True
+ }
+
+ def __init__(self, controller):
+ self._controller = controller
+ self._asset_docs = None
+ self._task_names_by_asset_name = {}
+
+ @property
+ def dbcon(self):
+ return self._controller.dbcon
+
+ def reset(self):
+ self._asset_docs = None
+ self._task_names_by_asset_name = {}
+
+ def _query(self):
+ if self._asset_docs is None:
+ asset_docs = list(self.dbcon.find(
+ {"type": "asset"},
+ self.projection
+ ))
+ task_names_by_asset_name = {}
+ for asset_doc in asset_docs:
+ asset_name = asset_doc["name"]
+ asset_tasks = asset_doc.get("data", {}).get("tasks") or {}
+ task_names_by_asset_name[asset_name] = list(asset_tasks.keys())
+ self._asset_docs = asset_docs
+ self._task_names_by_asset_name = task_names_by_asset_name
+
+ def get_asset_docs(self):
+ self._query()
+ return copy.deepcopy(self._asset_docs)
+
+ def get_task_names_by_asset_name(self):
+ self._query()
+ return copy.deepcopy(self._task_names_by_asset_name)
+
+
+class PublishReport:
+ """Report for single publishing process.
+
+ Report keeps current state of publishing and currently processed plugin.
+ """
+ def __init__(self, controller):
+ self.controller = controller
+ self._publish_discover_result = None
+ self._plugin_data = []
+ self._plugin_data_with_plugin = []
+
+ self._stored_plugins = []
+ self._current_plugin_data = []
+ self._all_instances_by_id = {}
+ self._current_context = None
+
+ def reset(self, context, publish_discover_result=None):
+ """Reset report and clear all data."""
+ self._publish_discover_result = publish_discover_result
+ self._plugin_data = []
+ self._plugin_data_with_plugin = []
+ self._current_plugin_data = {}
+ self._all_instances_by_id = {}
+ self._current_context = context
+
+ def add_plugin_iter(self, plugin, context):
+ """Add report about single iteration of plugin."""
+ for instance in context:
+ self._all_instances_by_id[instance.id] = instance
+
+ if self._current_plugin_data:
+ self._current_plugin_data["passed"] = True
+
+ self._current_plugin_data = self._add_plugin_data_item(plugin)
+
+ def _get_plugin_data_item(self, plugin):
+ store_item = None
+ for item in self._plugin_data_with_plugin:
+ if item["plugin"] is plugin:
+ store_item = item["data"]
+ break
+ return store_item
+
+ def _add_plugin_data_item(self, plugin):
+ if plugin in self._stored_plugins:
+ raise ValueError("Plugin is already stored")
+
+ self._stored_plugins.append(plugin)
+
+ label = None
+ if hasattr(plugin, "label"):
+ label = plugin.label
+
+ plugin_data_item = {
+ "name": plugin.__name__,
+ "label": label,
+ "order": plugin.order,
+ "instances_data": [],
+ "actions_data": [],
+ "skipped": False,
+ "passed": False
+ }
+ self._plugin_data_with_plugin.append({
+ "plugin": plugin,
+ "data": plugin_data_item
+ })
+ self._plugin_data.append(plugin_data_item)
+ return plugin_data_item
+
+ def set_plugin_skipped(self):
+ """Set that current plugin has been skipped."""
+ self._current_plugin_data["skipped"] = True
+
+ def add_result(self, result):
+ """Handle result of one plugin and it's instance."""
+ instance = result["instance"]
+ instance_id = None
+ if instance is not None:
+ instance_id = instance.id
+ self._current_plugin_data["instances_data"].append({
+ "id": instance_id,
+ "logs": self._extract_instance_log_items(result)
+ })
+
+ def add_action_result(self, action, result):
+ """Add result of single action."""
+ plugin = result["plugin"]
+
+ store_item = self._get_plugin_data_item(plugin)
+ if store_item is None:
+ store_item = self._add_plugin_data_item(plugin)
+
+ action_name = action.__name__
+ action_label = action.label or action_name
+ log_items = self._extract_log_items(result)
+ store_item["actions_data"].append({
+ "success": result["success"],
+ "name": action_name,
+ "label": action_label,
+ "logs": log_items
+ })
+
+ def get_report(self, publish_plugins=None):
+ """Report data with all details of current state."""
+ instances_details = {}
+ for instance in self._all_instances_by_id.values():
+ instances_details[instance.id] = self._extract_instance_data(
+ instance, instance in self._current_context
+ )
+
+ plugins_data = copy.deepcopy(self._plugin_data)
+ if plugins_data and not plugins_data[-1]["passed"]:
+ plugins_data[-1]["passed"] = True
+
+ if publish_plugins:
+ for plugin in publish_plugins:
+ if plugin not in self._stored_plugins:
+ plugins_data.append(self._add_plugin_data_item(plugin))
+
+ crashed_file_paths = {}
+ if self._publish_discover_result is not None:
+ items = self._publish_discover_result.crashed_file_paths.items()
+ for filepath, exc_info in items:
+ crashed_file_paths[filepath] = "".join(
+ traceback.format_exception(*exc_info)
+ )
+
+ return {
+ "plugins_data": plugins_data,
+ "instances": instances_details,
+ "context": self._extract_context_data(self._current_context),
+ "crashed_file_paths": crashed_file_paths
+ }
+
+ def _extract_context_data(self, context):
+ return {
+ "label": context.data.get("label")
+ }
+
+ def _extract_instance_data(self, instance, exists):
+ return {
+ "name": instance.data.get("name"),
+ "label": instance.data.get("label"),
+ "family": instance.data["family"],
+ "families": instance.data.get("families") or [],
+ "exists": exists
+ }
+
+ def _extract_instance_log_items(self, result):
+ instance = result["instance"]
+ instance_id = None
+ if instance:
+ instance_id = instance.id
+
+ log_items = self._extract_log_items(result)
+ for item in log_items:
+ item["instance_id"] = instance_id
+ return log_items
+
+ def _extract_log_items(self, result):
+ output = []
+ records = result.get("records") or []
+ for record in records:
+ record_exc_info = record.exc_info
+ if record_exc_info is not None:
+ record_exc_info = "".join(
+ traceback.format_exception(*record_exc_info)
+ )
+
+ try:
+ msg = record.getMessage()
+ except Exception:
+ msg = str(record.msg)
+
+ output.append({
+ "type": "record",
+ "msg": msg,
+ "name": record.name,
+ "lineno": record.lineno,
+ "levelno": record.levelno,
+ "levelname": record.levelname,
+ "threadName": record.threadName,
+ "filename": record.filename,
+ "pathname": record.pathname,
+ "msecs": record.msecs,
+ "exc_info": record_exc_info
+ })
+
+ exception = result.get("error")
+ if exception:
+ fname, line_no, func, exc = exception.traceback
+ output.append({
+ "type": "error",
+ "msg": str(exception),
+ "filename": str(fname),
+ "lineno": str(line_no),
+ "func": str(func),
+ "traceback": exception.formatted_traceback
+ })
+
+ return output
+
+
+class PublisherController:
+ """Middleware between UI, CreateContext and publish Context.
+
+ Handle both creation and publishing parts.
+
+ Args:
+ dbcon (AvalonMongoDB): Connection to mongo with context.
+ headless (bool): Headless publishing. ATM not implemented or used.
+ """
+ def __init__(self, dbcon=None, headless=False):
+ self.log = logging.getLogger("PublisherController")
+ self.host = avalon.api.registered_host()
+ self.headless = headless
+
+ self.create_context = CreateContext(
+ self.host, dbcon, headless=headless, reset=False
+ )
+
+ # pyblish.api.Context
+ self._publish_context = None
+ # Pyblish report
+ self._publish_report = PublishReport(self)
+ # Store exceptions of validation error
+ self._publish_validation_errors = []
+ # Currently processing plugin errors
+ self._publish_current_plugin_validation_errors = None
+ # Any other exception that happened during publishing
+ self._publish_error = None
+ # Publishing is in progress
+ self._publish_is_running = False
+ # Publishing is over validation order
+ self._publish_validated = False
+ # Publishing should stop at validation stage
+ self._publish_up_validation = False
+ # All publish plugins are processed
+ self._publish_finished = False
+ self._publish_max_progress = 0
+ self._publish_progress = 0
+ # This information is not much important for controller but for widget
+ # which can change (and set) the comment.
+ self._publish_comment_is_set = False
+
+ # Validation order
+ # - plugin with order same or higher than this value is extractor or
+ # higher
+ self._validation_order = (
+ pyblish.api.ValidatorOrder + PLUGIN_ORDER_OFFSET
+ )
+
+ # Qt based main thread processor
+ self._main_thread_processor = MainThreadProcess()
+ # Plugin iterator
+ self._main_thread_iter = None
+
+ # Variables where callbacks are stored
+ self._instances_refresh_callback_refs = set()
+ self._plugins_refresh_callback_refs = set()
+
+ self._publish_reset_callback_refs = set()
+ self._publish_started_callback_refs = set()
+ self._publish_validated_callback_refs = set()
+ self._publish_stopped_callback_refs = set()
+
+ self._publish_instance_changed_callback_refs = set()
+ self._publish_plugin_changed_callback_refs = set()
+
+ # State flags to prevent executing method which is already in progress
+ self._resetting_plugins = False
+ self._resetting_instances = False
+
+ # Cacher of avalon documents
+ self._asset_docs_cache = AssetDocsCache(self)
+
+ @property
+ def project_name(self):
+ """Current project context."""
+ return self.dbcon.Session["AVALON_PROJECT"]
+
+ @property
+ def dbcon(self):
+ """Pointer to AvalonMongoDB in creator context."""
+ return self.create_context.dbcon
+
+ @property
+ def instances(self):
+ """Current instances in create context."""
+ return self.create_context.instances
+
+ @property
+ def creators(self):
+ """All creators loaded in create context."""
+ return self.create_context.creators
+
+ @property
+ def manual_creators(self):
+ """Creators that can be shown in create dialog."""
+ return self.create_context.manual_creators
+
+ @property
+ def host_is_valid(self):
+ """Host is valid for creation."""
+ return self.create_context.host_is_valid
+
+ @property
+ def publish_plugins(self):
+ """Publish plugins."""
+ return self.create_context.publish_plugins
+
+ @property
+ def plugins_with_defs(self):
+ """Publish plugins with possible attribute definitions."""
+ return self.create_context.plugins_with_defs
+
+ def _create_reference(self, callback):
+ if inspect.ismethod(callback):
+ ref = WeakMethod(callback)
+ elif callable(callback):
+ ref = weakref.ref(callback)
+ else:
+ raise TypeError("Expected function or method got {}".format(
+ str(type(callback))
+ ))
+ return ref
+
+ def add_instances_refresh_callback(self, callback):
+ """Callbacks triggered on instances refresh."""
+ ref = self._create_reference(callback)
+ self._instances_refresh_callback_refs.add(ref)
+
+ def add_plugins_refresh_callback(self, callback):
+ """Callbacks triggered on plugins refresh."""
+ ref = self._create_reference(callback)
+ self._plugins_refresh_callback_refs.add(ref)
+
+ # --- Publish specific callbacks ---
+ def add_publish_reset_callback(self, callback):
+ """Callbacks triggered on publishing reset."""
+ ref = self._create_reference(callback)
+ self._publish_reset_callback_refs.add(ref)
+
+ def add_publish_started_callback(self, callback):
+ """Callbacks triggered on publishing start."""
+ ref = self._create_reference(callback)
+ self._publish_started_callback_refs.add(ref)
+
+ def add_publish_validated_callback(self, callback):
+ """Callbacks triggered on passing last possible validation order."""
+ ref = self._create_reference(callback)
+ self._publish_validated_callback_refs.add(ref)
+
+ def add_instance_change_callback(self, callback):
+ """Callbacks triggered before next publish instance process."""
+ ref = self._create_reference(callback)
+ self._publish_instance_changed_callback_refs.add(ref)
+
+ def add_plugin_change_callback(self, callback):
+ """Callbacks triggered before next plugin processing."""
+ ref = self._create_reference(callback)
+ self._publish_plugin_changed_callback_refs.add(ref)
+
+ def add_publish_stopped_callback(self, callback):
+ """Callbacks triggered on publishing stop (any reason)."""
+ ref = self._create_reference(callback)
+ self._publish_stopped_callback_refs.add(ref)
+
+ def get_asset_docs(self):
+ """Get asset documents from cache for whole project."""
+ return self._asset_docs_cache.get_asset_docs()
+
+ def get_context_title(self):
+ """Get context title for artist shown at the top of main window."""
+ context_title = None
+ if hasattr(self.host, "get_context_title"):
+ context_title = self.host.get_context_title()
+
+ if context_title is None:
+ context_title = os.environ.get("AVALON_APP_NAME")
+ if context_title is None:
+ context_title = os.environ.get("AVALON_APP")
+
+ return context_title
+
+ def get_asset_hierarchy(self):
+ """Prepare asset documents into hierarchy."""
+ _queue = collections.deque(self.get_asset_docs())
+
+ output = collections.defaultdict(list)
+ while _queue:
+ asset_doc = _queue.popleft()
+ parent_id = asset_doc["data"]["visualParent"]
+ output[parent_id].append(asset_doc)
+ return output
+
+ def get_task_names_by_asset_names(self, asset_names):
+ """Prepare task names by asset name."""
+ task_names_by_asset_name = (
+ self._asset_docs_cache.get_task_names_by_asset_name()
+ )
+ result = {}
+ for asset_name in asset_names:
+ result[asset_name] = set(
+ task_names_by_asset_name.get(asset_name) or []
+ )
+ return result
+
+ def _trigger_callbacks(self, callbacks, *args, **kwargs):
+ """Helper method to trigger callbacks stored by their rerence."""
+ # Trigger reset callbacks
+ to_remove = set()
+ for ref in callbacks:
+ callback = ref()
+ if callback:
+ callback(*args, **kwargs)
+ else:
+ to_remove.add(ref)
+
+ for ref in to_remove:
+ callbacks.remove(ref)
+
+ def reset(self):
+ """Reset everything related to creation and publishing."""
+ # Stop publishing
+ self.stop_publish()
+
+ # Reset avalon context
+ self.create_context.reset_avalon_context()
+
+ self._reset_plugins()
+ # Publish part must be resetted after plugins
+ self._reset_publish()
+ self._reset_instances()
+
+ def _reset_plugins(self):
+ """Reset to initial state."""
+ if self._resetting_plugins:
+ return
+
+ self._resetting_plugins = True
+
+ self.create_context.reset_plugins()
+
+ self._resetting_plugins = False
+
+ self._trigger_callbacks(self._plugins_refresh_callback_refs)
+
+ def _reset_instances(self):
+ """Reset create instances."""
+ if self._resetting_instances:
+ return
+
+ self._resetting_instances = True
+
+ self.create_context.reset_context_data()
+ with self.create_context.bulk_instances_collection():
+ self.create_context.reset_instances()
+ self.create_context.execute_autocreators()
+
+ self._resetting_instances = False
+
+ self._trigger_callbacks(self._instances_refresh_callback_refs)
+
+ def get_creator_attribute_definitions(self, instances):
+ """Collect creator attribute definitions for multuple instances.
+
+ Args:
+ instances(list): List of created instances for
+ which should be attribute definitions returned.
+ """
+ output = []
+ _attr_defs = {}
+ for instance in instances:
+ for attr_def in instance.creator_attribute_defs:
+ found_idx = None
+ for idx, _attr_def in _attr_defs.items():
+ if attr_def == _attr_def:
+ found_idx = idx
+ break
+
+ value = instance.creator_attributes[attr_def.key]
+ if found_idx is None:
+ idx = len(output)
+ output.append((attr_def, [instance], [value]))
+ _attr_defs[idx] = attr_def
+ else:
+ item = output[found_idx]
+ item[1].append(instance)
+ item[2].append(value)
+ return output
+
+ def get_publish_attribute_definitions(self, instances, include_context):
+ """Collect publish attribute definitions for passed instances.
+
+ Args:
+ instances(list): List of created instances for
+ which should be attribute definitions returned.
+ include_context(bool): Add context specific attribute definitions.
+ """
+ _tmp_items = []
+ if include_context:
+ _tmp_items.append(self.create_context)
+
+ for instance in instances:
+ _tmp_items.append(instance)
+
+ all_defs_by_plugin_name = {}
+ all_plugin_values = {}
+ for item in _tmp_items:
+ for plugin_name, attr_val in item.publish_attributes.items():
+ attr_defs = attr_val.attr_defs
+ if not attr_defs:
+ continue
+
+ if plugin_name not in all_defs_by_plugin_name:
+ all_defs_by_plugin_name[plugin_name] = attr_val.attr_defs
+
+ if plugin_name not in all_plugin_values:
+ all_plugin_values[plugin_name] = {}
+
+ plugin_values = all_plugin_values[plugin_name]
+
+ for attr_def in attr_defs:
+ if attr_def.key not in plugin_values:
+ plugin_values[attr_def.key] = []
+ attr_values = plugin_values[attr_def.key]
+
+ value = attr_val[attr_def.key]
+ attr_values.append((item, value))
+
+ output = []
+ for plugin in self.plugins_with_defs:
+ plugin_name = plugin.__name__
+ if plugin_name not in all_defs_by_plugin_name:
+ continue
+ output.append((
+ plugin_name,
+ all_defs_by_plugin_name[plugin_name],
+ all_plugin_values
+ ))
+ return output
+
+ def get_icon_for_family(self, family):
+ """TODO rename to get creator icon."""
+ creator = self.creators.get(family)
+ if creator is not None:
+ return creator.get_icon()
+ return None
+
+ def create(
+ self, creator_identifier, subset_name, instance_data, options
+ ):
+ """Trigger creation and refresh of instances in UI."""
+ creator = self.creators[creator_identifier]
+ creator.create(subset_name, instance_data, options)
+
+ self._trigger_callbacks(self._instances_refresh_callback_refs)
+
+ def save_changes(self):
+ """Save changes happened during creation."""
+ if self.create_context.host_is_valid:
+ self.create_context.save_changes()
+
+ def remove_instances(self, instances):
+ """"""
+ # QUESTION Expect that instaces are really removed? In that case save
+ # reset is not required and save changes too.
+ self.save_changes()
+
+ self.create_context.remove_instances(instances)
+
+ self._trigger_callbacks(self._instances_refresh_callback_refs)
+
+ # --- Publish specific implementations ---
+ @property
+ def publish_has_finished(self):
+ return self._publish_finished
+
+ @property
+ def publish_is_running(self):
+ return self._publish_is_running
+
+ @property
+ def publish_has_validated(self):
+ return self._publish_validated
+
+ @property
+ def publish_has_crashed(self):
+ return bool(self._publish_error)
+
+ @property
+ def publish_has_validation_errors(self):
+ return bool(self._publish_validation_errors)
+
+ @property
+ def publish_max_progress(self):
+ return self._publish_max_progress
+
+ @property
+ def publish_progress(self):
+ return self._publish_progress
+
+ @property
+ def publish_comment_is_set(self):
+ return self._publish_comment_is_set
+
+ def get_publish_crash_error(self):
+ return self._publish_error
+
+ def get_publish_report(self):
+ return self._publish_report.get_report(self.publish_plugins)
+
+ def get_validation_errors(self):
+ return self._publish_validation_errors
+
+ def _reset_publish(self):
+ self._publish_is_running = False
+ self._publish_validated = False
+ self._publish_up_validation = False
+ self._publish_finished = False
+ self._publish_comment_is_set = False
+ self._main_thread_processor.clear()
+ self._main_thread_iter = self._publish_iterator()
+ self._publish_context = pyblish.api.Context()
+ # Make sure "comment" is set on publish context
+ self._publish_context.data["comment"] = ""
+ # Add access to create context during publishing
+ # - must not be used for changing CreatedInstances during publishing!
+ # QUESTION
+ # - pop the key after first collector using it would be safest option?
+ self._publish_context.data["create_context"] = self.create_context
+
+ self._publish_report.reset(
+ self._publish_context,
+ self.create_context.publish_discover_result
+ )
+ self._publish_validation_errors = []
+ self._publish_current_plugin_validation_errors = None
+ self._publish_error = None
+
+ self._publish_max_progress = len(self.publish_plugins)
+ self._publish_progress = 0
+
+ self._trigger_callbacks(self._publish_reset_callback_refs)
+
+ def set_comment(self, comment):
+ self._publish_context.data["comment"] = comment
+ self._publish_comment_is_set = True
+
+ def publish(self):
+ """Run publishing."""
+ self._publish_up_validation = False
+ self._start_publish()
+
+ def validate(self):
+ """Run publishing and stop after Validation."""
+ if self._publish_validated:
+ return
+ self._publish_up_validation = True
+ self._start_publish()
+
+ def _start_publish(self):
+ """Start or continue in publishing."""
+ if self._publish_is_running:
+ return
+
+ # Make sure changes are saved
+ self.save_changes()
+
+ self._publish_is_running = True
+ self._trigger_callbacks(self._publish_started_callback_refs)
+ self._main_thread_processor.start()
+ self._publish_next_process()
+
+ def _stop_publish(self):
+ """Stop or pause publishing."""
+ self._publish_is_running = False
+ self._main_thread_processor.stop()
+ self._trigger_callbacks(self._publish_stopped_callback_refs)
+
+ def stop_publish(self):
+ """Stop publishing process (any reason)."""
+ if self._publish_is_running:
+ self._stop_publish()
+
+ def run_action(self, plugin, action):
+ # TODO handle result in UI
+ result = pyblish.plugin.process(
+ plugin, self._publish_context, None, action.id
+ )
+ self._publish_report.add_action_result(action, result)
+
+ def _publish_next_process(self):
+ # Validations of progress before using iterator
+ # - same conditions may be inside iterator but they may be used
+ # only in specific cases (e.g. when it happens for a first time)
+
+ # There are validation errors and validation is passed
+ # - can't do any progree
+ if (
+ self._publish_validated
+ and self._publish_validation_errors
+ ):
+ item = MainThreadItem(self.stop_publish)
+
+ # Any unexpected error happened
+ # - everything should stop
+ elif self._publish_error:
+ item = MainThreadItem(self.stop_publish)
+
+ # Everything is ok so try to get new processing item
+ else:
+ item = next(self._main_thread_iter)
+
+ self._main_thread_processor.add_item(item)
+
+ def _publish_iterator(self):
+ """Main logic center of publishing.
+
+ Iterator returns `MainThreadItem` objects with callbacks that should be
+ processed in main thread (threaded in future?). Cares about changing
+ states of currently processed publish plugin and instance. Also
+ change state of processed orders like validation order has passed etc.
+
+ Also stops publishing if should stop on validation.
+
+ QUESTION:
+ Does validate button still make sense?
+ """
+ for idx, plugin in enumerate(self.publish_plugins):
+ self._publish_progress = idx
+ # Add plugin to publish report
+ self._publish_report.add_plugin_iter(plugin, self._publish_context)
+
+ # Reset current plugin validations error
+ self._publish_current_plugin_validation_errors = None
+
+ # Check if plugin is over validation order
+ if not self._publish_validated:
+ self._publish_validated = (
+ plugin.order >= self._validation_order
+ )
+ # Trigger callbacks when validation stage is passed
+ if self._publish_validated:
+ self._trigger_callbacks(
+ self._publish_validated_callback_refs
+ )
+
+ # Stop if plugin is over validation order and process
+ # should process up to validation.
+ if self._publish_up_validation and self._publish_validated:
+ yield MainThreadItem(self.stop_publish)
+
+ # Stop if validation is over and validation errors happened
+ if (
+ self._publish_validated
+ and self._publish_validation_errors
+ ):
+ yield MainThreadItem(self.stop_publish)
+
+ # Trigger callback that new plugin is going to be processed
+ self._trigger_callbacks(
+ self._publish_plugin_changed_callback_refs, plugin
+ )
+ # Plugin is instance plugin
+ if plugin.__instanceEnabled__:
+ instances = pyblish.logic.instances_by_plugin(
+ self._publish_context, plugin
+ )
+ if not instances:
+ self._publish_report.set_plugin_skipped()
+ continue
+
+ for instance in instances:
+ if instance.data.get("publish") is False:
+ continue
+
+ self._trigger_callbacks(
+ self._publish_instance_changed_callback_refs,
+ self._publish_context,
+ instance
+ )
+ yield MainThreadItem(
+ self._process_and_continue, plugin, instance
+ )
+ else:
+ families = collect_families_from_instances(
+ self._publish_context, only_active=True
+ )
+ plugins = pyblish.logic.plugins_by_families(
+ [plugin], families
+ )
+ if plugins:
+ self._trigger_callbacks(
+ self._publish_instance_changed_callback_refs,
+ self._publish_context,
+ None
+ )
+ yield MainThreadItem(
+ self._process_and_continue, plugin, None
+ )
+ else:
+ self._publish_report.set_plugin_skipped()
+
+ # Cleanup of publishing process
+ self._publish_finished = True
+ self._publish_progress = self._publish_max_progress
+ yield MainThreadItem(self.stop_publish)
+
+ def _add_validation_error(self, result):
+ if self._publish_current_plugin_validation_errors is None:
+ self._publish_current_plugin_validation_errors = {
+ "plugin": result["plugin"],
+ "errors": []
+ }
+ self._publish_validation_errors.append(
+ self._publish_current_plugin_validation_errors
+ )
+
+ self._publish_current_plugin_validation_errors["errors"].append({
+ "exception": result["error"],
+ "instance": result["instance"]
+ })
+
+ def _process_and_continue(self, plugin, instance):
+ result = pyblish.plugin.process(
+ plugin, self._publish_context, instance
+ )
+
+ self._publish_report.add_result(result)
+
+ exception = result.get("error")
+ if exception:
+ if (
+ isinstance(exception, PublishValidationError)
+ and not self._publish_validated
+ ):
+ self._add_validation_error(result)
+
+ else:
+ self._publish_error = exception
+
+ self._publish_next_process()
+
+
+def collect_families_from_instances(instances, only_active=False):
+ """Collect all families for passed publish instances.
+
+ Args:
+ instances(list): List of publish instances from
+ which are families collected.
+ only_active(bool): Return families only for active instances.
+ """
+ all_families = set()
+ for instance in instances:
+ if only_active:
+ if instance.data.get("publish") is False:
+ continue
+ family = instance.data.get("family")
+ if family:
+ all_families.add(family)
+
+ families = instance.data.get("families") or tuple()
+ for family in families:
+ all_families.add(family)
+
+ return list(all_families)
diff --git a/openpype/tools/publisher/publish_report_viewer/__init__.py b/openpype/tools/publisher/publish_report_viewer/__init__.py
new file mode 100644
index 0000000000..3cfaaa5a05
--- /dev/null
+++ b/openpype/tools/publisher/publish_report_viewer/__init__.py
@@ -0,0 +1,14 @@
+from .widgets import (
+ PublishReportViewerWidget
+)
+
+from .window import (
+ PublishReportViewerWindow
+)
+
+
+__all__ = (
+ "PublishReportViewerWidget",
+
+ "PublishReportViewerWindow",
+)
diff --git a/openpype/tools/publisher/publish_report_viewer/constants.py b/openpype/tools/publisher/publish_report_viewer/constants.py
new file mode 100644
index 0000000000..8fbb9342ca
--- /dev/null
+++ b/openpype/tools/publisher/publish_report_viewer/constants.py
@@ -0,0 +1,20 @@
+from Qt import QtCore
+
+
+ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
+ITEM_IS_GROUP_ROLE = QtCore.Qt.UserRole + 2
+ITEM_LABEL_ROLE = QtCore.Qt.UserRole + 3
+ITEM_ERRORED_ROLE = QtCore.Qt.UserRole + 4
+PLUGIN_SKIPPED_ROLE = QtCore.Qt.UserRole + 5
+PLUGIN_PASSED_ROLE = QtCore.Qt.UserRole + 6
+INSTANCE_REMOVED_ROLE = QtCore.Qt.UserRole + 7
+
+
+__all__ = (
+ "ITEM_ID_ROLE",
+ "ITEM_IS_GROUP_ROLE",
+ "ITEM_LABEL_ROLE",
+ "ITEM_ERRORED_ROLE",
+ "PLUGIN_SKIPPED_ROLE",
+ "INSTANCE_REMOVED_ROLE"
+)
diff --git a/openpype/tools/publisher/publish_report_viewer/delegates.py b/openpype/tools/publisher/publish_report_viewer/delegates.py
new file mode 100644
index 0000000000..9cd4f52174
--- /dev/null
+++ b/openpype/tools/publisher/publish_report_viewer/delegates.py
@@ -0,0 +1,331 @@
+import collections
+from Qt import QtWidgets, QtCore, QtGui
+from .constants import (
+ ITEM_IS_GROUP_ROLE,
+ ITEM_ERRORED_ROLE,
+ PLUGIN_SKIPPED_ROLE,
+ PLUGIN_PASSED_ROLE,
+ INSTANCE_REMOVED_ROLE
+)
+
+colors = {
+ "error": QtGui.QColor("#ff4a4a"),
+ "warning": QtGui.QColor("#ff9900"),
+ "ok": QtGui.QColor("#77AE24"),
+ "active": QtGui.QColor("#99CEEE"),
+ "idle": QtCore.Qt.white,
+ "inactive": QtGui.QColor("#888"),
+ "hover": QtGui.QColor(255, 255, 255, 5),
+ "selected": QtGui.QColor(255, 255, 255, 10),
+ "outline": QtGui.QColor("#333"),
+ "group": QtGui.QColor("#21252B"),
+ "group-hover": QtGui.QColor("#3c3c3c"),
+ "group-selected-hover": QtGui.QColor("#555555")
+}
+
+
+class GroupItemDelegate(QtWidgets.QStyledItemDelegate):
+ """Generic delegate for instance header"""
+
+ _item_icons_by_name_and_size = collections.defaultdict(dict)
+
+ _minus_pixmaps = {}
+ _plus_pixmaps = {}
+ _path_stroker = None
+
+ _item_pix_offset_ratio = 1.0 / 5.0
+ _item_border_size = 1.0 / 7.0
+ _group_pix_offset_ratio = 1.0 / 3.0
+ _group_pix_stroke_size_ratio = 1.0 / 7.0
+
+ @classmethod
+ def _get_path_stroker(cls):
+ if cls._path_stroker is None:
+ path_stroker = QtGui.QPainterPathStroker()
+ path_stroker.setCapStyle(QtCore.Qt.RoundCap)
+ path_stroker.setJoinStyle(QtCore.Qt.RoundJoin)
+
+ cls._path_stroker = path_stroker
+ return cls._path_stroker
+
+ @classmethod
+ def _get_plus_pixmap(cls, size):
+ pix = cls._minus_pixmaps.get(size)
+ if pix is not None:
+ return pix
+
+ pix = QtGui.QPixmap(size, size)
+ pix.fill(QtCore.Qt.transparent)
+
+ offset = int(size * cls._group_pix_offset_ratio)
+ pnt_1 = QtCore.QPoint(offset, int(size / 2))
+ pnt_2 = QtCore.QPoint(size - offset, int(size / 2))
+ pnt_3 = QtCore.QPoint(int(size / 2), offset)
+ pnt_4 = QtCore.QPoint(int(size / 2), size - offset)
+ path_1 = QtGui.QPainterPath(pnt_1)
+ path_1.lineTo(pnt_2)
+ path_2 = QtGui.QPainterPath(pnt_3)
+ path_2.lineTo(pnt_4)
+
+ path_stroker = cls._get_path_stroker()
+ path_stroker.setWidth(size * cls._group_pix_stroke_size_ratio)
+ stroked_path_1 = path_stroker.createStroke(path_1)
+ stroked_path_2 = path_stroker.createStroke(path_2)
+
+ pix = QtGui.QPixmap(size, size)
+ pix.fill(QtCore.Qt.transparent)
+
+ painter = QtGui.QPainter(pix)
+ painter.setRenderHint(QtGui.QPainter.Antialiasing)
+ painter.setPen(QtCore.Qt.transparent)
+ painter.setBrush(QtCore.Qt.white)
+ painter.drawPath(stroked_path_1)
+ painter.drawPath(stroked_path_2)
+ painter.end()
+
+ cls._minus_pixmaps[size] = pix
+
+ return pix
+
+ @classmethod
+ def _get_minus_pixmap(cls, size):
+ pix = cls._plus_pixmaps.get(size)
+ if pix is not None:
+ return pix
+
+ offset = int(size * cls._group_pix_offset_ratio)
+ pnt_1 = QtCore.QPoint(offset, int(size / 2))
+ pnt_2 = QtCore.QPoint(size - offset, int(size / 2))
+ path = QtGui.QPainterPath(pnt_1)
+ path.lineTo(pnt_2)
+ path_stroker = cls._get_path_stroker()
+ path_stroker.setWidth(size * cls._group_pix_stroke_size_ratio)
+ stroked_path = path_stroker.createStroke(path)
+
+ pix = QtGui.QPixmap(size, size)
+ pix.fill(QtCore.Qt.transparent)
+
+ painter = QtGui.QPainter(pix)
+ painter.setRenderHint(QtGui.QPainter.Antialiasing)
+ painter.setPen(QtCore.Qt.transparent)
+ painter.setBrush(QtCore.Qt.white)
+ painter.drawPath(stroked_path)
+ painter.end()
+
+ cls._plus_pixmaps[size] = pix
+
+ return pix
+
+ @classmethod
+ def _get_icon_color(cls, name):
+ if name == "error":
+ return QtGui.QColor(colors["error"])
+ return QtGui.QColor(QtCore.Qt.white)
+
+ @classmethod
+ def _get_icon(cls, name, size):
+ icons_by_size = cls._item_icons_by_name_and_size[name]
+ if icons_by_size and size in icons_by_size:
+ return icons_by_size[size]
+
+ offset = int(size * cls._item_pix_offset_ratio)
+ offset_size = size - (2 * offset)
+ pix = QtGui.QPixmap(size, size)
+ pix.fill(QtCore.Qt.transparent)
+
+ painter = QtGui.QPainter(pix)
+ painter.setRenderHint(QtGui.QPainter.Antialiasing)
+
+ draw_ellipse = True
+ if name == "error":
+ color = QtGui.QColor(colors["error"])
+ painter.setPen(QtCore.Qt.NoPen)
+ painter.setBrush(color)
+
+ elif name == "skipped":
+ color = QtGui.QColor(QtCore.Qt.white)
+ pen = QtGui.QPen(color)
+ pen.setWidth(int(size * cls._item_border_size))
+ painter.setPen(pen)
+ painter.setBrush(QtCore.Qt.transparent)
+
+ elif name == "passed":
+ color = QtGui.QColor(colors["ok"])
+ painter.setPen(QtCore.Qt.NoPen)
+ painter.setBrush(color)
+
+ elif name == "removed":
+ draw_ellipse = False
+
+ offset = offset * 1.5
+ p1 = QtCore.QPoint(offset, offset)
+ p2 = QtCore.QPoint(size - offset, size - offset)
+ p3 = QtCore.QPoint(offset, size - offset)
+ p4 = QtCore.QPoint(size - offset, offset)
+
+ pen = QtGui.QPen(QtCore.Qt.white)
+ pen.setWidth(offset_size / 4)
+ pen.setCapStyle(QtCore.Qt.RoundCap)
+ painter.setPen(pen)
+ painter.setBrush(QtCore.Qt.transparent)
+ painter.drawLine(p1, p2)
+ painter.drawLine(p3, p4)
+
+ else:
+ color = QtGui.QColor(QtCore.Qt.white)
+ painter.setPen(QtCore.Qt.NoPen)
+ painter.setBrush(color)
+
+ if draw_ellipse:
+ painter.drawEllipse(offset, offset, offset_size, offset_size)
+
+ painter.end()
+
+ cls._item_icons_by_name_and_size[name][size] = pix
+
+ return pix
+
+ def paint(self, painter, option, index):
+ if index.data(ITEM_IS_GROUP_ROLE):
+ self.group_item_paint(painter, option, index)
+ else:
+ self.item_paint(painter, option, index)
+
+ def item_paint(self, painter, option, index):
+ self.initStyleOption(option, index)
+
+ widget = option.widget
+ if widget:
+ style = widget.style()
+ else:
+ style = QtWidgets.QApplicaion.style()
+
+ style.proxy().drawPrimitive(
+ style.PE_PanelItemViewItem, option, painter, widget
+ )
+ _rect = style.proxy().subElementRect(
+ style.SE_ItemViewItemText, option, widget
+ )
+ bg_rect = QtCore.QRectF(option.rect)
+ bg_rect.setY(_rect.y())
+ bg_rect.setHeight(_rect.height())
+
+ expander_rect = QtCore.QRectF(bg_rect)
+ expander_rect.setWidth(expander_rect.height() + 5)
+
+ label_rect = QtCore.QRectF(
+ expander_rect.x() + expander_rect.width(),
+ expander_rect.y(),
+ bg_rect.width() - expander_rect.width(),
+ expander_rect.height()
+ )
+
+ icon_size = expander_rect.height()
+ if index.data(ITEM_ERRORED_ROLE):
+ expander_icon = self._get_icon("error", icon_size)
+ elif index.data(PLUGIN_SKIPPED_ROLE):
+ expander_icon = self._get_icon("skipped", icon_size)
+ elif index.data(PLUGIN_PASSED_ROLE):
+ expander_icon = self._get_icon("passed", icon_size)
+ elif index.data(INSTANCE_REMOVED_ROLE):
+ expander_icon = self._get_icon("removed", icon_size)
+ else:
+ expander_icon = self._get_icon("", icon_size)
+
+ label = index.data(QtCore.Qt.DisplayRole)
+ label = option.fontMetrics.elidedText(
+ label, QtCore.Qt.ElideRight, label_rect.width()
+ )
+
+ painter.save()
+ # Draw icon
+ pix_point = QtCore.QPoint(
+ expander_rect.center().x() - int(expander_icon.width() / 2),
+ expander_rect.top()
+ )
+ painter.drawPixmap(pix_point, expander_icon)
+
+ # Draw label
+ painter.setFont(option.font)
+ painter.drawText(label_rect, QtCore.Qt.AlignVCenter, label)
+
+ # Ok, we're done, tidy up.
+ painter.restore()
+
+ def group_item_paint(self, painter, option, index):
+ """Paint text
+ _
+ My label
+ """
+ self.initStyleOption(option, index)
+
+ widget = option.widget
+ if widget:
+ style = widget.style()
+ else:
+ style = QtWidgets.QApplicaion.style()
+ _rect = style.proxy().subElementRect(
+ style.SE_ItemViewItemText, option, widget
+ )
+
+ bg_rect = QtCore.QRectF(option.rect)
+ bg_rect.setY(_rect.y())
+ bg_rect.setHeight(_rect.height())
+
+ expander_height = bg_rect.height()
+ expander_width = expander_height + 5
+ expander_y_offset = expander_height % 2
+ expander_height -= expander_y_offset
+ expander_rect = QtCore.QRectF(
+ bg_rect.x(),
+ bg_rect.y() + expander_y_offset,
+ expander_width,
+ expander_height
+ )
+
+ label_rect = QtCore.QRectF(
+ bg_rect.x() + expander_width,
+ bg_rect.y(),
+ bg_rect.width() - expander_width,
+ bg_rect.height()
+ )
+
+ bg_path = QtGui.QPainterPath()
+ radius = (bg_rect.height() / 2) - 0.01
+ bg_path.addRoundedRect(bg_rect, radius, radius)
+
+ painter.fillPath(bg_path, colors["group"])
+
+ selected = option.state & QtWidgets.QStyle.State_Selected
+ hovered = option.state & QtWidgets.QStyle.State_MouseOver
+
+ if selected and hovered:
+ painter.fillPath(bg_path, colors["selected"])
+ elif hovered:
+ painter.fillPath(bg_path, colors["hover"])
+
+ expanded = self.parent().isExpanded(index)
+ if expanded:
+ expander_icon = self._get_minus_pixmap(expander_height)
+ else:
+ expander_icon = self._get_plus_pixmap(expander_height)
+
+ label = index.data(QtCore.Qt.DisplayRole)
+ label = option.fontMetrics.elidedText(
+ label, QtCore.Qt.ElideRight, label_rect.width()
+ )
+
+ # Maintain reference to state, so we can restore it once we're done
+ painter.save()
+ pix_point = QtCore.QPoint(
+ expander_rect.center().x() - int(expander_icon.width() / 2),
+ expander_rect.top()
+ )
+ painter.drawPixmap(pix_point, expander_icon)
+
+ # Draw label
+ painter.setFont(option.font)
+ painter.drawText(label_rect, QtCore.Qt.AlignVCenter, label)
+
+ # Ok, we're done, tidy up.
+ painter.restore()
diff --git a/openpype/tools/publisher/publish_report_viewer/model.py b/openpype/tools/publisher/publish_report_viewer/model.py
new file mode 100644
index 0000000000..460d3e12d1
--- /dev/null
+++ b/openpype/tools/publisher/publish_report_viewer/model.py
@@ -0,0 +1,200 @@
+import uuid
+from Qt import QtCore, QtGui
+
+import pyblish.api
+
+from .constants import (
+ ITEM_ID_ROLE,
+ ITEM_IS_GROUP_ROLE,
+ ITEM_LABEL_ROLE,
+ ITEM_ERRORED_ROLE,
+ PLUGIN_SKIPPED_ROLE,
+ PLUGIN_PASSED_ROLE,
+ INSTANCE_REMOVED_ROLE
+)
+
+
+class InstancesModel(QtGui.QStandardItemModel):
+ def __init__(self, *args, **kwargs):
+ super(InstancesModel, self).__init__(*args, **kwargs)
+
+ self._items_by_id = {}
+ self._plugin_items_by_id = {}
+
+ def get_items_by_id(self):
+ return self._items_by_id
+
+ def set_report(self, report_item):
+ self.clear()
+ self._items_by_id.clear()
+ self._plugin_items_by_id.clear()
+
+ root_item = self.invisibleRootItem()
+
+ families = set(report_item.instance_items_by_family.keys())
+ families.remove(None)
+ all_families = list(sorted(families))
+ all_families.insert(0, None)
+
+ family_items = []
+ for family in all_families:
+ items = []
+ instance_items = report_item.instance_items_by_family[family]
+ all_removed = True
+ for instance_item in instance_items:
+ item = QtGui.QStandardItem(instance_item.label)
+ item.setData(instance_item.label, ITEM_LABEL_ROLE)
+ item.setData(instance_item.errored, ITEM_ERRORED_ROLE)
+ item.setData(instance_item.id, ITEM_ID_ROLE)
+ item.setData(instance_item.removed, INSTANCE_REMOVED_ROLE)
+ if all_removed and not instance_item.removed:
+ all_removed = False
+ item.setData(False, ITEM_IS_GROUP_ROLE)
+ items.append(item)
+ self._items_by_id[instance_item.id] = item
+ self._plugin_items_by_id[instance_item.id] = item
+
+ if family is None:
+ family_items.extend(items)
+ continue
+
+ family_item = QtGui.QStandardItem(family)
+ family_item.setData(family, ITEM_LABEL_ROLE)
+ family_item.setFlags(QtCore.Qt.ItemIsEnabled)
+ family_id = uuid.uuid4()
+ family_item.setData(family_id, ITEM_ID_ROLE)
+ family_item.setData(all_removed, INSTANCE_REMOVED_ROLE)
+ family_item.setData(True, ITEM_IS_GROUP_ROLE)
+ family_item.appendRows(items)
+ family_items.append(family_item)
+ self._items_by_id[family_id] = family_item
+
+ root_item.appendRows(family_items)
+
+
+class InstanceProxyModel(QtCore.QSortFilterProxyModel):
+ def __init__(self, *args, **kwargs):
+ super(InstanceProxyModel, self).__init__(*args, **kwargs)
+
+ self._ignore_removed = True
+
+ @property
+ def ignore_removed(self):
+ return self._ignore_removed
+
+ def set_ignore_removed(self, value):
+ if value == self._ignore_removed:
+ return
+ self._ignore_removed = value
+
+ if self.sourceModel():
+ self.invalidateFilter()
+
+ def filterAcceptsRow(self, row, parent):
+ source_index = self.sourceModel().index(row, 0, parent)
+ if self._ignore_removed and source_index.data(INSTANCE_REMOVED_ROLE):
+ return False
+ return True
+
+
+class PluginsModel(QtGui.QStandardItemModel):
+ order_label_mapping = (
+ (pyblish.api.CollectorOrder + 0.5, "Collect"),
+ (pyblish.api.ValidatorOrder + 0.5, "Validate"),
+ (pyblish.api.ExtractorOrder + 0.5, "Extract"),
+ (pyblish.api.IntegratorOrder + 0.5, "Integrate"),
+ (None, "Other")
+ )
+
+ def __init__(self, *args, **kwargs):
+ super(PluginsModel, self).__init__(*args, **kwargs)
+
+ self._items_by_id = {}
+ self._plugin_items_by_id = {}
+
+ def get_items_by_id(self):
+ return self._items_by_id
+
+ def set_report(self, report_item):
+ self.clear()
+ self._items_by_id.clear()
+ self._plugin_items_by_id.clear()
+
+ root_item = self.invisibleRootItem()
+
+ labels_iter = iter(self.order_label_mapping)
+ cur_order, cur_label = next(labels_iter)
+ cur_plugin_items = []
+
+ plugin_items_by_group_labels = []
+ plugin_items_by_group_labels.append((cur_label, cur_plugin_items))
+ for plugin_id in report_item.plugins_id_order:
+ plugin_item = report_item.plugins_items_by_id[plugin_id]
+ if cur_order is not None and plugin_item.order >= cur_order:
+ cur_order, cur_label = next(labels_iter)
+ cur_plugin_items = []
+ plugin_items_by_group_labels.append(
+ (cur_label, cur_plugin_items)
+ )
+
+ cur_plugin_items.append(plugin_item)
+
+ group_items = []
+ for group_label, plugin_items in plugin_items_by_group_labels:
+ group_id = uuid.uuid4()
+ group_item = QtGui.QStandardItem(group_label)
+ group_item.setData(group_label, ITEM_LABEL_ROLE)
+ group_item.setData(group_id, ITEM_ID_ROLE)
+ group_item.setData(True, ITEM_IS_GROUP_ROLE)
+ group_item.setFlags(QtCore.Qt.ItemIsEnabled)
+ group_items.append(group_item)
+
+ self._items_by_id[group_id] = group_item
+
+ if not plugin_items:
+ continue
+
+ items = []
+ for plugin_item in plugin_items:
+ item = QtGui.QStandardItem(plugin_item.label)
+ item.setData(False, ITEM_IS_GROUP_ROLE)
+ item.setData(plugin_item.label, ITEM_LABEL_ROLE)
+ item.setData(plugin_item.id, ITEM_ID_ROLE)
+ item.setData(plugin_item.skipped, PLUGIN_SKIPPED_ROLE)
+ item.setData(plugin_item.passed, PLUGIN_PASSED_ROLE)
+ item.setData(plugin_item.errored, ITEM_ERRORED_ROLE)
+ items.append(item)
+ self._items_by_id[plugin_item.id] = item
+ self._plugin_items_by_id[plugin_item.id] = item
+ group_item.appendRows(items)
+
+ root_item.appendRows(group_items)
+
+
+class PluginProxyModel(QtCore.QSortFilterProxyModel):
+ def __init__(self, *args, **kwargs):
+ super(PluginProxyModel, self).__init__(*args, **kwargs)
+
+ self._ignore_skipped = True
+
+ @property
+ def ignore_skipped(self):
+ return self._ignore_skipped
+
+ def set_ignore_skipped(self, value):
+ if value == self._ignore_skipped:
+ return
+ self._ignore_skipped = value
+
+ if self.sourceModel():
+ self.invalidateFilter()
+
+ def filterAcceptsRow(self, row, parent):
+ model = self.sourceModel()
+ source_index = model.index(row, 0, parent)
+ if source_index.data(ITEM_IS_GROUP_ROLE):
+ return model.rowCount(source_index) > 0
+
+ if self._ignore_skipped and source_index.data(PLUGIN_SKIPPED_ROLE):
+ return False
+ return True
diff --git a/openpype/tools/publisher/publish_report_viewer/widgets.py b/openpype/tools/publisher/publish_report_viewer/widgets.py
new file mode 100644
index 0000000000..24f1d33d0e
--- /dev/null
+++ b/openpype/tools/publisher/publish_report_viewer/widgets.py
@@ -0,0 +1,334 @@
+import copy
+import uuid
+
+from Qt import QtWidgets, QtCore
+
+from openpype.widgets.nice_checkbox import NiceCheckbox
+
+from .constants import (
+ ITEM_ID_ROLE,
+ ITEM_IS_GROUP_ROLE
+)
+from .delegates import GroupItemDelegate
+from .model import (
+ InstancesModel,
+ InstanceProxyModel,
+ PluginsModel,
+ PluginProxyModel
+)
+
+
+class PluginItem:
+ def __init__(self, plugin_data):
+ self._id = uuid.uuid4()
+
+ self.name = plugin_data["name"]
+ self.label = plugin_data["label"]
+ self.order = plugin_data["order"]
+ self.skipped = plugin_data["skipped"]
+ self.passed = plugin_data["passed"]
+
+ logs = []
+ errored = False
+ for instance_data in plugin_data["instances_data"]:
+ for log_item in instance_data["logs"]:
+ if not errored:
+ errored = log_item["type"] == "error"
+ logs.append(copy.deepcopy(log_item))
+
+ self.errored = errored
+ self.logs = logs
+
+ @property
+ def id(self):
+ return self._id
+
+
+class InstanceItem:
+ def __init__(self, instance_id, instance_data, report_data):
+ self._id = instance_id
+ self.label = instance_data.get("label") or instance_data.get("name")
+ self.family = instance_data.get("family")
+ self.removed = not instance_data.get("exists", True)
+
+ logs = []
+ for plugin_data in report_data["plugins_data"]:
+ for instance_data_item in plugin_data["instances_data"]:
+ if instance_data_item["id"] == self._id:
+ logs.extend(copy.deepcopy(instance_data_item["logs"]))
+
+ errored = False
+ for log in logs:
+ if log["type"] == "error":
+ errored = True
+ break
+
+ self.errored = errored
+ self.logs = logs
+
+ @property
+ def id(self):
+ return self._id
+
+
+class PublishReport:
+ def __init__(self, report_data):
+ data = copy.deepcopy(report_data)
+
+ context_data = data["context"]
+ context_data["name"] = "context"
+ context_data["label"] = context_data["label"] or "Context"
+
+ instance_items_by_id = {}
+ instance_items_by_family = {}
+ context_item = InstanceItem(None, context_data, data)
+ instance_items_by_id[context_item.id] = context_item
+ instance_items_by_family[context_item.family] = [context_item]
+
+ for instance_id, instance_data in data["instances"].items():
+ item = InstanceItem(instance_id, instance_data, data)
+ instance_items_by_id[item.id] = item
+ if item.family not in instance_items_by_family:
+ instance_items_by_family[item.family] = []
+ instance_items_by_family[item.family].append(item)
+
+ all_logs = []
+ plugins_items_by_id = {}
+ plugins_id_order = []
+ for plugin_data in data["plugins_data"]:
+ item = PluginItem(plugin_data)
+ plugins_id_order.append(item.id)
+ plugins_items_by_id[item.id] = item
+ all_logs.extend(copy.deepcopy(item.logs))
+
+ self.instance_items_by_id = instance_items_by_id
+ self.instance_items_by_family = instance_items_by_family
+
+ self.plugins_id_order = plugins_id_order
+ self.plugins_items_by_id = plugins_items_by_id
+
+ self.logs = all_logs
+
+
+class DetailsWidget(QtWidgets.QWidget):
+ def __init__(self, parent):
+ super(DetailsWidget, self).__init__(parent)
+
+ output_widget = QtWidgets.QPlainTextEdit(self)
+ output_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
+ output_widget.setObjectName("PublishLogConsole")
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(output_widget)
+
+ self._output_widget = output_widget
+
+ def clear(self):
+ self._output_widget.setPlainText("")
+
+ def set_logs(self, logs):
+ lines = []
+ for log in logs:
+ if log["type"] == "record":
+ message = "{}: {}".format(log["levelname"], log["msg"])
+
+ lines.append(message)
+ exc_info = log["exc_info"]
+ if exc_info:
+ lines.append(exc_info)
+
+ elif log["type"] == "error":
+ lines.append(log["traceback"])
+
+ else:
+ print(log["type"])
+
+ text = "\n".join(lines)
+ self._output_widget.setPlainText(text)
+
+
+class PublishReportViewerWidget(QtWidgets.QWidget):
+ def __init__(self, parent=None):
+ super(PublishReportViewerWidget, self).__init__(parent)
+
+ instances_model = InstancesModel()
+ instances_proxy = InstanceProxyModel()
+ instances_proxy.setSourceModel(instances_model)
+
+ plugins_model = PluginsModel()
+ plugins_proxy = PluginProxyModel()
+ plugins_proxy.setSourceModel(plugins_model)
+
+ removed_instances_check = NiceCheckbox(parent=self)
+ removed_instances_check.setChecked(instances_proxy.ignore_removed)
+ removed_instances_label = QtWidgets.QLabel(
+ "Hide removed instances", self
+ )
+
+ removed_instances_layout = QtWidgets.QHBoxLayout()
+ removed_instances_layout.setContentsMargins(0, 0, 0, 0)
+ removed_instances_layout.addWidget(removed_instances_check, 0)
+ removed_instances_layout.addWidget(removed_instances_label, 1)
+
+ instances_view = QtWidgets.QTreeView(self)
+ instances_view.setObjectName("PublishDetailViews")
+ instances_view.setModel(instances_proxy)
+ instances_view.setIndentation(0)
+ instances_view.setHeaderHidden(True)
+ instances_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers)
+ instances_view.setExpandsOnDoubleClick(False)
+
+ instances_delegate = GroupItemDelegate(instances_view)
+ instances_view.setItemDelegate(instances_delegate)
+
+ skipped_plugins_check = NiceCheckbox(parent=self)
+ skipped_plugins_check.setChecked(plugins_proxy.ignore_skipped)
+ skipped_plugins_label = QtWidgets.QLabel("Hide skipped plugins", self)
+
+ skipped_plugins_layout = QtWidgets.QHBoxLayout()
+ skipped_plugins_layout.setContentsMargins(0, 0, 0, 0)
+ skipped_plugins_layout.addWidget(skipped_plugins_check, 0)
+ skipped_plugins_layout.addWidget(skipped_plugins_label, 1)
+
+ plugins_view = QtWidgets.QTreeView(self)
+ plugins_view.setObjectName("PublishDetailViews")
+ plugins_view.setModel(plugins_proxy)
+ plugins_view.setIndentation(0)
+ plugins_view.setHeaderHidden(True)
+ plugins_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers)
+ plugins_view.setExpandsOnDoubleClick(False)
+
+ plugins_delegate = GroupItemDelegate(plugins_view)
+ plugins_view.setItemDelegate(plugins_delegate)
+
+ details_widget = DetailsWidget(self)
+
+ layout = QtWidgets.QGridLayout(self)
+ # Row 1
+ layout.addLayout(removed_instances_layout, 0, 0)
+ layout.addLayout(skipped_plugins_layout, 0, 1)
+ # Row 2
+ layout.addWidget(instances_view, 1, 0)
+ layout.addWidget(plugins_view, 1, 1)
+ layout.addWidget(details_widget, 1, 2)
+
+ layout.setColumnStretch(2, 1)
+
+ instances_view.selectionModel().selectionChanged.connect(
+ self._on_instance_change
+ )
+ instances_view.clicked.connect(self._on_instance_view_clicked)
+ plugins_view.clicked.connect(self._on_plugin_view_clicked)
+ plugins_view.selectionModel().selectionChanged.connect(
+ self._on_plugin_change
+ )
+
+ skipped_plugins_check.stateChanged.connect(
+ self._on_skipped_plugin_check
+ )
+ removed_instances_check.stateChanged.connect(
+ self._on_removed_instances_check
+ )
+
+ self._ignore_selection_changes = False
+ self._report_item = None
+ self._details_widget = details_widget
+
+ self._removed_instances_check = removed_instances_check
+ self._instances_view = instances_view
+ self._instances_model = instances_model
+ self._instances_proxy = instances_proxy
+
+ self._instances_delegate = instances_delegate
+ self._plugins_delegate = plugins_delegate
+
+ self._skipped_plugins_check = skipped_plugins_check
+ self._plugins_view = plugins_view
+ self._plugins_model = plugins_model
+ self._plugins_proxy = plugins_proxy
+
+ def _on_instance_view_clicked(self, index):
+ if not index.isValid() or not index.data(ITEM_IS_GROUP_ROLE):
+ return
+
+ if self._instances_view.isExpanded(index):
+ self._instances_view.collapse(index)
+ else:
+ self._instances_view.expand(index)
+
+ def _on_plugin_view_clicked(self, index):
+ if not index.isValid() or not index.data(ITEM_IS_GROUP_ROLE):
+ return
+
+ if self._plugins_view.isExpanded(index):
+ self._plugins_view.collapse(index)
+ else:
+ self._plugins_view.expand(index)
+
+ def set_report(self, report_data):
+ self._ignore_selection_changes = True
+
+ report_item = PublishReport(report_data)
+ self._report_item = report_item
+
+ self._instances_model.set_report(report_item)
+ self._plugins_model.set_report(report_item)
+ self._details_widget.set_logs(report_item.logs)
+
+ self._ignore_selection_changes = False
+
+ def _on_instance_change(self, *_args):
+ if self._ignore_selection_changes:
+ return
+
+ valid_index = None
+ for index in self._instances_view.selectedIndexes():
+ if index.isValid():
+ valid_index = index
+ break
+
+ if valid_index is None:
+ return
+
+ if self._plugins_view.selectedIndexes():
+ self._ignore_selection_changes = True
+ self._plugins_view.selectionModel().clearSelection()
+ self._ignore_selection_changes = False
+
+ plugin_id = valid_index.data(ITEM_ID_ROLE)
+ instance_item = self._report_item.instance_items_by_id[plugin_id]
+ self._details_widget.set_logs(instance_item.logs)
+
+ def _on_plugin_change(self, *_args):
+ if self._ignore_selection_changes:
+ return
+
+ valid_index = None
+ for index in self._plugins_view.selectedIndexes():
+ if index.isValid():
+ valid_index = index
+ break
+
+ if valid_index is None:
+ self._details_widget.set_logs(self._report_item.logs)
+ return
+
+ if self._instances_view.selectedIndexes():
+ self._ignore_selection_changes = True
+ self._instances_view.selectionModel().clearSelection()
+ self._ignore_selection_changes = False
+
+ plugin_id = valid_index.data(ITEM_ID_ROLE)
+ plugin_item = self._report_item.plugins_items_by_id[plugin_id]
+ self._details_widget.set_logs(plugin_item.logs)
+
+ def _on_skipped_plugin_check(self):
+ self._plugins_proxy.set_ignore_skipped(
+ self._skipped_plugins_check.isChecked()
+ )
+
+ def _on_removed_instances_check(self):
+ self._instances_proxy.set_ignore_removed(
+ self._removed_instances_check.isChecked()
+ )
diff --git a/openpype/tools/publisher/publish_report_viewer/window.py b/openpype/tools/publisher/publish_report_viewer/window.py
new file mode 100644
index 0000000000..7a0fef7d91
--- /dev/null
+++ b/openpype/tools/publisher/publish_report_viewer/window.py
@@ -0,0 +1,29 @@
+from Qt import QtWidgets
+
+from openpype import style
+if __package__:
+ from .widgets import PublishReportViewerWidget
+else:
+ from widgets import PublishReportViewerWidget
+
+
+class PublishReportViewerWindow(QtWidgets.QWidget):
+ # TODO add buttons to be able load report file or paste content of report
+ default_width = 1200
+ default_height = 600
+
+ def __init__(self, parent=None):
+ super(PublishReportViewerWindow, self).__init__(parent)
+
+ main_widget = PublishReportViewerWidget(self)
+
+ layout = QtWidgets.QHBoxLayout(self)
+ layout.addWidget(main_widget)
+
+ self._main_widget = main_widget
+
+ self.resize(self.default_width, self.default_height)
+ self.setStyleSheet(style.load_stylesheet())
+
+ def set_report(self, report_data):
+ self._main_widget.set_report(report_data)
diff --git a/openpype/tools/publisher/widgets/__init__.py b/openpype/tools/publisher/widgets/__init__.py
new file mode 100644
index 0000000000..9b22a6cf25
--- /dev/null
+++ b/openpype/tools/publisher/widgets/__init__.py
@@ -0,0 +1,64 @@
+from .icons import (
+ get_icon_path,
+ get_pixmap,
+ get_icon
+)
+from .border_label_widget import (
+ BorderedLabelWidget
+)
+from .widgets import (
+ SubsetAttributesWidget,
+
+ PixmapLabel,
+
+ StopBtn,
+ ResetBtn,
+ ValidateBtn,
+ PublishBtn,
+
+ CreateInstanceBtn,
+ RemoveInstanceBtn,
+ ChangeViewBtn
+)
+from .publish_widget import (
+ PublishFrame
+)
+from .create_dialog import (
+ CreateDialog
+)
+
+from .card_view_widgets import (
+ InstanceCardView
+)
+
+from .list_view_widgets import (
+ InstanceListView
+)
+
+
+__all__ = (
+ "get_icon_path",
+ "get_pixmap",
+ "get_icon",
+
+ "SubsetAttributesWidget",
+ "BorderedLabelWidget",
+
+ "PixmapLabel",
+
+ "StopBtn",
+ "ResetBtn",
+ "ValidateBtn",
+ "PublishBtn",
+
+ "CreateInstanceBtn",
+ "RemoveInstanceBtn",
+ "ChangeViewBtn",
+
+ "PublishFrame",
+
+ "CreateDialog",
+
+ "InstanceCardView",
+ "InstanceListView",
+)
diff --git a/openpype/tools/publisher/widgets/border_label_widget.py b/openpype/tools/publisher/widgets/border_label_widget.py
new file mode 100644
index 0000000000..3d49af410a
--- /dev/null
+++ b/openpype/tools/publisher/widgets/border_label_widget.py
@@ -0,0 +1,255 @@
+# -*- coding: utf-8 -*-
+
+from Qt import QtWidgets, QtCore, QtGui
+
+from openpype.style import get_objected_colors
+
+
+class _VLineWidget(QtWidgets.QWidget):
+ """Widget drawing 1px wide vertical line.
+
+ ``` β ```
+
+ Line is drawn in the middle of widget.
+
+ It is expected that parent widget will set width.
+ """
+ def __init__(self, color, left, parent):
+ super(_VLineWidget, self).__init__(parent)
+ self._color = color
+ self._left = left
+
+ def paintEvent(self, event):
+ if not self.isVisible():
+ return
+
+ if self._left:
+ pos_x = 0
+ else:
+ pos_x = self.width()
+ painter = QtGui.QPainter(self)
+ painter.setRenderHints(
+ painter.Antialiasing
+ | painter.SmoothPixmapTransform
+ )
+ if self._color:
+ pen = QtGui.QPen(self._color)
+ else:
+ pen = painter.pen()
+ pen.setWidth(1)
+ painter.setPen(pen)
+ painter.setBrush(QtCore.Qt.transparent)
+ painter.drawLine(pos_x, 0, pos_x, self.height())
+ painter.end()
+
+
+class _HBottomLineWidget(QtWidgets.QWidget):
+ """Widget drawing 1px wide vertical line with side lines going upwards.
+
+ ```βββββββββββββββ```
+
+ Corners may have curve set by radius (`set_radius`). Radius should expect
+ height of widget.
+
+ Bottom line is drawed at the bottom of widget. If radius is 0 then height
+ of widget should be 1px.
+
+ It is expected that parent widget will set height and radius.
+ """
+ def __init__(self, color, parent):
+ super(_HBottomLineWidget, self).__init__(parent)
+ self._color = color
+ self._radius = 0
+
+ def set_radius(self, radius):
+ self._radius = radius
+
+ def paintEvent(self, event):
+ if not self.isVisible():
+ return
+
+ rect = QtCore.QRect(
+ 0, -self._radius, self.width(), self.height() + self._radius
+ )
+ painter = QtGui.QPainter(self)
+ painter.setRenderHints(
+ painter.Antialiasing
+ | painter.SmoothPixmapTransform
+ )
+ if self._color:
+ pen = QtGui.QPen(self._color)
+ else:
+ pen = painter.pen()
+ pen.setWidth(1)
+ painter.setPen(pen)
+ painter.setBrush(QtCore.Qt.transparent)
+ painter.drawRoundedRect(rect, self._radius, self._radius)
+ painter.end()
+
+
+class _HTopCornerLineWidget(QtWidgets.QWidget):
+ """Widget drawing 1px wide horizontal line with side line going downwards.
+
+ ```βββββββββ```
+ or
+ ```ββββββββ```
+
+ Horizontal line is drawed in the middle of widget.
+
+ Widget represents left or right corner. Corner may have curve set by
+ radius (`set_radius`). Radius should expect height of widget (maximum half
+ height of widget).
+
+ It is expected that parent widget will set height and radius.
+ """
+ def __init__(self, color, left_side, parent):
+ super(_HTopCornerLineWidget, self).__init__(parent)
+ self._left_side = left_side
+ self._color = color
+ self._radius = 0
+
+ def set_radius(self, radius):
+ self._radius = radius
+
+ def paintEvent(self, event):
+ if not self.isVisible():
+ return
+
+ pos_y = self.height() / 2
+
+ if self._left_side:
+ rect = QtCore.QRect(
+ 0, pos_y, self.width() + self._radius, self.height()
+ )
+ else:
+ rect = QtCore.QRect(
+ -self._radius,
+ pos_y,
+ self.width() + self._radius,
+ self.height()
+ )
+
+ painter = QtGui.QPainter(self)
+ painter.setRenderHints(
+ painter.Antialiasing
+ | painter.SmoothPixmapTransform
+ )
+ if self._color:
+ pen = QtGui.QPen(self._color)
+ else:
+ pen = painter.pen()
+ pen.setWidth(1)
+ painter.setPen(pen)
+ painter.setBrush(QtCore.Qt.transparent)
+ painter.drawRoundedRect(rect, self._radius, self._radius)
+ painter.end()
+
+
+class BorderedLabelWidget(QtWidgets.QFrame):
+ """Draws borders around widget with label in the middle of top.
+
+ ββββββββ Label βββββββββ
+ β β
+ β β
+ β CONTENT β
+ β β
+ β β
+ ββββββββββββββββββββββββ
+ """
+ def __init__(self, label, parent):
+ super(BorderedLabelWidget, self).__init__(parent)
+ colors_data = get_objected_colors()
+ color_value = colors_data.get("border")
+ color = None
+ if color_value:
+ color = color_value.get_qcolor()
+
+ top_left_w = _HTopCornerLineWidget(color, True, self)
+ top_right_w = _HTopCornerLineWidget(color, False, self)
+
+ label_widget = QtWidgets.QLabel(label, self)
+
+ top_layout = QtWidgets.QHBoxLayout()
+ top_layout.setContentsMargins(0, 0, 0, 0)
+ top_layout.setSpacing(5)
+ top_layout.addWidget(top_left_w, 1)
+ top_layout.addWidget(label_widget, 0)
+ top_layout.addWidget(top_right_w, 1)
+
+ left_w = _VLineWidget(color, True, self)
+ right_w = _VLineWidget(color, False, self)
+
+ bottom_w = _HBottomLineWidget(color, self)
+
+ center_layout = QtWidgets.QHBoxLayout()
+ center_layout.setContentsMargins(5, 5, 5, 5)
+
+ layout = QtWidgets.QGridLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ layout.addLayout(top_layout, 0, 0, 1, 3)
+
+ layout.addWidget(left_w, 1, 0)
+ layout.addLayout(center_layout, 1, 1)
+ layout.addWidget(right_w, 1, 2)
+
+ layout.addWidget(bottom_w, 2, 0, 1, 3)
+
+ layout.setColumnStretch(1, 1)
+ layout.setRowStretch(1, 1)
+
+ self._widget = None
+
+ self._radius = 0
+
+ self._top_left_w = top_left_w
+ self._top_right_w = top_right_w
+ self._left_w = left_w
+ self._right_w = right_w
+ self._bottom_w = bottom_w
+ self._label_widget = label_widget
+ self._center_layout = center_layout
+
+ def set_content_margins(self, value):
+ """Set margins around content."""
+ self._center_layout.setContentsMargins(
+ value, value, value, value
+ )
+
+ def showEvent(self, event):
+ super(BorderedLabelWidget, self).showEvent(event)
+
+ height = self._label_widget.height()
+ radius = (height + (height % 2)) / 2
+ self._radius = radius
+
+ side_width = 1 + radius
+ # Dont't use fixed width/height as that would set also set
+ # the other size (When fixed width is set then is also set
+ # fixed height).
+ self._left_w.setMinimumWidth(side_width)
+ self._left_w.setMaximumWidth(side_width)
+ self._right_w.setMinimumWidth(side_width)
+ self._right_w.setMaximumWidth(side_width)
+ self._bottom_w.setMinimumHeight(radius)
+ self._bottom_w.setMaximumHeight(radius)
+ self._bottom_w.set_radius(radius)
+ self._top_right_w.set_radius(radius)
+ self._top_left_w.set_radius(radius)
+ if self._widget:
+ self._widget.update()
+
+ def set_center_widget(self, widget):
+ """Set content widget and add it to center."""
+ while self._center_layout.count():
+ item = self._center_layout.takeAt(0)
+ widget = item.widget()
+ if widget:
+ widget.deleteLater()
+
+ self._widget = widget
+ if isinstance(widget, QtWidgets.QLayout):
+ self._center_layout.addLayout(widget)
+ else:
+ self._center_layout.addWidget(widget)
diff --git a/openpype/tools/publisher/widgets/card_view_widgets.py b/openpype/tools/publisher/widgets/card_view_widgets.py
new file mode 100644
index 0000000000..271d06e94c
--- /dev/null
+++ b/openpype/tools/publisher/widgets/card_view_widgets.py
@@ -0,0 +1,539 @@
+# -*- coding: utf-8 -*-
+"""Card view instance with more information about each instance.
+
+Instances are grouped under groups. Groups are defined by `creator_label`
+attribute on instance (Group defined by creator).
+
+Only one item can be selected at a time.
+
+```
+ : Icon. Can have Warning icon when context is not right
+ββββββββββββββββββββββββ
+β Options β
+β ββββββββββ β
+β [x]β
+β [x]β
+β ββββββββββ β
+β [x]β
+β ... β
+ββββββββββββββββββββββββ
+```
+"""
+
+import re
+import collections
+
+from Qt import QtWidgets, QtCore
+
+from openpype.widgets.nice_checkbox import NiceCheckbox
+
+from .widgets import (
+ AbstractInstanceView,
+ ContextWarningLabel,
+ ClickableFrame,
+ IconValuePixmapLabel,
+ TransparentPixmapLabel
+)
+from ..constants import (
+ CONTEXT_ID,
+ CONTEXT_LABEL
+)
+
+
+class GroupWidget(QtWidgets.QWidget):
+ """Widget wrapping instances under group."""
+ selected = QtCore.Signal(str, str)
+ active_changed = QtCore.Signal()
+ removed_selected = QtCore.Signal()
+
+ def __init__(self, group_name, group_icons, parent):
+ super(GroupWidget, self).__init__(parent)
+
+ label_widget = QtWidgets.QLabel(group_name, self)
+
+ line_widget = QtWidgets.QWidget(self)
+ line_widget.setObjectName("Separator")
+ line_widget.setMinimumHeight(2)
+ line_widget.setMaximumHeight(2)
+
+ label_layout = QtWidgets.QHBoxLayout()
+ label_layout.setAlignment(QtCore.Qt.AlignVCenter)
+ label_layout.setSpacing(10)
+ label_layout.setContentsMargins(0, 0, 0, 0)
+ label_layout.addWidget(label_widget, 0)
+ label_layout.addWidget(line_widget, 1)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addLayout(label_layout, 0)
+
+ self._group = group_name
+ self._group_icons = group_icons
+
+ self._widgets_by_id = {}
+
+ self._label_widget = label_widget
+ self._content_layout = layout
+
+ def get_widget_by_instance_id(self, instance_id):
+ """Get instance widget by it's id."""
+ return self._widgets_by_id.get(instance_id)
+
+ def update_instance_values(self):
+ """Trigger update on instance widgets."""
+ for widget in self._widgets_by_id.values():
+ widget.update_instance_values()
+
+ def confirm_remove_instance_id(self, instance_id):
+ """Delete widget by instance id."""
+ widget = self._widgets_by_id.pop(instance_id)
+ widget.setVisible(False)
+ self._content_layout.removeWidget(widget)
+ widget.deleteLater()
+
+ def update_instances(self, instances):
+ """Update instances for the group.
+
+ Args:
+ instances(list): List of instances in
+ CreateContext.
+ """
+ # Store instances by id and by subset name
+ instances_by_id = {}
+ instances_by_subset_name = collections.defaultdict(list)
+ for instance in instances:
+ instances_by_id[instance.id] = instance
+ subset_name = instance["subset"]
+ instances_by_subset_name[subset_name].append(instance)
+
+ # Remove instance widgets that are not in passed instances
+ for instance_id in tuple(self._widgets_by_id.keys()):
+ if instance_id in instances_by_id:
+ continue
+
+ widget = self._widgets_by_id.pop(instance_id)
+ if widget.is_selected:
+ self.removed_selected.emit()
+
+ widget.setVisible(False)
+ self._content_layout.removeWidget(widget)
+ widget.deleteLater()
+
+ # Sort instances by subset name
+ sorted_subset_names = list(sorted(instances_by_subset_name.keys()))
+ # Add new instances to widget
+ widget_idx = 1
+ for subset_names in sorted_subset_names:
+ for instance in instances_by_subset_name[subset_names]:
+ if instance.id in self._widgets_by_id:
+ widget = self._widgets_by_id[instance.id]
+ widget.update_instance(instance)
+ else:
+ group_icon = self._group_icons[instance.creator_identifier]
+ widget = InstanceCardWidget(
+ instance, group_icon, self
+ )
+ widget.selected.connect(self.selected)
+ widget.active_changed.connect(self.active_changed)
+ self._widgets_by_id[instance.id] = widget
+ self._content_layout.insertWidget(widget_idx, widget)
+ widget_idx += 1
+
+
+class CardWidget(ClickableFrame):
+ """Clickable card used as bigger button."""
+ selected = QtCore.Signal(str, str)
+ # Group identifier of card
+ # - this must be set because if send when mouse is released with card id
+ _group_identifier = None
+
+ def __init__(self, parent):
+ super(CardWidget, self).__init__(parent)
+ self.setObjectName("CardViewWidget")
+
+ self._selected = False
+ self._id = None
+
+ @property
+ def is_selected(self):
+ """Is card selected."""
+ return self._selected
+
+ def set_selected(self, selected):
+ """Set card as selected."""
+ if selected == self._selected:
+ return
+ self._selected = selected
+ state = "selected" if selected else ""
+ self.setProperty("state", state)
+ self.style().polish(self)
+
+ def _mouse_release_callback(self):
+ """Trigger selected signal."""
+ self.selected.emit(self._id, self._group_identifier)
+
+
+class ContextCardWidget(CardWidget):
+ """Card for global context.
+
+ Is not visually under group widget and is always at the top of card view.
+ """
+ def __init__(self, parent):
+ super(ContextCardWidget, self).__init__(parent)
+
+ self._id = CONTEXT_ID
+ self._group_identifier = ""
+
+ icon_widget = TransparentPixmapLabel(self)
+ icon_widget.setObjectName("FamilyIconLabel")
+
+ label_widget = QtWidgets.QLabel(CONTEXT_LABEL, self)
+
+ icon_layout = QtWidgets.QHBoxLayout()
+ icon_layout.setContentsMargins(5, 5, 5, 5)
+ icon_layout.addWidget(icon_widget)
+
+ layout = QtWidgets.QHBoxLayout(self)
+ layout.setContentsMargins(0, 5, 10, 5)
+ layout.addLayout(icon_layout, 0)
+ layout.addWidget(label_widget, 1)
+
+ self._icon_widget = icon_widget
+ self._label_widget = label_widget
+
+
+class InstanceCardWidget(CardWidget):
+ """Card widget representing instance."""
+ active_changed = QtCore.Signal()
+
+ def __init__(self, instance, group_icon, parent):
+ super(InstanceCardWidget, self).__init__(parent)
+
+ self._id = instance.id
+ self._group_identifier = instance.creator_label
+ self._group_icon = group_icon
+
+ self.instance = instance
+
+ self._last_subset_name = None
+ self._last_variant = None
+
+ icon_widget = IconValuePixmapLabel(group_icon, self)
+ icon_widget.setObjectName("FamilyIconLabel")
+ context_warning = ContextWarningLabel(self)
+
+ icon_layout = QtWidgets.QHBoxLayout()
+ icon_layout.setContentsMargins(10, 5, 5, 5)
+ icon_layout.addWidget(icon_widget)
+ icon_layout.addWidget(context_warning)
+
+ label_widget = QtWidgets.QLabel(self)
+ active_checkbox = NiceCheckbox(parent=self)
+
+ expand_btn = QtWidgets.QToolButton(self)
+ # Not yet implemented
+ expand_btn.setVisible(False)
+ expand_btn.setObjectName("ArrowBtn")
+ expand_btn.setArrowType(QtCore.Qt.DownArrow)
+ expand_btn.setMaximumWidth(14)
+ expand_btn.setEnabled(False)
+
+ detail_widget = QtWidgets.QWidget(self)
+ detail_widget.setVisible(False)
+ self.detail_widget = detail_widget
+
+ top_layout = QtWidgets.QHBoxLayout()
+ top_layout.addLayout(icon_layout, 0)
+ top_layout.addWidget(label_widget, 1)
+ top_layout.addWidget(context_warning, 0)
+ top_layout.addWidget(active_checkbox, 0)
+ top_layout.addWidget(expand_btn, 0)
+
+ layout = QtWidgets.QHBoxLayout(self)
+ layout.setContentsMargins(0, 5, 10, 5)
+ layout.addLayout(top_layout)
+ layout.addWidget(detail_widget)
+
+ active_checkbox.setAttribute(QtCore.Qt.WA_TranslucentBackground)
+ expand_btn.setAttribute(QtCore.Qt.WA_TranslucentBackground)
+
+ active_checkbox.stateChanged.connect(self._on_active_change)
+ expand_btn.clicked.connect(self._on_expend_clicked)
+
+ self._icon_widget = icon_widget
+ self._label_widget = label_widget
+ self._context_warning = context_warning
+ self._active_checkbox = active_checkbox
+ self._expand_btn = expand_btn
+
+ self.update_instance_values()
+
+ def set_active(self, new_value):
+ """Set instance as active."""
+ checkbox_value = self._active_checkbox.isChecked()
+ instance_value = self.instance["active"]
+
+ # First change instance value and them change checkbox
+ # - prevent to trigger `active_changed` signal
+ if instance_value != new_value:
+ self.instance["active"] = new_value
+
+ if checkbox_value != new_value:
+ self._active_checkbox.setChecked(new_value)
+
+ def update_instance(self, instance):
+ """Update instance object and update UI."""
+ self.instance = instance
+ self.update_instance_values()
+
+ def _validate_context(self):
+ valid = self.instance.has_valid_context
+ self._icon_widget.setVisible(valid)
+ self._context_warning.setVisible(not valid)
+
+ def _update_subset_name(self):
+ variant = self.instance["variant"]
+ subset_name = self.instance["subset"]
+ if (
+ variant == self._last_variant
+ and subset_name == self._last_subset_name
+ ):
+ return
+
+ self._last_variant = variant
+ self._last_subset_name = subset_name
+ # Make `variant` bold
+ found_parts = set(re.findall(variant, subset_name, re.IGNORECASE))
+ if found_parts:
+ for part in found_parts:
+ replacement = "{}".format(part)
+ subset_name = subset_name.replace(part, replacement)
+
+ self._label_widget.setText(subset_name)
+ # HTML text will cause that label start catch mouse clicks
+ # - disabling with changing interaction flag
+ self._label_widget.setTextInteractionFlags(
+ QtCore.Qt.NoTextInteraction
+ )
+
+ def update_instance_values(self):
+ """Update instance data"""
+ self._update_subset_name()
+ self.set_active(self.instance["active"])
+ self._validate_context()
+
+ def _set_expanded(self, expanded=None):
+ if expanded is None:
+ expanded = not self.detail_widget.isVisible()
+ self.detail_widget.setVisible(expanded)
+
+ def _on_active_change(self):
+ new_value = self._active_checkbox.isChecked()
+ old_value = self.instance["active"]
+ if new_value == old_value:
+ return
+
+ self.instance["active"] = new_value
+ self.active_changed.emit()
+
+ def _on_expend_clicked(self):
+ self._set_expanded()
+
+
+class InstanceCardView(AbstractInstanceView):
+ """Publish access to card view.
+
+ Wrapper of all widgets in card view.
+ """
+ def __init__(self, controller, parent):
+ super(InstanceCardView, self).__init__(parent)
+
+ self.controller = controller
+
+ scroll_area = QtWidgets.QScrollArea(self)
+ scroll_area.setWidgetResizable(True)
+ scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+ scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
+ scrollbar_bg = scroll_area.verticalScrollBar().parent()
+ if scrollbar_bg:
+ scrollbar_bg.setAttribute(QtCore.Qt.WA_TranslucentBackground)
+ scroll_area.setViewportMargins(0, 0, 0, 0)
+
+ content_widget = QtWidgets.QWidget(scroll_area)
+
+ scroll_area.setWidget(content_widget)
+
+ content_layout = QtWidgets.QVBoxLayout(content_widget)
+ content_layout.setContentsMargins(0, 0, 0, 0)
+ content_layout.addStretch(1)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(scroll_area)
+
+ self._scroll_area = scroll_area
+ self._content_layout = content_layout
+ self._content_widget = content_widget
+
+ self._widgets_by_group = {}
+ self._context_widget = None
+
+ self._selected_group = None
+ self._selected_instance_id = None
+
+ self.setSizePolicy(
+ QtWidgets.QSizePolicy.Minimum,
+ self.sizePolicy().verticalPolicy()
+ )
+
+ def sizeHint(self):
+ """Modify sizeHint based on visibility of scroll bars."""
+ # Calculate width hint by content widget and verticall scroll bar
+ scroll_bar = self._scroll_area.verticalScrollBar()
+ width = (
+ self._content_widget.sizeHint().width()
+ + scroll_bar.sizeHint().width()
+ )
+
+ result = super(InstanceCardView, self).sizeHint()
+ result.setWidth(width)
+ return result
+
+ def _get_selected_widget(self):
+ if self._selected_instance_id == CONTEXT_ID:
+ return self._context_widget
+
+ group_widget = self._widgets_by_group.get(
+ self._selected_group
+ )
+ if group_widget is not None:
+ widget = group_widget.get_widget_by_instance_id(
+ self._selected_instance_id
+ )
+ if widget is not None:
+ return widget
+
+ return None
+
+ def refresh(self):
+ """Refresh instances in view based on CreatedContext."""
+ # Create context item if is not already existing
+ # - this must be as first thing to do as context item should be at the
+ # top
+ if self._context_widget is None:
+ widget = ContextCardWidget(self._content_widget)
+ widget.selected.connect(self._on_widget_selection)
+
+ self._context_widget = widget
+
+ self.selection_changed.emit()
+ self._content_layout.insertWidget(0, widget)
+
+ self.select_item(CONTEXT_ID, None)
+
+ # Prepare instances by group and identifiers by group
+ instances_by_group = collections.defaultdict(list)
+ identifiers_by_group = collections.defaultdict(set)
+ for instance in self.controller.instances:
+ group_name = instance.creator_label
+ instances_by_group[group_name].append(instance)
+ identifiers_by_group[group_name].add(
+ instance.creator_identifier
+ )
+
+ # Remove groups that were not found in apassed instances
+ for group_name in tuple(self._widgets_by_group.keys()):
+ if group_name in instances_by_group:
+ continue
+
+ if group_name == self._selected_group:
+ self._on_remove_selected()
+ widget = self._widgets_by_group.pop(group_name)
+ widget.setVisible(False)
+ self._content_layout.removeWidget(widget)
+ widget.deleteLater()
+
+ # Sort groups
+ sorted_group_names = list(sorted(instances_by_group.keys()))
+ # Keep track of widget indexes
+ # - we start with 1 because Context item as at the top
+ widget_idx = 1
+ for group_name in sorted_group_names:
+ if group_name in self._widgets_by_group:
+ group_widget = self._widgets_by_group[group_name]
+ else:
+ group_icons = {
+ idenfier: self.controller.get_icon_for_family(idenfier)
+ for idenfier in identifiers_by_group[group_name]
+ }
+
+ group_widget = GroupWidget(
+ group_name, group_icons, self._content_widget
+ )
+ group_widget.active_changed.connect(self._on_active_changed)
+ group_widget.selected.connect(self._on_widget_selection)
+ group_widget.removed_selected.connect(
+ self._on_remove_selected
+ )
+ self._content_layout.insertWidget(widget_idx, group_widget)
+ self._widgets_by_group[group_name] = group_widget
+
+ widget_idx += 1
+ group_widget.update_instances(
+ instances_by_group[group_name]
+ )
+
+ def refresh_instance_states(self):
+ """Trigger update of instances on group widgets."""
+ for widget in self._widgets_by_group.values():
+ widget.update_instance_values()
+
+ def _on_active_changed(self):
+ self.active_changed.emit()
+
+ def _on_widget_selection(self, instance_id, group_name):
+ self.select_item(instance_id, group_name)
+
+ def select_item(self, instance_id, group_name):
+ """Select specific item by instance id.
+
+ Pass `CONTEXT_ID` as instance id and empty string as group to select
+ global context item.
+ """
+ if instance_id == CONTEXT_ID:
+ new_widget = self._context_widget
+ else:
+ group_widget = self._widgets_by_group[group_name]
+ new_widget = group_widget.get_widget_by_instance_id(instance_id)
+
+ selected_widget = self._get_selected_widget()
+ if new_widget is selected_widget:
+ return
+
+ if selected_widget is not None:
+ selected_widget.set_selected(False)
+
+ self._selected_instance_id = instance_id
+ self._selected_group = group_name
+ if new_widget is not None:
+ new_widget.set_selected(True)
+
+ self.selection_changed.emit()
+
+ def _on_remove_selected(self):
+ selected_widget = self._get_selected_widget()
+ if selected_widget is None:
+ self._on_widget_selection(CONTEXT_ID, None)
+
+ def get_selected_items(self):
+ """Get selected instance ids and context."""
+ instances = []
+ context_selected = False
+ selected_widget = self._get_selected_widget()
+ if selected_widget is self._context_widget:
+ context_selected = True
+
+ elif selected_widget is not None:
+ instances.append(selected_widget.instance)
+
+ return instances, context_selected
diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py
new file mode 100644
index 0000000000..0206f038fb
--- /dev/null
+++ b/openpype/tools/publisher/widgets/create_dialog.py
@@ -0,0 +1,559 @@
+import sys
+import re
+import traceback
+import copy
+
+try:
+ import commonmark
+except Exception:
+ commonmark = None
+from Qt import QtWidgets, QtCore, QtGui
+
+from openpype.pipeline.create import CreatorError
+
+from .widgets import IconValuePixmapLabel
+from ..constants import (
+ SUBSET_NAME_ALLOWED_SYMBOLS,
+ VARIANT_TOOLTIP,
+ CREATOR_IDENTIFIER_ROLE,
+ FAMILY_ROLE
+)
+
+SEPARATORS = ("---separator---", "---")
+
+
+class CreateErrorMessageBox(QtWidgets.QDialog):
+ def __init__(
+ self,
+ creator_label,
+ subset_name,
+ asset_name,
+ exc_msg,
+ formatted_traceback,
+ parent=None
+ ):
+ super(CreateErrorMessageBox, self).__init__(parent)
+ self.setWindowTitle("Creation failed")
+ self.setFocusPolicy(QtCore.Qt.StrongFocus)
+ if not parent:
+ self.setWindowFlags(
+ self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint
+ )
+
+ body_layout = QtWidgets.QVBoxLayout(self)
+
+ main_label = (
+ "Failed to create"
+ )
+ main_label_widget = QtWidgets.QLabel(main_label, self)
+ body_layout.addWidget(main_label_widget)
+
+ item_name_template = (
+ "Creator: {}
"
+ "Subset: {}
"
+ "Asset: {}
"
+ )
+ exc_msg_template = "{}"
+
+ line = self._create_line()
+ body_layout.addWidget(line)
+
+ item_name = item_name_template.format(
+ creator_label, subset_name, asset_name
+ )
+ item_name_widget = QtWidgets.QLabel(
+ item_name.replace("\n", "
"), self
+ )
+ body_layout.addWidget(item_name_widget)
+
+ exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
"))
+ message_label_widget = QtWidgets.QLabel(exc_msg, self)
+ body_layout.addWidget(message_label_widget)
+
+ if formatted_traceback:
+ tb_widget = QtWidgets.QLabel(
+ formatted_traceback.replace("\n", "
"), self
+ )
+ tb_widget.setTextInteractionFlags(
+ QtCore.Qt.TextBrowserInteraction
+ )
+ body_layout.addWidget(tb_widget)
+
+ footer_widget = QtWidgets.QWidget(self)
+ footer_layout = QtWidgets.QHBoxLayout(footer_widget)
+ button_box = QtWidgets.QDialogButtonBox(QtCore.Qt.Vertical)
+ button_box.setStandardButtons(
+ QtWidgets.QDialogButtonBox.StandardButton.Ok
+ )
+ button_box.accepted.connect(self._on_accept)
+ footer_layout.addWidget(button_box, alignment=QtCore.Qt.AlignRight)
+ body_layout.addWidget(footer_widget)
+
+ def _on_accept(self):
+ self.close()
+
+ def _create_line(self):
+ line = QtWidgets.QFrame(self)
+ line.setFixedHeight(2)
+ line.setFrameShape(QtWidgets.QFrame.HLine)
+ line.setFrameShadow(QtWidgets.QFrame.Sunken)
+ return line
+
+
+# TODO add creator identifier/label to details
+class CreatorDescriptionWidget(QtWidgets.QWidget):
+ def __init__(self, parent=None):
+ super(CreatorDescriptionWidget, self).__init__(parent=parent)
+
+ icon_widget = IconValuePixmapLabel(None, self)
+ icon_widget.setObjectName("FamilyIconLabel")
+
+ family_label = QtWidgets.QLabel("family")
+ family_label.setAlignment(
+ QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft
+ )
+
+ description_label = QtWidgets.QLabel("description")
+ description_label.setAlignment(
+ QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft
+ )
+
+ detail_description_widget = QtWidgets.QTextEdit(self)
+ detail_description_widget.setObjectName("InfoText")
+ detail_description_widget.setTextInteractionFlags(
+ QtCore.Qt.TextBrowserInteraction
+ )
+
+ label_layout = QtWidgets.QVBoxLayout()
+ label_layout.setSpacing(0)
+ label_layout.addWidget(family_label)
+ label_layout.addWidget(description_label)
+
+ top_layout = QtWidgets.QHBoxLayout()
+ top_layout.setContentsMargins(0, 0, 0, 0)
+ top_layout.addWidget(icon_widget, 0)
+ top_layout.addLayout(label_layout, 1)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addLayout(top_layout, 0)
+ layout.addWidget(detail_description_widget, 1)
+
+ self.icon_widget = icon_widget
+ self.family_label = family_label
+ self.description_label = description_label
+ self.detail_description_widget = detail_description_widget
+
+ def set_plugin(self, plugin=None):
+ if not plugin:
+ self.icon_widget.set_icon_def(None)
+ self.family_label.setText("")
+ self.description_label.setText("")
+ self.detail_description_widget.setPlainText("")
+ return
+
+ plugin_icon = plugin.get_icon()
+ description = plugin.get_description() or ""
+ detailed_description = plugin.get_detail_description() or ""
+
+ self.icon_widget.set_icon_def(plugin_icon)
+ self.family_label.setText("{}".format(plugin.family))
+ self.family_label.setTextInteractionFlags(QtCore.Qt.NoTextInteraction)
+ self.description_label.setText(description)
+
+ if commonmark:
+ html = commonmark.commonmark(detailed_description)
+ self.detail_description_widget.setHtml(html)
+ else:
+ self.detail_description_widget.setMarkdown(detailed_description)
+
+
+class CreateDialog(QtWidgets.QDialog):
+ def __init__(
+ self, controller, asset_name=None, task_name=None, parent=None
+ ):
+ super(CreateDialog, self).__init__(parent)
+
+ self.setWindowTitle("Create new instance")
+
+ self.controller = controller
+
+ if asset_name is None:
+ asset_name = self.dbcon.Session.get("AVALON_ASSET")
+
+ if task_name is None:
+ task_name = self.dbcon.Session.get("AVALON_TASK")
+
+ self._asset_name = asset_name
+ self._task_name = task_name
+
+ self._last_pos = None
+ self._asset_doc = None
+ self._subset_names = None
+ self._selected_creator = None
+
+ self._prereq_available = False
+
+ self.message_dialog = None
+
+ name_pattern = "^[{}]*$".format(SUBSET_NAME_ALLOWED_SYMBOLS)
+ self._name_pattern = name_pattern
+ self._compiled_name_pattern = re.compile(name_pattern)
+
+ creator_description_widget = CreatorDescriptionWidget(self)
+
+ creators_view = QtWidgets.QListView(self)
+ creators_model = QtGui.QStandardItemModel()
+ creators_view.setModel(creators_model)
+
+ variant_input = QtWidgets.QLineEdit(self)
+ variant_input.setObjectName("VariantInput")
+ variant_input.setToolTip(VARIANT_TOOLTIP)
+
+ variant_hints_btn = QtWidgets.QPushButton(self)
+ variant_hints_btn.setFixedWidth(18)
+
+ variant_hints_menu = QtWidgets.QMenu(variant_hints_btn)
+ variant_hints_group = QtWidgets.QActionGroup(variant_hints_menu)
+ variant_hints_btn.setMenu(variant_hints_menu)
+
+ variant_layout = QtWidgets.QHBoxLayout()
+ variant_layout.setContentsMargins(0, 0, 0, 0)
+ variant_layout.setSpacing(0)
+ variant_layout.addWidget(variant_input, 1)
+ variant_layout.addWidget(variant_hints_btn, 0)
+
+ subset_name_input = QtWidgets.QLineEdit(self)
+ subset_name_input.setEnabled(False)
+
+ create_btn = QtWidgets.QPushButton("Create", self)
+ create_btn.setEnabled(False)
+
+ form_layout = QtWidgets.QFormLayout()
+ form_layout.addRow("Name:", variant_layout)
+ form_layout.addRow("Subset:", subset_name_input)
+
+ left_layout = QtWidgets.QVBoxLayout()
+ left_layout.addWidget(QtWidgets.QLabel("Choose family:", self))
+ left_layout.addWidget(creators_view, 1)
+ left_layout.addLayout(form_layout, 0)
+ left_layout.addWidget(create_btn, 0)
+
+ layout = QtWidgets.QHBoxLayout(self)
+ layout.addLayout(left_layout, 0)
+ layout.addSpacing(5)
+ layout.addWidget(creator_description_widget, 1)
+
+ create_btn.clicked.connect(self._on_create)
+ variant_input.returnPressed.connect(self._on_create)
+ variant_input.textChanged.connect(self._on_variant_change)
+ creators_view.selectionModel().currentChanged.connect(
+ self._on_item_change
+ )
+ variant_hints_menu.triggered.connect(self._on_variant_action)
+
+ controller.add_plugins_refresh_callback(self._on_plugins_refresh)
+
+ self.creator_description_widget = creator_description_widget
+
+ self.subset_name_input = subset_name_input
+
+ self.variant_input = variant_input
+ self.variant_hints_btn = variant_hints_btn
+ self.variant_hints_menu = variant_hints_menu
+ self.variant_hints_group = variant_hints_group
+
+ self.creators_model = creators_model
+ self.creators_view = creators_view
+ self.create_btn = create_btn
+
+ @property
+ def dbcon(self):
+ return self.controller.dbcon
+
+ def refresh(self):
+ self._prereq_available = True
+
+ # Refresh data before update of creators
+ self._refresh_asset()
+ # Then refresh creators which may trigger callbacks using refreshed
+ # data
+ self._refresh_creators()
+
+ if self._asset_doc is None:
+ # QUESTION how to handle invalid asset?
+ self.subset_name_input.setText("< Asset is not set >")
+ self._prereq_available = False
+
+ if self.creators_model.rowCount() < 1:
+ self._prereq_available = False
+
+ self.create_btn.setEnabled(self._prereq_available)
+ self.creators_view.setEnabled(self._prereq_available)
+ self.variant_input.setEnabled(self._prereq_available)
+ self.variant_hints_btn.setEnabled(self._prereq_available)
+
+ def _refresh_asset(self):
+ asset_name = self._asset_name
+
+ # Skip if asset did not change
+ if self._asset_doc and self._asset_doc["name"] == asset_name:
+ return
+
+ # Make sure `_asset_doc` and `_subset_names` variables are reset
+ self._asset_doc = None
+ self._subset_names = None
+ if asset_name is None:
+ return
+
+ asset_doc = self.dbcon.find_one({
+ "type": "asset",
+ "name": asset_name
+ })
+ self._asset_doc = asset_doc
+
+ if asset_doc:
+ subset_docs = self.dbcon.find(
+ {
+ "type": "subset",
+ "parent": asset_doc["_id"]
+ },
+ {"name": 1}
+ )
+ self._subset_names = set(subset_docs.distinct("name"))
+
+ def _refresh_creators(self):
+ # Refresh creators and add their families to list
+ existing_items = {}
+ old_creators = set()
+ for row in range(self.creators_model.rowCount()):
+ item = self.creators_model.item(row, 0)
+ identifier = item.data(CREATOR_IDENTIFIER_ROLE)
+ existing_items[identifier] = item
+ old_creators.add(identifier)
+
+ # Add new families
+ new_creators = set()
+ for identifier, creator in self.controller.manual_creators.items():
+ # TODO add details about creator
+ new_creators.add(identifier)
+ if identifier in existing_items:
+ item = existing_items[identifier]
+ else:
+ item = QtGui.QStandardItem()
+ item.setFlags(
+ QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
+ )
+ self.creators_model.appendRow(item)
+
+ label = creator.label or identifier
+ item.setData(label, QtCore.Qt.DisplayRole)
+ item.setData(identifier, CREATOR_IDENTIFIER_ROLE)
+ item.setData(creator.family, FAMILY_ROLE)
+
+ # Remove families that are no more available
+ for identifier in (old_creators - new_creators):
+ item = existing_items[identifier]
+ self.creators_model.takeRow(item.row())
+
+ if self.creators_model.rowCount() < 1:
+ return
+
+ # Make sure there is a selection
+ indexes = self.creators_view.selectedIndexes()
+ if not indexes:
+ index = self.creators_model.index(0, 0)
+ self.creators_view.setCurrentIndex(index)
+
+ def _on_plugins_refresh(self):
+ # Trigger refresh only if is visible
+ if self.isVisible():
+ self.refresh()
+
+ def _on_item_change(self, new_index, _old_index):
+ identifier = None
+ if new_index.isValid():
+ identifier = new_index.data(CREATOR_IDENTIFIER_ROLE)
+
+ creator = self.controller.manual_creators.get(identifier)
+
+ self.creator_description_widget.set_plugin(creator)
+
+ self._selected_creator = creator
+ if not creator:
+ return
+
+ default_variants = creator.get_default_variants()
+ if not default_variants:
+ default_variants = ["Main"]
+
+ default_variant = creator.get_default_variant()
+ if not default_variant:
+ default_variant = default_variants[0]
+
+ for action in tuple(self.variant_hints_menu.actions()):
+ self.variant_hints_menu.removeAction(action)
+ action.deleteLater()
+
+ for variant in default_variants:
+ if variant in SEPARATORS:
+ self.variant_hints_menu.addSeparator()
+ elif variant:
+ self.variant_hints_menu.addAction(variant)
+
+ self.variant_input.setText(default_variant or "Main")
+
+ def _on_variant_action(self, action):
+ value = action.text()
+ if self.variant_input.text() != value:
+ self.variant_input.setText(value)
+
+ def _on_variant_change(self, variant_value):
+ if not self._prereq_available or not self._selected_creator:
+ if self.subset_name_input.text():
+ self.subset_name_input.setText("")
+ return
+
+ match = self._compiled_name_pattern.match(variant_value)
+ valid = bool(match)
+ self.create_btn.setEnabled(valid)
+ if not valid:
+ self._set_variant_state_property("invalid")
+ self.subset_name_input.setText("< Invalid variant >")
+ return
+
+ project_name = self.controller.project_name
+ task_name = self._task_name
+
+ asset_doc = copy.deepcopy(self._asset_doc)
+ # Calculate subset name with Creator plugin
+ subset_name = self._selected_creator.get_subset_name(
+ variant_value, task_name, asset_doc, project_name
+ )
+ self.subset_name_input.setText(subset_name)
+
+ self._validate_subset_name(subset_name, variant_value)
+
+ def _validate_subset_name(self, subset_name, variant_value):
+ # Get all subsets of the current asset
+ if self._subset_names:
+ existing_subset_names = set(self._subset_names)
+ else:
+ existing_subset_names = set()
+ existing_subset_names_low = set(
+ _name.lower()
+ for _name in existing_subset_names
+ )
+
+ # Replace
+ compare_regex = re.compile(re.sub(
+ variant_value, "(.+)", subset_name, flags=re.IGNORECASE
+ ))
+ variant_hints = set()
+ if variant_value:
+ for _name in existing_subset_names:
+ _result = compare_regex.search(_name)
+ if _result:
+ variant_hints |= set(_result.groups())
+
+ # Remove previous hints from menu
+ for action in tuple(self.variant_hints_group.actions()):
+ self.variant_hints_group.removeAction(action)
+ self.variant_hints_menu.removeAction(action)
+ action.deleteLater()
+
+ # Add separator if there are hints and menu already has actions
+ if variant_hints and self.variant_hints_menu.actions():
+ self.variant_hints_menu.addSeparator()
+
+ # Add hints to actions
+ for variant_hint in variant_hints:
+ action = self.variant_hints_menu.addAction(variant_hint)
+ self.variant_hints_group.addAction(action)
+
+ # Indicate subset existence
+ if not variant_value:
+ property_value = "empty"
+
+ elif subset_name.lower() in existing_subset_names_low:
+ # validate existence of subset name with lowered text
+ # - "renderMain" vs. "rendermain" mean same path item for
+ # windows
+ property_value = "exists"
+ else:
+ property_value = "new"
+
+ self._set_variant_state_property(property_value)
+
+ variant_is_valid = variant_value.strip() != ""
+ if variant_is_valid != self.create_btn.isEnabled():
+ self.create_btn.setEnabled(variant_is_valid)
+
+ def _set_variant_state_property(self, state):
+ current_value = self.variant_input.property("state")
+ if current_value != state:
+ self.variant_input.setProperty("state", state)
+ self.variant_input.style().polish(self.variant_input)
+
+ def moveEvent(self, event):
+ super(CreateDialog, self).moveEvent(event)
+ self._last_pos = self.pos()
+
+ def showEvent(self, event):
+ super(CreateDialog, self).showEvent(event)
+ if self._last_pos is not None:
+ self.move(self._last_pos)
+
+ self.refresh()
+
+ def _on_create(self):
+ indexes = self.creators_view.selectedIndexes()
+ if not indexes or len(indexes) > 1:
+ return
+
+ if not self.create_btn.isEnabled():
+ return
+
+ index = indexes[0]
+ creator_label = index.data(QtCore.Qt.DisplayRole)
+ creator_identifier = index.data(CREATOR_IDENTIFIER_ROLE)
+ family = index.data(FAMILY_ROLE)
+ subset_name = self.subset_name_input.text()
+ variant = self.variant_input.text()
+ asset_name = self._asset_name
+ task_name = self._task_name
+ options = {}
+ # Where to define these data?
+ # - what data show be stored?
+ instance_data = {
+ "asset": asset_name,
+ "task": task_name,
+ "variant": variant,
+ "family": family
+ }
+
+ error_info = None
+ try:
+ self.controller.create(
+ creator_identifier, subset_name, instance_data, options
+ )
+
+ except CreatorError as exc:
+ error_info = (str(exc), None)
+
+ # Use bare except because some hosts raise their exceptions that
+ # do not inherit from python's `BaseException`
+ except:
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ formatted_traceback = "".join(traceback.format_exception(
+ exc_type, exc_value, exc_traceback
+ ))
+ error_info = (str(exc_value), formatted_traceback)
+
+ if error_info:
+ box = CreateErrorMessageBox(
+ creator_label, subset_name, asset_name, *error_info
+ )
+ box.show()
+ # Store dialog so is not garbage collected before is shown
+ self.message_dialog = box
diff --git a/openpype/tools/publisher/widgets/icons.py b/openpype/tools/publisher/widgets/icons.py
new file mode 100644
index 0000000000..fd5c45f901
--- /dev/null
+++ b/openpype/tools/publisher/widgets/icons.py
@@ -0,0 +1,45 @@
+import os
+
+from Qt import QtGui
+
+
+def get_icon_path(icon_name=None, filename=None):
+ """Path to image in './images' folder."""
+ if icon_name is None and filename is None:
+ return None
+
+ if filename is None:
+ filename = "{}.png".format(icon_name)
+
+ path = os.path.join(
+ os.path.dirname(os.path.abspath(__file__)),
+ "images",
+ filename
+ )
+ if os.path.exists(path):
+ return path
+ return None
+
+
+def get_image(icon_name=None, filename=None):
+ """Load image from './images' as QImage."""
+ path = get_icon_path(icon_name, filename)
+ if path:
+ return QtGui.QImage(path)
+ return None
+
+
+def get_pixmap(icon_name=None, filename=None):
+ """Load image from './images' as QPixmap."""
+ path = get_icon_path(icon_name, filename)
+ if path:
+ return QtGui.QPixmap(path)
+ return None
+
+
+def get_icon(icon_name=None, filename=None):
+ """Load image from './images' as QICon."""
+ pix = get_pixmap(icon_name, filename)
+ if pix:
+ return QtGui.QIcon(pix)
+ return None
diff --git a/openpype/tools/publisher/widgets/images/add.png b/openpype/tools/publisher/widgets/images/add.png
new file mode 100644
index 0000000000..7fece2f3c6
Binary files /dev/null and b/openpype/tools/publisher/widgets/images/add.png differ
diff --git a/openpype/tools/publisher/widgets/images/branch_closed.png b/openpype/tools/publisher/widgets/images/branch_closed.png
new file mode 100644
index 0000000000..135cd0b29d
Binary files /dev/null and b/openpype/tools/publisher/widgets/images/branch_closed.png differ
diff --git a/openpype/tools/publisher/widgets/images/branch_open.png b/openpype/tools/publisher/widgets/images/branch_open.png
new file mode 100644
index 0000000000..1a83955306
Binary files /dev/null and b/openpype/tools/publisher/widgets/images/branch_open.png differ
diff --git a/openpype/tools/publisher/widgets/images/change_view.png b/openpype/tools/publisher/widgets/images/change_view.png
new file mode 100644
index 0000000000..bda0ef1689
Binary files /dev/null and b/openpype/tools/publisher/widgets/images/change_view.png differ
diff --git a/openpype/tools/publisher/widgets/images/copy.png b/openpype/tools/publisher/widgets/images/copy.png
new file mode 100644
index 0000000000..522afcdc87
Binary files /dev/null and b/openpype/tools/publisher/widgets/images/copy.png differ
diff --git a/openpype/tools/publisher/widgets/images/delete.png b/openpype/tools/publisher/widgets/images/delete.png
new file mode 100644
index 0000000000..ab02768ba3
Binary files /dev/null and b/openpype/tools/publisher/widgets/images/delete.png differ
diff --git a/openpype/tools/publisher/widgets/images/download_arrow.png b/openpype/tools/publisher/widgets/images/download_arrow.png
new file mode 100644
index 0000000000..a35a12fb39
Binary files /dev/null and b/openpype/tools/publisher/widgets/images/download_arrow.png differ
diff --git a/openpype/tools/publisher/widgets/images/minus.png b/openpype/tools/publisher/widgets/images/minus.png
new file mode 100644
index 0000000000..4d0d6f486c
Binary files /dev/null and b/openpype/tools/publisher/widgets/images/minus.png differ
diff --git a/openpype/tools/publisher/widgets/images/play.png b/openpype/tools/publisher/widgets/images/play.png
new file mode 100644
index 0000000000..7019bf19e9
Binary files /dev/null and b/openpype/tools/publisher/widgets/images/play.png differ
diff --git a/openpype/tools/publisher/widgets/images/refresh.png b/openpype/tools/publisher/widgets/images/refresh.png
new file mode 100644
index 0000000000..0b7f1565a7
Binary files /dev/null and b/openpype/tools/publisher/widgets/images/refresh.png differ
diff --git a/openpype/tools/publisher/widgets/images/stop.png b/openpype/tools/publisher/widgets/images/stop.png
new file mode 100644
index 0000000000..eda18d1db1
Binary files /dev/null and b/openpype/tools/publisher/widgets/images/stop.png differ
diff --git a/openpype/tools/publisher/widgets/images/thumbnail.png b/openpype/tools/publisher/widgets/images/thumbnail.png
new file mode 100644
index 0000000000..adea862e5b
Binary files /dev/null and b/openpype/tools/publisher/widgets/images/thumbnail.png differ
diff --git a/openpype/tools/publisher/widgets/images/validate.png b/openpype/tools/publisher/widgets/images/validate.png
new file mode 100644
index 0000000000..d3cfa0b75d
Binary files /dev/null and b/openpype/tools/publisher/widgets/images/validate.png differ
diff --git a/openpype/tools/publisher/widgets/images/view_report.png b/openpype/tools/publisher/widgets/images/view_report.png
new file mode 100644
index 0000000000..50e214c3f8
Binary files /dev/null and b/openpype/tools/publisher/widgets/images/view_report.png differ
diff --git a/openpype/tools/publisher/widgets/images/warning.png b/openpype/tools/publisher/widgets/images/warning.png
new file mode 100644
index 0000000000..76d1e34b6c
Binary files /dev/null and b/openpype/tools/publisher/widgets/images/warning.png differ
diff --git a/openpype/tools/publisher/widgets/list_view_widgets.py b/openpype/tools/publisher/widgets/list_view_widgets.py
new file mode 100644
index 0000000000..e87ea3e130
--- /dev/null
+++ b/openpype/tools/publisher/widgets/list_view_widgets.py
@@ -0,0 +1,815 @@
+"""Simple easy instance view grouping instances into collapsible groups.
+
+View has multiselection ability. Groups are defined by `creator_label`
+attribute on instance (Group defined by creator).
+
+Each item can be enabled/disabled with their checkbox, whole group
+can be enabled/disabled with checkbox on group or
+selection can be enabled disabled using checkbox or keyboard key presses:
+- Space - change state of selection to oposite
+- Enter - enable selection
+- Backspace - disable selection
+
+```
+|- Options
+|- [x]
+| |- [x]
+| |- [x]
+| ...
+|- [ ]
+| |- [ ]
+| ...
+...
+```
+"""
+import collections
+
+from Qt import QtWidgets, QtCore, QtGui
+
+from openpype.style import get_objected_colors
+from openpype.widgets.nice_checkbox import NiceCheckbox
+from .widgets import AbstractInstanceView
+from ..constants import (
+ INSTANCE_ID_ROLE,
+ SORT_VALUE_ROLE,
+ IS_GROUP_ROLE,
+ CONTEXT_ID,
+ CONTEXT_LABEL
+)
+
+
+class ListItemDelegate(QtWidgets.QStyledItemDelegate):
+ """Generic delegate for instance group.
+
+ All indexes having `IS_GROUP_ROLE` data set to True will use
+ `group_item_paint` method to draw it's content otherwise default styled
+ item delegate paint method is used.
+
+ Goal is to draw group items with different colors for normal, hover and
+ pressed state.
+ """
+ radius_ratio = 0.3
+
+ def __init__(self, parent):
+ super(ListItemDelegate, self).__init__(parent)
+
+ colors_data = get_objected_colors()
+ group_color_info = colors_data["publisher"]["list-view-group"]
+
+ self._group_colors = {
+ key: value.get_qcolor()
+ for key, value in group_color_info.items()
+ }
+
+ def paint(self, painter, option, index):
+ if index.data(IS_GROUP_ROLE):
+ self.group_item_paint(painter, option, index)
+ else:
+ super(ListItemDelegate, self).paint(painter, option, index)
+
+ def group_item_paint(self, painter, option, index):
+ """Paint group item."""
+ self.initStyleOption(option, index)
+
+ bg_rect = QtCore.QRectF(
+ option.rect.left(), option.rect.top() + 1,
+ option.rect.width(), option.rect.height() - 2
+ )
+ ratio = bg_rect.height() * self.radius_ratio
+ bg_path = QtGui.QPainterPath()
+ bg_path.addRoundedRect(
+ QtCore.QRectF(bg_rect), ratio, ratio
+ )
+
+ painter.save()
+ painter.setRenderHints(
+ painter.Antialiasing
+ | painter.SmoothPixmapTransform
+ | painter.TextAntialiasing
+ )
+
+ # Draw backgrounds
+ painter.fillPath(bg_path, self._group_colors["bg"])
+ selected = option.state & QtWidgets.QStyle.State_Selected
+ hovered = option.state & QtWidgets.QStyle.State_MouseOver
+ if selected and hovered:
+ painter.fillPath(bg_path, self._group_colors["bg-selected-hover"])
+
+ elif hovered:
+ painter.fillPath(bg_path, self._group_colors["bg-hover"])
+
+ painter.restore()
+
+
+class InstanceListItemWidget(QtWidgets.QWidget):
+ """Widget with instance info drawn over delegate paint.
+
+ This is required to be able use custom checkbox on custom place.
+ """
+ active_changed = QtCore.Signal(str, bool)
+
+ def __init__(self, instance, parent):
+ super(InstanceListItemWidget, self).__init__(parent)
+
+ self.instance = instance
+
+ subset_name_label = QtWidgets.QLabel(instance["subset"], self)
+ subset_name_label.setObjectName("ListViewSubsetName")
+
+ active_checkbox = NiceCheckbox(parent=self)
+ active_checkbox.setChecked(instance["active"])
+
+ layout = QtWidgets.QHBoxLayout(self)
+ content_margins = layout.contentsMargins()
+ layout.setContentsMargins(content_margins.left() + 2, 0, 2, 0)
+ layout.addWidget(subset_name_label)
+ layout.addStretch(1)
+ layout.addWidget(active_checkbox)
+
+ self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
+ subset_name_label.setAttribute(QtCore.Qt.WA_TranslucentBackground)
+ active_checkbox.setAttribute(QtCore.Qt.WA_TranslucentBackground)
+
+ active_checkbox.stateChanged.connect(self._on_active_change)
+
+ self._subset_name_label = subset_name_label
+ self._active_checkbox = active_checkbox
+
+ self._has_valid_context = None
+
+ self._set_valid_property(instance.has_valid_context)
+
+ def _set_valid_property(self, valid):
+ if self._has_valid_context == valid:
+ return
+ self._has_valid_context = valid
+ state = ""
+ if not valid:
+ state = "invalid"
+ self._subset_name_label.setProperty("state", state)
+ self._subset_name_label.style().polish(self._subset_name_label)
+
+ def is_active(self):
+ """Instance is activated."""
+ return self.instance["active"]
+
+ def set_active(self, new_value):
+ """Change active state of instance and checkbox."""
+ checkbox_value = self._active_checkbox.isChecked()
+ instance_value = self.instance["active"]
+ if new_value is None:
+ new_value = not instance_value
+
+ # First change instance value and them change checkbox
+ # - prevent to trigger `active_changed` signal
+ if instance_value != new_value:
+ self.instance["active"] = new_value
+
+ if checkbox_value != new_value:
+ self._active_checkbox.setChecked(new_value)
+
+ def update_instance(self, instance):
+ """Update instance object."""
+ self.instance = instance
+ self.update_instance_values()
+
+ def update_instance_values(self):
+ """Update instance data propagated to widgets."""
+ # Check subset name
+ subset_name = self.instance["subset"]
+ if subset_name != self._subset_name_label.text():
+ self._subset_name_label.setText(subset_name)
+ # Check active state
+ self.set_active(self.instance["active"])
+ # Check valid states
+ self._set_valid_property(self.instance.has_valid_context)
+
+ def _on_active_change(self):
+ new_value = self._active_checkbox.isChecked()
+ old_value = self.instance["active"]
+ if new_value == old_value:
+ return
+
+ self.instance["active"] = new_value
+ self.active_changed.emit(self.instance.id, new_value)
+
+
+class ListContextWidget(QtWidgets.QFrame):
+ """Context (or global attributes) widget."""
+ def __init__(self, parent):
+ super(ListContextWidget, self).__init__(parent)
+
+ label_widget = QtWidgets.QLabel(CONTEXT_LABEL, self)
+
+ layout = QtWidgets.QHBoxLayout(self)
+ layout.setContentsMargins(5, 0, 2, 0)
+ layout.addWidget(
+ label_widget, 1, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter
+ )
+
+ self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
+ label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
+
+ self.label_widget = label_widget
+
+
+class InstanceListGroupWidget(QtWidgets.QFrame):
+ """Widget representing group of instances.
+
+ Has collapse/expand indicator, label of group and checkbox modifying all of
+ it's children.
+ """
+ expand_changed = QtCore.Signal(str, bool)
+ toggle_requested = QtCore.Signal(str, int)
+
+ def __init__(self, group_name, parent):
+ super(InstanceListGroupWidget, self).__init__(parent)
+ self.setObjectName("InstanceListGroupWidget")
+
+ self.group_name = group_name
+ self._expanded = False
+
+ expand_btn = QtWidgets.QToolButton(self)
+ expand_btn.setObjectName("ArrowBtn")
+ expand_btn.setArrowType(QtCore.Qt.RightArrow)
+ expand_btn.setMaximumWidth(14)
+
+ name_label = QtWidgets.QLabel(group_name, self)
+
+ toggle_checkbox = NiceCheckbox(parent=self)
+
+ layout = QtWidgets.QHBoxLayout(self)
+ layout.setContentsMargins(5, 0, 2, 0)
+ layout.addWidget(expand_btn)
+ layout.addWidget(
+ name_label, 1, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter
+ )
+ layout.addWidget(toggle_checkbox, 0)
+
+ name_label.setAttribute(QtCore.Qt.WA_TranslucentBackground)
+ expand_btn.setAttribute(QtCore.Qt.WA_TranslucentBackground)
+
+ expand_btn.clicked.connect(self._on_expand_clicked)
+ toggle_checkbox.stateChanged.connect(self._on_checkbox_change)
+
+ self._ignore_state_change = False
+
+ self._expected_checkstate = None
+
+ self.name_label = name_label
+ self.expand_btn = expand_btn
+ self.toggle_checkbox = toggle_checkbox
+
+ def set_checkstate(self, state):
+ """Change checkstate of "active" checkbox.
+
+ Args:
+ state(QtCore.Qt.CheckState): Checkstate of checkbox. Have 3
+ variants Unchecked, Checked and PartiallyChecked.
+ """
+ if self.checkstate() == state:
+ return
+ self._ignore_state_change = True
+ self.toggle_checkbox.setCheckState(state)
+ self._ignore_state_change = False
+
+ def checkstate(self):
+ """CUrrent checkstate of "active" checkbox."""
+ return self.toggle_checkbox.checkState()
+
+ def _on_checkbox_change(self, state):
+ if not self._ignore_state_change:
+ self.toggle_requested.emit(self.group_name, state)
+
+ def _on_expand_clicked(self):
+ self.expand_changed.emit(self.group_name, not self._expanded)
+
+ def set_expanded(self, expanded):
+ """Change icon of collapse/expand identifier."""
+ if self._expanded == expanded:
+ return
+
+ self._expanded = expanded
+ if expanded:
+ self.expand_btn.setArrowType(QtCore.Qt.DownArrow)
+ else:
+ self.expand_btn.setArrowType(QtCore.Qt.RightArrow)
+
+
+class InstanceTreeView(QtWidgets.QTreeView):
+ """View showing instances and their groups."""
+ toggle_requested = QtCore.Signal(int)
+
+ def __init__(self, *args, **kwargs):
+ super(InstanceTreeView, self).__init__(*args, **kwargs)
+
+ self.setObjectName("InstanceListView")
+ self.setHeaderHidden(True)
+ self.setIndentation(0)
+ self.setExpandsOnDoubleClick(False)
+ self.setSelectionMode(
+ QtWidgets.QAbstractItemView.ExtendedSelection
+ )
+ self.viewport().setMouseTracking(True)
+ self._pressed_group_index = None
+
+ def _expand_item(self, index, expand=None):
+ is_expanded = self.isExpanded(index)
+ if expand is None:
+ expand = not is_expanded
+
+ if expand != is_expanded:
+ if expand:
+ self.expand(index)
+ else:
+ self.collapse(index)
+
+ def get_selected_instance_ids(self):
+ """Ids of selected instances."""
+ instance_ids = set()
+ for index in self.selectionModel().selectedIndexes():
+ instance_id = index.data(INSTANCE_ID_ROLE)
+ if instance_id is not None:
+ instance_ids.add(instance_id)
+ return instance_ids
+
+ def event(self, event):
+ if not event.type() == QtCore.QEvent.KeyPress:
+ pass
+
+ elif event.key() == QtCore.Qt.Key_Space:
+ self.toggle_requested.emit(-1)
+ return True
+
+ elif event.key() == QtCore.Qt.Key_Backspace:
+ self.toggle_requested.emit(0)
+ return True
+
+ elif event.key() == QtCore.Qt.Key_Return:
+ self.toggle_requested.emit(1)
+ return True
+
+ return super(InstanceTreeView, self).event(event)
+
+ def _mouse_press(self, event):
+ """Store index of pressed group.
+
+ This is to be able change state of group and process mouse
+ "double click" as 2x "single click".
+ """
+ if event.button() != QtCore.Qt.LeftButton:
+ return
+
+ pressed_group_index = None
+ pos_index = self.indexAt(event.pos())
+ if pos_index.data(IS_GROUP_ROLE):
+ pressed_group_index = pos_index
+
+ self._pressed_group_index = pressed_group_index
+
+ def mousePressEvent(self, event):
+ self._mouse_press(event)
+ super(InstanceTreeView, self).mousePressEvent(event)
+
+ def mouseDoubleClickEvent(self, event):
+ self._mouse_press(event)
+ super(InstanceTreeView, self).mouseDoubleClickEvent(event)
+
+ def _mouse_release(self, event, pressed_index):
+ if event.button() != QtCore.Qt.LeftButton:
+ return False
+
+ pos_index = self.indexAt(event.pos())
+ if not pos_index.data(IS_GROUP_ROLE) or pressed_index != pos_index:
+ return False
+
+ if self.state() == QtWidgets.QTreeView.State.DragSelectingState:
+ indexes = self.selectionModel().selectedIndexes()
+ if len(indexes) != 1 or indexes[0] != pos_index:
+ return False
+
+ self._expand_item(pos_index)
+ return True
+
+ def mouseReleaseEvent(self, event):
+ pressed_index = self._pressed_group_index
+ self._pressed_group_index = None
+ result = self._mouse_release(event, pressed_index)
+ if not result:
+ super(InstanceTreeView, self).mouseReleaseEvent(event)
+
+
+class InstanceListView(AbstractInstanceView):
+ """Widget providing abstract methods of AbstractInstanceView for list view.
+
+ This is public access to and from list view.
+ """
+ def __init__(self, controller, parent):
+ super(InstanceListView, self).__init__(parent)
+
+ self.controller = controller
+
+ instance_view = InstanceTreeView(self)
+ instance_delegate = ListItemDelegate(instance_view)
+ instance_view.setItemDelegate(instance_delegate)
+ instance_model = QtGui.QStandardItemModel()
+
+ proxy_model = QtCore.QSortFilterProxyModel()
+ proxy_model.setSourceModel(instance_model)
+ proxy_model.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
+ proxy_model.setSortRole(SORT_VALUE_ROLE)
+ proxy_model.setFilterKeyColumn(0)
+ proxy_model.setDynamicSortFilter(True)
+
+ instance_view.setModel(proxy_model)
+
+ layout = QtWidgets.QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(instance_view)
+
+ instance_view.selectionModel().selectionChanged.connect(
+ self._on_selection_change
+ )
+ instance_view.collapsed.connect(self._on_collapse)
+ instance_view.expanded.connect(self._on_expand)
+ instance_view.toggle_requested.connect(self._on_toggle_request)
+
+ self._group_items = {}
+ self._group_widgets = {}
+ self._widgets_by_id = {}
+ self._group_by_instance_id = {}
+ self._context_item = None
+ self._context_widget = None
+
+ self._instance_view = instance_view
+ self._instance_delegate = instance_delegate
+ self._instance_model = instance_model
+ self._proxy_model = proxy_model
+
+ def _on_expand(self, index):
+ group_name = index.data(SORT_VALUE_ROLE)
+ group_widget = self._group_widgets.get(group_name)
+ if group_widget:
+ group_widget.set_expanded(True)
+
+ def _on_collapse(self, index):
+ group_name = index.data(SORT_VALUE_ROLE)
+ group_widget = self._group_widgets.get(group_name)
+ if group_widget:
+ group_widget.set_expanded(False)
+
+ def _on_toggle_request(self, toggle):
+ selected_instance_ids = self._instance_view.get_selected_instance_ids()
+ if toggle == -1:
+ active = None
+ elif toggle == 1:
+ active = True
+ else:
+ active = False
+
+ for instance_id in selected_instance_ids:
+ widget = self._widgets_by_id.get(instance_id)
+ if widget is not None:
+ widget.set_active(active)
+
+ def _update_group_checkstate(self, group_name):
+ widget = self._group_widgets.get(group_name)
+ if widget is None:
+ return
+
+ activity = None
+ for instance_id, _group_name in self._group_by_instance_id.items():
+ if _group_name != group_name:
+ continue
+
+ instance_widget = self._widgets_by_id.get(instance_id)
+ if not instance_widget:
+ continue
+
+ if activity is None:
+ activity = int(instance_widget.is_active())
+
+ elif activity != instance_widget.is_active():
+ activity = -1
+ break
+
+ if activity is None:
+ return
+
+ state = QtCore.Qt.PartiallyChecked
+ if activity == 0:
+ state = QtCore.Qt.Unchecked
+ elif activity == 1:
+ state = QtCore.Qt.Checked
+ widget.set_checkstate(state)
+
+ def refresh(self):
+ """Refresh instances in the view."""
+ # Prepare instances by their groups
+ instances_by_group_name = collections.defaultdict(list)
+ group_names = set()
+ for instance in self.controller.instances:
+ group_label = instance.creator_label
+ group_names.add(group_label)
+ instances_by_group_name[group_label].append(instance)
+
+ # Sort view at the end of refresh
+ # - is turned off until any change in view happens
+ sort_at_the_end = False
+
+ # Access to root item of main model
+ root_item = self._instance_model.invisibleRootItem()
+
+ # Create or use already existing context item
+ # - context widget does not change so we don't have to update anything
+ context_item = None
+ if self._context_item is None:
+ sort_at_the_end = True
+ context_item = QtGui.QStandardItem()
+ context_item.setData(0, SORT_VALUE_ROLE)
+ context_item.setData(CONTEXT_ID, INSTANCE_ID_ROLE)
+
+ root_item.appendRow(context_item)
+
+ index = self._instance_model.index(
+ context_item.row(), context_item.column()
+ )
+ proxy_index = self._proxy_model.mapFromSource(index)
+ widget = ListContextWidget(self._instance_view)
+ self._instance_view.setIndexWidget(proxy_index, widget)
+
+ self._context_widget = widget
+ self._context_item = context_item
+
+ # Create new groups based on prepared `instances_by_group_name`
+ new_group_items = []
+ for group_name in group_names:
+ if group_name in self._group_items:
+ continue
+
+ group_item = QtGui.QStandardItem()
+ group_item.setData(group_name, SORT_VALUE_ROLE)
+ group_item.setData(True, IS_GROUP_ROLE)
+ group_item.setFlags(QtCore.Qt.ItemIsEnabled)
+ self._group_items[group_name] = group_item
+ new_group_items.append(group_item)
+
+ # Add new group items to root item if there are any
+ if new_group_items:
+ # Trigger sort at the end
+ sort_at_the_end = True
+ root_item.appendRows(new_group_items)
+
+ # Create widget for each new group item and store it for future usage
+ for group_item in new_group_items:
+ index = self._instance_model.index(
+ group_item.row(), group_item.column()
+ )
+ proxy_index = self._proxy_model.mapFromSource(index)
+ group_name = group_item.data(SORT_VALUE_ROLE)
+ widget = InstanceListGroupWidget(group_name, self._instance_view)
+ widget.expand_changed.connect(self._on_group_expand_request)
+ widget.toggle_requested.connect(self._on_group_toggle_request)
+ self._group_widgets[group_name] = widget
+ self._instance_view.setIndexWidget(proxy_index, widget)
+
+ # Remove groups that are not available anymore
+ for group_name in tuple(self._group_items.keys()):
+ if group_name in group_names:
+ continue
+
+ group_item = self._group_items.pop(group_name)
+ root_item.removeRow(group_item.row())
+ widget = self._group_widgets.pop(group_name)
+ widget.deleteLater()
+
+ # Store which groups should be expanded at the end
+ expand_groups = set()
+ # Process changes in each group item
+ # - create new instance, update existing and remove not existing
+ for group_name, group_item in self._group_items.items():
+ # Instance items to remove
+ # - will contain all exising instance ids at the start
+ # - instance ids may be removed when existing instances are checked
+ to_remove = set()
+ # Mapping of existing instances under group item
+ existing_mapping = {}
+
+ # Get group index to be able get children indexes
+ group_index = self._instance_model.index(
+ group_item.row(), group_item.column()
+ )
+
+ # Iterate over children indexes of group item
+ for idx in range(group_item.rowCount()):
+ index = self._instance_model.index(idx, 0, group_index)
+ instance_id = index.data(INSTANCE_ID_ROLE)
+ # Add all instance into `to_remove` set
+ to_remove.add(instance_id)
+ existing_mapping[instance_id] = idx
+
+ # Collect all new instances that are not existing under group
+ # New items
+ new_items = []
+ # Tuples of new instance and instance itself
+ new_items_with_instance = []
+ # Group activity (should be {-1;0;1} at the end)
+ # - 0 when all instances are disabled
+ # - 1 when all instances are enabled
+ # - -1 when it's mixed
+ activity = None
+ for instance in instances_by_group_name[group_name]:
+ instance_id = instance.id
+ # Handle group activity
+ if activity is None:
+ activity = int(instance["active"])
+ elif activity == -1:
+ pass
+ elif activity != instance["active"]:
+ activity = -1
+
+ self._group_by_instance_id[instance_id] = group_name
+ # Remove instance id from `to_remove` if already exists and
+ # trigger update of widget
+ if instance_id in to_remove:
+ to_remove.remove(instance_id)
+ widget = self._widgets_by_id[instance_id]
+ widget.update_instance(instance)
+ continue
+
+ # Create new item and store it as new
+ item = QtGui.QStandardItem()
+ item.setData(instance["subset"], SORT_VALUE_ROLE)
+ item.setData(instance_id, INSTANCE_ID_ROLE)
+ new_items.append(item)
+ new_items_with_instance.append((item, instance))
+
+ # Set checkstate of group checkbox
+ state = QtCore.Qt.PartiallyChecked
+ if activity == 0:
+ state = QtCore.Qt.Unchecked
+ elif activity == 1:
+ state = QtCore.Qt.Checked
+
+ widget = self._group_widgets[group_name]
+ widget.set_checkstate(state)
+
+ # Remove items that were not found
+ idx_to_remove = []
+ for instance_id in to_remove:
+ idx_to_remove.append(existing_mapping[instance_id])
+
+ # Remove them in reverse order to prevend row index changes
+ for idx in reversed(sorted(idx_to_remove)):
+ group_item.removeRows(idx, 1)
+
+ # Cleanup instance related widgets
+ for instance_id in to_remove:
+ self._group_by_instance_id.pop(instance_id)
+ widget = self._widgets_by_id.pop(instance_id)
+ widget.deleteLater()
+
+ # Process new instance items and add them to model and create
+ # their widgets
+ if new_items:
+ # Trigger sort at the end when new instances are available
+ sort_at_the_end = True
+
+ # Add items under group item
+ group_item.appendRows(new_items)
+
+ for item, instance in new_items_with_instance:
+ if not instance.has_valid_context:
+ expand_groups.add(group_name)
+ item_index = self._instance_model.index(
+ item.row(),
+ item.column(),
+ group_index
+ )
+ proxy_index = self._proxy_model.mapFromSource(item_index)
+ widget = InstanceListItemWidget(
+ instance, self._instance_view
+ )
+ widget.active_changed.connect(self._on_active_changed)
+ self._instance_view.setIndexWidget(proxy_index, widget)
+ self._widgets_by_id[instance.id] = widget
+
+ # Trigger sort at the end of refresh
+ if sort_at_the_end:
+ self._proxy_model.sort(0)
+
+ # Expand groups marked for expanding
+ for group_name in expand_groups:
+ group_item = self._group_items[group_name]
+ proxy_index = self._proxy_model.mapFromSource(group_item.index())
+
+ self._instance_view.expand(proxy_index)
+
+ def refresh_instance_states(self):
+ """Trigger update of all instances."""
+ for widget in self._widgets_by_id.values():
+ widget.update_instance_values()
+
+ def _on_active_changed(self, changed_instance_id, new_value):
+ selected_instances, _ = self.get_selected_items()
+
+ selected_ids = set()
+ found = False
+ for instance in selected_instances:
+ selected_ids.add(instance.id)
+ if not found and instance.id == changed_instance_id:
+ found = True
+
+ if not found:
+ selected_ids = set()
+ selected_ids.add(changed_instance_id)
+
+ self._change_active_instances(selected_ids, new_value)
+ group_names = set()
+ for instance_id in selected_ids:
+ group_name = self._group_by_instance_id.get(instance_id)
+ if group_name is not None:
+ group_names.add(group_name)
+
+ for group_name in group_names:
+ self._update_group_checkstate(group_name)
+
+ def _change_active_instances(self, instance_ids, new_value):
+ if not instance_ids:
+ return
+
+ changed_ids = set()
+ for instance_id in instance_ids:
+ widget = self._widgets_by_id.get(instance_id)
+ if widget:
+ changed_ids.add(instance_id)
+ widget.set_active(new_value)
+
+ if changed_ids:
+ self.active_changed.emit()
+
+ def get_selected_items(self):
+ """Get selected instance ids and context selection.
+
+ Returns:
+ tuple: Selected instance ids and boolean if context
+ is selected.
+ """
+ instances = []
+ context_selected = False
+ instances_by_id = {
+ instance.id: instance
+ for instance in self.controller.instances
+ }
+
+ for index in self._instance_view.selectionModel().selectedIndexes():
+ instance_id = index.data(INSTANCE_ID_ROLE)
+ if not context_selected and instance_id == CONTEXT_ID:
+ context_selected = True
+
+ elif instance_id is not None:
+ instance = instances_by_id.get(instance_id)
+ if instance:
+ instances.append(instance)
+
+ return instances, context_selected
+
+ def _on_selection_change(self, *_args):
+ self.selection_changed.emit()
+
+ def _on_group_expand_request(self, group_name, expanded):
+ group_item = self._group_items.get(group_name)
+ if not group_item:
+ return
+
+ group_index = self._instance_model.index(
+ group_item.row(), group_item.column()
+ )
+ proxy_index = self.mapFromSource(group_index)
+ self._instance_view.setExpanded(proxy_index, expanded)
+
+ def _on_group_toggle_request(self, group_name, state):
+ if state == QtCore.Qt.PartiallyChecked:
+ return
+
+ if state == QtCore.Qt.Checked:
+ active = True
+ else:
+ active = False
+
+ group_item = self._group_items.get(group_name)
+ if not group_item:
+ return
+
+ instance_ids = set()
+ for row in range(group_item.rowCount()):
+ item = group_item.child(row)
+ instance_id = item.data(INSTANCE_ID_ROLE)
+ if instance_id is not None:
+ instance_ids.add(instance_id)
+
+ self._change_active_instances(instance_ids, active)
+
+ proxy_index = self.mapFromSource(group_item.index())
+ if not self._instance_view.isExpanded(proxy_index):
+ self._instance_view.expand(proxy_index)
diff --git a/openpype/tools/publisher/widgets/models.py b/openpype/tools/publisher/widgets/models.py
new file mode 100644
index 0000000000..0cfd771ef1
--- /dev/null
+++ b/openpype/tools/publisher/widgets/models.py
@@ -0,0 +1,201 @@
+import re
+import collections
+
+from Qt import QtCore, QtGui
+
+
+class AssetsHierarchyModel(QtGui.QStandardItemModel):
+ """Assets hiearrchy model.
+
+ For selecting asset for which should beinstance created.
+
+ Uses controller to load asset hierarchy. All asset documents are stored by
+ their parents.
+ """
+ def __init__(self, controller):
+ super(AssetsHierarchyModel, self).__init__()
+ self._controller = controller
+
+ self._items_by_name = {}
+
+ def reset(self):
+ self.clear()
+
+ self._items_by_name = {}
+ assets_by_parent_id = self._controller.get_asset_hierarchy()
+
+ items_by_name = {}
+ _queue = collections.deque()
+ _queue.append((self.invisibleRootItem(), None))
+ while _queue:
+ parent_item, parent_id = _queue.popleft()
+ children = assets_by_parent_id.get(parent_id)
+ if not children:
+ continue
+
+ children_by_name = {
+ child["name"]: child
+ for child in children
+ }
+ items = []
+ for name in sorted(children_by_name.keys()):
+ child = children_by_name[name]
+ item = QtGui.QStandardItem(name)
+ items_by_name[name] = item
+ items.append(item)
+ _queue.append((item, child["_id"]))
+
+ parent_item.appendRows(items)
+
+ self._items_by_name = items_by_name
+
+ def name_is_valid(self, item_name):
+ return item_name in self._items_by_name
+
+ def get_index_by_name(self, item_name):
+ item = self._items_by_name.get(item_name)
+ if item:
+ return item.index()
+ return QtCore.QModelIndex()
+
+
+class TasksModel(QtGui.QStandardItemModel):
+ """Tasks model.
+
+ Task model must have set context of asset documents.
+
+ Items in model are based on 0-infinite asset documents. Always contain
+ an interserction of context asset tasks. When no assets are in context
+ them model is empty if 2 or more are in context assets that don't have
+ tasks with same names then model is empty too.
+
+ Args:
+ controller (PublisherController): Controller which handles creation and
+ publishing.
+ """
+ def __init__(self, controller):
+ super(TasksModel, self).__init__()
+ self._controller = controller
+ self._items_by_name = {}
+ self._asset_names = []
+ self._task_names_by_asset_name = {}
+
+ def set_asset_names(self, asset_names):
+ """Set assets context."""
+ self._asset_names = asset_names
+ self.reset()
+
+ @staticmethod
+ def get_intersection_of_tasks(task_names_by_asset_name):
+ """Calculate intersection of task names from passed data.
+
+ Example:
+ ```
+ # Passed `task_names_by_asset_name`
+ {
+ "asset_1": ["compositing", "animation"],
+ "asset_2": ["compositing", "editorial"]
+ }
+ ```
+ Result:
+ ```
+ # Set
+ {"compositing"}
+ ```
+
+ Args:
+ task_names_by_asset_name (dict): Task names in iterable by parent.
+ """
+ tasks = None
+ for task_names in task_names_by_asset_name.values():
+ if tasks is None:
+ tasks = set(task_names)
+ else:
+ tasks &= set(task_names)
+
+ if not tasks:
+ break
+ return tasks or set()
+
+ def is_task_name_valid(self, asset_name, task_name):
+ """Is task name available for asset.
+
+ Args:
+ asset_name (str): Name of asset where should look for task.
+ task_name (str): Name of task which should be available in asset's
+ tasks.
+ """
+ task_names = self._task_names_by_asset_name.get(asset_name)
+ if task_names and task_name in task_names:
+ return True
+ return False
+
+ def reset(self):
+ """Update model by current context."""
+ if not self._asset_names:
+ self._items_by_name = {}
+ self._task_names_by_asset_name = {}
+ self.clear()
+ return
+
+ task_names_by_asset_name = (
+ self._controller.get_task_names_by_asset_names(self._asset_names)
+ )
+ self._task_names_by_asset_name = task_names_by_asset_name
+
+ new_task_names = self.get_intersection_of_tasks(
+ task_names_by_asset_name
+ )
+ old_task_names = set(self._items_by_name.keys())
+ if new_task_names == old_task_names:
+ return
+
+ root_item = self.invisibleRootItem()
+ for task_name in old_task_names:
+ if task_name not in new_task_names:
+ item = self._items_by_name.pop(task_name)
+ root_item.removeRow(item.row())
+
+ new_items = []
+ for task_name in new_task_names:
+ if task_name in self._items_by_name:
+ continue
+
+ item = QtGui.QStandardItem(task_name)
+ self._items_by_name[task_name] = item
+ new_items.append(item)
+ root_item.appendRows(new_items)
+
+
+class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel):
+ """Recursive proxy model.
+
+ Item is not filtered if any children match the filter.
+
+ Use case: Filtering by string - parent won't be filtered if does not match
+ the filter string but first checks if any children does.
+ """
+ def filterAcceptsRow(self, row, parent_index):
+ regex = self.filterRegExp()
+ if not regex.isEmpty():
+ model = self.sourceModel()
+ source_index = model.index(
+ row, self.filterKeyColumn(), parent_index
+ )
+ if source_index.isValid():
+ pattern = regex.pattern()
+
+ # Check current index itself
+ value = model.data(source_index, self.filterRole())
+ if re.search(pattern, value, re.IGNORECASE):
+ return True
+
+ rows = model.rowCount(source_index)
+ for idx in range(rows):
+ if self.filterAcceptsRow(idx, source_index):
+ return True
+ return False
+
+ return super(RecursiveSortFilterProxyModel, self).filterAcceptsRow(
+ row, parent_index
+ )
diff --git a/openpype/tools/publisher/widgets/publish_widget.py b/openpype/tools/publisher/widgets/publish_widget.py
new file mode 100644
index 0000000000..e4f3579978
--- /dev/null
+++ b/openpype/tools/publisher/widgets/publish_widget.py
@@ -0,0 +1,521 @@
+import os
+import json
+import time
+
+from Qt import QtWidgets, QtCore, QtGui
+
+from openpype.pipeline import KnownPublishError
+
+from .validations_widget import ValidationsWidget
+from ..publish_report_viewer import PublishReportViewerWidget
+from .widgets import (
+ StopBtn,
+ ResetBtn,
+ ValidateBtn,
+ PublishBtn,
+ CopyPublishReportBtn,
+ SavePublishReportBtn,
+ ShowPublishReportBtn
+)
+
+
+class ActionsButton(QtWidgets.QToolButton):
+ def __init__(self, parent=None):
+ super(ActionsButton, self).__init__(parent)
+
+ self.setText("< No action >")
+ self.setPopupMode(self.MenuButtonPopup)
+ menu = QtWidgets.QMenu(self)
+
+ self.setMenu(menu)
+
+ self._menu = menu
+ self._actions = []
+ self._current_action = None
+
+ self.clicked.connect(self._on_click)
+
+ def current_action(self):
+ return self._current_action
+
+ def add_action(self, action):
+ self._actions.append(action)
+ action.triggered.connect(self._on_action_trigger)
+ self._menu.addAction(action)
+ if self._current_action is None:
+ self._set_action(action)
+
+ def set_action(self, action):
+ if action not in self._actions:
+ self.add_action(action)
+ self._set_action(action)
+
+ def _set_action(self, action):
+ if action is self._current_action:
+ return
+ self._current_action = action
+ self.setText(action.text())
+ self.setIcon(action.icon())
+
+ def _on_click(self):
+ self._current_action.trigger()
+
+ def _on_action_trigger(self):
+ action = self.sender()
+ if action not in self._actions:
+ return
+
+ self._set_action(action)
+
+
+class PublishFrame(QtWidgets.QFrame):
+ """Frame showed during publishing.
+
+ Shows all information related to publishing. Contains validation error
+ widget which is showed if only validation error happens during validation.
+
+ Processing layer is default layer. Validation error layer is shown if only
+ validation exception is raised during publishing. Report layer is available
+ only when publishing process is stopped and must be manually triggered to
+ change into that layer.
+
+ +------------------------------------------------------------------------+
+ | |
+ | |
+ | |
+ | < Validation error widget > |
+ | |
+ | |
+ | |
+ | |
+ +------------------------------------------------------------------------+
+ | < Main label > |
+ | < Label top > |
+ | (#### 10%