Merge pull request #2778 from pypeclub/feature/OP-2267_Standalone-publisher-using-new-publisher

This commit is contained in:
Milan Kolar 2022-02-24 17:04:02 +01:00 committed by GitHub
commit 09315decbf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1836 additions and 326 deletions

View file

@ -42,6 +42,12 @@ def standalonepublisher():
PypeCommands().launch_standalone_publisher()
@main.command()
def traypublisher():
"""Show new OpenPype Standalone publisher UI."""
PypeCommands().launch_traypublisher()
@main.command()
@click.option("-d", "--debug",
is_flag=True, help=("Run pype tray in debug mode"))

View file

@ -8,7 +8,7 @@
"asset": "sq01_sh0010",
"task": "Compositing",
"variant": "myVariant",
"uuid": "a485f148-9121-46a5-8157-aa64df0fb449",
"instance_id": "a485f148-9121-46a5-8157-aa64df0fb449",
"creator_attributes": {
"number_key": 10,
"ha": 10
@ -29,8 +29,8 @@
"asset": "sq01_sh0010",
"task": "Compositing",
"variant": "myVariant2",
"uuid": "a485f148-9121-46a5-8157-aa64df0fb444",
"creator_attributes": {},
"instance_id": "a485f148-9121-46a5-8157-aa64df0fb444",
"publish_attributes": {
"CollectFtrackApi": {
"add_ftrack_family": true
@ -47,8 +47,8 @@
"asset": "sq01_sh0010",
"task": "Compositing",
"variant": "Main",
"uuid": "3607bc95-75f6-4648-a58d-e699f413d09f",
"creator_attributes": {},
"instance_id": "3607bc95-75f6-4648-a58d-e699f413d09f",
"publish_attributes": {
"CollectFtrackApi": {
"add_ftrack_family": true
@ -65,7 +65,7 @@
"asset": "sq01_sh0020",
"task": "Compositing",
"variant": "Main2",
"uuid": "4ccf56f6-9982-4837-967c-a49695dbe8eb",
"instance_id": "4ccf56f6-9982-4837-967c-a49695dbe8eb",
"creator_attributes": {},
"publish_attributes": {
"CollectFtrackApi": {
@ -83,7 +83,7 @@
"asset": "sq01_sh0020",
"task": "Compositing",
"variant": "Main2",
"uuid": "4ccf56f6-9982-4837-967c-a49695dbe8ec",
"instance_id": "4ccf56f6-9982-4837-967c-a49695dbe8ec",
"creator_attributes": {},
"publish_attributes": {
"CollectFtrackApi": {
@ -101,7 +101,7 @@
"asset": "Alpaca_01",
"task": "modeling",
"variant": "Main",
"uuid": "7c9ddfc7-9f9c-4c1c-b233-38c966735fb6",
"instance_id": "7c9ddfc7-9f9c-4c1c-b233-38c966735fb6",
"creator_attributes": {},
"publish_attributes": {}
}

View file

@ -114,7 +114,7 @@ def update_instances(update_list):
instances = HostContext.get_instances()
for instance_data in instances:
instance_id = instance_data["uuid"]
instance_id = instance_data["instance_id"]
if instance_id in updated_instances:
new_instance_data = updated_instances[instance_id]
old_keys = set(instance_data.keys())
@ -132,10 +132,10 @@ def remove_instances(instances):
current_instances = HostContext.get_instances()
for instance in instances:
instance_id = instance.data["uuid"]
instance_id = instance.data["instance_id"]
found_idx = None
for idx, _instance in enumerate(current_instances):
if instance_id == _instance["uuid"]:
if instance_id == _instance["instance_id"]:
found_idx = idx
break

View file

@ -0,0 +1,20 @@
from .pipeline import (
install,
ls,
set_project_name,
get_context_title,
get_context_data,
update_context_data,
)
__all__ = (
"install",
"ls",
"set_project_name",
"get_context_title",
"get_context_data",
"update_context_data",
)

View file

@ -0,0 +1,180 @@
import os
import json
import tempfile
import atexit
from avalon import io
import avalon.api
import pyblish.api
from openpype.pipeline import BaseCreator
ROOT_DIR = os.path.dirname(os.path.dirname(
os.path.abspath(__file__)
))
PUBLISH_PATH = os.path.join(ROOT_DIR, "plugins", "publish")
CREATE_PATH = os.path.join(ROOT_DIR, "plugins", "create")
class HostContext:
_context_json_path = None
@staticmethod
def _on_exit():
if (
HostContext._context_json_path
and os.path.exists(HostContext._context_json_path)
):
os.remove(HostContext._context_json_path)
@classmethod
def get_context_json_path(cls):
if cls._context_json_path is None:
output_file = tempfile.NamedTemporaryFile(
mode="w", prefix="traypub_", suffix=".json"
)
output_file.close()
cls._context_json_path = output_file.name
atexit.register(HostContext._on_exit)
print(cls._context_json_path)
return cls._context_json_path
@classmethod
def _get_data(cls, group=None):
json_path = cls.get_context_json_path()
data = {}
if not os.path.exists(json_path):
with open(json_path, "w") as json_stream:
json.dump(data, json_stream)
else:
with open(json_path, "r") as json_stream:
content = json_stream.read()
if content:
data = json.loads(content)
if group is None:
return data
return data.get(group)
@classmethod
def _save_data(cls, group, new_data):
json_path = cls.get_context_json_path()
data = cls._get_data()
data[group] = new_data
with open(json_path, "w") as json_stream:
json.dump(data, json_stream)
@classmethod
def add_instance(cls, instance):
instances = cls.get_instances()
instances.append(instance)
cls.save_instances(instances)
@classmethod
def get_instances(cls):
return cls._get_data("instances") or []
@classmethod
def save_instances(cls, instances):
cls._save_data("instances", instances)
@classmethod
def get_context_data(cls):
return cls._get_data("context") or {}
@classmethod
def save_context_data(cls, data):
cls._save_data("context", data)
@classmethod
def get_project_name(cls):
return cls._get_data("project_name")
@classmethod
def set_project_name(cls, project_name):
cls._save_data("project_name", project_name)
@classmethod
def get_data_to_store(cls):
return {
"project_name": cls.get_project_name(),
"instances": cls.get_instances(),
"context": cls.get_context_data(),
}
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["instance_id"]
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["instance_id"]
found_idx = None
for idx, _instance in enumerate(current_instances):
if instance_id == _instance["instance_id"]:
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_project_name()
def ls():
"""Probably will never return loaded containers."""
return []
def install():
"""This is called before a project is known.
Project is defined with 'set_project_name'.
"""
os.environ["AVALON_APP"] = "traypublisher"
pyblish.api.register_host("traypublisher")
pyblish.api.register_plugin_path(PUBLISH_PATH)
avalon.api.register_plugin_path(BaseCreator, CREATE_PATH)
def set_project_name(project_name):
# TODO Deregister project specific plugins and register new project plugins
os.environ["AVALON_PROJECT"] = project_name
avalon.api.Session["AVALON_PROJECT"] = project_name
io.install()
HostContext.set_project_name(project_name)

View file

@ -0,0 +1,97 @@
from openpype.hosts.traypublisher.api import pipeline
from openpype.pipeline import (
Creator,
CreatedInstance,
lib
)
class WorkfileCreator(Creator):
identifier = "workfile"
label = "Workfile"
family = "workfile"
description = "Publish backup of workfile"
create_allow_context_change = True
extensions = [
# Maya
".ma", ".mb",
# Nuke
".nk",
# Hiero
".hrox",
# Houdini
".hip", ".hiplc", ".hipnc",
# Blender
".blend",
# Celaction
".scn",
# TVPaint
".tvpp",
# Fusion
".comp",
# Harmony
".zip",
# Premiere
".prproj",
# Resolve
".drp",
# Photoshop
".psd", ".psb",
# Aftereffects
".aep"
]
def get_icon(self):
return "fa.file"
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, pre_create_data):
# Pass precreate data to creator attributes
data["creator_attributes"] = pre_create_data
# Create new instance
new_instance = CreatedInstance(self.family, subset_name, data, self)
# Host implementation of storing metadata about instance
pipeline.HostContext.add_instance(new_instance.data_to_store())
# Add instance to current context
self._add_instance_to_context(new_instance)
def get_default_variants(self):
return [
"Main"
]
def get_instance_attr_defs(self):
output = [
lib.FileDef(
"filepath",
folders=False,
extensions=self.extensions,
label="Filepath"
)
]
return output
def get_pre_create_attr_defs(self):
# Use same attributes as for instance attrobites
return self.get_instance_attr_defs()
def get_detail_description(self):
return """# Publish workfile backup"""

View file

@ -0,0 +1,24 @@
import pyblish.api
class CollectSource(pyblish.api.ContextPlugin):
"""Collecting instances from traypublisher host."""
label = "Collect source"
order = pyblish.api.CollectorOrder - 0.49
hosts = ["traypublisher"]
def process(self, context):
# get json paths from os and load them
source_name = "traypublisher"
for instance in context:
source = instance.data.get("source")
if not source:
instance.data["source"] = source_name
self.log.info((
"Source of instance \"{}\" is changed to \"{}\""
).format(instance.data["name"], source_name))
else:
self.log.info((
"Source of instance \"{}\" was already set to \"{}\""
).format(instance.data["name"], source))

View file

@ -0,0 +1,31 @@
import os
import pyblish.api
class CollectWorkfile(pyblish.api.InstancePlugin):
"""Collect representation of workfile instances."""
label = "Collect Workfile"
order = pyblish.api.CollectorOrder - 0.49
families = ["workfile"]
hosts = ["traypublisher"]
def process(self, instance):
if "representations" not in instance.data:
instance.data["representations"] = []
repres = instance.data["representations"]
creator_attributes = instance.data["creator_attributes"]
filepath = creator_attributes["filepath"]
instance.data["sourceFilepath"] = filepath
staging_dir = os.path.dirname(filepath)
filename = os.path.basename(filepath)
ext = os.path.splitext(filename)[-1]
repres.append({
"ext": ext,
"name": ext,
"stagingDir": staging_dir,
"files": filename
})

View file

@ -0,0 +1,24 @@
import os
import pyblish.api
from openpype.pipeline import PublishValidationError
class ValidateWorkfilePath(pyblish.api.InstancePlugin):
"""Validate existence of workfile instance existence."""
label = "Collect Workfile"
order = pyblish.api.ValidatorOrder - 0.49
families = ["workfile"]
hosts = ["traypublisher"]
def process(self, instance):
filepath = instance.data["sourceFilepath"]
if not filepath:
raise PublishValidationError((
"Filepath of 'workfile' instance \"{}\" is not set"
).format(instance.data["name"]))
if not os.path.exists(filepath):
raise PublishValidationError((
"Filepath of 'workfile' instance \"{}\" does not exist: {}"
).format(instance.data["name"], filepath))

View file

@ -29,6 +29,7 @@ from .execute import (
get_linux_launcher_args,
execute,
run_subprocess,
run_detached_process,
run_openpype_process,
clean_envs_for_openpype_process,
path_to_subprocess_arg,
@ -188,6 +189,7 @@ __all__ = [
"get_linux_launcher_args",
"execute",
"run_subprocess",
"run_detached_process",
"run_openpype_process",
"clean_envs_for_openpype_process",
"path_to_subprocess_arg",

View file

@ -1,5 +1,9 @@
import os
import sys
import subprocess
import platform
import json
import tempfile
import distutils.spawn
from .log import PypeLogger as Logger
@ -181,6 +185,80 @@ def run_openpype_process(*args, **kwargs):
return run_subprocess(args, env=env, **kwargs)
def run_detached_process(args, **kwargs):
"""Execute process with passed arguments as separated process.
Values from 'os.environ' are used for environments if are not passed.
They are cleaned using 'clean_envs_for_openpype_process' function.
Example:
```
run_detached_openpype_process("run", "<path to .py script>")
```
Args:
*args (tuple): OpenPype cli arguments.
**kwargs (dict): Keyword arguments for for subprocess.Popen.
Returns:
subprocess.Popen: Pointer to launched process but it is possible that
launched process is already killed (on linux).
"""
env = kwargs.pop("env", None)
# Keep env untouched if are passed and not empty
if not env:
env = os.environ
# Create copy of passed env
kwargs["env"] = {k: v for k, v in env.items()}
low_platform = platform.system().lower()
if low_platform == "darwin":
new_args = ["open", "-na", args.pop(0), "--args"]
new_args.extend(args)
args = new_args
elif low_platform == "windows":
flags = (
subprocess.CREATE_NEW_PROCESS_GROUP
| subprocess.DETACHED_PROCESS
)
kwargs["creationflags"] = flags
if not sys.stdout:
kwargs["stdout"] = subprocess.DEVNULL
kwargs["stderr"] = subprocess.DEVNULL
elif low_platform == "linux" and get_linux_launcher_args() is not None:
json_data = {
"args": args,
"env": kwargs.pop("env")
}
json_temp = tempfile.NamedTemporaryFile(
mode="w", prefix="op_app_args", suffix=".json", delete=False
)
json_temp.close()
json_temp_filpath = json_temp.name
with open(json_temp_filpath, "w") as stream:
json.dump(json_data, stream)
new_args = get_linux_launcher_args()
new_args.append(json_temp_filpath)
# Create mid-process which will launch application
process = subprocess.Popen(new_args, **kwargs)
# Wait until the process finishes
# - This is important! The process would stay in "open" state.
process.wait()
# Remove the temp file
os.remove(json_temp_filpath)
# Return process which is already terminated
return process
process = subprocess.Popen(args, **kwargs)
return process
def path_to_subprocess_arg(path):
"""Prepare path for subprocess arguments.

View file

@ -44,6 +44,7 @@ DEFAULT_OPENPYPE_MODULES = (
"project_manager_action",
"settings_action",
"standalonepublish_action",
"traypublish_action",
"job_queue",
"timers_manager",
"sync_server",
@ -846,6 +847,7 @@ class TrayModulesManager(ModulesManager):
"avalon",
"clockify",
"standalonepublish_tool",
"traypublish_tool",
"log_viewer",
"local_settings",
"settings"

View file

@ -1,4 +1,3 @@
import os
import logging
import pyblish.api
import avalon.api
@ -43,37 +42,48 @@ class CollectFtrackApi(pyblish.api.ContextPlugin):
).format(project_name))
project_entity = project_entities[0]
self.log.debug("Project found: {0}".format(project_entity))
# Find asset entity
entity_query = (
'TypedContext where project_id is "{0}"'
' and name is "{1}"'
).format(project_entity["id"], asset_name)
self.log.debug("Asset entity query: < {0} >".format(entity_query))
asset_entities = []
for entity in session.query(entity_query).all():
# Skip tasks
if entity.entity_type.lower() != "task":
asset_entities.append(entity)
asset_entity = None
if asset_name:
# Find asset entity
entity_query = (
'TypedContext where project_id is "{0}"'
' and name is "{1}"'
).format(project_entity["id"], asset_name)
self.log.debug("Asset entity query: < {0} >".format(entity_query))
asset_entities = []
for entity in session.query(entity_query).all():
# Skip tasks
if entity.entity_type.lower() != "task":
asset_entities.append(entity)
if len(asset_entities) == 0:
raise AssertionError((
"Entity with name \"{0}\" not found"
" in Ftrack project \"{1}\"."
).format(asset_name, project_name))
if len(asset_entities) == 0:
raise AssertionError((
"Entity with name \"{0}\" not found"
" in Ftrack project \"{1}\"."
).format(asset_name, project_name))
elif len(asset_entities) > 1:
raise AssertionError((
"Found more than one entity with name \"{0}\""
" in Ftrack project \"{1}\"."
).format(asset_name, project_name))
elif len(asset_entities) > 1:
raise AssertionError((
"Found more than one entity with name \"{0}\""
" in Ftrack project \"{1}\"."
).format(asset_name, project_name))
asset_entity = asset_entities[0]
asset_entity = asset_entities[0]
self.log.debug("Asset found: {0}".format(asset_entity))
task_entity = None
# Find task entity if task is set
if task_name:
if not asset_entity:
self.log.warning(
"Asset entity is not set. Skipping query of task entity."
)
elif not task_name:
self.log.warning("Task name is not set.")
else:
task_query = (
'Task where name is "{0}" and parent_id is "{1}"'
).format(task_name, asset_entity["id"])
@ -88,10 +98,6 @@ class CollectFtrackApi(pyblish.api.ContextPlugin):
else:
self.log.debug("Task entity found: {0}".format(task_entity))
else:
task_entity = None
self.log.warning("Task name is not set.")
context.data["ftrackSession"] = session
context.data["ftrackPythonModule"] = ftrack_api
context.data["ftrackProject"] = project_entity

View file

@ -122,6 +122,7 @@ class ITrayAction(ITrayModule):
admin_action = False
_admin_submenu = None
_action_item = None
@property
@abstractmethod
@ -149,6 +150,7 @@ class ITrayAction(ITrayModule):
tray_menu.addAction(action)
action.triggered.connect(self.on_action_trigger)
self._action_item = action
def tray_start(self):
return

View file

@ -0,0 +1,49 @@
import os
from openpype.lib import get_openpype_execute_args
from openpype.lib.execute import run_detached_process
from openpype.modules import OpenPypeModule
from openpype_interfaces import ITrayAction
class TrayPublishAction(OpenPypeModule, ITrayAction):
label = "New Publish (beta)"
name = "traypublish_tool"
def initialize(self, modules_settings):
import openpype
self.enabled = True
self.publish_paths = [
os.path.join(
openpype.PACKAGE_DIR,
"hosts",
"traypublisher",
"plugins",
"publish"
)
]
self._experimental_tools = None
def tray_init(self):
from openpype.tools.experimental_tools import ExperimentalTools
self._experimental_tools = ExperimentalTools()
def tray_menu(self, *args, **kwargs):
super(TrayPublishAction, self).tray_menu(*args, **kwargs)
traypublisher = self._experimental_tools.get("traypublisher")
visible = False
if traypublisher and traypublisher.enabled:
visible = True
self._action_item.setVisible(visible)
def on_action_trigger(self):
self.run_traypublisher()
def connect_with_modules(self, enabled_modules):
"""Collect publish paths from other modules."""
publish_paths = self.manager.collect_plugin_paths()["publish"]
self.publish_paths.extend(publish_paths)
def run_traypublisher(self):
args = get_openpype_execute_args("traypublisher")
run_detached_process(args)

View file

@ -14,7 +14,7 @@ Except creating and removing instances are all changes not automatically propaga
## 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.
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 `instance_id` 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.
@ -26,7 +26,7 @@ Family tells how should be instance processed and subset what name will publishe
## Identifier that this data represents instance for publishing (automatically assigned)
"id": "pyblish.avalon.instance",
## Identifier of this specific instance (automatically assigned)
"uuid": <uuid4>,
"instance_id": <uuid4>,
## Instance family (used from Creator)
"family": <family>,

View file

@ -361,7 +361,7 @@ class CreatedInstance:
# their individual children but not on their own
__immutable_keys = (
"id",
"uuid",
"instance_id",
"family",
"creator_identifier",
"creator_attributes",
@ -434,8 +434,8 @@ class CreatedInstance:
if data:
self._data.update(data)
if not self._data.get("uuid"):
self._data["uuid"] = str(uuid4())
if not self._data.get("instance_id"):
self._data["instance_id"] = str(uuid4())
self._asset_is_valid = self.has_set_asset
self._task_is_valid = self.has_set_task
@ -551,7 +551,7 @@ class CreatedInstance:
@property
def id(self):
"""Instance identifier."""
return self._data["uuid"]
return self._data["instance_id"]
@property
def data(self):

View file

@ -44,42 +44,18 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin):
label = "Collect Anatomy Context Data"
def process(self, context):
task_name = api.Session["AVALON_TASK"]
project_entity = context.data["projectEntity"]
asset_entity = context.data["assetEntity"]
asset_tasks = asset_entity["data"]["tasks"]
task_type = asset_tasks.get(task_name, {}).get("type")
project_task_types = project_entity["config"]["tasks"]
task_code = project_task_types.get(task_type, {}).get("short_name")
asset_parents = asset_entity["data"]["parents"]
hierarchy = "/".join(asset_parents)
parent_name = project_entity["name"]
if asset_parents:
parent_name = asset_parents[-1]
context_data = {
"project": {
"name": project_entity["name"],
"code": project_entity["data"].get("code")
},
"asset": asset_entity["name"],
"parent": parent_name,
"hierarchy": hierarchy,
"task": {
"name": task_name,
"type": task_type,
"short": task_code,
},
"username": context.data["user"],
"app": context.data["hostName"]
}
context.data["anatomyData"] = context_data
# add system general settings anatomy data
system_general_data = get_system_general_anatomy_data()
context_data.update(system_general_data)
@ -87,7 +63,33 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin):
datetime_data = context.data.get("datetimeData") or {}
context_data.update(datetime_data)
context.data["anatomyData"] = context_data
asset_entity = context.data.get("assetEntity")
if asset_entity:
task_name = api.Session["AVALON_TASK"]
asset_tasks = asset_entity["data"]["tasks"]
task_type = asset_tasks.get(task_name, {}).get("type")
project_task_types = project_entity["config"]["tasks"]
task_code = project_task_types.get(task_type, {}).get("short_name")
asset_parents = asset_entity["data"]["parents"]
hierarchy = "/".join(asset_parents)
parent_name = project_entity["name"]
if asset_parents:
parent_name = asset_parents[-1]
context_data.update({
"asset": asset_entity["name"],
"parent": parent_name,
"hierarchy": hierarchy,
"task": {
"name": task_name,
"type": task_type,
"short": task_code,
}
})
self.log.info("Global anatomy Data collected")
self.log.debug(json.dumps(context_data, indent=4))

View file

@ -52,7 +52,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
def fill_missing_asset_docs(self, context):
self.log.debug("Qeurying asset documents for instances.")
context_asset_doc = context.data["assetEntity"]
context_asset_doc = context.data.get("assetEntity")
instances_with_missing_asset_doc = collections.defaultdict(list)
for instance in context:
@ -69,7 +69,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
# Check if asset name is the same as what is in context
# - they may be different, e.g. in NukeStudio
if context_asset_doc["name"] == _asset_name:
if context_asset_doc and context_asset_doc["name"] == _asset_name:
instance.data["assetEntity"] = context_asset_doc
else:
@ -212,7 +212,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
self.log.debug("Storing anatomy data to instance data.")
project_doc = context.data["projectEntity"]
context_asset_doc = context.data["assetEntity"]
context_asset_doc = context.data.get("assetEntity")
project_task_types = project_doc["config"]["tasks"]
@ -240,7 +240,13 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
# Hiearchy
asset_doc = instance.data.get("assetEntity")
if asset_doc and asset_doc["_id"] != context_asset_doc["_id"]:
if (
asset_doc
and (
not context_asset_doc
or asset_doc["_id"] != context_asset_doc["_id"]
)
):
parents = asset_doc["data"].get("parents") or list()
parent_name = project_doc["name"]
if parents:

View file

@ -33,6 +33,11 @@ class CollectAvalonEntities(pyblish.api.ContextPlugin):
).format(project_name)
self.log.debug("Collected Project \"{}\"".format(project_entity))
context.data["projectEntity"] = project_entity
if not asset_name:
self.log.info("Context is not set. Can't collect global data.")
return
asset_entity = io.find_one({
"type": "asset",
"name": asset_name,
@ -44,7 +49,6 @@ class CollectAvalonEntities(pyblish.api.ContextPlugin):
self.log.debug("Collected Asset \"{}\"".format(asset_entity))
context.data["projectEntity"] = project_entity
context.data["assetEntity"] = asset_entity
data = asset_entity['data']

View file

@ -148,7 +148,10 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
project_entity = instance.data["projectEntity"]
context_asset_name = context.data["assetEntity"]["name"]
context_asset_name = None
context_asset_doc = context.data.get("assetEntity")
if context_asset_doc:
context_asset_name = context_asset_doc["name"]
asset_name = instance.data["asset"]
asset_entity = instance.data.get("assetEntity")

View file

@ -0,0 +1,31 @@
import pyblish.api
from openpype.pipeline import PublishValidationError
class ValidateContainers(pyblish.api.InstancePlugin):
"""Validate existence of asset asset documents on instances.
Without asset document it is not possible to publish the instance.
If context has set asset document the validation is skipped.
Plugin was added because there are cases when context asset is not defined
e.g. in tray publisher.
"""
label = "Validate Asset docs"
order = pyblish.api.ValidatorOrder
def process(self, instance):
context_asset_doc = instance.context.data.get("assetEntity")
if context_asset_doc:
return
if instance.data.get("assetEntity"):
self.log.info("Instance have set asset document in it's data.")
else:
raise PublishValidationError((
"Instance \"{}\" don't have set asset"
" document which is needed for publishing."
).format(instance.data["name"]))

View file

@ -80,6 +80,11 @@ class PypeCommands:
from openpype.tools import standalonepublish
standalonepublish.main()
@staticmethod
def launch_traypublisher():
from openpype.tools import traypublisher
traypublisher.main()
@staticmethod
def publish(paths, targets=None, gui=False):
"""Start headless publishing.

View file

@ -1261,6 +1261,12 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
background: {color:restart-btn-bg};
}
/* Tray publisher */
#ChooseProjectLabel {
font-size: 15pt;
font-weight: 750;
}
/* Globally used names */
#Separator {
background: {color:bg-menu-separator};

View file

@ -82,7 +82,7 @@ class ExperimentalToolsDialog(QtWidgets.QDialog):
tool_btns_layout.addWidget(tool_btns_label, 0)
experimental_tools = ExperimentalTools(
parent=parent, filter_hosts=True
parent_widget=parent, refresh=False
)
# Main layout
@ -116,7 +116,8 @@ class ExperimentalToolsDialog(QtWidgets.QDialog):
self._experimental_tools.refresh_availability()
buttons_to_remove = set(self._buttons_by_tool_identifier.keys())
for idx, tool in enumerate(self._experimental_tools.tools):
tools = self._experimental_tools.get_tools_for_host()
for idx, tool in enumerate(tools):
identifier = tool.identifier
if identifier in buttons_to_remove:
buttons_to_remove.remove(identifier)

View file

@ -5,7 +5,32 @@ from openpype.settings import get_local_settings
LOCAL_EXPERIMENTAL_KEY = "experimental_tools"
class ExperimentalTool:
class ExperimentalTool(object):
"""Definition of experimental tool.
Definition is used in local settings.
Args:
identifier (str): String identifier of tool (unique).
label (str): Label shown in UI.
"""
def __init__(self, identifier, label, tooltip):
self.identifier = identifier
self.label = label
self.tooltip = tooltip
self._enabled = True
@property
def enabled(self):
"""Is tool enabled and button is clickable."""
return self._enabled
def set_enabled(self, enabled=True):
"""Change if tool is enabled."""
self._enabled = enabled
class ExperimentalHostTool(ExperimentalTool):
"""Definition of experimental tool.
Definition is used in local settings and in experimental tools dialog.
@ -19,12 +44,10 @@ class ExperimentalTool:
Some tools may not be available in all hosts.
"""
def __init__(
self, identifier, label, callback, tooltip, hosts_filter=None
self, identifier, label, tooltip, callback, hosts_filter=None
):
self.identifier = identifier
self.label = label
super(ExperimentalHostTool, self).__init__(identifier, label, tooltip)
self.callback = callback
self.tooltip = tooltip
self.hosts_filter = hosts_filter
self._enabled = True
@ -33,18 +56,9 @@ class ExperimentalTool:
return host_name in self.hosts_filter
return True
@property
def enabled(self):
"""Is tool enabled and button is clickable."""
return self._enabled
def set_enabled(self, enabled=True):
"""Change if tool is enabled."""
self._enabled = enabled
def execute(self):
def execute(self, *args, **kwargs):
"""Trigger registered callback."""
self.callback()
self.callback(*args, **kwargs)
class ExperimentalTools:
@ -53,57 +67,36 @@ class ExperimentalTools:
To add/remove experimental tool just add/remove tool to
`experimental_tools` variable in __init__ function.
Args:
parent (QtWidgets.QWidget): Parent widget for tools.
host_name (str): Name of host in which context we're now. Environment
value 'AVALON_APP' is used when not passed.
filter_hosts (bool): Should filter tools. By default is set to 'True'
when 'host_name' is passed. Is always set to 'False' if 'host_name'
is not defined.
--- Example tool (callback will just print on click) ---
def example_callback(*args):
print("Triggered tool")
experimental_tools = [
ExperimentalHostTool(
"example",
"Example experimental tool",
example_callback,
"Example tool tooltip."
)
]
---
"""
def __init__(self, parent=None, host_name=None, filter_hosts=None):
def __init__(self, parent_widget=None, refresh=True):
# Definition of experimental tools
experimental_tools = [
ExperimentalTool(
ExperimentalHostTool(
"publisher",
"New publisher",
self._show_publisher,
"Combined creation and publishing into one tool."
"Combined creation and publishing into one tool.",
self._show_publisher
),
ExperimentalTool(
"traypublisher",
"New Standalone Publisher",
"Standalone publisher using new publisher. Requires restart"
)
]
# --- Example tool (callback will just print on click) ---
# def example_callback(*args):
# print("Triggered tool")
#
# experimental_tools = [
# ExperimentalTool(
# "example",
# "Example experimental tool",
# example_callback,
# "Example tool tooltip."
# )
# ]
# Try to get host name from env variable `AVALON_APP`
if not host_name:
host_name = os.environ.get("AVALON_APP")
# Decide if filtering by host name should happen
if filter_hosts is None:
filter_hosts = host_name is not None
if filter_hosts and not host_name:
filter_hosts = False
# Filter tools by host name
if filter_hosts:
experimental_tools = [
tool
for tool in experimental_tools
if tool.is_available_for_host(host_name)
]
# Store tools by identifier
tools_by_identifier = {}
for tool in experimental_tools:
@ -115,10 +108,13 @@ class ExperimentalTools:
self._tools_by_identifier = tools_by_identifier
self._tools = experimental_tools
self._parent_widget = parent
self._parent_widget = parent_widget
self._publisher_tool = None
if refresh:
self.refresh_availability()
@property
def tools(self):
"""Tools in list.
@ -139,6 +135,22 @@ class ExperimentalTools:
"""
return self._tools_by_identifier
def get(self, tool_identifier):
"""Get tool by identifier."""
return self.tools_by_identifier.get(tool_identifier)
def get_tools_for_host(self, host_name=None):
if not host_name:
host_name = os.environ.get("AVALON_APP")
tools = []
for tool in self.tools:
if (
isinstance(tool, ExperimentalHostTool)
and tool.is_available_for_host(host_name)
):
tools.append(tool)
return tools
def refresh_availability(self):
"""Reload local settings and check if any tool changed ability."""
local_settings = get_local_settings()

View file

@ -42,18 +42,23 @@ class MainThreadProcess(QtCore.QObject):
This approach gives ability to update UI meanwhile plugin is in progress.
"""
timer_interval = 3
count_timeout = 2
def __init__(self):
super(MainThreadProcess, self).__init__()
self._items_to_process = collections.deque()
timer = QtCore.QTimer()
timer.setInterval(self.timer_interval)
timer.setInterval(0)
timer.timeout.connect(self._execute)
self._timer = timer
self._switch_counter = self.count_timeout
def process(self, func, *args, **kwargs):
item = MainThreadItem(func, *args, **kwargs)
self.add_item(item)
def add_item(self, item):
self._items_to_process.append(item)
@ -62,6 +67,12 @@ class MainThreadProcess(QtCore.QObject):
if not self._items_to_process:
return
if self._switch_counter > 0:
self._switch_counter -= 1
return
self._switch_counter = self.count_timeout
item = self._items_to_process.popleft()
item.process()
@ -173,11 +184,21 @@ class PublishReport:
self._stored_plugins.append(plugin)
plugin_data_item = self._create_plugin_data_item(plugin)
self._plugin_data_with_plugin.append({
"plugin": plugin,
"data": plugin_data_item
})
self._plugin_data.append(plugin_data_item)
return plugin_data_item
def _create_plugin_data_item(self, plugin):
label = None
if hasattr(plugin, "label"):
label = plugin.label
plugin_data_item = {
return {
"name": plugin.__name__,
"label": label,
"order": plugin.order,
@ -186,12 +207,6 @@ class PublishReport:
"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."""
@ -241,7 +256,7 @@ class PublishReport:
if publish_plugins:
for plugin in publish_plugins:
if plugin not in self._stored_plugins:
plugins_data.append(self._add_plugin_data_item(plugin))
plugins_data.append(self._create_plugin_data_item(plugin))
crashed_file_paths = {}
if self._publish_discover_result is not None:
@ -971,6 +986,9 @@ class PublisherController:
self._publish_next_process()
def reset_project_data_cache(self):
self._asset_docs_cache.reset()
def collect_families_from_instances(instances, only_active=False):
"""Collect all families for passed publish instances.

View file

@ -1,3 +1,6 @@
from .report_items import (
PublishReport
)
from .widgets import (
PublishReportViewerWidget
)
@ -8,6 +11,8 @@ from .window import (
__all__ = (
"PublishReport",
"PublishReportViewerWidget",
"PublishReportViewerWindow",

View file

@ -28,6 +28,8 @@ class InstancesModel(QtGui.QStandardItemModel):
self.clear()
self._items_by_id.clear()
self._plugin_items_by_id.clear()
if not report_item:
return
root_item = self.invisibleRootItem()
@ -119,6 +121,8 @@ class PluginsModel(QtGui.QStandardItemModel):
self.clear()
self._items_by_id.clear()
self._plugin_items_by_id.clear()
if not report_item:
return
root_item = self.invisibleRootItem()

View file

@ -0,0 +1,126 @@
import uuid
import collections
import copy
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"]
errored = False
for instance_data in plugin_data["instances_data"]:
for log_item in instance_data["logs"]:
errored = log_item["type"] == "error"
if errored:
break
if errored:
break
self.errored = errored
@property
def id(self):
return self._id
class InstanceItem:
def __init__(self, instance_id, instance_data, logs_by_instance_id):
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 = logs_by_instance_id.get(instance_id) or []
errored = False
for log_item in logs:
if log_item.errored:
errored = True
break
self.errored = errored
@property
def id(self):
return self._id
class LogItem:
def __init__(self, log_item_data, plugin_id, instance_id):
self._instance_id = instance_id
self._plugin_id = plugin_id
self._errored = log_item_data["type"] == "error"
self.data = log_item_data
def __getitem__(self, key):
return self.data[key]
@property
def errored(self):
return self._errored
@property
def instance_id(self):
return self._instance_id
@property
def plugin_id(self):
return self._plugin_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"
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
for instance_data_item in plugin_data["instances_data"]:
instance_id = instance_data_item["id"]
for log_item_data in instance_data_item["logs"]:
log_item = LogItem(
copy.deepcopy(log_item_data), item.id, instance_id
)
logs.append(log_item)
logs_by_instance_id = collections.defaultdict(list)
for log_item in logs:
logs_by_instance_id[log_item.instance_id].append(log_item)
instance_items_by_id = {}
instance_items_by_family = {}
context_item = InstanceItem(None, context_data, logs_by_instance_id)
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, logs_by_instance_id
)
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)
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 = logs
self.crashed_plugin_paths = report_data["crashed_file_paths"]

View file

@ -1,10 +1,8 @@
import copy
import uuid
from Qt import QtWidgets, QtCore
from Qt import QtWidgets, QtCore, QtGui
from openpype.widgets.nice_checkbox import NiceCheckbox
# from openpype.tools.utils import DeselectableTreeView
from .constants import (
ITEM_ID_ROLE,
ITEM_IS_GROUP_ROLE
@ -16,98 +14,127 @@ from .model import (
PluginsModel,
PluginProxyModel
)
from .report_items import PublishReport
FILEPATH_ROLE = QtCore.Qt.UserRole + 1
TRACEBACK_ROLE = QtCore.Qt.UserRole + 2
IS_DETAIL_ITEM_ROLE = QtCore.Qt.UserRole + 3
class PluginItem:
def __init__(self, plugin_data):
self._id = uuid.uuid4()
class PluginLoadReportModel(QtGui.QStandardItemModel):
def set_report(self, report):
parent = self.invisibleRootItem()
parent.removeRows(0, parent.rowCount())
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"]
new_items = []
new_items_by_filepath = {}
for filepath in report.crashed_plugin_paths.keys():
item = QtGui.QStandardItem(filepath)
new_items.append(item)
new_items_by_filepath[filepath] = item
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))
if not new_items:
return
self.errored = errored
self.logs = logs
@property
def id(self):
return self._id
parent.appendRows(new_items)
for filepath, item in new_items_by_filepath.items():
traceback_txt = report.crashed_plugin_paths[filepath]
detail_item = QtGui.QStandardItem()
detail_item.setData(filepath, FILEPATH_ROLE)
detail_item.setData(traceback_txt, TRACEBACK_ROLE)
detail_item.setData(True, IS_DETAIL_ITEM_ROLE)
item.appendRow(detail_item)
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)
class DetailWidget(QtWidgets.QTextEdit):
def __init__(self, text, *args, **kwargs):
super(DetailWidget, self).__init__(*args, **kwargs)
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"]))
self.setReadOnly(True)
self.setHtml(text)
self.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
self.setWordWrapMode(
QtGui.QTextOption.WrapAtWordBoundaryOrAnywhere
)
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
def sizeHint(self):
content_margins = (
self.contentsMargins().top()
+ self.contentsMargins().bottom()
)
size = self.document().documentLayout().documentSize().toSize()
size.setHeight(size.height() + content_margins)
return size
class PublishReport:
def __init__(self, report_data):
data = copy.deepcopy(report_data)
class PluginLoadReportWidget(QtWidgets.QWidget):
def __init__(self, parent):
super(PluginLoadReportWidget, self).__init__(parent)
context_data = data["context"]
context_data["name"] = "context"
context_data["label"] = context_data["label"] or "Context"
view = QtWidgets.QTreeView(self)
view.setEditTriggers(view.NoEditTriggers)
view.setTextElideMode(QtCore.Qt.ElideLeft)
view.setHeaderHidden(True)
view.setAlternatingRowColors(True)
view.setVerticalScrollMode(view.ScrollPerPixel)
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]
model = PluginLoadReportModel()
view.setModel(model)
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)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(view, 1)
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))
view.expanded.connect(self._on_expand)
self.instance_items_by_id = instance_items_by_id
self.instance_items_by_family = instance_items_by_family
self._view = view
self._model = model
self._widgets_by_filepath = {}
self.plugins_id_order = plugins_id_order
self.plugins_items_by_id = plugins_items_by_id
def _on_expand(self, index):
for row in range(self._model.rowCount(index)):
child_index = self._model.index(row, index.column(), index)
self._create_widget(child_index)
self.logs = all_logs
def showEvent(self, event):
super(PluginLoadReportWidget, self).showEvent(event)
self._update_widgets_size_hints()
def resizeEvent(self, event):
super(PluginLoadReportWidget, self).resizeEvent(event)
self._update_widgets_size_hints()
def _update_widgets_size_hints(self):
for item in self._widgets_by_filepath.values():
widget, index = item
if not widget.isVisible():
continue
self._model.setData(
index, widget.sizeHint(), QtCore.Qt.SizeHintRole
)
def _create_widget(self, index):
if not index.data(IS_DETAIL_ITEM_ROLE):
return
filepath = index.data(FILEPATH_ROLE)
if filepath in self._widgets_by_filepath:
return
traceback_txt = index.data(TRACEBACK_ROLE)
detail_text = (
"<b>Filepath:</b><br/>"
"{}<br/><br/>"
"<b>Traceback:</b><br/>"
"{}"
).format(filepath, traceback_txt.replace("\n", "<br/>"))
widget = DetailWidget(detail_text, self)
self._view.setIndexWidget(index, widget)
self._widgets_by_filepath[filepath] = (widget, index)
def set_report(self, report):
self._widgets_by_filepath = {}
self._model.set_report(report)
class DetailsWidget(QtWidgets.QWidget):
@ -123,11 +150,50 @@ class DetailsWidget(QtWidgets.QWidget):
layout.addWidget(output_widget)
self._output_widget = output_widget
self._report_item = None
self._instance_filter = set()
self._plugin_filter = set()
def clear(self):
self._output_widget.setPlainText("")
def set_logs(self, logs):
def set_report(self, report):
self._report_item = report
self._plugin_filter = set()
self._instance_filter = set()
self._update_logs()
def set_plugin_filter(self, plugin_filter):
self._plugin_filter = plugin_filter
self._update_logs()
def set_instance_filter(self, instance_filter):
self._instance_filter = instance_filter
self._update_logs()
def _update_logs(self):
if not self._report_item:
self._output_widget.setPlainText("")
return
filtered_logs = []
for log in self._report_item.logs:
if (
self._instance_filter
and log.instance_id not in self._instance_filter
):
continue
if (
self._plugin_filter
and log.plugin_id not in self._plugin_filter
):
continue
filtered_logs.append(log)
self._set_logs(filtered_logs)
def _set_logs(self, logs):
lines = []
for log in logs:
if log["type"] == "record":
@ -148,6 +214,60 @@ class DetailsWidget(QtWidgets.QWidget):
self._output_widget.setPlainText(text)
class DeselectableTreeView(QtWidgets.QTreeView):
"""A tree view that deselects on clicking on an empty area in the view"""
def mousePressEvent(self, event):
index = self.indexAt(event.pos())
clear_selection = False
if not index.isValid():
modifiers = QtWidgets.QApplication.keyboardModifiers()
if modifiers == QtCore.Qt.ShiftModifier:
return
elif modifiers == QtCore.Qt.ControlModifier:
return
clear_selection = True
else:
indexes = self.selectedIndexes()
if len(indexes) == 1 and index in indexes:
clear_selection = True
if clear_selection:
# clear the selection
self.clearSelection()
# clear the current index
self.setCurrentIndex(QtCore.QModelIndex())
event.accept()
return
QtWidgets.QTreeView.mousePressEvent(self, event)
class DetailsPopup(QtWidgets.QDialog):
closed = QtCore.Signal()
def __init__(self, parent, center_widget):
super(DetailsPopup, self).__init__(parent)
self.setWindowTitle("Report Details")
layout = QtWidgets.QHBoxLayout(self)
self._center_widget = center_widget
self._first_show = True
self._layout = layout
def showEvent(self, event):
layout = self.layout()
layout.insertWidget(0, self._center_widget)
super(DetailsPopup, self).showEvent(event)
if self._first_show:
self._first_show = False
self.resize(700, 400)
def closeEvent(self, event):
super(DetailsPopup, self).closeEvent(event)
self.closed.emit()
class PublishReportViewerWidget(QtWidgets.QWidget):
def __init__(self, parent=None):
super(PublishReportViewerWidget, self).__init__(parent)
@ -171,12 +291,13 @@ class PublishReportViewerWidget(QtWidgets.QWidget):
removed_instances_layout.addWidget(removed_instances_check, 0)
removed_instances_layout.addWidget(removed_instances_label, 1)
instances_view = QtWidgets.QTreeView(self)
instances_view = DeselectableTreeView(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.setSelectionMode(QtWidgets.QTreeView.ExtendedSelection)
instances_view.setExpandsOnDoubleClick(False)
instances_delegate = GroupItemDelegate(instances_view)
@ -191,29 +312,49 @@ class PublishReportViewerWidget(QtWidgets.QWidget):
skipped_plugins_layout.addWidget(skipped_plugins_check, 0)
skipped_plugins_layout.addWidget(skipped_plugins_label, 1)
plugins_view = QtWidgets.QTreeView(self)
plugins_view = DeselectableTreeView(self)
plugins_view.setObjectName("PublishDetailViews")
plugins_view.setModel(plugins_proxy)
plugins_view.setIndentation(0)
plugins_view.setHeaderHidden(True)
plugins_view.setSelectionMode(QtWidgets.QTreeView.ExtendedSelection)
plugins_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers)
plugins_view.setExpandsOnDoubleClick(False)
plugins_delegate = GroupItemDelegate(plugins_view)
plugins_view.setItemDelegate(plugins_delegate)
details_widget = DetailsWidget(self)
details_widget = QtWidgets.QWidget(self)
details_tab_widget = QtWidgets.QTabWidget(details_widget)
details_popup_btn = QtWidgets.QPushButton("PopUp", details_widget)
layout = QtWidgets.QGridLayout(self)
details_layout = QtWidgets.QVBoxLayout(details_widget)
details_layout.setContentsMargins(0, 0, 0, 0)
details_layout.addWidget(details_tab_widget, 1)
details_layout.addWidget(details_popup_btn, 0)
details_popup = DetailsPopup(self, details_tab_widget)
logs_text_widget = DetailsWidget(details_tab_widget)
plugin_load_report_widget = PluginLoadReportWidget(details_tab_widget)
details_tab_widget.addTab(logs_text_widget, "Logs")
details_tab_widget.addTab(plugin_load_report_widget, "Crashed plugins")
middle_widget = QtWidgets.QWidget(self)
middle_layout = QtWidgets.QGridLayout(middle_widget)
middle_layout.setContentsMargins(0, 0, 0, 0)
# Row 1
layout.addLayout(removed_instances_layout, 0, 0)
layout.addLayout(skipped_plugins_layout, 0, 1)
middle_layout.addLayout(removed_instances_layout, 0, 0)
middle_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)
middle_layout.addWidget(instances_view, 1, 0)
middle_layout.addWidget(plugins_view, 1, 1)
layout.setColumnStretch(2, 1)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(middle_widget, 0)
layout.addWidget(details_widget, 1)
instances_view.selectionModel().selectionChanged.connect(
self._on_instance_change
@ -230,10 +371,13 @@ class PublishReportViewerWidget(QtWidgets.QWidget):
removed_instances_check.stateChanged.connect(
self._on_removed_instances_check
)
details_popup_btn.clicked.connect(self._on_details_popup)
details_popup.closed.connect(self._on_popup_close)
self._ignore_selection_changes = False
self._report_item = None
self._details_widget = details_widget
self._logs_text_widget = logs_text_widget
self._plugin_load_report_widget = plugin_load_report_widget
self._removed_instances_check = removed_instances_check
self._instances_view = instances_view
@ -248,6 +392,10 @@ class PublishReportViewerWidget(QtWidgets.QWidget):
self._plugins_model = plugins_model
self._plugins_proxy = plugins_proxy
self._details_widget = details_widget
self._details_tab_widget = details_tab_widget
self._details_popup = details_popup
def _on_instance_view_clicked(self, index):
if not index.isValid() or not index.data(ITEM_IS_GROUP_ROLE):
return
@ -266,62 +414,46 @@ class PublishReportViewerWidget(QtWidgets.QWidget):
else:
self._plugins_view.expand(index)
def set_report(self, report_data):
def set_report_data(self, report_data):
report = PublishReport(report_data)
self.set_report(report)
def set_report(self, report):
self._ignore_selection_changes = True
report_item = PublishReport(report_data)
self._report_item = report_item
self._report_item = report
self._instances_model.set_report(report_item)
self._plugins_model.set_report(report_item)
self._details_widget.set_logs(report_item.logs)
self._instances_model.set_report(report)
self._plugins_model.set_report(report)
self._logs_text_widget.set_report(report)
self._plugin_load_report_widget.set_report(report)
self._ignore_selection_changes = False
self._instances_view.expandAll()
self._plugins_view.expandAll()
def _on_instance_change(self, *_args):
if self._ignore_selection_changes:
return
valid_index = None
instance_ids = set()
for index in self._instances_view.selectedIndexes():
if index.isValid():
valid_index = index
break
instance_ids.add(index.data(ITEM_ID_ROLE))
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)
self._logs_text_widget.set_instance_filter(instance_ids)
def _on_plugin_change(self, *_args):
if self._ignore_selection_changes:
return
valid_index = None
plugin_ids = set()
for index in self._plugins_view.selectedIndexes():
if index.isValid():
valid_index = index
break
plugin_ids.add(index.data(ITEM_ID_ROLE))
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)
self._logs_text_widget.set_plugin_filter(plugin_ids)
def _on_skipped_plugin_check(self):
self._plugins_proxy.set_ignore_skipped(
@ -332,3 +464,16 @@ class PublishReportViewerWidget(QtWidgets.QWidget):
self._instances_proxy.set_ignore_removed(
self._removed_instances_check.isChecked()
)
def _on_details_popup(self):
self._details_widget.setVisible(False)
self._details_popup.show()
def _on_popup_close(self):
self._details_widget.setVisible(True)
layout = self._details_widget.layout()
layout.insertWidget(0, self._details_tab_widget)
def close_details_popup(self):
if self._details_popup.isVisible():
self._details_popup.close()

View file

@ -1,29 +1,355 @@
from Qt import QtWidgets
import os
import json
import six
import appdirs
from Qt import QtWidgets, QtCore, QtGui
from openpype import style
from openpype.lib import JSONSettingRegistry
from openpype.resources import get_openpype_icon_filepath
from openpype.tools import resources
from openpype.tools.utils import (
IconButton,
paint_image_with_color
)
from openpype.tools.utils.delegates import PrettyTimeDelegate
if __package__:
from .widgets import PublishReportViewerWidget
from .report_items import PublishReport
else:
from widgets import PublishReportViewerWidget
from report_items import PublishReport
FILEPATH_ROLE = QtCore.Qt.UserRole + 1
MODIFIED_ROLE = QtCore.Qt.UserRole + 2
class PublisherReportRegistry(JSONSettingRegistry):
"""Class handling storing publish report tool.
Attributes:
vendor (str): Name used for path construction.
product (str): Additional name used for path construction.
"""
def __init__(self):
self.vendor = "pypeclub"
self.product = "openpype"
name = "publish_report_viewer"
path = appdirs.user_data_dir(self.product, self.vendor)
super(PublisherReportRegistry, self).__init__(name, path)
class LoadedFilesMopdel(QtGui.QStandardItemModel):
def __init__(self, *args, **kwargs):
super(LoadedFilesMopdel, self).__init__(*args, **kwargs)
self.setColumnCount(2)
self._items_by_filepath = {}
self._reports_by_filepath = {}
self._registry = PublisherReportRegistry()
self._loading_registry = False
self._load_registry()
def headerData(self, section, orientation, role):
if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole):
if section == 0:
return "Exports"
if section == 1:
return "Modified"
return ""
super(LoadedFilesMopdel, self).headerData(section, orientation, role)
def _load_registry(self):
self._loading_registry = True
try:
filepaths = self._registry.get_item("filepaths")
self.add_filepaths(filepaths)
except ValueError:
pass
self._loading_registry = False
def _store_registry(self):
if self._loading_registry:
return
filepaths = list(self._items_by_filepath.keys())
self._registry.set_item("filepaths", filepaths)
def data(self, index, role=None):
if role is None:
role = QtCore.Qt.DisplayRole
col = index.column()
if col != 0:
index = self.index(index.row(), 0, index.parent())
if role == QtCore.Qt.ToolTipRole:
if col == 0:
role = FILEPATH_ROLE
elif col == 1:
return "File modified"
return None
elif role == QtCore.Qt.DisplayRole:
if col == 1:
role = MODIFIED_ROLE
return super(LoadedFilesMopdel, self).data(index, role)
def add_filepaths(self, filepaths):
if not filepaths:
return
if isinstance(filepaths, six.string_types):
filepaths = [filepaths]
filtered_paths = []
for filepath in filepaths:
normalized_path = os.path.normpath(filepath)
if normalized_path in self._items_by_filepath:
continue
if (
os.path.exists(normalized_path)
and normalized_path not in filtered_paths
):
filtered_paths.append(normalized_path)
if not filtered_paths:
return
new_items = []
for normalized_path in filtered_paths:
try:
with open(normalized_path, "r") as stream:
data = json.load(stream)
report = PublishReport(data)
except Exception:
# TODO handle errors
continue
modified = os.path.getmtime(normalized_path)
item = QtGui.QStandardItem(os.path.basename(normalized_path))
item.setColumnCount(self.columnCount())
item.setData(normalized_path, FILEPATH_ROLE)
item.setData(modified, MODIFIED_ROLE)
new_items.append(item)
self._items_by_filepath[normalized_path] = item
self._reports_by_filepath[normalized_path] = report
if not new_items:
return
parent = self.invisibleRootItem()
parent.appendRows(new_items)
self._store_registry()
def remove_filepaths(self, filepaths):
if not filepaths:
return
if isinstance(filepaths, six.string_types):
filepaths = [filepaths]
filtered_paths = []
for filepath in filepaths:
normalized_path = os.path.normpath(filepath)
if normalized_path in self._items_by_filepath:
filtered_paths.append(normalized_path)
if not filtered_paths:
return
parent = self.invisibleRootItem()
for filepath in filtered_paths:
self._reports_by_filepath.pop(normalized_path)
item = self._items_by_filepath.pop(filepath)
parent.removeRow(item.row())
self._store_registry()
def get_report_by_filepath(self, filepath):
return self._reports_by_filepath.get(filepath)
class LoadedFilesView(QtWidgets.QTreeView):
selection_changed = QtCore.Signal()
def __init__(self, *args, **kwargs):
super(LoadedFilesView, self).__init__(*args, **kwargs)
self.setEditTriggers(self.NoEditTriggers)
self.setIndentation(0)
self.setAlternatingRowColors(True)
model = LoadedFilesMopdel()
self.setModel(model)
time_delegate = PrettyTimeDelegate()
self.setItemDelegateForColumn(1, time_delegate)
remove_btn = IconButton(self)
remove_icon_path = resources.get_icon_path("delete")
loaded_remove_image = QtGui.QImage(remove_icon_path)
pix = paint_image_with_color(loaded_remove_image, QtCore.Qt.white)
icon = QtGui.QIcon(pix)
remove_btn.setIcon(icon)
model.rowsInserted.connect(self._on_rows_inserted)
remove_btn.clicked.connect(self._on_remove_clicked)
self.selectionModel().selectionChanged.connect(
self._on_selection_change
)
self._model = model
self._time_delegate = time_delegate
self._remove_btn = remove_btn
def _update_remove_btn(self):
viewport = self.viewport()
height = viewport.height() + self.header().height()
pos_x = viewport.width() - self._remove_btn.width() - 5
pos_y = height - self._remove_btn.height() - 5
self._remove_btn.move(max(0, pos_x), max(0, pos_y))
def _on_rows_inserted(self):
header = self.header()
header.resizeSections(header.ResizeToContents)
def resizeEvent(self, event):
super(LoadedFilesView, self).resizeEvent(event)
self._update_remove_btn()
def showEvent(self, event):
super(LoadedFilesView, self).showEvent(event)
self._update_remove_btn()
header = self.header()
header.resizeSections(header.ResizeToContents)
def _on_selection_change(self):
self.selection_changed.emit()
def add_filepaths(self, filepaths):
self._model.add_filepaths(filepaths)
self._fill_selection()
def remove_filepaths(self, filepaths):
self._model.remove_filepaths(filepaths)
self._fill_selection()
def _on_remove_clicked(self):
index = self.currentIndex()
filepath = index.data(FILEPATH_ROLE)
self.remove_filepaths(filepath)
def _fill_selection(self):
index = self.currentIndex()
if index.isValid():
return
index = self._model.index(0, 0)
if index.isValid():
self.setCurrentIndex(index)
def get_current_report(self):
index = self.currentIndex()
filepath = index.data(FILEPATH_ROLE)
return self._model.get_report_by_filepath(filepath)
class LoadedFilesWidget(QtWidgets.QWidget):
report_changed = QtCore.Signal()
def __init__(self, parent):
super(LoadedFilesWidget, self).__init__(parent)
self.setAcceptDrops(True)
view = LoadedFilesView(self)
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(view, 1)
view.selection_changed.connect(self._on_report_change)
self._view = view
def dragEnterEvent(self, event):
mime_data = event.mimeData()
if mime_data.hasUrls():
event.setDropAction(QtCore.Qt.CopyAction)
event.accept()
def dragLeaveEvent(self, event):
event.accept()
def dropEvent(self, event):
mime_data = event.mimeData()
if mime_data.hasUrls():
filepaths = []
for url in mime_data.urls():
filepath = url.toLocalFile()
ext = os.path.splitext(filepath)[-1]
if os.path.exists(filepath) and ext == ".json":
filepaths.append(filepath)
self._add_filepaths(filepaths)
event.accept()
def _on_report_change(self):
self.report_changed.emit()
def _add_filepaths(self, filepaths):
self._view.add_filepaths(filepaths)
def get_current_report(self):
return self._view.get_current_report()
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)
self.setWindowTitle("Publish report viewer")
icon = QtGui.QIcon(get_openpype_icon_filepath())
self.setWindowIcon(icon)
main_widget = PublishReportViewerWidget(self)
body = QtWidgets.QSplitter(self)
body.setContentsMargins(0, 0, 0, 0)
body.setSizePolicy(
QtWidgets.QSizePolicy.Expanding,
QtWidgets.QSizePolicy.Expanding
)
body.setOrientation(QtCore.Qt.Horizontal)
loaded_files_widget = LoadedFilesWidget(body)
main_widget = PublishReportViewerWidget(body)
body.addWidget(loaded_files_widget)
body.addWidget(main_widget)
body.setStretchFactor(0, 70)
body.setStretchFactor(1, 65)
layout = QtWidgets.QHBoxLayout(self)
layout.addWidget(main_widget)
layout.addWidget(body, 1)
loaded_files_widget.report_changed.connect(self._on_report_change)
self._loaded_files_widget = loaded_files_widget
self._main_widget = main_widget
self.resize(self.default_width, self.default_height)
self.setStyleSheet(style.load_stylesheet())
def _on_report_change(self):
report = self._loaded_files_widget.get_current_report()
self.set_report(report)
def set_report(self, report_data):
self._main_widget.set_report(report_data)

View file

@ -174,6 +174,8 @@ class CreatorDescriptionWidget(QtWidgets.QWidget):
class CreateDialog(QtWidgets.QDialog):
default_size = (900, 500)
def __init__(
self, controller, asset_name=None, task_name=None, parent=None
):
@ -262,11 +264,16 @@ class CreateDialog(QtWidgets.QDialog):
mid_layout.addLayout(form_layout, 0)
mid_layout.addWidget(create_btn, 0)
splitter_widget = QtWidgets.QSplitter(self)
splitter_widget.addWidget(context_widget)
splitter_widget.addWidget(mid_widget)
splitter_widget.addWidget(pre_create_widget)
splitter_widget.setStretchFactor(0, 1)
splitter_widget.setStretchFactor(1, 1)
splitter_widget.setStretchFactor(2, 1)
layout = QtWidgets.QHBoxLayout(self)
layout.setSpacing(10)
layout.addWidget(context_widget, 1)
layout.addWidget(mid_widget, 1)
layout.addWidget(pre_create_widget, 1)
layout.addWidget(splitter_widget, 1)
prereq_timer = QtCore.QTimer()
prereq_timer.setInterval(50)
@ -289,6 +296,8 @@ class CreateDialog(QtWidgets.QDialog):
controller.add_plugins_refresh_callback(self._on_plugins_refresh)
self._splitter_widget = splitter_widget
self._pre_create_widget = pre_create_widget
self._context_widget = context_widget
@ -308,6 +317,7 @@ class CreateDialog(QtWidgets.QDialog):
self.create_btn = create_btn
self._prereq_timer = prereq_timer
self._first_show = True
def _context_change_is_enabled(self):
return self._context_widget.isEnabled()
@ -643,6 +653,16 @@ class CreateDialog(QtWidgets.QDialog):
def showEvent(self, event):
super(CreateDialog, self).showEvent(event)
if self._first_show:
self._first_show = False
width, height = self.default_size
self.resize(width, height)
third_size = int(width / 3)
self._splitter_widget.setSizes(
[third_size, third_size, width - (2 * third_size)]
)
if self._last_pos is not None:
self.move(self._last_pos)

View file

@ -213,7 +213,6 @@ class PublishFrame(QtWidgets.QFrame):
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)
@ -495,10 +494,11 @@ class PublishFrame(QtWidgets.QFrame):
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)
report_data = self.controller.get_publish_report()
self._report_view.set_report_data(report_data)
def _on_close_report_clicked(self):
self._report_view.close_details_popup()
if self.controller.get_publish_crash_error():
self._change_bg_property()

View file

@ -10,6 +10,9 @@ from openpype.tools.utils import BaseClickableFrame
from .widgets import (
IconValuePixmapLabel
)
from ..constants import (
INSTANCE_ID_ROLE
)
class ValidationErrorInstanceList(QtWidgets.QListView):
@ -22,19 +25,20 @@ class ValidationErrorInstanceList(QtWidgets.QListView):
self.setObjectName("ValidationErrorInstanceList")
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setSelectionMode(QtWidgets.QListView.ExtendedSelection)
def minimumSizeHint(self):
result = super(ValidationErrorInstanceList, self).minimumSizeHint()
result.setHeight(self.sizeHint().height())
return result
return self.sizeHint()
def sizeHint(self):
result = super(ValidationErrorInstanceList, self).sizeHint()
row_count = self.model().rowCount()
height = 0
if row_count > 0:
height = self.sizeHintForRow(0) * row_count
return QtCore.QSize(self.width(), height)
result.setHeight(height)
return result
class ValidationErrorTitleWidget(QtWidgets.QWidget):
@ -47,6 +51,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
if there is a list (Valdation error may happen on context).
"""
selected = QtCore.Signal(int)
instance_changed = QtCore.Signal(int)
def __init__(self, index, error_info, parent):
super(ValidationErrorTitleWidget, self).__init__(parent)
@ -64,32 +69,38 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow)
toggle_instance_btn.setMaximumWidth(14)
exception = error_info["exception"]
label_widget = QtWidgets.QLabel(exception.title, title_frame)
label_widget = QtWidgets.QLabel(error_info["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"]
error_info = error_info["error_info"]
help_text_by_instance_id = {}
context_validation = False
if (
not instances
or (len(instances) == 1 and instances[0] is None)
not error_info
or (len(error_info) == 1 and error_info[0][0] is None)
):
context_validation = True
toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow)
description = self._prepare_description(error_info[0][1])
help_text_by_instance_id[None] = description
else:
items = []
for instance in instances:
for instance, exception in error_info:
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)
item.setData(label, QtCore.Qt.ToolTipRole)
item.setData(instance.id, INSTANCE_ID_ROLE)
items.append(item)
description = self._prepare_description(exception)
help_text_by_instance_id[instance.id] = description
instances_model.invisibleRootItem().appendRows(items)
@ -114,17 +125,64 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
if not context_validation:
toggle_instance_btn.clicked.connect(self._on_toggle_btn_click)
instances_view.selectionModel().selectionChanged.connect(
self._on_seleciton_change
)
self._title_frame = title_frame
self._toggle_instance_btn = toggle_instance_btn
self._view_layout = view_layout
self._instances_model = instances_model
self._instances_view = instances_view
self._context_validation = context_validation
self._help_text_by_instance_id = help_text_by_instance_id
def sizeHint(self):
result = super().sizeHint()
expected_width = 0
for idx in range(self._view_layout.count()):
expected_width += self._view_layout.itemAt(idx).sizeHint().width()
if expected_width < 200:
expected_width = 200
if result.width() < expected_width:
result.setWidth(expected_width)
return result
def minimumSizeHint(self):
return self.sizeHint()
def _prepare_description(self, exception):
dsc = exception.description
detail = exception.detail
if detail:
dsc += "<br/><br/>{}".format(detail)
description = dsc
if commonmark:
description = commonmark.commonmark(dsc)
return description
def _mouse_release_callback(self):
"""Mark this widget as selected on click."""
self.set_selected(True)
def current_desctiption_text(self):
if self._context_validation:
return self._help_text_by_instance_id[None]
index = self._instances_view.currentIndex()
# TODO make sure instance is selected
if not index.isValid():
index = self._instances_model.index(0, 0)
indence_id = index.data(INSTANCE_ID_ROLE)
return self._help_text_by_instance_id[indence_id]
@property
def is_selected(self):
"""Is widget marked a selected"""
@ -167,6 +225,9 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
else:
self._toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow)
def _on_seleciton_change(self):
self.instance_changed.emit(self._index)
class ActionButton(BaseClickableFrame):
"""Plugin's action callback button.
@ -185,13 +246,15 @@ class ActionButton(BaseClickableFrame):
action_label = action.label or action.__name__
action_icon = getattr(action, "icon", None)
label_widget = QtWidgets.QLabel(action_label, self)
icon_label = None
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)
if icon_label:
layout.addWidget(icon_label, 0)
self.setSizePolicy(
QtWidgets.QSizePolicy.Minimum,
@ -231,6 +294,7 @@ class ValidateActionsWidget(QtWidgets.QFrame):
item = self._content_layout.takeAt(0)
widget = item.widget()
if widget:
widget.setVisible(False)
widget.deleteLater()
self._actions_mapping = {}
@ -363,24 +427,23 @@ class ValidationsWidget(QtWidgets.QWidget):
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_frame = QtWidgets.QFrame(self)
error_details_input = QtWidgets.QTextEdit(error_details_frame)
error_details_input.setObjectName("InfoText")
error_details_input.setTextInteractionFlags(
QtCore.Qt.TextBrowserInteraction
)
actions_widget = ValidateActionsWidget(controller, self)
actions_widget.setFixedWidth(140)
actions_widget.setMinimumWidth(140)
error_details_layout = QtWidgets.QHBoxLayout(error_details_widget)
error_details_layout = QtWidgets.QHBoxLayout(error_details_frame)
error_details_layout.addWidget(error_details_input, 1)
error_details_layout.addWidget(actions_widget, 0)
@ -389,7 +452,7 @@ class ValidationsWidget(QtWidgets.QWidget):
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.addWidget(errors_scroll, 0)
content_layout.addWidget(error_details_widget, 1)
content_layout.addWidget(error_details_frame, 1)
top_label = QtWidgets.QLabel("Publish validation report", self)
top_label.setObjectName("PublishInfoMainLabel")
@ -403,7 +466,7 @@ class ValidationsWidget(QtWidgets.QWidget):
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_frame = error_details_frame
self._error_details_input = error_details_input
self._actions_widget = actions_widget
@ -423,7 +486,7 @@ class ValidationsWidget(QtWidgets.QWidget):
widget.deleteLater()
self._top_label.setVisible(False)
self._error_details_widget.setVisible(False)
self._error_details_frame.setVisible(False)
self._errors_widget.setVisible(False)
self._actions_widget.setVisible(False)
@ -434,34 +497,35 @@ class ValidationsWidget(QtWidgets.QWidget):
return
self._top_label.setVisible(True)
self._error_details_widget.setVisible(True)
self._error_details_frame.setVisible(True)
self._errors_widget.setVisible(True)
errors_by_title = []
for plugin_info in errors:
titles = []
exception_by_title = {}
instances_by_title = {}
error_info_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"])
error_info_by_title[title] = []
error_info_by_title[title].append(
(error_info["instance"], exception)
)
for title in titles:
errors_by_title.append({
"plugin": plugin_info["plugin"],
"exception": exception_by_title[title],
"instances": instances_by_title[title]
"error_info": error_info_by_title[title],
"title": title
})
for idx, item in enumerate(errors_by_title):
widget = ValidationErrorTitleWidget(idx, item, self)
widget.selected.connect(self._on_select)
widget.instance_changed.connect(self._on_instance_change)
self._errors_layout.addWidget(widget)
self._title_widgets[idx] = widget
self._error_info[idx] = item
@ -471,6 +535,8 @@ class ValidationsWidget(QtWidgets.QWidget):
if self._title_widgets:
self._title_widgets[0].set_selected(True)
self.updateGeometry()
def _on_select(self, index):
if self._previous_select:
if self._previous_select.index == index:
@ -481,10 +547,19 @@ class ValidationsWidget(QtWidgets.QWidget):
error_item = self._error_info[index]
dsc = error_item["exception"].description
self._actions_widget.set_plugin(error_item["plugin"])
self._update_description()
def _on_instance_change(self, index):
if self._previous_select and self._previous_select.index != index:
return
self._update_description()
def _update_description(self):
description = self._previous_select.current_desctiption_text()
if commonmark:
html = commonmark.commonmark(dsc)
html = commonmark.commonmark(description)
self._error_details_input.setHtml(html)
else:
self._error_details_input.setMarkdown(dsc)
self._actions_widget.set_plugin(error_item["plugin"])
self._error_details_input.setMarkdown(description)

View file

@ -535,6 +535,7 @@ class TasksCombobox(QtWidgets.QComboBox):
return
self._text = text
self.repaint()
def paintEvent(self, event):
"""Paint custom text without using QLineEdit.
@ -548,6 +549,7 @@ class TasksCombobox(QtWidgets.QComboBox):
self.initStyleOption(opt)
if self._text is not None:
opt.currentText = self._text
style = self.style()
style.drawComplexControl(
QtWidgets.QStyle.CC_ComboBox, opt, painter, self
@ -609,11 +611,15 @@ class TasksCombobox(QtWidgets.QComboBox):
if self._selected_items:
is_valid = True
valid_task_names = []
for task_name in self._selected_items:
is_valid = self._model.is_task_name_valid(asset_name, task_name)
if not is_valid:
break
_is_valid = self._model.is_task_name_valid(asset_name, task_name)
if _is_valid:
valid_task_names.append(task_name)
else:
is_valid = _is_valid
self._selected_items = valid_task_names
if len(self._selected_items) == 0:
self.set_selected_item("")
@ -625,6 +631,7 @@ class TasksCombobox(QtWidgets.QComboBox):
if multiselection_text is None:
multiselection_text = "|".join(self._selected_items)
self.set_selected_item(multiselection_text)
self._set_is_valid(is_valid)
def set_selected_items(self, asset_task_combinations=None):
@ -708,8 +715,7 @@ class TasksCombobox(QtWidgets.QComboBox):
idx = self.findText(item_name)
# Set current index (must be set to -1 if is invalid)
self.setCurrentIndex(idx)
if idx < 0:
self.set_text(item_name)
self.set_text(item_name)
def reset_to_origin(self):
"""Change to task names set with last `set_selected_items` call."""

View file

@ -84,7 +84,7 @@ class PublisherWindow(QtWidgets.QDialog):
# Content
# Subset widget
subset_frame = QtWidgets.QWidget(self)
subset_frame = QtWidgets.QFrame(self)
subset_views_widget = BorderedLabelWidget(
"Subsets to publish", subset_frame
@ -225,6 +225,9 @@ class PublisherWindow(QtWidgets.QDialog):
controller.add_publish_validated_callback(self._on_publish_validated)
controller.add_publish_stopped_callback(self._on_publish_stop)
# Store header for TrayPublisher
self._header_layout = header_layout
self.content_stacked_layout = content_stacked_layout
self.publish_frame = publish_frame
self.subset_frame = subset_frame

View file

@ -28,7 +28,7 @@ class LocalExperimentalToolsWidgets(QtWidgets.QWidget):
layout.addRow(empty_label)
experimental_defs = ExperimentalTools(filter_hosts=False)
experimental_defs = ExperimentalTools(refresh=False)
checkboxes_by_identifier = {}
for tool in experimental_defs.tools:
checkbox = QtWidgets.QCheckBox(self)

View file

@ -0,0 +1,6 @@
from .window import main
__all__ = (
"main",
)

View file

@ -0,0 +1,158 @@
"""Tray publisher is extending publisher tool.
Adds ability to select project using overlay widget with list of projects.
Tray publisher can be considered as host implementeation with creators and
publishing plugins.
"""
from Qt import QtWidgets, QtCore
import avalon.api
from avalon.api import AvalonMongoDB
from openpype.hosts.traypublisher import (
api as traypublisher
)
from openpype.tools.publisher import PublisherWindow
from openpype.tools.utils.constants import PROJECT_NAME_ROLE
from openpype.tools.utils.models import (
ProjectModel,
ProjectSortFilterProxy
)
class StandaloneOverlayWidget(QtWidgets.QFrame):
project_selected = QtCore.Signal(str)
def __init__(self, publisher_window):
super(StandaloneOverlayWidget, self).__init__(publisher_window)
self.setObjectName("OverlayFrame")
# Create db connection for projects model
dbcon = AvalonMongoDB()
dbcon.install()
header_label = QtWidgets.QLabel("Choose project", self)
header_label.setObjectName("ChooseProjectLabel")
# Create project models and view
projects_model = ProjectModel(dbcon)
projects_proxy = ProjectSortFilterProxy()
projects_proxy.setSourceModel(projects_model)
projects_view = QtWidgets.QListView(self)
projects_view.setModel(projects_proxy)
projects_view.setEditTriggers(
QtWidgets.QAbstractItemView.NoEditTriggers
)
confirm_btn = QtWidgets.QPushButton("Choose", self)
btns_layout = QtWidgets.QHBoxLayout()
btns_layout.addStretch(1)
btns_layout.addWidget(confirm_btn, 0)
layout = QtWidgets.QGridLayout(self)
layout.addWidget(header_label, 0, 1, alignment=QtCore.Qt.AlignCenter)
layout.addWidget(projects_view, 1, 1)
layout.addLayout(btns_layout, 2, 1)
layout.setColumnStretch(0, 1)
layout.setColumnStretch(1, 0)
layout.setColumnStretch(2, 1)
layout.setRowStretch(0, 0)
layout.setRowStretch(1, 1)
layout.setRowStretch(2, 0)
projects_view.doubleClicked.connect(self._on_double_click)
confirm_btn.clicked.connect(self._on_confirm_click)
self._projects_view = projects_view
self._projects_model = projects_model
self._confirm_btn = confirm_btn
self._publisher_window = publisher_window
def showEvent(self, event):
self._projects_model.refresh()
super(StandaloneOverlayWidget, self).showEvent(event)
def _on_double_click(self):
self.set_selected_project()
def _on_confirm_click(self):
self.set_selected_project()
def set_selected_project(self):
index = self._projects_view.currentIndex()
project_name = index.data(PROJECT_NAME_ROLE)
if not project_name:
return
traypublisher.set_project_name(project_name)
self.setVisible(False)
self.project_selected.emit(project_name)
class TrayPublishWindow(PublisherWindow):
def __init__(self, *args, **kwargs):
super(TrayPublishWindow, self).__init__(reset_on_show=False)
overlay_widget = StandaloneOverlayWidget(self)
btns_widget = QtWidgets.QWidget(self)
back_to_overlay_btn = QtWidgets.QPushButton(
"Change project", btns_widget
)
save_btn = QtWidgets.QPushButton("Save", btns_widget)
# TODO implement save mechanism of tray publisher
save_btn.setVisible(False)
btns_layout = QtWidgets.QHBoxLayout(btns_widget)
btns_layout.setContentsMargins(0, 0, 0, 0)
btns_layout.addWidget(save_btn, 0)
btns_layout.addWidget(back_to_overlay_btn, 0)
self._header_layout.addWidget(btns_widget, 0)
overlay_widget.project_selected.connect(self._on_project_select)
back_to_overlay_btn.clicked.connect(self._on_back_to_overlay)
save_btn.clicked.connect(self._on_tray_publish_save)
self._back_to_overlay_btn = back_to_overlay_btn
self._overlay_widget = overlay_widget
def _on_back_to_overlay(self):
self._overlay_widget.setVisible(True)
self._resize_overlay()
def _resize_overlay(self):
self._overlay_widget.resize(
self.width(),
self.height()
)
def resizeEvent(self, event):
super(TrayPublishWindow, self).resizeEvent(event)
self._resize_overlay()
def _on_project_select(self, project_name):
# TODO register project specific plugin paths
self.controller.save_changes()
self.controller.reset_project_data_cache()
self.reset()
if not self.controller.instances:
self._on_create_clicked()
def _on_tray_publish_save(self):
self.controller.save_changes()
print("NOT YET IMPLEMENTED")
def main():
avalon.api.install(traypublisher)
app = QtWidgets.QApplication([])
window = TrayPublishWindow()
window.show()
app.exec_()

View file

@ -2,11 +2,12 @@ from .widgets import (
PlaceholderLineEdit,
BaseClickableFrame,
ClickableFrame,
ClickableLabel,
ExpandBtn,
PixmapLabel,
IconButton,
)
from .views import DeselectableTreeView
from .error_dialog import ErrorMessageBox
from .lib import (
WrappedCallbackItem,
@ -24,10 +25,13 @@ __all__ = (
"PlaceholderLineEdit",
"BaseClickableFrame",
"ClickableFrame",
"ClickableLabel",
"ExpandBtn",
"PixmapLabel",
"IconButton",
"DeselectableTreeView",
"ErrorMessageBox",
"WrappedCallbackItem",

View file

@ -63,6 +63,29 @@ class ClickableFrame(BaseClickableFrame):
self.clicked.emit()
class ClickableLabel(QtWidgets.QLabel):
"""Label that catch left mouse click and can trigger 'clicked' signal."""
clicked = QtCore.Signal()
def __init__(self, parent):
super(ClickableLabel, self).__init__(parent)
self._mouse_pressed = False
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
self._mouse_pressed = True
super(ClickableLabel, self).mousePressEvent(event)
def mouseReleaseEvent(self, event):
if self._mouse_pressed:
self._mouse_pressed = False
if self.rect().contains(event.pos()):
self.clicked.emit()
super(ClickableLabel, self).mouseReleaseEvent(event)
class ExpandBtnLabel(QtWidgets.QLabel):
"""Label showing expand icon meant for ExpandBtn."""
def __init__(self, parent):

View file

@ -433,7 +433,7 @@ class MultiFilesWidget(QtWidgets.QFrame):
filenames = index.data(FILENAMES_ROLE)
for filename in filenames:
filepaths.add(os.path.join(dirpath, filename))
return filepaths
return list(filepaths)
def set_filters(self, folders_allowed, exts_filter):
self._files_proxy_model.set_allow_folders(folders_allowed)
@ -552,7 +552,7 @@ class MultiFilesWidget(QtWidgets.QFrame):
self._update_visibility()
def _update_visibility(self):
files_exists = self._files_model.rowCount() > 0
files_exists = self._files_proxy_model.rowCount() > 0
self._files_view.setVisible(files_exists)
self._empty_widget.setVisible(not files_exists)