Merge remote-tracking branch 'origin/develop' into bugfix/OP-1901-Maya-multiple-subsets-review-broken

This commit is contained in:
karimmozlia 2021-11-05 15:54:03 +02:00
commit fb5e1c46df
106 changed files with 12811 additions and 255 deletions

View file

@ -1,15 +1,20 @@
# Changelog
## [3.6.0-nightly.2](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)
@ -18,6 +23,7 @@
**🐛 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)
@ -26,6 +32,7 @@
- Maya: Fix hotbox broken by scriptsmenu [\#2151](https://github.com/pypeclub/OpenPype/pull/2151)
- Ftrack: Ignore save warnings exception in Prepare project action [\#2150](https://github.com/pypeclub/OpenPype/pull/2150)
- Loader thumbnails with smooth edges [\#2147](https://github.com/pypeclub/OpenPype/pull/2147)
- Added validator for source files for Standalone Publisher [\#2138](https://github.com/pypeclub/OpenPype/pull/2138)
**Merged pull requests:**
@ -69,7 +76,6 @@
**🐛 Bug fixes**
- Added validator for source files for Standalone Publisher [\#2138](https://github.com/pypeclub/OpenPype/pull/2138)
- Maya: fix model publishing [\#2130](https://github.com/pypeclub/OpenPype/pull/2130)
- Fix - oiiotool wasn't recognized even if present [\#2129](https://github.com/pypeclub/OpenPype/pull/2129)
- General: Disk mapping group [\#2120](https://github.com/pypeclub/OpenPype/pull/2120)
@ -106,8 +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)
**🐛 Bug fixes**
@ -125,17 +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)
- Loader & Library loader: Use tools from OpenPype [\#2038](https://github.com/pypeclub/OpenPype/pull/2038)
- 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)
## [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)

View file

@ -0,0 +1,217 @@
"""Load audio in Blender."""
from pathlib import Path
from pprint import pformat
from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
class AudioLoader(plugin.AssetLoader):
"""Load audio in Blender."""
families = ["audio"]
representations = ["wav"]
label = "Load Audio"
icon = "volume-up"
color = "orange"
def process_asset(
self, context: dict, name: str, namespace: Optional[str] = None,
options: Optional[Dict] = None
) -> Optional[List]:
"""
Arguments:
name: Use pre-defined name
namespace: Use pre-defined namespace
context: Full parenthood of representation to load
options: Additional settings dictionary
"""
libpath = self.fname
asset = context["asset"]["name"]
subset = context["subset"]["name"]
asset_name = plugin.asset_name(asset, subset)
unique_number = plugin.get_unique_number(asset, subset)
group_name = plugin.asset_name(asset, subset, unique_number)
namespace = namespace or f"{asset}_{unique_number}"
avalon_container = bpy.data.collections.get(AVALON_CONTAINERS)
if not avalon_container:
avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS)
bpy.context.scene.collection.children.link(avalon_container)
asset_group = bpy.data.objects.new(group_name, object_data=None)
avalon_container.objects.link(asset_group)
# Blender needs the Sequence Editor in the current window, to be able
# to load the audio. We take one of the areas in the window, save its
# type, and switch to the Sequence Editor. After loading the audio,
# we switch back to the previous area.
window_manager = bpy.context.window_manager
old_type = window_manager.windows[-1].screen.areas[0].type
window_manager.windows[-1].screen.areas[0].type = "SEQUENCE_EDITOR"
# We override the context to load the audio in the sequence editor.
oc = bpy.context.copy()
oc["area"] = window_manager.windows[-1].screen.areas[0]
bpy.ops.sequencer.sound_strip_add(oc, filepath=libpath, frame_start=1)
window_manager.windows[-1].screen.areas[0].type = old_type
p = Path(libpath)
audio = p.name
asset_group[AVALON_PROPERTY] = {
"schema": "openpype:container-2.0",
"id": AVALON_CONTAINER_ID,
"name": name,
"namespace": namespace or '',
"loader": str(self.__class__.__name__),
"representation": str(context["representation"]["_id"]),
"libpath": libpath,
"asset_name": asset_name,
"parent": str(context["representation"]["parent"]),
"family": context["representation"]["context"]["family"],
"objectName": group_name,
"audio": audio
}
objects = []
self[:] = objects
return [objects]
def exec_update(self, container: Dict, representation: Dict):
"""Update an audio strip in the sequence editor.
Arguments:
container (openpype:container-1.0): Container to update,
from `host.ls()`.
representation (openpype:representation-1.0): Representation to
update, from `host.ls()`.
"""
object_name = container["objectName"]
asset_group = bpy.data.objects.get(object_name)
libpath = Path(api.get_representation_path(representation))
self.log.info(
"Container: %s\nRepresentation: %s",
pformat(container, indent=2),
pformat(representation, indent=2),
)
assert asset_group, (
f"The asset is not loaded: {container['objectName']}"
)
assert libpath, (
"No existing library file found for {container['objectName']}"
)
assert libpath.is_file(), (
f"The file doesn't exist: {libpath}"
)
metadata = asset_group.get(AVALON_PROPERTY)
group_libpath = metadata["libpath"]
normalized_group_libpath = (
str(Path(bpy.path.abspath(group_libpath)).resolve())
)
normalized_libpath = (
str(Path(bpy.path.abspath(str(libpath))).resolve())
)
self.log.debug(
"normalized_group_libpath:\n %s\nnormalized_libpath:\n %s",
normalized_group_libpath,
normalized_libpath,
)
if normalized_group_libpath == normalized_libpath:
self.log.info("Library already loaded, not updating...")
return
old_audio = container["audio"]
p = Path(libpath)
new_audio = p.name
# Blender needs the Sequence Editor in the current window, to be able
# to update the audio. We take one of the areas in the window, save its
# type, and switch to the Sequence Editor. After updating the audio,
# we switch back to the previous area.
window_manager = bpy.context.window_manager
old_type = window_manager.windows[-1].screen.areas[0].type
window_manager.windows[-1].screen.areas[0].type = "SEQUENCE_EDITOR"
# We override the context to load the audio in the sequence editor.
oc = bpy.context.copy()
oc["area"] = window_manager.windows[-1].screen.areas[0]
# We deselect all sequencer strips, and then select the one we
# need to remove.
bpy.ops.sequencer.select_all(oc, action='DESELECT')
scene = bpy.context.scene
scene.sequence_editor.sequences_all[old_audio].select = True
bpy.ops.sequencer.delete(oc)
bpy.data.sounds.remove(bpy.data.sounds[old_audio])
bpy.ops.sequencer.sound_strip_add(
oc, filepath=str(libpath), frame_start=1)
window_manager.windows[-1].screen.areas[0].type = old_type
metadata["libpath"] = str(libpath)
metadata["representation"] = str(representation["_id"])
metadata["parent"] = str(representation["parent"])
metadata["audio"] = new_audio
def exec_remove(self, container: Dict) -> bool:
"""Remove an audio strip from the sequence editor and the container.
Arguments:
container (openpype:container-1.0): Container to remove,
from `host.ls()`.
Returns:
bool: Whether the container was deleted.
"""
object_name = container["objectName"]
asset_group = bpy.data.objects.get(object_name)
if not asset_group:
return False
audio = container["audio"]
# Blender needs the Sequence Editor in the current window, to be able
# to remove the audio. We take one of the areas in the window, save its
# type, and switch to the Sequence Editor. After removing the audio,
# we switch back to the previous area.
window_manager = bpy.context.window_manager
old_type = window_manager.windows[-1].screen.areas[0].type
window_manager.windows[-1].screen.areas[0].type = "SEQUENCE_EDITOR"
# We override the context to load the audio in the sequence editor.
oc = bpy.context.copy()
oc["area"] = window_manager.windows[-1].screen.areas[0]
# We deselect all sequencer strips, and then select the one we
# need to remove.
bpy.ops.sequencer.select_all(oc, action='DESELECT')
bpy.context.scene.sequence_editor.sequences_all[audio].select = True
bpy.ops.sequencer.delete(oc)
window_manager.windows[-1].screen.areas[0].type = old_type
bpy.data.sounds.remove(bpy.data.sounds[audio])
bpy.data.objects.remove(asset_group)
return True

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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))

View file

@ -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`.

View file

View file

@ -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"
)

View file

@ -0,0 +1 @@
{}

View file

@ -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": {}
}
]

View file

@ -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("<b>{}</b>".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()

View file

@ -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

View file

@ -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?
"""

View file

@ -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. [![Awesome](https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg)](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)
"""

View file

@ -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"

View file

@ -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)
)

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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 = []

View file

@ -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]

View file

@ -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

View file

@ -101,9 +101,14 @@ class DropboxHandler(AbstractProvider):
},
# roots could be overriden only on Project level, User cannot
{
'key': "roots",
'label': "Roots",
'type': 'dict'
"key": "roots",
"label": "Roots",
"type": "dict-roots",
"object_type": {
"type": "path",
"multiplatform": True,
"multipath": False
}
}
]

View file

@ -131,9 +131,14 @@ class GDriveHandler(AbstractProvider):
},
# roots could be overriden only on Project leve, User cannot
{
'key': "roots",
'label': "Roots",
'type': 'dict'
"key": "roots",
"label": "Roots",
"type": "dict-roots",
"object_type": {
"type": "path",
"multiplatform": True,
"multipath": False
}
}
]
return editable

View file

@ -50,9 +50,14 @@ class LocalDriveHandler(AbstractProvider):
# for non 'studio' sites, 'studio' is configured in Anatomy
editable = [
{
'key': "roots",
'label': "Roots",
'type': 'dict'
"key": "roots",
"label": "Roots",
"type": "dict-roots",
"object_type": {
"type": "path",
"multiplatform": True,
"multipath": False
}
}
]
return editable

View file

@ -139,9 +139,14 @@ class SFTPHandler(AbstractProvider):
},
# roots could be overriden only on Project leve, User cannot
{
'key': "roots",
'label': "Roots",
'type': 'dict'
"key": "roots",
"label": "Roots",
"type": "dict-roots",
"object_type": {
"type": "path",
"multiplatform": True,
"multipath": False
}
}
]
return editable

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -421,6 +421,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()]

View file

@ -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
@ -711,22 +816,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
return
self.lock = threading.Lock()
try:
self.sync_server_thread = SyncServerThread(self)
from .tray.app import SyncServerWindow
self.widget = SyncServerWindow(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
self.sync_server_thread = SyncServerThread(self)
def tray_start(self):
"""
@ -1347,7 +1437,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:
@ -1398,13 +1488,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:
@ -1417,11 +1514,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)
@ -1496,7 +1601,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):

View file

@ -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},

View file

@ -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):
"""

View file

@ -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"
)

View file

@ -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": <uuid4>,
## Instance family (used from Creator)
"family": <family>,
# Mutable data
## Subset name based on subset name template - may change overtime (on context change)
"subset": <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!
<Plugin name>: {...},
...
},
## 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.

View file

@ -0,0 +1,24 @@
from .creator_plugins import (
CreatorError,
BaseCreator,
Creator,
AutoCreator
)
from .context import (
CreatedInstance,
CreateContext
)
__all__ = (
"CreatorError",
"BaseCreator",
"Creator",
"AutoCreator",
"CreatedInstance",
"CreateContext"
)

File diff suppressed because it is too large Load diff

View file

@ -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<CreatedInstance>): 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<AbtractAttrDef>: 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<str>: 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

View file

@ -0,0 +1,18 @@
from .attribute_definitions import (
AbtractAttrDef,
UnknownDef,
NumberDef,
TextDef,
EnumDef,
BoolDef
)
__all__ = (
"AbtractAttrDef",
"UnknownDef",
"NumberDef",
"TextDef",
"EnumDef",
"BoolDef"
)

View file

@ -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

View file

@ -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`.

View file

@ -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"
)

View file

@ -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

View file

@ -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<AbtractAttrDef>: 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

View file

@ -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()

View file

@ -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)

View file

@ -172,5 +172,16 @@
}
]
}
},
"maya": {
"colorManagementPreference": {
"configFilePath": {
"windows": [],
"darwin": [],
"linux": []
},
"renderSpace": "scene-linear Rec 709/sRGB",
"viewTransform": "sRGB gamma"
}
}
}

View file

@ -255,6 +255,11 @@
"optional": true,
"active": true
},
"ValidateMeshNgons": {
"enabled": false,
"optional": true,
"active": true
},
"ValidateMeshNonManifold": {
"enabled": false,
"optional": true,

View file

@ -112,7 +112,8 @@ from .enum_entity import (
from .list_entity import ListEntity
from .dict_immutable_keys_entity import (
DictImmutableKeysEntity,
RootsDictEntity
RootsDictEntity,
SyncServerSites
)
from .dict_mutable_keys_entity import DictMutableKeysEntity
from .dict_conditional import (
@ -173,6 +174,7 @@ __all__ = (
"DictImmutableKeysEntity",
"RootsDictEntity",
"SyncServerSites",
"DictMutableKeysEntity",

View file

@ -572,7 +572,7 @@ class RootsDictEntity(DictImmutableKeysEntity):
object_type = {"type": object_type}
self.object_type = object_type
if not self.is_group:
if self.group_item is None and not self.is_group:
self.is_group = True
schema_data = copy.deepcopy(self.schema_data)
@ -629,7 +629,7 @@ class RootsDictEntity(DictImmutableKeysEntity):
self._add_children(schema_data)
self._set_children_values(state)
self._set_children_values(state, ignore_missing_defaults)
super(RootsDictEntity, self).set_override_state(
state, True
@ -652,11 +652,14 @@ class RootsDictEntity(DictImmutableKeysEntity):
return super(RootsDictEntity, self).on_child_change(child_obj)
def _set_children_values(self, state):
def _set_children_values(self, state, ignore_missing_defaults):
if state >= OverrideState.DEFAULTS:
default_value = self._default_value
if default_value is NOT_SET:
if state > OverrideState.DEFAULTS:
if (
not ignore_missing_defaults
and state > OverrideState.DEFAULTS
):
raise DefaultsNotDefined(self)
else:
default_value = {}
@ -724,3 +727,195 @@ class RootsDictEntity(DictImmutableKeysEntity):
self._project_value = value
self._project_override_metadata = {}
self.had_project_override = value is not NOT_SET
class SyncServerSites(DictImmutableKeysEntity):
"""Dictionary enity for sync sites.
Can be used only in project settings.
Is loading sites from system settings. Uses site name as key and by site's
provider loads project settings schemas calling method
`get_project_settings_schema` on provider.
Each provider have `enabled` boolean entity to be able know if site should
be enabled for the project. Enabled is by default set to False.
"""
schema_types = ["sync-server-sites"]
def _item_initialization(self):
# Make sure this is a group
if self.group_item is None and not self.is_group:
self.is_group = True
# Fake children for `dict` validations
self.schema_data["children"] = []
# Site names changed or were removed
# - to find out that site names was removed so project values
# contain more data than should
self._sites_changed = False
super(SyncServerSites, self)._item_initialization()
def set_override_state(self, state, ignore_missing_defaults):
# Cleanup children related attributes
self.children = []
self.non_gui_children = {}
self.gui_layout = []
# Create copy of schema
schema_data = copy.deepcopy(self.schema_data)
# Collect children
children = self._get_children()
schema_data["children"] = children
self._add_children(schema_data)
self._sites_changed = False
self._set_children_values(state, ignore_missing_defaults)
super(SyncServerSites, self).set_override_state(state, True)
@property
def has_unsaved_changes(self):
if self._sites_changed:
return True
return super(SyncServerSites, self).has_unsaved_changes
@property
def has_studio_override(self):
if self._sites_changed:
return True
return super(SyncServerSites, self).has_studio_override
@property
def has_project_override(self):
if self._sites_changed:
return True
return super(SyncServerSites, self).has_project_override
def _get_children(self):
from openpype_modules import sync_server
# Load system settings to find out all created sites
modules_entity = self.get_entity_from_path("system_settings/modules")
sync_server_settings_entity = modules_entity.get("sync_server")
# Get project settings configurations for all providers
project_settings_schema = (
sync_server
.SyncServerModule
.get_project_settings_schema()
)
children = []
# Add 'enabled' for each site to be able know if should be used for
# the project
checkbox_child = {
"type": "boolean",
"key": "enabled",
"default": False
}
if sync_server_settings_entity is not None:
sites_entity = sync_server_settings_entity["sites"]
for site_name, provider_entity in sites_entity.items():
provider_name = provider_entity["provider"].value
provider_children = copy.deepcopy(
project_settings_schema.get(provider_name)
) or []
provider_children.insert(0, copy.deepcopy(checkbox_child))
children.append({
"type": "dict",
"key": site_name,
"label": site_name,
"checkbox_key": "enabled",
"children": provider_children
})
return children
def _set_children_values(self, state, ignore_missing_defaults):
current_site_names = set(self.non_gui_children.keys())
if state >= OverrideState.DEFAULTS:
default_value = self._default_value
if default_value is NOT_SET:
if (
not ignore_missing_defaults
and 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 is OverrideState.STUDIO:
value_keys = set(value.keys())
self._sites_changed = value_keys != current_site_names
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)
if state is OverrideState.PROJECT:
value_keys = set(value.keys())
self._sites_changed = value_keys != current_site_names
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

View file

@ -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"
}
]
}
]
}
]
}

View file

@ -274,6 +274,10 @@
"key": "ValidateMeshLaminaFaces",
"label": "ValidateMeshLaminaFaces"
},
{
"key": "ValidateMeshNgons",
"label": "ValidateMeshNgons"
},
{
"key": "ValidateMeshNonManifold",
"label": "ValidateMeshNonManifold"

View file

@ -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"
}
}
}
}

View file

@ -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;
}

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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,

View file

@ -20,7 +20,7 @@ from .widgets import (
SlidePageWidget
)
from .flickcharm import FlickCharm
from openpype.tools.flickcharm import FlickCharm
class ProjectIconView(QtWidgets.QListView):

View file

@ -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()

View file

@ -0,0 +1,7 @@
from .app import show
from .window import PublisherWindow
__all__ = (
"show",
"PublisherWindow"
)

View file

@ -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

View file

@ -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"
)

View file

@ -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<CreatedInstance>): 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<CreatedInstance>): 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<pyblish.api.Instance>): 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)

View file

@ -0,0 +1,14 @@
from .widgets import (
PublishReportViewerWidget
)
from .window import (
PublishReportViewerWindow
)
__all__ = (
"PublishReportViewerWidget",
"PublishReportViewerWindow",
)

View file

@ -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"
)

View file

@ -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()

View file

@ -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

View file

@ -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()
)

View file

@ -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)

View file

@ -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",
)

View file

@ -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)

View file

@ -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.
```
<i> : Icon. Can have Warning icon when context is not right
Options
<Group 1>
<i> <Instance 1> [x]
<i> <Instance 2> [x]
<Group 2>
<i> <Instance 3> [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<CreatedInstance>): 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 = "<b>{}</b>".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

View file

@ -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 = (
"<span style='font-size:18pt;'>Failed to create</span>"
)
main_label_widget = QtWidgets.QLabel(main_label, self)
body_layout.addWidget(main_label_widget)
item_name_template = (
"<span style='font-weight:bold;'>Creator:</span> {}<br>"
"<span style='font-weight:bold;'>Subset:</span> {}<br>"
"<span style='font-weight:bold;'>Asset:</span> {}<br>"
)
exc_msg_template = "<span style='font-weight:bold'>{}</span>"
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", "<br>"), self
)
body_layout.addWidget(item_name_widget)
exc_msg = exc_msg_template.format(exc_msg.replace("\n", "<br>"))
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", "<br>"), 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("<b>{}</b>".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

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View file

@ -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
|- <Group 1> [x]
| |- <Instance 1> [x]
| |- <Instance 2> [x]
| ...
|- <Group 2> [ ]
| |- <Instance 3> [ ]
| ...
...
```
"""
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<list, bool>: 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)

View file

@ -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
)

View file

@ -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% <Progress bar> ) |
| <Instance label> <Plugin label> |
| Report: <Copy><Save> <Label bottom> <Reset><Stop><Validate><Publish> |
+------------------------------------------------------------------------+
"""
def __init__(self, controller, parent):
super(PublishFrame, self).__init__(parent)
self.setObjectName("PublishFrame")
# Widget showing validation errors. Their details and action callbacks.
validation_errors_widget = ValidationsWidget(controller, self)
# Bottom part of widget where process and callback buttons are showed
# - QFrame used to be able set background using stylesheets easily
# and not override all children widgets style
info_frame = QtWidgets.QFrame(self)
info_frame.setObjectName("PublishInfoFrame")
# Content of info frame
# - separated into QFrame and QWidget (widget has transparent bg)
content_widget = QtWidgets.QWidget(info_frame)
content_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
info_layout = QtWidgets.QVBoxLayout(info_frame)
info_layout.setContentsMargins(0, 0, 0, 0)
info_layout.addWidget(content_widget)
# Center widget displaying current state (without any specific info)
main_label = QtWidgets.QLabel(content_widget)
main_label.setObjectName("PublishInfoMainLabel")
main_label.setAlignment(QtCore.Qt.AlignCenter)
# Supporting labels for main label
# Top label is displayed just under main label
message_label_top = QtWidgets.QLabel(content_widget)
message_label_top.setAlignment(QtCore.Qt.AlignCenter)
# Bottom label is displayed between report and publish buttons
# at bottom part of info frame
message_label_bottom = QtWidgets.QLabel(content_widget)
message_label_bottom.setAlignment(QtCore.Qt.AlignCenter)
# Label showing currently processed instance
instance_label = QtWidgets.QLabel("<Instance name>", content_widget)
instance_label.setAlignment(
QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter
)
# Label showing currently processed plugin
plugin_label = QtWidgets.QLabel("<Plugin name>", content_widget)
plugin_label.setAlignment(
QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter
)
instance_plugin_layout = QtWidgets.QHBoxLayout()
instance_plugin_layout.addWidget(instance_label, 1)
instance_plugin_layout.addWidget(plugin_label, 1)
# Progress bar showing progress of publishing
progress_widget = QtWidgets.QProgressBar(content_widget)
progress_widget.setObjectName("PublishProgressBar")
# Report buttons to be able copy, save or see report
report_btns_widget = QtWidgets.QWidget(content_widget)
report_btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
# Hidden by default
report_btns_widget.setVisible(False)
report_label = QtWidgets.QLabel("Report:", report_btns_widget)
copy_report_btn = CopyPublishReportBtn(report_btns_widget)
export_report_btn = SavePublishReportBtn(report_btns_widget)
show_details_btn = ShowPublishReportBtn(report_btns_widget)
report_btns_layout = QtWidgets.QHBoxLayout(report_btns_widget)
report_btns_layout.setContentsMargins(0, 0, 0, 0)
report_btns_layout.addWidget(report_label, 0)
report_btns_layout.addWidget(copy_report_btn, 0)
report_btns_layout.addWidget(export_report_btn, 0)
report_btns_layout.addWidget(show_details_btn, 0)
# Publishing buttons to stop, reset or trigger publishing
reset_btn = ResetBtn(content_widget)
stop_btn = StopBtn(content_widget)
validate_btn = ValidateBtn(content_widget)
publish_btn = PublishBtn(content_widget)
# Footer on info frame layout
info_footer_layout = QtWidgets.QHBoxLayout()
info_footer_layout.addWidget(report_btns_widget, 0)
info_footer_layout.addWidget(message_label_bottom, 1)
info_footer_layout.addWidget(reset_btn, 0)
info_footer_layout.addWidget(stop_btn, 0)
info_footer_layout.addWidget(validate_btn, 0)
info_footer_layout.addWidget(publish_btn, 0)
# Info frame content
content_layout = QtWidgets.QVBoxLayout(content_widget)
content_layout.setSpacing(5)
content_layout.setAlignment(QtCore.Qt.AlignCenter)
content_layout.addWidget(main_label)
content_layout.addStretch(1)
content_layout.addWidget(message_label_top)
content_layout.addStretch(1)
content_layout.addLayout(instance_plugin_layout)
content_layout.addWidget(progress_widget)
content_layout.addStretch(1)
content_layout.addLayout(info_footer_layout)
# Whole widget layout
publish_widget = QtWidgets.QWidget(self)
publish_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
publish_layout = QtWidgets.QVBoxLayout(publish_widget)
publish_layout.addWidget(validation_errors_widget, 1)
publish_layout.addWidget(info_frame, 0)
details_widget = QtWidgets.QWidget(self)
report_view = PublishReportViewerWidget(details_widget)
close_report_btn = QtWidgets.QPushButton(details_widget)
close_report_icon = self._get_report_close_icon()
close_report_btn.setIcon(close_report_icon)
details_layout = QtWidgets.QVBoxLayout(details_widget)
details_layout.setContentsMargins(0, 0, 0, 0)
details_layout.addWidget(report_view)
details_layout.addWidget(close_report_btn)
main_layout = QtWidgets.QStackedLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setStackingMode(main_layout.StackOne)
main_layout.addWidget(publish_widget)
main_layout.addWidget(details_widget)
main_layout.setCurrentWidget(publish_widget)
show_details_btn.clicked.connect(self._on_show_details)
copy_report_btn.clicked.connect(self._on_copy_report)
export_report_btn.clicked.connect(self._on_export_report)
reset_btn.clicked.connect(self._on_reset_clicked)
stop_btn.clicked.connect(self._on_stop_clicked)
validate_btn.clicked.connect(self._on_validate_clicked)
publish_btn.clicked.connect(self._on_publish_clicked)
close_report_btn.clicked.connect(self._on_close_report_clicked)
controller.add_publish_reset_callback(self._on_publish_reset)
controller.add_publish_started_callback(self._on_publish_start)
controller.add_publish_validated_callback(self._on_publish_validated)
controller.add_publish_stopped_callback(self._on_publish_stop)
controller.add_instance_change_callback(self._on_instance_change)
controller.add_plugin_change_callback(self._on_plugin_change)
self.controller = controller
self._info_frame = info_frame
self._publish_widget = publish_widget
self._validation_errors_widget = validation_errors_widget
self._main_layout = main_layout
self._main_label = main_label
self._message_label_top = message_label_top
self._instance_label = instance_label
self._plugin_label = plugin_label
self._progress_widget = progress_widget
self._report_btns_widget = report_btns_widget
self._message_label_bottom = message_label_bottom
self._reset_btn = reset_btn
self._stop_btn = stop_btn
self._validate_btn = validate_btn
self._publish_btn = publish_btn
self._details_widget = details_widget
self._report_view = report_view
def _get_report_close_icon(self):
size = 100
pix = QtGui.QPixmap(size, size)
pix.fill(QtCore.Qt.transparent)
half_stroke_size = size / 12
stroke_size = 2 * half_stroke_size
size_part = size / 5
p1 = QtCore.QPoint(half_stroke_size, size_part)
p2 = QtCore.QPoint(size / 2, size_part * 4)
p3 = QtCore.QPoint(size - half_stroke_size, size_part)
painter = QtGui.QPainter(pix)
pen = QtGui.QPen(QtCore.Qt.white)
pen.setWidth(stroke_size)
pen.setCapStyle(QtCore.Qt.RoundCap)
painter.setPen(pen)
painter.setBrush(QtCore.Qt.transparent)
painter.drawLine(p1, p2)
painter.drawLine(p2, p3)
painter.end()
return QtGui.QIcon(pix)
def _on_publish_reset(self):
self._set_success_property()
self._change_bg_property()
self._set_progress_visibility(True)
self._main_label.setText("Hit publish (play button)! If you want")
self._message_label_top.setText("")
self._message_label_bottom.setText("")
self._report_btns_widget.setVisible(False)
self._reset_btn.setEnabled(True)
self._stop_btn.setEnabled(False)
self._validate_btn.setEnabled(True)
self._publish_btn.setEnabled(True)
self._progress_widget.setValue(self.controller.publish_progress)
self._progress_widget.setMaximum(self.controller.publish_max_progress)
def _on_publish_start(self):
self._validation_errors_widget.clear()
self._set_success_property(-1)
self._change_bg_property()
self._set_progress_visibility(True)
self._main_label.setText("Publishing...")
self._report_btns_widget.setVisible(False)
self._reset_btn.setEnabled(False)
self._stop_btn.setEnabled(True)
self._validate_btn.setEnabled(False)
self._publish_btn.setEnabled(False)
def _on_publish_validated(self):
self._validate_btn.setEnabled(False)
def _on_instance_change(self, context, instance):
"""Change instance label when instance is going to be processed."""
if instance is None:
new_name = (
context.data.get("label")
or getattr(context, "label", None)
or context.data.get("name")
or "Context"
)
else:
new_name = (
instance.data.get("label")
or getattr(instance, "label", None)
or instance.data["name"]
)
self._instance_label.setText(new_name)
QtWidgets.QApplication.processEvents()
def _on_plugin_change(self, plugin):
"""Change plugin label when instance is going to be processed."""
plugin_name = plugin.__name__
if hasattr(plugin, "label") and plugin.label:
plugin_name = plugin.label
self._progress_widget.setValue(self.controller.publish_progress)
self._plugin_label.setText(plugin_name)
QtWidgets.QApplication.processEvents()
def _on_publish_stop(self):
self._progress_widget.setValue(self.controller.publish_progress)
self._report_btns_widget.setVisible(True)
self._reset_btn.setEnabled(True)
self._stop_btn.setEnabled(False)
validate_enabled = not self.controller.publish_has_crashed
publish_enabled = not self.controller.publish_has_crashed
if validate_enabled:
validate_enabled = not self.controller.publish_has_validated
if publish_enabled:
if (
self.controller.publish_has_validated
and self.controller.publish_has_validation_errors
):
publish_enabled = False
else:
publish_enabled = not self.controller.publish_has_finished
self._validate_btn.setEnabled(validate_enabled)
self._publish_btn.setEnabled(publish_enabled)
error = self.controller.get_publish_crash_error()
validation_errors = self.controller.get_validation_errors()
if error:
self._set_error(error)
elif validation_errors:
self._set_progress_visibility(False)
self._change_bg_property(1)
self._set_validation_errors(validation_errors)
elif self.controller.publish_has_finished:
self._set_finished()
else:
self._set_stopped()
def _set_stopped(self):
main_label = "Publish paused"
if self.controller.publish_has_validated:
main_label += " - Validation passed"
self._main_label.setText(main_label)
self._message_label_top.setText(
"Hit publish (play button) to continue."
)
self._set_success_property(-1)
def _set_error(self, error):
self._main_label.setText("Error happened")
if isinstance(error, KnownPublishError):
msg = str(error)
else:
msg = (
"Something went wrong. Send report"
" to your supervisor or OpenPype."
)
self._message_label_top.setText(msg)
self._message_label_bottom.setText("")
self._set_success_property(0)
def _set_validation_errors(self, validation_errors):
self._main_label.setText("Your publish didn't pass studio validations")
self._message_label_top.setText("")
self._message_label_bottom.setText("Check results above please")
self._set_success_property(2)
self._validation_errors_widget.set_errors(validation_errors)
def _set_finished(self):
self._main_label.setText("Finished")
self._message_label_top.setText("")
self._message_label_bottom.setText("")
self._set_success_property(1)
def _change_bg_property(self, state=None):
self.setProperty("state", str(state or ""))
self.style().polish(self)
def _set_progress_visibility(self, visible):
self._instance_label.setVisible(visible)
self._plugin_label.setVisible(visible)
self._progress_widget.setVisible(visible)
self._message_label_top.setVisible(visible)
def _set_success_property(self, state=None):
if state is None:
state = ""
else:
state = str(state)
for widget in (self._progress_widget, self._info_frame):
if widget.property("state") != state:
widget.setProperty("state", state)
widget.style().polish(widget)
def _on_copy_report(self):
logs = self.controller.get_publish_report()
logs_string = json.dumps(logs, indent=4)
mime_data = QtCore.QMimeData()
mime_data.setText(logs_string)
QtWidgets.QApplication.instance().clipboard().setMimeData(
mime_data
)
def _on_export_report(self):
default_filename = "publish-report-{}".format(
time.strftime("%y%m%d-%H-%M")
)
default_filepath = os.path.join(
os.path.expanduser("~"),
default_filename
)
new_filepath, ext = QtWidgets.QFileDialog.getSaveFileName(
self, "Save report", default_filepath, ".json"
)
if not ext or not new_filepath:
return
logs = self.controller.get_publish_report()
full_path = new_filepath + ext
dir_path = os.path.dirname(full_path)
if not os.path.exists(dir_path):
os.makedirs(dir_path)
with open(full_path, "w") as file_stream:
json.dump(logs, file_stream)
def _on_show_details(self):
self._change_bg_property(2)
self._main_layout.setCurrentWidget(self._details_widget)
logs = self.controller.get_publish_report()
self._report_view.set_report(logs)
def _on_close_report_clicked(self):
if self.controller.get_publish_crash_error():
self._change_bg_property()
elif self.controller.get_validation_errors():
self._change_bg_property(1)
else:
self._change_bg_property(2)
self._main_layout.setCurrentWidget(self._publish_widget)
def _on_reset_clicked(self):
self.controller.reset()
def _on_stop_clicked(self):
self.controller.stop_publish()
def _on_validate_clicked(self):
self.controller.validate()
def _on_publish_clicked(self):
self.controller.publish()

View file

@ -0,0 +1,490 @@
# -*- coding: utf-8 -*-
try:
import commonmark
except Exception:
commonmark = None
from Qt import QtWidgets, QtCore, QtGui
from .widgets import (
ClickableFrame,
IconValuePixmapLabel
)
class ValidationErrorInstanceList(QtWidgets.QListView):
"""List of publish instances that caused a validation error.
Instances are collected per plugin's validation error title.
"""
def __init__(self, *args, **kwargs):
super(ValidationErrorInstanceList, self).__init__(*args, **kwargs)
self.setObjectName("ValidationErrorInstanceList")
self.setSelectionMode(QtWidgets.QListView.ExtendedSelection)
def minimumSizeHint(self):
result = super(ValidationErrorInstanceList, self).minimumSizeHint()
result.setHeight(self.sizeHint().height())
return result
def sizeHint(self):
row_count = self.model().rowCount()
height = 0
if row_count > 0:
height = self.sizeHintForRow(0) * row_count
return QtCore.QSize(self.width(), height)
class ValidationErrorTitleWidget(QtWidgets.QWidget):
"""Title of validation error.
Widget is used as radio button so requires clickable functionality and
changing style on selection/deselection.
Has toggle button to show/hide instances on which validation error happened
if there is a list (Valdation error may happen on context).
"""
selected = QtCore.Signal(int)
def __init__(self, index, error_info, parent):
super(ValidationErrorTitleWidget, self).__init__(parent)
self._index = index
self._error_info = error_info
self._selected = False
title_frame = ClickableFrame(self)
title_frame.setObjectName("ValidationErrorTitleFrame")
title_frame._mouse_release_callback = self._mouse_release_callback
toggle_instance_btn = QtWidgets.QToolButton(title_frame)
toggle_instance_btn.setObjectName("ArrowBtn")
toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow)
toggle_instance_btn.setMaximumWidth(14)
exception = error_info["exception"]
label_widget = QtWidgets.QLabel(exception.title, title_frame)
title_frame_layout = QtWidgets.QHBoxLayout(title_frame)
title_frame_layout.addWidget(toggle_instance_btn)
title_frame_layout.addWidget(label_widget)
instances_model = QtGui.QStandardItemModel()
instances = error_info["instances"]
context_validation = False
if (
not instances
or (len(instances) == 1 and instances[0] is None)
):
context_validation = True
toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow)
else:
items = []
for instance in instances:
label = instance.data.get("label") or instance.data.get("name")
item = QtGui.QStandardItem(label)
item.setFlags(
QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
)
item.setData(instance.id)
items.append(item)
instances_model.invisibleRootItem().appendRows(items)
instances_view = ValidationErrorInstanceList(self)
instances_view.setModel(instances_model)
instances_view.setVisible(False)
self.setLayoutDirection(QtCore.Qt.LeftToRight)
view_layout = QtWidgets.QHBoxLayout()
view_layout.setContentsMargins(0, 0, 0, 0)
view_layout.setSpacing(0)
view_layout.addSpacing(14)
view_layout.addWidget(instances_view)
layout = QtWidgets.QVBoxLayout(self)
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(title_frame)
layout.addLayout(view_layout)
if not context_validation:
toggle_instance_btn.clicked.connect(self._on_toggle_btn_click)
self._title_frame = title_frame
self._toggle_instance_btn = toggle_instance_btn
self._instances_model = instances_model
self._instances_view = instances_view
def _mouse_release_callback(self):
"""Mark this widget as selected on click."""
self.set_selected(True)
@property
def is_selected(self):
"""Is widget marked a selected"""
return self._selected
@property
def index(self):
"""Widget's index set by parent."""
return self._index
def set_index(self, index):
"""Set index of widget (called by parent)."""
self._index = index
def _change_style_property(self, selected):
"""Change style of widget based on selection."""
value = "1" if selected else ""
self._title_frame.setProperty("selected", value)
self._title_frame.style().polish(self._title_frame)
def set_selected(self, selected=None):
"""Change selected state of widget."""
if selected is None:
selected = not self._selected
elif selected == self._selected:
return
self._selected = selected
self._change_style_property(selected)
if selected:
self.selected.emit(self._index)
def _on_toggle_btn_click(self):
"""Show/hide instances list."""
new_visible = not self._instances_view.isVisible()
self._instances_view.setVisible(new_visible)
if new_visible:
self._toggle_instance_btn.setArrowType(QtCore.Qt.DownArrow)
else:
self._toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow)
class ActionButton(ClickableFrame):
"""Plugin's action callback button.
Action may have label or icon or both.
"""
action_clicked = QtCore.Signal(str)
def __init__(self, action, parent):
super(ActionButton, self).__init__(parent)
self.setObjectName("ValidationActionButton")
self.action = action
action_label = action.label or action.__name__
action_icon = getattr(action, "icon", None)
label_widget = QtWidgets.QLabel(action_label, self)
if action_icon:
icon_label = IconValuePixmapLabel(action_icon, self)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(5, 0, 5, 0)
layout.addWidget(label_widget, 1)
layout.addWidget(icon_label, 0)
self.setSizePolicy(
QtWidgets.QSizePolicy.Minimum,
self.sizePolicy().verticalPolicy()
)
def _mouse_release_callback(self):
self.action_clicked.emit(self.action.id)
class ValidateActionsWidget(QtWidgets.QFrame):
"""Wrapper widget for plugin actions.
Change actions based on selected validation error.
"""
def __init__(self, controller, parent):
super(ValidateActionsWidget, self).__init__(parent)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
content_widget = QtWidgets.QWidget(self)
content_layout = QtWidgets.QVBoxLayout(content_widget)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(content_widget)
self.controller = controller
self._content_widget = content_widget
self._content_layout = content_layout
self._plugin = None
self._actions_mapping = {}
def clear(self):
"""Remove actions from widget."""
while self._content_layout.count():
item = self._content_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
self._actions_mapping = {}
def set_plugin(self, plugin):
"""Set selected plugin and show it's actions.
Clears current actions from widget and recreate them from the plugin.
"""
self.clear()
self._plugin = plugin
if not plugin:
self.setVisible(False)
return
actions = getattr(plugin, "actions", [])
for action in actions:
if not action.active:
continue
if action.on not in ("failed", "all"):
continue
self._actions_mapping[action.id] = action
action_btn = ActionButton(action, self._content_widget)
action_btn.action_clicked.connect(self._on_action_click)
self._content_layout.addWidget(action_btn)
if self._content_layout.count() > 0:
self.setVisible(True)
self._content_layout.addStretch(1)
else:
self.setVisible(False)
def _on_action_click(self, action_id):
action = self._actions_mapping[action_id]
self.controller.run_action(self._plugin, action)
class VerticallScrollArea(QtWidgets.QScrollArea):
"""Scroll area for validation error titles.
The biggest difference is that the scroll area has scroll bar on left side
and resize of content will also resize scrollarea itself.
Resize if deffered by 100ms because at the moment of resize are not yet
propagated sizes and visibility of scroll bars.
"""
def __init__(self, *args, **kwargs):
super(VerticallScrollArea, self).__init__(*args, **kwargs)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
self.setLayoutDirection(QtCore.Qt.RightToLeft)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
# Background of scrollbar will be transparent
scrollbar_bg = self.verticalScrollBar().parent()
if scrollbar_bg:
scrollbar_bg.setAttribute(QtCore.Qt.WA_TranslucentBackground)
self.setViewportMargins(0, 0, 0, 0)
self.verticalScrollBar().installEventFilter(self)
# Timer with 100ms offset after changing size
size_changed_timer = QtCore.QTimer()
size_changed_timer.setInterval(100)
size_changed_timer.setSingleShot(True)
size_changed_timer.timeout.connect(self._on_timer_timeout)
self._size_changed_timer = size_changed_timer
def setVerticalScrollBar(self, widget):
old_widget = self.verticalScrollBar()
if old_widget:
old_widget.removeEventFilter(self)
super(VerticallScrollArea, self).setVerticalScrollBar(widget)
if widget:
widget.installEventFilter(self)
def setWidget(self, widget):
old_widget = self.widget()
if old_widget:
old_widget.removeEventFilter(self)
super(VerticallScrollArea, self).setWidget(widget)
if widget:
widget.installEventFilter(self)
def _on_timer_timeout(self):
width = self.widget().width()
if self.verticalScrollBar().isVisible():
width += self.verticalScrollBar().width()
self.setMinimumWidth(width)
def eventFilter(self, obj, event):
if (
event.type() == QtCore.QEvent.Resize
and (obj is self.widget() or obj is self.verticalScrollBar())
):
self._size_changed_timer.start()
return super(VerticallScrollArea, self).eventFilter(obj, event)
class ValidationsWidget(QtWidgets.QWidget):
"""Widgets showing validation error.
This widget is shown if validation error/s happened during validation part.
Shows validation error titles with instances on which happened and
validation error detail with possible actions (repair).
titles actions
Error detail
Publish buttons
"""
def __init__(self, controller, parent):
super(ValidationsWidget, self).__init__(parent)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
errors_scroll = VerticallScrollArea(self)
errors_scroll.setWidgetResizable(True)
errors_widget = QtWidgets.QWidget(errors_scroll)
errors_widget.setFixedWidth(200)
errors_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
errors_layout = QtWidgets.QVBoxLayout(errors_widget)
errors_layout.setContentsMargins(0, 0, 0, 0)
errors_scroll.setWidget(errors_widget)
error_details_widget = QtWidgets.QWidget(self)
error_details_input = QtWidgets.QTextEdit(error_details_widget)
error_details_input.setObjectName("InfoText")
error_details_input.setTextInteractionFlags(
QtCore.Qt.TextBrowserInteraction
)
actions_widget = ValidateActionsWidget(controller, self)
actions_widget.setFixedWidth(140)
error_details_layout = QtWidgets.QHBoxLayout(error_details_widget)
error_details_layout.addWidget(error_details_input, 1)
error_details_layout.addWidget(actions_widget, 0)
content_layout = QtWidgets.QHBoxLayout()
content_layout.setSpacing(0)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.addWidget(errors_scroll, 0)
content_layout.addWidget(error_details_widget, 1)
top_label = QtWidgets.QLabel("Publish validation report", self)
top_label.setObjectName("PublishInfoMainLabel")
top_label.setAlignment(QtCore.Qt.AlignCenter)
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(top_label)
layout.addLayout(content_layout)
self._top_label = top_label
self._errors_widget = errors_widget
self._errors_layout = errors_layout
self._error_details_widget = error_details_widget
self._error_details_input = error_details_input
self._actions_widget = actions_widget
self._title_widgets = {}
self._error_info = {}
self._previous_select = None
def clear(self):
"""Delete all dynamic widgets and hide all wrappers."""
self._title_widgets = {}
self._error_info = {}
self._previous_select = None
while self._errors_layout.count():
item = self._errors_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
self._top_label.setVisible(False)
self._error_details_widget.setVisible(False)
self._errors_widget.setVisible(False)
self._actions_widget.setVisible(False)
def set_errors(self, errors):
"""Set errors into context and created titles."""
self.clear()
if not errors:
return
self._top_label.setVisible(True)
self._error_details_widget.setVisible(True)
self._errors_widget.setVisible(True)
errors_by_title = []
for plugin_info in errors:
titles = []
exception_by_title = {}
instances_by_title = {}
for error_info in plugin_info["errors"]:
exception = error_info["exception"]
title = exception.title
if title not in titles:
titles.append(title)
instances_by_title[title] = []
exception_by_title[title] = exception
instances_by_title[title].append(error_info["instance"])
for title in titles:
errors_by_title.append({
"plugin": plugin_info["plugin"],
"exception": exception_by_title[title],
"instances": instances_by_title[title]
})
for idx, item in enumerate(errors_by_title):
widget = ValidationErrorTitleWidget(idx, item, self)
widget.selected.connect(self._on_select)
self._errors_layout.addWidget(widget)
self._title_widgets[idx] = widget
self._error_info[idx] = item
self._errors_layout.addStretch(1)
if self._title_widgets:
self._title_widgets[0].set_selected(True)
def _on_select(self, index):
if self._previous_select:
if self._previous_select.index == index:
return
self._previous_select.set_selected(False)
self._previous_select = self._title_widgets[index]
error_item = self._error_info[index]
dsc = error_item["exception"].description
if commonmark:
html = commonmark.commonmark(dsc)
self._error_details_input.setHtml(html)
else:
self._error_details_input.setMarkdown(dsc)
self._actions_widget.set_plugin(error_item["plugin"])

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,468 @@
from Qt import QtWidgets, QtCore, QtGui
from openpype import (
resources,
style
)
from .control import PublisherController
from .widgets import (
BorderedLabelWidget,
PublishFrame,
SubsetAttributesWidget,
InstanceCardView,
InstanceListView,
CreateDialog,
PixmapLabel,
StopBtn,
ResetBtn,
ValidateBtn,
PublishBtn,
CreateInstanceBtn,
RemoveInstanceBtn,
ChangeViewBtn
)
class PublisherWindow(QtWidgets.QDialog):
"""Main window of publisher."""
default_width = 1000
default_height = 600
def __init__(self, parent=None):
super(PublisherWindow, self).__init__(parent)
self.setWindowTitle("OpenPype publisher")
icon = QtGui.QIcon(resources.get_openpype_icon_filepath())
self.setWindowIcon(icon)
if parent is None:
on_top_flag = QtCore.Qt.WindowStaysOnTopHint
else:
on_top_flag = QtCore.Qt.Dialog
self.setWindowFlags(
self.windowFlags()
| QtCore.Qt.WindowTitleHint
| QtCore.Qt.WindowMaximizeButtonHint
| QtCore.Qt.WindowMinimizeButtonHint
| QtCore.Qt.WindowCloseButtonHint
| on_top_flag
)
self._first_show = True
self._refreshing_instances = False
controller = PublisherController()
# Header
header_widget = QtWidgets.QWidget(self)
icon_pixmap = QtGui.QPixmap(resources.get_openpype_icon_filepath())
icon_label = PixmapLabel(icon_pixmap, header_widget)
icon_label.setObjectName("PublishContextLabel")
context_label = QtWidgets.QLabel(header_widget)
context_label.setObjectName("PublishContextLabel")
header_layout = QtWidgets.QHBoxLayout(header_widget)
header_layout.setContentsMargins(15, 15, 15, 15)
header_layout.setSpacing(15)
header_layout.addWidget(icon_label, 0)
header_layout.addWidget(context_label, 1)
line_widget = QtWidgets.QWidget(self)
line_widget.setObjectName("Separator")
line_widget.setMinimumHeight(2)
# Content
# Subset widget
subset_frame = QtWidgets.QWidget(self)
subset_views_widget = BorderedLabelWidget(
"Subsets to publish", subset_frame
)
subset_view_cards = InstanceCardView(controller, subset_views_widget)
subset_list_view = InstanceListView(controller, subset_views_widget)
subset_views_layout = QtWidgets.QStackedLayout()
subset_views_layout.addWidget(subset_view_cards)
subset_views_layout.addWidget(subset_list_view)
# Buttons at the bottom of subset view
create_btn = CreateInstanceBtn(subset_frame)
delete_btn = RemoveInstanceBtn(subset_frame)
change_view_btn = ChangeViewBtn(subset_frame)
# Subset details widget
subset_attributes_wrap = BorderedLabelWidget(
"Publish options", subset_frame
)
subset_attributes_widget = SubsetAttributesWidget(
controller, subset_attributes_wrap
)
subset_attributes_wrap.set_center_widget(subset_attributes_widget)
# Layout of buttons at the bottom of subset view
subset_view_btns_layout = QtWidgets.QHBoxLayout()
subset_view_btns_layout.setContentsMargins(0, 5, 0, 0)
subset_view_btns_layout.addWidget(create_btn)
subset_view_btns_layout.addSpacing(5)
subset_view_btns_layout.addWidget(delete_btn)
subset_view_btns_layout.addStretch(1)
subset_view_btns_layout.addWidget(change_view_btn)
# Layout of view and buttons
subset_view_layout = QtWidgets.QVBoxLayout()
subset_view_layout.setContentsMargins(0, 0, 0, 0)
subset_view_layout.addLayout(subset_views_layout, 1)
subset_view_layout.addLayout(subset_view_btns_layout, 0)
subset_views_widget.set_center_widget(subset_view_layout)
# Whole subset layout with attributes and details
subset_content_widget = QtWidgets.QWidget(subset_frame)
subset_content_layout = QtWidgets.QHBoxLayout(subset_content_widget)
subset_content_layout.setContentsMargins(0, 0, 0, 0)
subset_content_layout.addWidget(subset_views_widget, 3)
subset_content_layout.addWidget(subset_attributes_wrap, 7)
# Footer
comment_input = QtWidgets.QLineEdit(subset_frame)
comment_input.setObjectName("PublishCommentInput")
comment_input.setPlaceholderText(
"Attach a comment to your publish"
)
reset_btn = ResetBtn(subset_frame)
stop_btn = StopBtn(subset_frame)
validate_btn = ValidateBtn(subset_frame)
publish_btn = PublishBtn(subset_frame)
footer_layout = QtWidgets.QHBoxLayout()
footer_layout.setContentsMargins(0, 0, 0, 0)
footer_layout.addWidget(comment_input, 1)
footer_layout.addWidget(reset_btn, 0)
footer_layout.addWidget(stop_btn, 0)
footer_layout.addWidget(validate_btn, 0)
footer_layout.addWidget(publish_btn, 0)
# Subset frame layout
subset_layout = QtWidgets.QVBoxLayout(subset_frame)
marings = subset_layout.contentsMargins()
marings.setLeft(marings.left() * 2)
marings.setRight(marings.right() * 2)
marings.setTop(marings.top() * 2)
marings.setBottom(marings.bottom() * 2)
subset_layout.setContentsMargins(marings)
subset_layout.addWidget(subset_content_widget, 1)
subset_layout.addLayout(footer_layout, 0)
# Create publish frame
publish_frame = PublishFrame(controller, self)
content_stacked_layout = QtWidgets.QStackedLayout()
content_stacked_layout.setStackingMode(
QtWidgets.QStackedLayout.StackAll
)
content_stacked_layout.addWidget(subset_frame)
content_stacked_layout.addWidget(publish_frame)
# Add main frame to this window
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
main_layout.addWidget(header_widget, 0)
main_layout.addWidget(line_widget, 0)
main_layout.addLayout(content_stacked_layout, 1)
creator_window = CreateDialog(controller, parent=self)
create_btn.clicked.connect(self._on_create_clicked)
delete_btn.clicked.connect(self._on_delete_clicked)
change_view_btn.clicked.connect(self._on_change_view_clicked)
reset_btn.clicked.connect(self._on_reset_clicked)
stop_btn.clicked.connect(self._on_stop_clicked)
validate_btn.clicked.connect(self._on_validate_clicked)
publish_btn.clicked.connect(self._on_publish_clicked)
# Selection changed
subset_list_view.selection_changed.connect(
self._on_subset_change
)
subset_view_cards.selection_changed.connect(
self._on_subset_change
)
# Active instances changed
subset_list_view.active_changed.connect(
self._on_active_changed
)
subset_view_cards.active_changed.connect(
self._on_active_changed
)
# Instance context has changed
subset_attributes_widget.instance_context_changed.connect(
self._on_instance_context_change
)
controller.add_instances_refresh_callback(self._on_instances_refresh)
controller.add_publish_reset_callback(self._on_publish_reset)
controller.add_publish_started_callback(self._on_publish_start)
controller.add_publish_validated_callback(self._on_publish_validated)
controller.add_publish_stopped_callback(self._on_publish_stop)
self.content_stacked_layout = content_stacked_layout
self.publish_frame = publish_frame
self.subset_frame = subset_frame
self.subset_content_widget = subset_content_widget
self.context_label = context_label
self.subset_view_cards = subset_view_cards
self.subset_list_view = subset_list_view
self.subset_views_layout = subset_views_layout
self.delete_btn = delete_btn
self.subset_attributes_widget = subset_attributes_widget
self.comment_input = comment_input
self.stop_btn = stop_btn
self.reset_btn = reset_btn
self.validate_btn = validate_btn
self.publish_btn = publish_btn
self.controller = controller
self.creator_window = creator_window
def showEvent(self, event):
super(PublisherWindow, self).showEvent(event)
if self._first_show:
self._first_show = False
self.resize(self.default_width, self.default_height)
self.setStyleSheet(style.load_stylesheet())
self.reset()
def closeEvent(self, event):
self.controller.save_changes()
super(PublisherWindow, self).closeEvent(event)
def reset(self):
self.controller.reset()
def set_context_label(self, label):
self.context_label.setText(label)
def get_selected_items(self):
view = self.subset_views_layout.currentWidget()
return view.get_selected_items()
def _on_instance_context_change(self):
current_idx = self.subset_views_layout.currentIndex()
for idx in range(self.subset_views_layout.count()):
if idx == current_idx:
continue
widget = self.subset_views_layout.widget(idx)
if widget.refreshed:
widget.set_refreshed(False)
current_widget = self.subset_views_layout.widget(current_idx)
current_widget.refresh_instance_states()
self._validate_create_instances()
def _change_view_type(self):
idx = self.subset_views_layout.currentIndex()
new_idx = (idx + 1) % self.subset_views_layout.count()
self.subset_views_layout.setCurrentIndex(new_idx)
new_view = self.subset_views_layout.currentWidget()
if not new_view.refreshed:
new_view.refresh()
new_view.set_refreshed(True)
else:
new_view.refresh_instance_states()
self._on_subset_change()
def _on_create_clicked(self):
self.creator_window.show()
def _on_delete_clicked(self):
instances, _ = self.get_selected_items()
# Ask user if he really wants to remove instances
dialog = QtWidgets.QMessageBox(self)
dialog.setIcon(QtWidgets.QMessageBox.Question)
dialog.setWindowTitle("Are you sure?")
if len(instances) > 1:
msg = (
"Do you really want to remove {} instances?"
).format(len(instances))
else:
msg = (
"Do you really want to remove the instance?"
)
dialog.setText(msg)
dialog.setStandardButtons(
QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel
)
dialog.setDefaultButton(QtWidgets.QMessageBox.Ok)
dialog.setEscapeButton(QtWidgets.QMessageBox.Cancel)
dialog.exec_()
# Skip if OK was not clicked
if dialog.result() == QtWidgets.QMessageBox.Ok:
self.controller.remove_instances(instances)
def _on_change_view_clicked(self):
self._change_view_type()
def _set_publish_visibility(self, visible):
if visible:
widget = self.publish_frame
else:
widget = self.subset_frame
self.content_stacked_layout.setCurrentWidget(widget)
def _on_reset_clicked(self):
self.controller.reset()
def _on_stop_clicked(self):
self.controller.stop_publish()
def _set_publish_comment(self):
if self.controller.publish_comment_is_set:
return
comment = self.comment_input.text()
self.controller.set_comment(comment)
def _on_validate_clicked(self):
self._set_publish_comment()
self._set_publish_visibility(True)
self.controller.validate()
def _on_publish_clicked(self):
self._set_publish_comment()
self._set_publish_visibility(True)
self.controller.publish()
def _refresh_instances(self):
if self._refreshing_instances:
return
self._refreshing_instances = True
for idx in range(self.subset_views_layout.count()):
widget = self.subset_views_layout.widget(idx)
widget.set_refreshed(False)
view = self.subset_views_layout.currentWidget()
view.refresh()
view.set_refreshed(True)
self._refreshing_instances = False
# Force to change instance and refresh details
self._on_subset_change()
def _on_instances_refresh(self):
self._refresh_instances()
self._validate_create_instances()
context_title = self.controller.get_context_title()
self.set_context_label(context_title)
def _on_subset_change(self, *_args):
# Ignore changes if in middle of refreshing
if self._refreshing_instances:
return
instances, context_selected = self.get_selected_items()
# Disable delete button if nothing is selected
self.delete_btn.setEnabled(len(instances) > 0)
self.subset_attributes_widget.set_current_instances(
instances, context_selected
)
def _on_active_changed(self):
if self._refreshing_instances:
return
self._validate_create_instances()
def _set_footer_enabled(self, enabled):
self.comment_input.setEnabled(enabled)
self.reset_btn.setEnabled(True)
if enabled:
self.stop_btn.setEnabled(False)
self.validate_btn.setEnabled(True)
self.publish_btn.setEnabled(True)
else:
self.stop_btn.setEnabled(enabled)
self.validate_btn.setEnabled(enabled)
self.publish_btn.setEnabled(enabled)
def _validate_create_instances(self):
if not self.controller.host_is_valid:
self._set_footer_enabled(True)
return
all_valid = None
for instance in self.controller.instances:
if not instance["active"]:
continue
if not instance.has_valid_context:
all_valid = False
break
if all_valid is None:
all_valid = True
self._set_footer_enabled(bool(all_valid))
def _on_publish_reset(self):
self._set_publish_visibility(False)
self.subset_content_widget.setEnabled(self.controller.host_is_valid)
self._set_footer_enabled(False)
def _on_publish_start(self):
self.reset_btn.setEnabled(False)
self.stop_btn.setEnabled(True)
self.validate_btn.setEnabled(False)
self.publish_btn.setEnabled(False)
def _on_publish_validated(self):
self.validate_btn.setEnabled(False)
def _on_publish_stop(self):
self.reset_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
validate_enabled = not self.controller.publish_has_crashed
publish_enabled = not self.controller.publish_has_crashed
if validate_enabled:
validate_enabled = not self.controller.publish_has_validated
if publish_enabled:
if (
self.controller.publish_has_validated
and self.controller.publish_has_validation_errors
):
publish_enabled = False
else:
publish_enabled = not self.controller.publish_has_finished
self.validate_btn.setEnabled(validate_enabled)
self.publish_btn.setEnabled(publish_enabled)

View file

@ -22,7 +22,7 @@ def format_version(value, hero_version=False):
@contextlib.contextmanager
def application():
def qt_app_context():
app = QtWidgets.QApplication.instance()
if not app:
@ -35,6 +35,10 @@ def application():
yield app
# Backwards compatibility
application = qt_app_context
class SharedObjects:
jobs = {}

View file

@ -10,9 +10,11 @@ import Qt
from Qt import QtWidgets, QtCore
from avalon import style, io, api, pipeline
from avalon.tools import lib as tools_lib
from avalon.tools.widgets import AssetWidget
from avalon.tools.delegates import PrettyTimeDelegate
from openpype.tools.utils.lib import (
schedule, qt_app_context
)
from openpype.tools.utils.widgets import AssetWidget
from openpype.tools.utils.delegates import PrettyTimeDelegate
from .model import (
TASK_NAME_ROLE,
@ -786,7 +788,7 @@ class FilesWidget(QtWidgets.QWidget):
self.files_model.refresh()
if self.auto_select_latest_modified:
tools_lib.schedule(self._select_last_modified_file, 100)
schedule(self._select_last_modified_file, 100)
def on_context_menu(self, point):
index = self.files_view.indexAt(point)
@ -1023,10 +1025,10 @@ class Window(QtWidgets.QMainWindow):
def on_task_changed(self):
# Since we query the disk give it slightly more delay
tools_lib.schedule(self._on_task_changed, 100, channel="mongo")
schedule(self._on_task_changed, 100, channel="mongo")
def on_asset_changed(self):
tools_lib.schedule(self._on_asset_changed, 50, channel="mongo")
schedule(self._on_asset_changed, 50, channel="mongo")
def on_file_select(self, filepath):
asset_docs = self.assets_widget.get_selected_assets()
@ -1181,7 +1183,7 @@ def show(root=None, debug=False, parent=None, use_context=True, save=True):
api.Session["AVALON_ASSET"] = "Mock"
api.Session["AVALON_TASK"] = "Testing"
with tools_lib.application():
with qt_app_context():
window = Window(parent=parent)
window.refresh()

Some files were not shown because too many files have changed in this diff Show more