diff --git a/.gitignore b/.gitignore
index 4b2eb5453a..101c1e6224 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,31 @@ __pycache__/
*.py[cod]
*$py.class
+# Mac Stuff
+###########
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+# Icon must end with two \r
+Icon
+# Thumbnails
+._*
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
# Documentation
###############
/docs/build
diff --git a/pype/api.py b/pype/api.py
index 5775bb3ce4..44a31f2626 100644
--- a/pype/api.py
+++ b/pype/api.py
@@ -12,6 +12,8 @@ from pypeapp.lib.mongo import (
get_default_components
)
+from . import resources
+
from .plugin import (
Extractor,
@@ -54,6 +56,8 @@ __all__ = [
"compose_url",
"get_default_components",
+ # Resources
+ "resources",
# plugin classes
"Extractor",
# ordering
diff --git a/pype/hooks/celaction/prelaunch.py b/pype/hooks/celaction/prelaunch.py
index df9da6cbbf..e1e86cc919 100644
--- a/pype/hooks/celaction/prelaunch.py
+++ b/pype/hooks/celaction/prelaunch.py
@@ -106,7 +106,7 @@ class CelactionPrelaunchHook(PypeHook):
f"--project {project}",
f"--asset {asset}",
f"--task {task}",
- "--currentFile \"*SCENE*\"",
+ "--currentFile \\\"\"*SCENE*\"\\\"",
"--chunk *CHUNK*",
"--frameStart *START*",
"--frameEnd *END*",
diff --git a/pype/hooks/premiere/prelaunch.py b/pype/hooks/premiere/prelaunch.py
index 118493e9a7..c0a65c0bf2 100644
--- a/pype/hooks/premiere/prelaunch.py
+++ b/pype/hooks/premiere/prelaunch.py
@@ -1,5 +1,6 @@
import os
import traceback
+import winreg
from avalon import api, io, lib
from pype.lib import PypeHook
from pype.api import Logger, Anatomy
@@ -14,6 +15,12 @@ class PremierePrelaunch(PypeHook):
shell script.
"""
project_code = None
+ reg_string_value = [{
+ "path": r"Software\Adobe\CSXS.9",
+ "name": "PlayerDebugMode",
+ "type": winreg.REG_SZ,
+ "value": "1"
+ }]
def __init__(self, logger=None):
if not logger:
@@ -55,6 +62,10 @@ class PremierePrelaunch(PypeHook):
# adding project code to env
env["AVALON_PROJECT_CODE"] = self.project_code
+ # add keys to registry
+ self.modify_registry()
+
+ # start avalon
try:
__import__("pype.hosts.premiere")
__import__("pyblish")
@@ -69,6 +80,24 @@ class PremierePrelaunch(PypeHook):
return True
+ def modify_registry(self):
+ # adding key to registry
+ for key in self.reg_string_value:
+ winreg.CreateKey(winreg.HKEY_CURRENT_USER, key["path"])
+ rg_key = winreg.OpenKey(
+ key=winreg.HKEY_CURRENT_USER,
+ sub_key=key["path"],
+ reserved=0,
+ access=winreg.KEY_ALL_ACCESS)
+
+ winreg.SetValueEx(
+ rg_key,
+ key["name"],
+ 0,
+ key["type"],
+ key["value"]
+ )
+
def get_anatomy_filled(self):
root_path = api.registered_root()
project_name = self._S["AVALON_PROJECT"]
diff --git a/pype/hosts/blender/__init__.py b/pype/hosts/blender/__init__.py
index a6d3cd82ef..dafeca5107 100644
--- a/pype/hosts/blender/__init__.py
+++ b/pype/hosts/blender/__init__.py
@@ -5,6 +5,8 @@ import traceback
from avalon import api as avalon
from pyblish import api as pyblish
+import bpy
+
from pype import PLUGINS_DIR
PUBLISH_PATH = os.path.join(PLUGINS_DIR, "blender", "publish")
@@ -25,6 +27,9 @@ def install():
avalon.register_plugin_path(avalon.Loader, str(LOAD_PATH))
avalon.register_plugin_path(avalon.Creator, str(CREATE_PATH))
+ avalon.on("new", on_new)
+ avalon.on("open", on_open)
+
def uninstall():
"""Uninstall Blender configuration for Avalon."""
@@ -32,3 +37,24 @@ def uninstall():
pyblish.deregister_plugin_path(str(PUBLISH_PATH))
avalon.deregister_plugin_path(avalon.Loader, str(LOAD_PATH))
avalon.deregister_plugin_path(avalon.Creator, str(CREATE_PATH))
+
+
+def set_start_end_frames():
+ from avalon import io
+
+ asset_name = io.Session["AVALON_ASSET"]
+ asset_doc = io.find_one({
+ "type": "asset",
+ "name": asset_name
+ })
+
+ bpy.context.scene.frame_start = asset_doc["data"]["frameStart"]
+ bpy.context.scene.frame_end = asset_doc["data"]["frameEnd"]
+
+
+def on_new(arg1, arg2):
+ set_start_end_frames()
+
+
+def on_open(arg1, arg2):
+ set_start_end_frames()
diff --git a/pype/hosts/blender/plugin.py b/pype/hosts/blender/plugin.py
index 77fce90d65..07080a86c4 100644
--- a/pype/hosts/blender/plugin.py
+++ b/pype/hosts/blender/plugin.py
@@ -14,12 +14,42 @@ def asset_name(
asset: str, subset: str, namespace: Optional[str] = None
) -> str:
"""Return a consistent name for an asset."""
- name = f"{asset}_{subset}"
+ name = f"{asset}"
if namespace:
- name = f"{namespace}:{name}"
+ name = f"{name}_{namespace}"
+ name = f"{name}_{subset}"
return name
+def get_unique_number(
+ asset: str, subset: str
+) -> str:
+ """Return a unique number based on the asset name."""
+ avalon_containers = [
+ c for c in bpy.data.collections
+ if c.name == 'AVALON_CONTAINERS'
+ ]
+ loaded_assets = []
+ for c in avalon_containers:
+ loaded_assets.extend(c.children)
+ collections_names = [
+ c.name for c in loaded_assets
+ ]
+ count = 1
+ name = f"{asset}_{count:0>2}_{subset}_CON"
+ while name in collections_names:
+ count += 1
+ name = f"{asset}_{count:0>2}_{subset}_CON"
+ return f"{count:0>2}"
+
+
+def prepare_data(data, container_name):
+ name = data.name
+ local_data = data.make_local()
+ local_data.name = f"{name}:{container_name}"
+ return local_data
+
+
def create_blender_context(active: Optional[bpy.types.Object] = None,
selected: Optional[bpy.types.Object] = None,):
"""Create a new Blender context. If an object is passed as
@@ -47,6 +77,25 @@ def create_blender_context(active: Optional[bpy.types.Object] = None,
raise Exception("Could not create a custom Blender context.")
+def get_parent_collection(collection):
+ """Get the parent of the input collection"""
+ check_list = [bpy.context.scene.collection]
+
+ for c in check_list:
+ if collection.name in c.children.keys():
+ return c
+ check_list.extend(c.children)
+
+ return None
+
+
+def get_local_collection_with_name(name):
+ for collection in bpy.data.collections:
+ if collection.name == name and collection.library is None:
+ return collection
+ return None
+
+
class AssetLoader(api.Loader):
"""A basic AssetLoader for Blender
diff --git a/pype/hosts/celaction/cli.py b/pype/hosts/celaction/cli.py
index 8cf2bcc791..42f7a1a385 100644
--- a/pype/hosts/celaction/cli.py
+++ b/pype/hosts/celaction/cli.py
@@ -46,9 +46,6 @@ def cli():
parser.add_argument("--resolutionHeight",
help=("Height of resolution"))
- # parser.add_argument("--programDir",
- # help=("Directory with celaction program installation"))
-
celaction.kwargs = parser.parse_args(sys.argv[1:]).__dict__
@@ -78,7 +75,7 @@ def _prepare_publish_environments():
env["AVALON_WORKDIR"] = os.getenv("AVALON_WORKDIR")
env["AVALON_HIERARCHY"] = hierarchy
env["AVALON_PROJECTCODE"] = project_doc["data"].get("code", "")
- env["AVALON_APP"] = publish_host
+ env["AVALON_APP"] = f"hosts.{publish_host}"
env["AVALON_APP_NAME"] = "celaction_local"
env["PYBLISH_HOSTS"] = publish_host
diff --git a/pype/hosts/harmony/__init__.py b/pype/hosts/harmony/__init__.py
index 3d49c60563..3cae695852 100644
--- a/pype/hosts/harmony/__init__.py
+++ b/pype/hosts/harmony/__init__.py
@@ -1,8 +1,9 @@
import os
import sys
-from avalon import api, harmony
+from avalon import api, io, harmony
from avalon.vendor import Qt
+import avalon.tools.sceneinventory
import pyblish.api
from pype import lib
@@ -92,6 +93,61 @@ def ensure_scene_settings():
set_scene_settings(valid_settings)
+def check_inventory():
+ if not lib.any_outdated():
+ return
+
+ host = avalon.api.registered_host()
+ outdated_containers = []
+ for container in host.ls():
+ representation = container['representation']
+ representation_doc = io.find_one(
+ {
+ "_id": io.ObjectId(representation),
+ "type": "representation"
+ },
+ projection={"parent": True}
+ )
+ if representation_doc and not lib.is_latest(representation_doc):
+ outdated_containers.append(container)
+
+ # Colour nodes.
+ func = """function func(args){
+ for( var i =0; i <= args[0].length - 1; ++i)
+ {
+ var red_color = new ColorRGBA(255, 0, 0, 255);
+ node.setColor(args[0][i], red_color);
+ }
+ }
+ func
+ """
+ outdated_nodes = []
+ for container in outdated_containers:
+ if container["loader"] == "ImageSequenceLoader":
+ outdated_nodes.append(
+ harmony.find_node_by_name(container["name"], "READ")
+ )
+ harmony.send({"function": func, "args": [outdated_nodes]})
+
+ # Warn about outdated containers.
+ print("Starting new QApplication..")
+ app = Qt.QtWidgets.QApplication(sys.argv)
+
+ message_box = Qt.QtWidgets.QMessageBox()
+ message_box.setIcon(Qt.QtWidgets.QMessageBox.Warning)
+ msg = "There are outdated containers in the scene."
+ message_box.setText(msg)
+ message_box.exec_()
+
+ # Garbage collect QApplication.
+ del app
+
+
+def application_launch():
+ ensure_scene_settings()
+ check_inventory()
+
+
def export_template(backdrops, nodes, filepath):
func = """function func(args)
{
@@ -161,7 +217,7 @@ def install():
"instanceToggled", on_pyblish_instance_toggled
)
- api.on("application.launched", ensure_scene_settings)
+ api.on("application.launched", application_launch)
def on_pyblish_instance_toggled(instance, old_value, new_value):
diff --git a/pype/hosts/maya/customize.py b/pype/hosts/maya/customize.py
index 8bd7052d9e..ee3ad4f239 100644
--- a/pype/hosts/maya/customize.py
+++ b/pype/hosts/maya/customize.py
@@ -69,17 +69,38 @@ def override_component_mask_commands():
def override_toolbox_ui():
"""Add custom buttons in Toolbox as replacement for Maya web help icon."""
+ inventory = None
+ loader = None
+ launch_workfiles_app = None
+ mayalookassigner = None
+ try:
+ import avalon.tools.sceneinventory as inventory
+ except Exception:
+ log.warning("Could not import SceneInventory tool")
- import pype
- res = os.path.join(os.path.dirname(os.path.dirname(pype.__file__)),
- "res")
- icons = os.path.join(res, "icons")
+ try:
+ import avalon.tools.loader as loader
+ except Exception:
+ log.warning("Could not import Loader tool")
- import avalon.tools.sceneinventory as inventory
- import avalon.tools.loader as loader
- from avalon.maya.pipeline import launch_workfiles_app
- import mayalookassigner
+ try:
+ from avalon.maya.pipeline import launch_workfiles_app
+ except Exception:
+ log.warning("Could not import Workfiles tool")
+ try:
+ import mayalookassigner
+ except Exception:
+ log.warning("Could not import Maya Look assigner tool")
+
+ from pype.api import resources
+
+ icons = resources.get_resource("icons")
+
+ if not any((
+ mayalookassigner, launch_workfiles_app, loader, inventory
+ )):
+ return
# Ensure the maya web icon on toolbox exists
web_button = "ToolBox|MainToolboxLayout|mayaWebButton"
@@ -99,65 +120,65 @@ def override_toolbox_ui():
# Create our controls
background_color = (0.267, 0.267, 0.267)
controls = []
+ if mayalookassigner:
+ controls.append(
+ mc.iconTextButton(
+ "pype_toolbox_lookmanager",
+ annotation="Look Manager",
+ label="Look Manager",
+ image=os.path.join(icons, "lookmanager.png"),
+ command=lambda: mayalookassigner.show(),
+ bgc=background_color,
+ width=icon_size,
+ height=icon_size,
+ parent=parent
+ )
+ )
- control = mc.iconTextButton(
- "pype_toolbox_lookmanager",
- annotation="Look Manager",
- label="Look Manager",
- image=os.path.join(icons, "lookmanager.png"),
- command=lambda: mayalookassigner.show(),
- bgc=background_color,
- width=icon_size,
- height=icon_size,
- parent=parent)
- controls.append(control)
+ if launch_workfiles_app:
+ controls.append(
+ mc.iconTextButton(
+ "pype_toolbox_workfiles",
+ annotation="Work Files",
+ label="Work Files",
+ image=os.path.join(icons, "workfiles.png"),
+ command=lambda: launch_workfiles_app(),
+ bgc=background_color,
+ width=icon_size,
+ height=icon_size,
+ parent=parent
+ )
+ )
- control = mc.iconTextButton(
- "pype_toolbox_workfiles",
- annotation="Work Files",
- label="Work Files",
- image=os.path.join(icons, "workfiles.png"),
- command=lambda: launch_workfiles_app(),
- bgc=background_color,
- width=icon_size,
- height=icon_size,
- parent=parent)
- controls.append(control)
+ if loader:
+ controls.append(
+ mc.iconTextButton(
+ "pype_toolbox_loader",
+ annotation="Loader",
+ label="Loader",
+ image=os.path.join(icons, "loader.png"),
+ command=lambda: loader.show(use_context=True),
+ bgc=background_color,
+ width=icon_size,
+ height=icon_size,
+ parent=parent
+ )
+ )
- control = mc.iconTextButton(
- "pype_toolbox_loader",
- annotation="Loader",
- label="Loader",
- image=os.path.join(icons, "loader.png"),
- command=lambda: loader.show(use_context=True),
- bgc=background_color,
- width=icon_size,
- height=icon_size,
- parent=parent)
- controls.append(control)
-
- control = mc.iconTextButton(
- "pype_toolbox_manager",
- annotation="Inventory",
- label="Inventory",
- image=os.path.join(icons, "inventory.png"),
- command=lambda: inventory.show(),
- bgc=background_color,
- width=icon_size,
- height=icon_size,
- parent=parent)
- controls.append(control)
-
- # control = mc.iconTextButton(
- # "pype_toolbox",
- # annotation="Kredenc",
- # label="Kredenc",
- # image=os.path.join(icons, "kredenc_logo.png"),
- # bgc=background_color,
- # width=icon_size,
- # height=icon_size,
- # parent=parent)
- # controls.append(control)
+ if inventory:
+ controls.append(
+ mc.iconTextButton(
+ "pype_toolbox_manager",
+ annotation="Inventory",
+ label="Inventory",
+ image=os.path.join(icons, "inventory.png"),
+ command=lambda: inventory.show(),
+ bgc=background_color,
+ width=icon_size,
+ height=icon_size,
+ parent=parent
+ )
+ )
# Add the buttons on the bottom and stack
# them above each other with side padding
diff --git a/pype/hosts/nuke/utils.py b/pype/hosts/nuke/utils.py
index aa5bc1077e..72c7b7bc14 100644
--- a/pype/hosts/nuke/utils.py
+++ b/pype/hosts/nuke/utils.py
@@ -1,6 +1,7 @@
import os
import nuke
from avalon.nuke import lib as anlib
+from pype.api import resources
def set_context_favorites(favorites={}):
@@ -9,9 +10,7 @@ def set_context_favorites(favorites={}):
Argumets:
favorites (dict): couples of {name:path}
"""
- dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
- icon_path = os.path.join(dir, 'res', 'icons', 'folder-favorite3.png')
-
+ icon_path = resources.get_resource("icons", "folder-favorite3.png")
for name, path in favorites.items():
nuke.addFavoriteDir(
name,
diff --git a/pype/hosts/nukestudio/workio.py b/pype/hosts/nukestudio/workio.py
index eee6654a4c..2cf898aa33 100644
--- a/pype/hosts/nukestudio/workio.py
+++ b/pype/hosts/nukestudio/workio.py
@@ -6,8 +6,9 @@ from pype.api import Logger
log = Logger().get_logger(__name__, "nukestudio")
+
def file_extensions():
- return [".hrox"]
+ return api.HOST_WORKFILE_EXTENSIONS["nukestudio"]
def has_unsaved_changes():
diff --git a/pype/hosts/photoshop/__init__.py b/pype/hosts/photoshop/__init__.py
index 01ed757a8d..564e9c8a05 100644
--- a/pype/hosts/photoshop/__init__.py
+++ b/pype/hosts/photoshop/__init__.py
@@ -1,9 +1,48 @@
import os
+import sys
-from avalon import api
+from avalon import api, io
+from avalon.vendor import Qt
+from pype import lib
import pyblish.api
+def check_inventory():
+ if not lib.any_outdated():
+ return
+
+ host = api.registered_host()
+ outdated_containers = []
+ for container in host.ls():
+ representation = container['representation']
+ representation_doc = io.find_one(
+ {
+ "_id": io.ObjectId(representation),
+ "type": "representation"
+ },
+ projection={"parent": True}
+ )
+ if representation_doc and not lib.is_latest(representation_doc):
+ outdated_containers.append(container)
+
+ # Warn about outdated containers.
+ print("Starting new QApplication..")
+ app = Qt.QtWidgets.QApplication(sys.argv)
+
+ message_box = Qt.QtWidgets.QMessageBox()
+ message_box.setIcon(Qt.QtWidgets.QMessageBox.Warning)
+ msg = "There are outdated containers in the scene."
+ message_box.setText(msg)
+ message_box.exec_()
+
+ # Garbage collect QApplication.
+ del app
+
+
+def application_launch():
+ check_inventory()
+
+
def install():
print("Installing Pype config...")
@@ -27,6 +66,8 @@ def install():
"instanceToggled", on_pyblish_instance_toggled
)
+ api.on("application.launched", application_launch)
+
def on_pyblish_instance_toggled(instance, old_value, new_value):
"""Toggle layer visibility on instance toggles."""
diff --git a/pype/hosts/premiere/extensions/com.pype/jsx/pype.jsx b/pype/hosts/premiere/extensions/com.pype/jsx/pype.jsx
index 684cef5e5a..3cd4502653 100644
--- a/pype/hosts/premiere/extensions/com.pype/jsx/pype.jsx
+++ b/pype/hosts/premiere/extensions/com.pype/jsx/pype.jsx
@@ -534,7 +534,9 @@ $.pype = {
if (instances === null) {
return null;
}
- if (audioOnly === true) {
+
+ // make only audio representations
+ if (audioOnly === 'true') {
$.pype.log('? looping if audio True');
for (var i = 0; i < instances.length; i++) {
var subsetToRepresentations = instances[i].subsetToRepresentations;
diff --git a/pype/modules/clockify/__init__.py b/pype/modules/clockify/__init__.py
index aab0d048de..8e11d2f5f4 100644
--- a/pype/modules/clockify/__init__.py
+++ b/pype/modules/clockify/__init__.py
@@ -1,14 +1,7 @@
-from .clockify_api import ClockifyAPI
-from .widget_settings import ClockifySettings
-from .widget_message import MessageWidget
from .clockify import ClockifyModule
-__all__ = [
- "ClockifyAPI",
- "ClockifySettings",
- "ClockifyModule",
- "MessageWidget"
-]
-
+CLASS_DEFINIION = ClockifyModule
+
+
def tray_init(tray_widget, main_widget):
return ClockifyModule(main_widget, tray_widget)
diff --git a/pype/modules/clockify/clockify.py b/pype/modules/clockify/clockify.py
index 2ab22702c1..fea15a1bea 100644
--- a/pype/modules/clockify/clockify.py
+++ b/pype/modules/clockify/clockify.py
@@ -3,17 +3,25 @@ import threading
from pype.api import Logger
from avalon import style
from Qt import QtWidgets
-from . import ClockifySettings, ClockifyAPI, MessageWidget
+from .widgets import ClockifySettings, MessageWidget
+from .clockify_api import ClockifyAPI
+from .constants import CLOCKIFY_FTRACK_USER_PATH
class ClockifyModule:
+ workspace_name = None
def __init__(self, main_parent=None, parent=None):
+ if not self.workspace_name:
+ raise Exception("Clockify Workspace is not set in config.")
+
+ os.environ["CLOCKIFY_WORKSPACE"] = self.workspace_name
+
self.log = Logger().get_logger(self.__class__.__name__, "PypeTray")
self.main_parent = main_parent
self.parent = parent
- self.clockapi = ClockifyAPI()
+ self.clockapi = ClockifyAPI(master_parent=self)
self.message_widget = None
self.widget_settings = ClockifySettings(main_parent, self)
self.widget_settings_required = None
@@ -24,8 +32,6 @@ class ClockifyModule:
self.bool_api_key_set = False
self.bool_workspace_set = False
self.bool_timer_run = False
-
- self.clockapi.set_master(self)
self.bool_api_key_set = self.clockapi.set_api()
def tray_start(self):
@@ -43,14 +49,12 @@ class ClockifyModule:
def process_modules(self, modules):
if 'FtrackModule' in modules:
- actions_path = os.path.sep.join([
- os.path.dirname(__file__),
- 'ftrack_actions'
- ])
current = os.environ.get('FTRACK_ACTIONS_PATH', '')
if current:
current += os.pathsep
- os.environ['FTRACK_ACTIONS_PATH'] = current + actions_path
+ os.environ['FTRACK_ACTIONS_PATH'] = (
+ current + CLOCKIFY_FTRACK_USER_PATH
+ )
if 'AvalonApps' in modules:
from launcher import lib
@@ -188,9 +192,10 @@ class ClockifyModule:
).format(project_name))
msg = (
- "Project \"{}\" is not in Clockify Workspace \"{}\"."
+ "Project \"{}\" is not"
+ " in Clockify Workspace \"{}\"."
"
Please inform your Project Manager."
- ).format(project_name, str(self.clockapi.workspace))
+ ).format(project_name, str(self.clockapi.workspace_name))
self.message_widget = MessageWidget(
self.main_parent, msg, "Clockify - Info Message"
diff --git a/pype/modules/clockify/clockify_api.py b/pype/modules/clockify/clockify_api.py
index f012efc002..d88b2ef8df 100644
--- a/pype/modules/clockify/clockify_api.py
+++ b/pype/modules/clockify/clockify_api.py
@@ -1,35 +1,39 @@
import os
import re
+import time
import requests
import json
import datetime
-import appdirs
+from .constants import (
+ CLOCKIFY_ENDPOINT, ADMIN_PERMISSION_NAMES, CREDENTIALS_JSON_PATH
+)
-class Singleton(type):
- _instances = {}
+def time_check(obj):
+ if obj.request_counter < 10:
+ obj.request_counter += 1
+ return
- def __call__(cls, *args, **kwargs):
- if cls not in cls._instances:
- cls._instances[cls] = super(
- Singleton, cls
- ).__call__(*args, **kwargs)
- return cls._instances[cls]
+ wait_time = 1 - (time.time() - obj.request_time)
+ if wait_time > 0:
+ time.sleep(wait_time)
+
+ obj.request_time = time.time()
+ obj.request_counter = 0
-class ClockifyAPI(metaclass=Singleton):
- endpoint = "https://api.clockify.me/api/"
- headers = {"X-Api-Key": None}
- app_dir = os.path.normpath(appdirs.user_data_dir('pype-app', 'pype'))
- file_name = 'clockify.json'
- fpath = os.path.join(app_dir, file_name)
- admin_permission_names = ['WORKSPACE_OWN', 'WORKSPACE_ADMIN']
- master_parent = None
- workspace = None
- workspace_id = None
-
- def set_master(self, master_parent):
+class ClockifyAPI:
+ def __init__(self, api_key=None, master_parent=None):
+ self.workspace_name = None
+ self.workspace_id = None
self.master_parent = master_parent
+ self.api_key = api_key
+ self.request_counter = 0
+ self.request_time = time.time()
+
+ @property
+ def headers(self):
+ return {"X-Api-Key": self.api_key}
def verify_api(self):
for key, value in self.headers.items():
@@ -42,7 +46,7 @@ class ClockifyAPI(metaclass=Singleton):
api_key = self.get_api_key()
if api_key is not None and self.validate_api_key(api_key) is True:
- self.headers["X-Api-Key"] = api_key
+ self.api_key = api_key
self.set_workspace()
if self.master_parent:
self.master_parent.signed_in()
@@ -52,8 +56,9 @@ class ClockifyAPI(metaclass=Singleton):
def validate_api_key(self, api_key):
test_headers = {'X-Api-Key': api_key}
action_url = 'workspaces/'
+ time_check(self)
response = requests.get(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=test_headers
)
if response.status_code != 200:
@@ -69,25 +74,27 @@ class ClockifyAPI(metaclass=Singleton):
action_url = "/workspaces/{}/users/{}/permissions".format(
workspace_id, user_id
)
+ time_check(self)
response = requests.get(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=self.headers
)
user_permissions = response.json()
for perm in user_permissions:
- if perm['name'] in self.admin_permission_names:
+ if perm['name'] in ADMIN_PERMISSION_NAMES:
return True
return False
def get_user_id(self):
action_url = 'v1/user/'
+ time_check(self)
response = requests.get(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=self.headers
)
# this regex is neccessary: UNICODE strings are crashing
# during json serialization
- id_regex ='\"{1}id\"{1}\:{1}\"{1}\w+\"{1}'
+ id_regex = '\"{1}id\"{1}\:{1}\"{1}\w+\"{1}'
result = re.findall(id_regex, str(response.content))
if len(result) != 1:
# replace with log and better message?
@@ -98,9 +105,9 @@ class ClockifyAPI(metaclass=Singleton):
def set_workspace(self, name=None):
if name is None:
name = os.environ.get('CLOCKIFY_WORKSPACE', None)
- self.workspace = name
+ self.workspace_name = name
self.workspace_id = None
- if self.workspace is None:
+ if self.workspace_name is None:
return
try:
result = self.validate_workspace()
@@ -115,7 +122,7 @@ class ClockifyAPI(metaclass=Singleton):
def validate_workspace(self, name=None):
if name is None:
- name = self.workspace
+ name = self.workspace_name
all_workspaces = self.get_workspaces()
if name in all_workspaces:
return all_workspaces[name]
@@ -124,25 +131,26 @@ class ClockifyAPI(metaclass=Singleton):
def get_api_key(self):
api_key = None
try:
- file = open(self.fpath, 'r')
+ file = open(CREDENTIALS_JSON_PATH, 'r')
api_key = json.load(file).get('api_key', None)
if api_key == '':
api_key = None
except Exception:
- file = open(self.fpath, 'w')
+ file = open(CREDENTIALS_JSON_PATH, 'w')
file.close()
return api_key
def save_api_key(self, api_key):
data = {'api_key': api_key}
- file = open(self.fpath, 'w')
+ file = open(CREDENTIALS_JSON_PATH, 'w')
file.write(json.dumps(data))
file.close()
def get_workspaces(self):
action_url = 'workspaces/'
+ time_check(self)
response = requests.get(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=self.headers
)
return {
@@ -153,8 +161,9 @@ class ClockifyAPI(metaclass=Singleton):
if workspace_id is None:
workspace_id = self.workspace_id
action_url = 'workspaces/{}/projects/'.format(workspace_id)
+ time_check(self)
response = requests.get(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=self.headers
)
@@ -168,8 +177,9 @@ class ClockifyAPI(metaclass=Singleton):
action_url = 'workspaces/{}/projects/{}/'.format(
workspace_id, project_id
)
+ time_check(self)
response = requests.get(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=self.headers
)
@@ -179,8 +189,9 @@ class ClockifyAPI(metaclass=Singleton):
if workspace_id is None:
workspace_id = self.workspace_id
action_url = 'workspaces/{}/tags/'.format(workspace_id)
+ time_check(self)
response = requests.get(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=self.headers
)
@@ -194,8 +205,9 @@ class ClockifyAPI(metaclass=Singleton):
action_url = 'workspaces/{}/projects/{}/tasks/'.format(
workspace_id, project_id
)
+ time_check(self)
response = requests.get(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=self.headers
)
@@ -276,8 +288,9 @@ class ClockifyAPI(metaclass=Singleton):
"taskId": task_id,
"tagIds": tag_ids
}
+ time_check(self)
response = requests.post(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=self.headers,
json=body
)
@@ -293,8 +306,9 @@ class ClockifyAPI(metaclass=Singleton):
action_url = 'workspaces/{}/timeEntries/inProgress'.format(
workspace_id
)
+ time_check(self)
response = requests.get(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=self.headers
)
try:
@@ -323,8 +337,9 @@ class ClockifyAPI(metaclass=Singleton):
"tagIds": current["tagIds"],
"end": self.get_current_time()
}
+ time_check(self)
response = requests.put(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=self.headers,
json=body
)
@@ -336,8 +351,9 @@ class ClockifyAPI(metaclass=Singleton):
if workspace_id is None:
workspace_id = self.workspace_id
action_url = 'workspaces/{}/timeEntries/'.format(workspace_id)
+ time_check(self)
response = requests.get(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=self.headers
)
return response.json()[:quantity]
@@ -348,8 +364,9 @@ class ClockifyAPI(metaclass=Singleton):
action_url = 'workspaces/{}/timeEntries/{}'.format(
workspace_id, tid
)
+ time_check(self)
response = requests.delete(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=self.headers
)
return response.json()
@@ -363,14 +380,15 @@ class ClockifyAPI(metaclass=Singleton):
"clientId": "",
"isPublic": "false",
"estimate": {
- # "estimate": "3600",
+ "estimate": 0,
"type": "AUTO"
},
"color": "#f44336",
"billable": "true"
}
+ time_check(self)
response = requests.post(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=self.headers,
json=body
)
@@ -379,8 +397,9 @@ class ClockifyAPI(metaclass=Singleton):
def add_workspace(self, name):
action_url = 'workspaces/'
body = {"name": name}
+ time_check(self)
response = requests.post(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=self.headers,
json=body
)
@@ -398,8 +417,9 @@ class ClockifyAPI(metaclass=Singleton):
"name": name,
"projectId": project_id
}
+ time_check(self)
response = requests.post(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=self.headers,
json=body
)
@@ -412,8 +432,9 @@ class ClockifyAPI(metaclass=Singleton):
body = {
"name": name
}
+ time_check(self)
response = requests.post(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=self.headers,
json=body
)
@@ -427,8 +448,9 @@ class ClockifyAPI(metaclass=Singleton):
action_url = '/workspaces/{}/projects/{}'.format(
workspace_id, project_id
)
+ time_check(self)
response = requests.delete(
- self.endpoint + action_url,
+ CLOCKIFY_ENDPOINT + action_url,
headers=self.headers,
)
return response.json()
diff --git a/pype/modules/clockify/constants.py b/pype/modules/clockify/constants.py
new file mode 100644
index 0000000000..38ad4b64cf
--- /dev/null
+++ b/pype/modules/clockify/constants.py
@@ -0,0 +1,17 @@
+import os
+import appdirs
+
+
+CLOCKIFY_FTRACK_SERVER_PATH = os.path.join(
+ os.path.dirname(__file__), "ftrack", "server"
+)
+CLOCKIFY_FTRACK_USER_PATH = os.path.join(
+ os.path.dirname(__file__), "ftrack", "user"
+)
+CREDENTIALS_JSON_PATH = os.path.normpath(os.path.join(
+ appdirs.user_data_dir("pype-app", "pype"),
+ "clockify.json"
+))
+
+ADMIN_PERMISSION_NAMES = ["WORKSPACE_OWN", "WORKSPACE_ADMIN"]
+CLOCKIFY_ENDPOINT = "https://api.clockify.me/api/"
diff --git a/pype/modules/clockify/ftrack/server/action_clockify_sync_server.py b/pype/modules/clockify/ftrack/server/action_clockify_sync_server.py
new file mode 100644
index 0000000000..ae911f6258
--- /dev/null
+++ b/pype/modules/clockify/ftrack/server/action_clockify_sync_server.py
@@ -0,0 +1,166 @@
+import os
+import json
+from pype.modules.ftrack.lib import BaseAction
+from pype.modules.clockify.clockify_api import ClockifyAPI
+
+
+class SyncClocifyServer(BaseAction):
+ '''Synchronise project names and task types.'''
+
+ identifier = "clockify.sync.server"
+ label = "Sync To Clockify (server)"
+ description = "Synchronise data to Clockify workspace"
+
+ discover_role_list = ["Pypeclub", "Administrator", "project Manager"]
+
+ def __init__(self, *args, **kwargs):
+ super(SyncClocifyServer, self).__init__(*args, **kwargs)
+
+ workspace_name = os.environ.get("CLOCKIFY_WORKSPACE")
+ api_key = os.environ.get("CLOCKIFY_API_KEY")
+ self.clockapi = ClockifyAPI(api_key)
+ self.clockapi.set_workspace(workspace_name)
+ if api_key is None:
+ modified_key = "None"
+ else:
+ str_len = int(len(api_key) / 2)
+ start_replace = int(len(api_key) / 4)
+ modified_key = ""
+ for idx in range(len(api_key)):
+ if idx >= start_replace and idx < start_replace + str_len:
+ replacement = "X"
+ else:
+ replacement = api_key[idx]
+ modified_key += replacement
+
+ self.log.info(
+ "Clockify info. Workspace: \"{}\" API key: \"{}\"".format(
+ str(workspace_name), str(modified_key)
+ )
+ )
+
+ def discover(self, session, entities, event):
+ if (
+ len(entities) != 1
+ or entities[0].entity_type.lower() != "project"
+ ):
+ return False
+
+ # Get user and check his roles
+ user_id = event.get("source", {}).get("user", {}).get("id")
+ if not user_id:
+ return False
+
+ user = session.query("User where id is \"{}\"".format(user_id)).first()
+ if not user:
+ return False
+
+ for role in user["user_security_roles"]:
+ if role["security_role"]["name"] in self.discover_role_list:
+ return True
+ return False
+
+ def register(self):
+ self.session.event_hub.subscribe(
+ "topic=ftrack.action.discover",
+ self._discover,
+ priority=self.priority
+ )
+
+ launch_subscription = (
+ "topic=ftrack.action.launch and data.actionIdentifier={}"
+ ).format(self.identifier)
+ self.session.event_hub.subscribe(launch_subscription, self._launch)
+
+ def launch(self, session, entities, event):
+ if self.clockapi.workspace_id is None:
+ return {
+ "success": False,
+ "message": "Clockify Workspace or API key are not set!"
+ }
+
+ if self.clockapi.validate_workspace_perm() is False:
+ return {
+ "success": False,
+ "message": "Missing permissions for this action!"
+ }
+
+ # JOB SETTINGS
+ user_id = event["source"]["user"]["id"]
+ user = session.query("User where id is " + user_id).one()
+
+ job = session.create("Job", {
+ "user": user,
+ "status": "running",
+ "data": json.dumps({"description": "Sync Ftrack to Clockify"})
+ })
+ session.commit()
+
+ project_entity = entities[0]
+ if project_entity.entity_type.lower() != "project":
+ project_entity = self.get_project_from_entity(project_entity)
+
+ project_name = project_entity["full_name"]
+ self.log.info(
+ "Synchronization of project \"{}\" to clockify begins.".format(
+ project_name
+ )
+ )
+ task_types = (
+ project_entity["project_schema"]["_task_type_schema"]["types"]
+ )
+ task_type_names = [
+ task_type["name"] for task_type in task_types
+ ]
+ try:
+ clockify_projects = self.clockapi.get_projects()
+ if project_name not in clockify_projects:
+ response = self.clockapi.add_project(project_name)
+ if "id" not in response:
+ self.log.warning(
+ "Project \"{}\" can't be created. Response: {}".format(
+ project_name, response
+ )
+ )
+ return {
+ "success": False,
+ "message": (
+ "Can't create clockify project \"{}\"."
+ " Unexpected error."
+ ).format(project_name)
+ }
+
+ clockify_workspace_tags = self.clockapi.get_tags()
+ for task_type_name in task_type_names:
+ if task_type_name in clockify_workspace_tags:
+ self.log.debug(
+ "Task \"{}\" already exist".format(task_type_name)
+ )
+ continue
+
+ response = self.clockapi.add_tag(task_type_name)
+ if "id" not in response:
+ self.log.warning(
+ "Task \"{}\" can't be created. Response: {}".format(
+ task_type_name, response
+ )
+ )
+
+ job["status"] = "done"
+
+ except Exception:
+ self.log.warning(
+ "Synchronization to clockify failed.",
+ exc_info=True
+ )
+
+ finally:
+ if job["status"] != "done":
+ job["status"] = "failed"
+ session.commit()
+
+ return True
+
+
+def register(session, **kw):
+ SyncClocifyServer(session).register()
diff --git a/pype/modules/clockify/ftrack/user/action_clockify_sync_local.py b/pype/modules/clockify/ftrack/user/action_clockify_sync_local.py
new file mode 100644
index 0000000000..e74bf3dbb3
--- /dev/null
+++ b/pype/modules/clockify/ftrack/user/action_clockify_sync_local.py
@@ -0,0 +1,122 @@
+import json
+from pype.modules.ftrack.lib import BaseAction, statics_icon
+from pype.modules.clockify.clockify_api import ClockifyAPI
+
+
+class SyncClocifyLocal(BaseAction):
+ '''Synchronise project names and task types.'''
+
+ #: Action identifier.
+ identifier = 'clockify.sync.local'
+ #: Action label.
+ label = 'Sync To Clockify (local)'
+ #: Action description.
+ description = 'Synchronise data to Clockify workspace'
+ #: roles that are allowed to register this action
+ role_list = ["Pypeclub", "Administrator", "project Manager"]
+ #: icon
+ icon = statics_icon("app_icons", "clockify-white.png")
+
+ #: CLockifyApi
+ clockapi = ClockifyAPI()
+
+ def discover(self, session, entities, event):
+ if (
+ len(entities) == 1
+ and entities[0].entity_type.lower() == "project"
+ ):
+ return True
+ return False
+
+ def launch(self, session, entities, event):
+ self.clockapi.set_api()
+ if self.clockapi.workspace_id is None:
+ return {
+ "success": False,
+ "message": "Clockify Workspace or API key are not set!"
+ }
+
+ if self.clockapi.validate_workspace_perm() is False:
+ return {
+ "success": False,
+ "message": "Missing permissions for this action!"
+ }
+
+ # JOB SETTINGS
+ userId = event['source']['user']['id']
+ user = session.query('User where id is ' + userId).one()
+
+ job = session.create('Job', {
+ 'user': user,
+ 'status': 'running',
+ 'data': json.dumps({
+ 'description': 'Sync Ftrack to Clockify'
+ })
+ })
+ session.commit()
+
+ project_entity = entities[0]
+ if project_entity.entity_type.lower() != "project":
+ project_entity = self.get_project_from_entity(project_entity)
+
+ project_name = project_entity["full_name"]
+ self.log.info(
+ "Synchronization of project \"{}\" to clockify begins.".format(
+ project_name
+ )
+ )
+ task_types = (
+ project_entity["project_schema"]["_task_type_schema"]["types"]
+ )
+ task_type_names = [
+ task_type["name"] for task_type in task_types
+ ]
+ try:
+ clockify_projects = self.clockapi.get_projects()
+ if project_name not in clockify_projects:
+ response = self.clockapi.add_project(project_name)
+ if "id" not in response:
+ self.log.warning(
+ "Project \"{}\" can't be created. Response: {}".format(
+ project_name, response
+ )
+ )
+ return {
+ "success": False,
+ "message": (
+ "Can't create clockify project \"{}\"."
+ " Unexpected error."
+ ).format(project_name)
+ }
+
+ clockify_workspace_tags = self.clockapi.get_tags()
+ for task_type_name in task_type_names:
+ if task_type_name in clockify_workspace_tags:
+ self.log.debug(
+ "Task \"{}\" already exist".format(task_type_name)
+ )
+ continue
+
+ response = self.clockapi.add_tag(task_type_name)
+ if "id" not in response:
+ self.log.warning(
+ "Task \"{}\" can't be created. Response: {}".format(
+ task_type_name, response
+ )
+ )
+
+ job["status"] = "done"
+
+ except Exception:
+ pass
+
+ finally:
+ if job["status"] != "done":
+ job["status"] = "failed"
+ session.commit()
+
+ return True
+
+
+def register(session, **kw):
+ SyncClocifyLocal(session).register()
diff --git a/pype/modules/clockify/ftrack_actions/action_clockify_sync.py b/pype/modules/clockify/ftrack_actions/action_clockify_sync.py
deleted file mode 100644
index a041e6ada6..0000000000
--- a/pype/modules/clockify/ftrack_actions/action_clockify_sync.py
+++ /dev/null
@@ -1,155 +0,0 @@
-import os
-import sys
-import argparse
-import logging
-import json
-import ftrack_api
-from pype.modules.ftrack import BaseAction, MissingPermision
-from pype.modules.clockify import ClockifyAPI
-
-
-class SyncClocify(BaseAction):
- '''Synchronise project names and task types.'''
-
- #: Action identifier.
- identifier = 'clockify.sync'
- #: Action label.
- label = 'Sync To Clockify'
- #: Action description.
- description = 'Synchronise data to Clockify workspace'
- #: roles that are allowed to register this action
- role_list = ["Pypeclub", "Administrator", "project Manager"]
- #: icon
- icon = '{}/app_icons/clockify-white.png'.format(
- os.environ.get('PYPE_STATICS_SERVER', '')
- )
- #: CLockifyApi
- clockapi = ClockifyAPI()
-
- def preregister(self):
- if self.clockapi.workspace_id is None:
- return "Clockify Workspace or API key are not set!"
-
- if self.clockapi.validate_workspace_perm() is False:
- raise MissingPermision('Clockify')
-
- return True
-
- def discover(self, session, entities, event):
- ''' Validation '''
- if len(entities) != 1:
- return False
-
- if entities[0].entity_type.lower() != "project":
- return False
- return True
-
- def launch(self, session, entities, event):
- # JOB SETTINGS
- userId = event['source']['user']['id']
- user = session.query('User where id is ' + userId).one()
-
- job = session.create('Job', {
- 'user': user,
- 'status': 'running',
- 'data': json.dumps({
- 'description': 'Sync Ftrack to Clockify'
- })
- })
- session.commit()
- try:
- entity = entities[0]
-
- if entity.entity_type.lower() == 'project':
- project = entity
- else:
- project = entity['project']
- project_name = project['full_name']
-
- task_types = []
- for task_type in project['project_schema']['_task_type_schema'][
- 'types'
- ]:
- task_types.append(task_type['name'])
-
- clockify_projects = self.clockapi.get_projects()
-
- if project_name not in clockify_projects:
- response = self.clockapi.add_project(project_name)
- if 'id' not in response:
- self.log.error('Project {} can\'t be created'.format(
- project_name
- ))
- return {
- 'success': False,
- 'message': 'Can\'t create project, unexpected error'
- }
- project_id = response['id']
- else:
- project_id = clockify_projects[project_name]
-
- clockify_workspace_tags = self.clockapi.get_tags()
- for task_type in task_types:
- if task_type not in clockify_workspace_tags:
- response = self.clockapi.add_tag(task_type)
- if 'id' not in response:
- self.log.error('Task {} can\'t be created'.format(
- task_type
- ))
- continue
- except Exception:
- job['status'] = 'failed'
- session.commit()
- return False
-
- job['status'] = 'done'
- session.commit()
- return True
-
-
-def register(session, **kw):
- '''Register plugin. Called when used as an plugin.'''
-
- if not isinstance(session, ftrack_api.session.Session):
- return
-
- SyncClocify(session).register()
-
-
-def main(arguments=None):
- '''Set up logging and register action.'''
- if arguments is None:
- arguments = []
-
- parser = argparse.ArgumentParser()
- # Allow setting of logging level from arguments.
- loggingLevels = {}
- for level in (
- logging.NOTSET, logging.DEBUG, logging.INFO, logging.WARNING,
- logging.ERROR, logging.CRITICAL
- ):
- loggingLevels[logging.getLevelName(level).lower()] = level
-
- parser.add_argument(
- '-v', '--verbosity',
- help='Set the logging output verbosity.',
- choices=loggingLevels.keys(),
- default='info'
- )
- namespace = parser.parse_args(arguments)
-
- # Set up basic logging
- logging.basicConfig(level=loggingLevels[namespace.verbosity])
-
- session = ftrack_api.Session()
- register(session)
-
- # Wait for events
- logging.info(
- 'Registered actions and listening for events. Use Ctrl-C to abort.'
- )
- session.event_hub.wait()
-
-
-if __name__ == '__main__':
- raise SystemExit(main(sys.argv[1:]))
diff --git a/pype/modules/clockify/launcher_actions/ClockifyStart.py b/pype/modules/clockify/launcher_actions/ClockifyStart.py
index d5e9164977..f97360662f 100644
--- a/pype/modules/clockify/launcher_actions/ClockifyStart.py
+++ b/pype/modules/clockify/launcher_actions/ClockifyStart.py
@@ -1,6 +1,6 @@
from avalon import api, io
from pype.api import Logger
-from pype.modules.clockify import ClockifyAPI
+from pype.modules.clockify.clockify_api import ClockifyAPI
log = Logger().get_logger(__name__, "clockify_start")
diff --git a/pype/modules/clockify/launcher_actions/ClockifySync.py b/pype/modules/clockify/launcher_actions/ClockifySync.py
index 0f20d1dce1..a77c038076 100644
--- a/pype/modules/clockify/launcher_actions/ClockifySync.py
+++ b/pype/modules/clockify/launcher_actions/ClockifySync.py
@@ -1,5 +1,5 @@
from avalon import api, io
-from pype.modules.clockify import ClockifyAPI
+from pype.modules.clockify.clockify_api import ClockifyAPI
from pype.api import Logger
log = Logger().get_logger(__name__, "clockify_sync")
diff --git a/pype/modules/clockify/widget_message.py b/pype/modules/clockify/widget_message.py
deleted file mode 100644
index f919c3f819..0000000000
--- a/pype/modules/clockify/widget_message.py
+++ /dev/null
@@ -1,91 +0,0 @@
-from Qt import QtCore, QtGui, QtWidgets
-from avalon import style
-
-
-class MessageWidget(QtWidgets.QWidget):
-
- SIZE_W = 300
- SIZE_H = 130
-
- closed = QtCore.Signal()
-
- def __init__(self, parent=None, messages=[], title="Message"):
-
- super(MessageWidget, self).__init__()
-
- self._parent = parent
-
- # Icon
- if parent and hasattr(parent, 'icon'):
- self.setWindowIcon(parent.icon)
- else:
- from pypeapp.resources import get_resource
- self.setWindowIcon(QtGui.QIcon(get_resource('icon.png')))
-
- self.setWindowFlags(
- QtCore.Qt.WindowCloseButtonHint |
- QtCore.Qt.WindowMinimizeButtonHint
- )
-
- # Font
- self.font = QtGui.QFont()
- self.font.setFamily("DejaVu Sans Condensed")
- self.font.setPointSize(9)
- self.font.setBold(True)
- self.font.setWeight(50)
- self.font.setKerning(True)
-
- # Size setting
- self.resize(self.SIZE_W, self.SIZE_H)
- self.setMinimumSize(QtCore.QSize(self.SIZE_W, self.SIZE_H))
- self.setMaximumSize(QtCore.QSize(self.SIZE_W+100, self.SIZE_H+100))
-
- # Style
- self.setStyleSheet(style.load_stylesheet())
-
- self.setLayout(self._ui_layout(messages))
- self.setWindowTitle(title)
-
- def _ui_layout(self, messages):
- if not messages:
- messages = ["*Misssing messages (This is a bug)*", ]
-
- elif not isinstance(messages, (tuple, list)):
- messages = [messages, ]
-
- main_layout = QtWidgets.QVBoxLayout(self)
-
- labels = []
- for message in messages:
- label = QtWidgets.QLabel(message)
- label.setFont(self.font)
- label.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor))
- label.setTextFormat(QtCore.Qt.RichText)
- label.setWordWrap(True)
-
- labels.append(label)
- main_layout.addWidget(label)
-
- btn_close = QtWidgets.QPushButton("Close")
- btn_close.setToolTip('Close this window')
- btn_close.clicked.connect(self.on_close_clicked)
-
- btn_group = QtWidgets.QHBoxLayout()
- btn_group.addStretch(1)
- btn_group.addWidget(btn_close)
-
- main_layout.addLayout(btn_group)
-
- self.labels = labels
- self.btn_group = btn_group
- self.btn_close = btn_close
- self.main_layout = main_layout
-
- return main_layout
-
- def on_close_clicked(self):
- self.close()
-
- def close(self, *args, **kwargs):
- self.closed.emit()
- super(MessageWidget, self).close(*args, **kwargs)
diff --git a/pype/modules/clockify/widget_settings.py b/pype/modules/clockify/widgets.py
similarity index 65%
rename from pype/modules/clockify/widget_settings.py
rename to pype/modules/clockify/widgets.py
index 956bdb1916..dc57a48ecb 100644
--- a/pype/modules/clockify/widget_settings.py
+++ b/pype/modules/clockify/widgets.py
@@ -1,6 +1,95 @@
-import os
from Qt import QtCore, QtGui, QtWidgets
from avalon import style
+from pype.api import resources
+
+
+class MessageWidget(QtWidgets.QWidget):
+
+ SIZE_W = 300
+ SIZE_H = 130
+
+ closed = QtCore.Signal()
+
+ def __init__(self, parent=None, messages=[], title="Message"):
+
+ super(MessageWidget, self).__init__()
+
+ self._parent = parent
+
+ # Icon
+ if parent and hasattr(parent, 'icon'):
+ self.setWindowIcon(parent.icon)
+ else:
+ icon = QtGui.QIcon(resources.pype_icon_filepath())
+ self.setWindowIcon(icon)
+
+ self.setWindowFlags(
+ QtCore.Qt.WindowCloseButtonHint |
+ QtCore.Qt.WindowMinimizeButtonHint
+ )
+
+ # Font
+ self.font = QtGui.QFont()
+ self.font.setFamily("DejaVu Sans Condensed")
+ self.font.setPointSize(9)
+ self.font.setBold(True)
+ self.font.setWeight(50)
+ self.font.setKerning(True)
+
+ # Size setting
+ self.resize(self.SIZE_W, self.SIZE_H)
+ self.setMinimumSize(QtCore.QSize(self.SIZE_W, self.SIZE_H))
+ self.setMaximumSize(QtCore.QSize(self.SIZE_W+100, self.SIZE_H+100))
+
+ # Style
+ self.setStyleSheet(style.load_stylesheet())
+
+ self.setLayout(self._ui_layout(messages))
+ self.setWindowTitle(title)
+
+ def _ui_layout(self, messages):
+ if not messages:
+ messages = ["*Misssing messages (This is a bug)*", ]
+
+ elif not isinstance(messages, (tuple, list)):
+ messages = [messages, ]
+
+ main_layout = QtWidgets.QVBoxLayout(self)
+
+ labels = []
+ for message in messages:
+ label = QtWidgets.QLabel(message)
+ label.setFont(self.font)
+ label.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor))
+ label.setTextFormat(QtCore.Qt.RichText)
+ label.setWordWrap(True)
+
+ labels.append(label)
+ main_layout.addWidget(label)
+
+ btn_close = QtWidgets.QPushButton("Close")
+ btn_close.setToolTip('Close this window')
+ btn_close.clicked.connect(self.on_close_clicked)
+
+ btn_group = QtWidgets.QHBoxLayout()
+ btn_group.addStretch(1)
+ btn_group.addWidget(btn_close)
+
+ main_layout.addLayout(btn_group)
+
+ self.labels = labels
+ self.btn_group = btn_group
+ self.btn_close = btn_close
+ self.main_layout = main_layout
+
+ return main_layout
+
+ def on_close_clicked(self):
+ self.close()
+
+ def close(self, *args, **kwargs):
+ self.closed.emit()
+ super(MessageWidget, self).close(*args, **kwargs)
class ClockifySettings(QtWidgets.QWidget):
@@ -26,10 +115,7 @@ class ClockifySettings(QtWidgets.QWidget):
elif hasattr(parent, 'parent') and hasattr(parent.parent, 'icon'):
self.setWindowIcon(self.parent.parent.icon)
else:
- pype_setup = os.getenv('PYPE_SETUP_PATH')
- items = [pype_setup, "app", "resources", "icon.png"]
- fname = os.path.sep.join(items)
- icon = QtGui.QIcon(fname)
+ icon = QtGui.QIcon(resources.pype_icon_filepath())
self.setWindowIcon(icon)
self.setWindowFlags(
diff --git a/pype/modules/ftrack/actions/action_delete_asset.py b/pype/modules/ftrack/actions/action_delete_asset.py
index 1074efee3b..27394770e1 100644
--- a/pype/modules/ftrack/actions/action_delete_asset.py
+++ b/pype/modules/ftrack/actions/action_delete_asset.py
@@ -497,9 +497,8 @@ class DeleteAssetSubset(BaseAction):
for entity in entities:
ftrack_id = entity["id"]
ftrack_id_name_map[ftrack_id] = entity["name"]
- if ftrack_id in ftrack_ids_to_delete:
- continue
- not_deleted_entities_id.append(ftrack_id)
+ if ftrack_id not in ftrack_ids_to_delete:
+ not_deleted_entities_id.append(ftrack_id)
mongo_proc_txt = "MongoProcessing: "
ftrack_proc_txt = "Ftrack processing: "
@@ -534,25 +533,20 @@ class DeleteAssetSubset(BaseAction):
ftrack_proc_txt, ", ".join(ftrack_ids_to_delete)
))
- joined_ids_to_delete = ", ".join(
- ["\"{}\"".format(id) for id in ftrack_ids_to_delete]
+ ftrack_ents_to_delete = (
+ self._filter_entities_to_delete(ftrack_ids_to_delete, session)
)
- ftrack_ents_to_delete = self.session.query(
- "select id, link from TypedContext where id in ({})".format(
- joined_ids_to_delete
- )
- ).all()
for entity in ftrack_ents_to_delete:
- self.session.delete(entity)
+ session.delete(entity)
try:
- self.session.commit()
+ session.commit()
except Exception:
ent_path = "/".join(
[ent["name"] for ent in entity["link"]]
)
msg = "Failed to delete entity"
report_messages[msg].append(ent_path)
- self.session.rollback()
+ session.rollback()
self.log.warning(
"{} <{}>".format(msg, ent_path),
exc_info=True
@@ -568,7 +562,7 @@ class DeleteAssetSubset(BaseAction):
for name in asset_names_to_delete
])
# Find assets of selected entities with names of checked subsets
- assets = self.session.query((
+ assets = session.query((
"select id from Asset where"
" context_id in ({}) and name in ({})"
).format(joined_not_deleted, joined_asset_names)).all()
@@ -578,20 +572,54 @@ class DeleteAssetSubset(BaseAction):
", ".join([asset["id"] for asset in assets])
))
for asset in assets:
- self.session.delete(asset)
+ session.delete(asset)
try:
- self.session.commit()
+ session.commit()
except Exception:
- self.session.rollback()
+ session.rollback()
msg = "Failed to delete asset"
report_messages[msg].append(asset["id"])
self.log.warning(
- "{} <{}>".format(asset["id"]),
+ "Asset: {} <{}>".format(asset["name"], asset["id"]),
exc_info=True
)
return self.report_handle(report_messages, project_name, event)
+ def _filter_entities_to_delete(self, ftrack_ids_to_delete, session):
+ """Filter children entities to avoid CircularDependencyError."""
+ joined_ids_to_delete = ", ".join(
+ ["\"{}\"".format(id) for id in ftrack_ids_to_delete]
+ )
+ to_delete_entities = session.query(
+ "select id, link from TypedContext where id in ({})".format(
+ joined_ids_to_delete
+ )
+ ).all()
+ filtered = to_delete_entities[:]
+ while True:
+ changed = False
+ _filtered = filtered[:]
+ for entity in filtered:
+ entity_id = entity["id"]
+
+ for _entity in tuple(_filtered):
+ if entity_id == _entity["id"]:
+ continue
+
+ for _link in _entity["link"]:
+ if entity_id == _link["id"] and _entity in _filtered:
+ _filtered.remove(_entity)
+ changed = True
+ break
+
+ filtered = _filtered
+
+ if not changed:
+ break
+
+ return filtered
+
def report_handle(self, report_messages, project_name, event):
if not report_messages:
return {
diff --git a/pype/modules/ftrack/actions/action_djvview.py b/pype/modules/ftrack/actions/action_djvview.py
index 9708503ad1..6f667c0604 100644
--- a/pype/modules/ftrack/actions/action_djvview.py
+++ b/pype/modules/ftrack/actions/action_djvview.py
@@ -1,13 +1,7 @@
import os
-import sys
-import logging
import subprocess
from operator import itemgetter
-import ftrack_api
from pype.modules.ftrack.lib import BaseAction, statics_icon
-from pype.api import Logger, config
-
-log = Logger().get_logger(__name__)
class DJVViewAction(BaseAction):
@@ -19,20 +13,18 @@ class DJVViewAction(BaseAction):
type = 'Application'
+ allowed_types = [
+ "cin", "dpx", "avi", "dv", "gif", "flv", "mkv", "mov", "mpg", "mpeg",
+ "mp4", "m4v", "mxf", "iff", "z", "ifl", "jpeg", "jpg", "jfif", "lut",
+ "1dl", "exr", "pic", "png", "ppm", "pnm", "pgm", "pbm", "rla", "rpf",
+ "sgi", "rgba", "rgb", "bw", "tga", "tiff", "tif", "img"
+ ]
+
def __init__(self, session, plugins_presets):
'''Expects a ftrack_api.Session instance'''
super().__init__(session, plugins_presets)
- self.djv_path = None
- self.config_data = config.get_presets()['djv_view']['config']
- self.set_djv_path()
-
- if self.djv_path is None:
- return
-
- self.allowed_types = self.config_data.get(
- 'file_ext', ["img", "mov", "exr"]
- )
+ self.djv_path = self.find_djv_path()
def preregister(self):
if self.djv_path is None:
@@ -53,11 +45,10 @@ class DJVViewAction(BaseAction):
return True
return False
- def set_djv_path(self):
- for path in self.config_data.get("djv_paths", []):
+ def find_djv_path(self):
+ for path in (os.environ.get("DJV_PATH") or "").split(os.pathsep):
if os.path.exists(path):
- self.djv_path = path
- break
+ return path
def interface(self, session, entities, event):
if event['data'].get('values', {}):
@@ -221,43 +212,3 @@ def register(session, plugins_presets={}):
"""Register hooks."""
DJVViewAction(session, plugins_presets).register()
-
-
-def main(arguments=None):
- '''Set up logging and register action.'''
- if arguments is None:
- arguments = []
-
- import argparse
- parser = argparse.ArgumentParser()
- # Allow setting of logging level from arguments.
- loggingLevels = {}
- for level in (
- logging.NOTSET, logging.DEBUG, logging.INFO, logging.WARNING,
- logging.ERROR, logging.CRITICAL
- ):
- loggingLevels[logging.getLevelName(level).lower()] = level
-
- parser.add_argument(
- '-v', '--verbosity',
- help='Set the logging output verbosity.',
- choices=loggingLevels.keys(),
- default='info'
- )
- namespace = parser.parse_args(arguments)
-
- # Set up basic logging
- logging.basicConfig(level=loggingLevels[namespace.verbosity])
-
- session = ftrack_api.Session()
- register(session)
-
- # Wait for events
- logging.info(
- 'Registered actions and listening for events. Use Ctrl-C to abort.'
- )
- session.event_hub.wait()
-
-
-if __name__ == '__main__':
- raise SystemExit(main(sys.argv[1:]))
diff --git a/pype/modules/ftrack/events/action_sync_to_avalon.py b/pype/modules/ftrack/events/action_sync_to_avalon.py
index a06b825d6a..4e119228c3 100644
--- a/pype/modules/ftrack/events/action_sync_to_avalon.py
+++ b/pype/modules/ftrack/events/action_sync_to_avalon.py
@@ -1,10 +1,8 @@
-import os
import time
import traceback
from pype.modules.ftrack import BaseAction
from pype.modules.ftrack.lib.avalon_sync import SyncEntitiesFactory
-from pype.api import config
class SyncToAvalonServer(BaseAction):
@@ -38,17 +36,6 @@ class SyncToAvalonServer(BaseAction):
variant = "- Sync To Avalon (Server)"
#: Action description.
description = "Send data from Ftrack to Avalon"
- #: Action icon.
- icon = "{}/ftrack/action_icons/PypeAdmin.svg".format(
- os.environ.get(
- "PYPE_STATICS_SERVER",
- "http://localhost:{}".format(
- config.get_presets().get("services", {}).get(
- "rest_api", {}
- ).get("default_port", 8021)
- )
- )
- )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
diff --git a/pype/modules/ftrack/events/event_version_to_task_statuses.py b/pype/modules/ftrack/events/event_version_to_task_statuses.py
index 3ff986f9c6..fdb48cbc37 100644
--- a/pype/modules/ftrack/events/event_version_to_task_statuses.py
+++ b/pype/modules/ftrack/events/event_version_to_task_statuses.py
@@ -84,6 +84,9 @@ class VersionToTaskStatus(BaseEvent):
if not task:
continue
+ if version["asset"]["type"]["short"].lower() == "scene":
+ continue
+
project_schema = task["project"]["project_schema"]
# Get all available statuses for Task
statuses = project_schema.get_statuses("Task", task["type_id"])
diff --git a/pype/modules/ftrack/ftrack_server/event_server_cli.py b/pype/modules/ftrack/ftrack_server/event_server_cli.py
index 73c7abfc5d..bf51c37290 100644
--- a/pype/modules/ftrack/ftrack_server/event_server_cli.py
+++ b/pype/modules/ftrack/ftrack_server/event_server_cli.py
@@ -522,6 +522,21 @@ def main(argv):
help="Load creadentials from apps dir",
action="store_true"
)
+ parser.add_argument(
+ "-clockifyapikey", type=str,
+ help=(
+ "Enter API key for Clockify actions."
+ " (default from environment: $CLOCKIFY_API_KEY)"
+ )
+ )
+ parser.add_argument(
+ "-clockifyworkspace", type=str,
+ help=(
+ "Enter workspace for Clockify."
+ " (default from module presets or "
+ "environment: $CLOCKIFY_WORKSPACE)"
+ )
+ )
ftrack_url = os.environ.get('FTRACK_SERVER')
username = os.environ.get('FTRACK_API_USER')
api_key = os.environ.get('FTRACK_API_KEY')
@@ -546,6 +561,12 @@ def main(argv):
if kwargs.ftrackapikey:
api_key = kwargs.ftrackapikey
+ if kwargs.clockifyworkspace:
+ os.environ["CLOCKIFY_WORKSPACE"] = kwargs.clockifyworkspace
+
+ if kwargs.clockifyapikey:
+ os.environ["CLOCKIFY_API_KEY"] = kwargs.clockifyapikey
+
legacy = kwargs.legacy
# Check url regex and accessibility
ftrack_url = check_ftrack_url(ftrack_url)
diff --git a/pype/modules/ftrack/ftrack_server/lib.py b/pype/modules/ftrack/ftrack_server/lib.py
index 8377187ebe..acf31ab437 100644
--- a/pype/modules/ftrack/ftrack_server/lib.py
+++ b/pype/modules/ftrack/ftrack_server/lib.py
@@ -26,7 +26,7 @@ from pype.api import (
compose_url
)
-from pype.modules.ftrack.lib.custom_db_connector import DbConnector
+from pype.modules.ftrack.lib.custom_db_connector import CustomDbConnector
TOPIC_STATUS_SERVER = "pype.event.server.status"
@@ -44,15 +44,8 @@ def get_ftrack_event_mongo_info():
mongo_url = os.environ.get("FTRACK_EVENTS_MONGO_URL")
if mongo_url is not None:
components = decompose_url(mongo_url)
- _used_ftrack_url = True
else:
components = get_default_components()
- _used_ftrack_url = False
-
- if not _used_ftrack_url or components["database"] is None:
- components["database"] = database_name
-
- components.pop("collection", None)
uri = compose_url(**components)
@@ -166,10 +159,10 @@ class ProcessEventHub(SocketBaseEventHub):
pypelog = Logger().get_logger("Session Processor")
def __init__(self, *args, **kwargs):
- self.dbcon = DbConnector(
+ self.dbcon = CustomDbConnector(
self.uri,
- self.port,
self.database,
+ self.port,
self.table_name
)
super(ProcessEventHub, self).__init__(*args, **kwargs)
diff --git a/pype/modules/ftrack/ftrack_server/socket_thread.py b/pype/modules/ftrack/ftrack_server/socket_thread.py
index dda4c7db35..e66e8bc775 100644
--- a/pype/modules/ftrack/ftrack_server/socket_thread.py
+++ b/pype/modules/ftrack/ftrack_server/socket_thread.py
@@ -11,7 +11,7 @@ from pype.api import Logger
class SocketThread(threading.Thread):
"""Thread that checks suprocess of storer of processor of events"""
- MAX_TIMEOUT = 35
+ MAX_TIMEOUT = int(os.environ.get("PYPE_FTRACK_SOCKET_TIMEOUT", 45))
def __init__(self, name, port, filepath, additional_args=[]):
super(SocketThread, self).__init__()
diff --git a/pype/modules/ftrack/ftrack_server/sub_event_processor.py b/pype/modules/ftrack/ftrack_server/sub_event_processor.py
index d7bb7a53b3..4a3241dd4f 100644
--- a/pype/modules/ftrack/ftrack_server/sub_event_processor.py
+++ b/pype/modules/ftrack/ftrack_server/sub_event_processor.py
@@ -9,7 +9,7 @@ from pype.modules.ftrack.ftrack_server.lib import (
SocketSession, ProcessEventHub, TOPIC_STATUS_SERVER
)
import ftrack_api
-from pype.api import Logger
+from pype.api import Logger, config
log = Logger().get_logger("Event processor")
@@ -55,6 +55,42 @@ def register(session):
)
+def clockify_module_registration():
+ module_name = "Clockify"
+
+ menu_items = config.get_presets()["tray"]["menu_items"]
+ if not menu_items["item_usage"][module_name]:
+ return
+
+ api_key = os.environ.get("CLOCKIFY_API_KEY")
+ if not api_key:
+ log.warning("Clockify API key is not set.")
+ return
+
+ workspace_name = os.environ.get("CLOCKIFY_WORKSPACE")
+ if not workspace_name:
+ workspace_name = (
+ menu_items
+ .get("attributes", {})
+ .get(module_name, {})
+ .get("workspace_name", {})
+ )
+
+ if not workspace_name:
+ log.warning("Clockify Workspace is not set.")
+ return
+
+ os.environ["CLOCKIFY_WORKSPACE"] = workspace_name
+
+ from pype.modules.clockify.constants import CLOCKIFY_FTRACK_SERVER_PATH
+
+ current = os.environ.get("FTRACK_EVENTS_PATH") or ""
+ if current:
+ current += os.pathsep
+ os.environ["FTRACK_EVENTS_PATH"] = current + CLOCKIFY_FTRACK_SERVER_PATH
+ return True
+
+
def main(args):
port = int(args[-1])
# Create a TCP/IP socket
@@ -66,6 +102,11 @@ def main(args):
sock.connect(server_address)
sock.sendall(b"CreatedProcess")
+ try:
+ clockify_module_registration()
+ except Exception:
+ log.info("Clockify registration failed.", exc_info=True)
+
try:
session = SocketSession(
auto_connect_event_hub=True, sock=sock, Eventhub=ProcessEventHub
diff --git a/pype/modules/ftrack/ftrack_server/sub_event_storer.py b/pype/modules/ftrack/ftrack_server/sub_event_storer.py
index 61b9aaf2c8..1635f6cea3 100644
--- a/pype/modules/ftrack/ftrack_server/sub_event_storer.py
+++ b/pype/modules/ftrack/ftrack_server/sub_event_storer.py
@@ -12,7 +12,7 @@ from pype.modules.ftrack.ftrack_server.lib import (
get_ftrack_event_mongo_info,
TOPIC_STATUS_SERVER, TOPIC_STATUS_SERVER_RESULT
)
-from pype.modules.ftrack.lib.custom_db_connector import DbConnector
+from pype.modules.ftrack.lib.custom_db_connector import CustomDbConnector
from pype.api import Logger
log = Logger().get_logger("Event storer")
@@ -24,7 +24,7 @@ class SessionFactory:
uri, port, database, table_name = get_ftrack_event_mongo_info()
-dbcon = DbConnector(uri, port, database, table_name)
+dbcon = CustomDbConnector(uri, database, port, table_name)
# ignore_topics = ["ftrack.meta.connected"]
ignore_topics = []
diff --git a/pype/modules/ftrack/lib/custom_db_connector.py b/pype/modules/ftrack/lib/custom_db_connector.py
index a734b3f80a..d498d041dc 100644
--- a/pype/modules/ftrack/lib/custom_db_connector.py
+++ b/pype/modules/ftrack/lib/custom_db_connector.py
@@ -9,6 +9,7 @@ import time
import logging
import functools
import atexit
+import os
# Third-party dependencies
import pymongo
@@ -40,7 +41,7 @@ def auto_reconnect(func):
def check_active_table(func):
- """Check if DbConnector has active table before db method is called"""
+ """Check if CustomDbConnector has active collection."""
@functools.wraps(func)
def decorated(obj, *args, **kwargs):
if not obj.active_table:
@@ -49,23 +50,12 @@ def check_active_table(func):
return decorated
-def check_active_table(func):
- """Handling auto reconnect in 3 retry times"""
- @functools.wraps(func)
- def decorated(obj, *args, **kwargs):
- if not obj.active_table:
- raise NotActiveTable("Active table is not set. (This is bug)")
- return func(obj, *args, **kwargs)
-
- return decorated
-
-
-class DbConnector:
+class CustomDbConnector:
log = logging.getLogger(__name__)
- timeout = 1000
+ timeout = int(os.environ["AVALON_TIMEOUT"])
def __init__(
- self, uri, port=None, database_name=None, table_name=None
+ self, uri, database_name, port=None, table_name=None
):
self._mongo_client = None
self._sentry_client = None
@@ -78,9 +68,6 @@ class DbConnector:
if port is None:
port = components.get("port")
- if database_name is None:
- database_name = components.get("database")
-
if database_name is None:
raise ValueError(
"Database is not defined for connection. {}".format(uri)
@@ -99,7 +86,7 @@ class DbConnector:
# not all methods of PyMongo database are implemented with this it is
# possible to use them too
try:
- return super(DbConnector, self).__getattribute__(attr)
+ return super(CustomDbConnector, self).__getattribute__(attr)
except AttributeError:
if self.active_table is None:
raise NotActiveTable()
diff --git a/pype/modules/ftrack/lib/ftrack_app_handler.py b/pype/modules/ftrack/lib/ftrack_app_handler.py
index 00bd13fd73..22fd6eeaab 100644
--- a/pype/modules/ftrack/lib/ftrack_app_handler.py
+++ b/pype/modules/ftrack/lib/ftrack_app_handler.py
@@ -4,9 +4,13 @@ import copy
import platform
import avalon.lib
import acre
+import getpass
from pype import lib as pypelib
from pype.api import config, Anatomy
from .ftrack_action_handler import BaseAction
+from avalon.api import (
+ last_workfile, HOST_WORKFILE_EXTENSIONS, should_start_last_workfile
+)
class AppAction(BaseAction):
@@ -82,7 +86,7 @@ class AppAction(BaseAction):
if (
len(entities) != 1
- or entities[0].entity_type.lower() != 'task'
+ or entities[0].entity_type.lower() != "task"
):
return False
@@ -90,21 +94,31 @@ class AppAction(BaseAction):
if entity["parent"].entity_type.lower() == "project":
return False
- ft_project = self.get_project_from_entity(entity)
- database = pypelib.get_avalon_database()
- project_name = ft_project["full_name"]
- avalon_project = database[project_name].find_one({
- "type": "project"
- })
+ avalon_project_apps = event["data"].get("avalon_project_apps", None)
+ avalon_project_doc = event["data"].get("avalon_project_doc", None)
+ if avalon_project_apps is None:
+ if avalon_project_doc is None:
+ ft_project = self.get_project_from_entity(entity)
+ database = pypelib.get_avalon_database()
+ project_name = ft_project["full_name"]
+ avalon_project_doc = database[project_name].find_one({
+ "type": "project"
+ }) or False
+ event["data"]["avalon_project_doc"] = avalon_project_doc
- if not avalon_project:
+ if not avalon_project_doc:
+ return False
+
+ project_apps_config = avalon_project_doc["config"].get("apps", [])
+ avalon_project_apps = [
+ app["name"] for app in project_apps_config
+ ] or False
+ event["data"]["avalon_project_apps"] = avalon_project_apps
+
+ if not avalon_project_apps:
return False
- project_apps = avalon_project["config"].get("apps", [])
- apps = [app["name"] for app in project_apps]
- if self.identifier in apps:
- return True
- return False
+ return self.identifier in avalon_project_apps
def _launch(self, event):
entities = self._translate_event(event)
@@ -140,6 +154,9 @@ class AppAction(BaseAction):
"""
entity = entities[0]
+
+ task_name = entity["name"]
+
project_name = entity["project"]["full_name"]
database = pypelib.get_avalon_database()
@@ -152,18 +169,19 @@ class AppAction(BaseAction):
hierarchy = ""
asset_doc_parents = asset_document["data"].get("parents")
- if len(asset_doc_parents) > 0:
+ if asset_doc_parents:
hierarchy = os.path.join(*asset_doc_parents)
application = avalon.lib.get_application(self.identifier)
+ host_name = application["application_dir"]
data = {
"project": {
"name": entity["project"]["full_name"],
"code": entity["project"]["name"]
},
- "task": entity["name"],
+ "task": task_name,
"asset": asset_name,
- "app": application["application_dir"],
+ "app": host_name,
"hierarchy": hierarchy
}
@@ -187,17 +205,48 @@ class AppAction(BaseAction):
except FileExistsError:
pass
+ last_workfile_path = None
+ extensions = HOST_WORKFILE_EXTENSIONS.get(host_name)
+ if extensions:
+ # Find last workfile
+ file_template = anatomy.templates["work"]["file"]
+ data.update({
+ "version": 1,
+ "user": getpass.getuser(),
+ "ext": extensions[0]
+ })
+
+ last_workfile_path = last_workfile(
+ workdir, file_template, data, extensions, True
+ )
+
# set environments for Avalon
prep_env = copy.deepcopy(os.environ)
prep_env.update({
"AVALON_PROJECT": project_name,
"AVALON_ASSET": asset_name,
- "AVALON_TASK": entity["name"],
- "AVALON_APP": self.identifier.split("_")[0],
+ "AVALON_TASK": task_name,
+ "AVALON_APP": host_name,
"AVALON_APP_NAME": self.identifier,
"AVALON_HIERARCHY": hierarchy,
"AVALON_WORKDIR": workdir
})
+
+ start_last_workfile = should_start_last_workfile(
+ project_name, host_name, task_name
+ )
+ # Store boolean as "0"(False) or "1"(True)
+ prep_env["AVALON_OPEN_LAST_WORKFILE"] = (
+ str(int(bool(start_last_workfile)))
+ )
+
+ if (
+ start_last_workfile
+ and last_workfile_path
+ and os.path.exists(last_workfile_path)
+ ):
+ prep_env["AVALON_LAST_WORKFILE"] = last_workfile_path
+
prep_env.update(anatomy.roots_obj.root_environments())
# collect all parents from the task
@@ -213,7 +262,6 @@ class AppAction(BaseAction):
tools_env = acre.get_tools(tools_attr)
env = acre.compute(tools_env)
env = acre.merge(env, current_env=dict(prep_env))
- env = acre.append(dict(prep_env), env)
# Get path to execute
st_temp_path = os.environ["PYPE_CONFIG"]
diff --git a/pype/modules/ftrack/tray/login_dialog.py b/pype/modules/ftrack/tray/login_dialog.py
index 3b8a366209..e0614513a3 100644
--- a/pype/modules/ftrack/tray/login_dialog.py
+++ b/pype/modules/ftrack/tray/login_dialog.py
@@ -3,6 +3,7 @@ import requests
from avalon import style
from pype.modules.ftrack import credentials
from . import login_tools
+from pype.api import resources
from Qt import QtCore, QtGui, QtWidgets
@@ -29,10 +30,7 @@ class Login_Dialog_ui(QtWidgets.QWidget):
elif hasattr(parent, 'parent') and hasattr(parent.parent, 'icon'):
self.setWindowIcon(self.parent.parent.icon)
else:
- pype_setup = os.getenv('PYPE_SETUP_PATH')
- items = [pype_setup, "app", "resources", "icon.png"]
- fname = os.path.sep.join(items)
- icon = QtGui.QIcon(fname)
+ icon = QtGui.QIcon(resources.pype_icon_filepath())
self.setWindowIcon(icon)
self.setWindowFlags(
diff --git a/pype/modules/ftrack/tray/login_tools.py b/pype/modules/ftrack/tray/login_tools.py
index b259f2d2ed..02982294f2 100644
--- a/pype/modules/ftrack/tray/login_tools.py
+++ b/pype/modules/ftrack/tray/login_tools.py
@@ -1,16 +1,16 @@
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib import parse
-import os
import webbrowser
import functools
-import pype
-import inspect
from Qt import QtCore
+from pype.api import resources
class LoginServerHandler(BaseHTTPRequestHandler):
'''Login server handler.'''
+ message_filepath = resources.get_resource("ftrack", "sign_in_message.html")
+
def __init__(self, login_callback, *args, **kw):
'''Initialise handler.'''
self.login_callback = login_callback
@@ -28,23 +28,21 @@ class LoginServerHandler(BaseHTTPRequestHandler):
login_credentials = parse.parse_qs(query)
api_user = login_credentials['api_user'][0]
api_key = login_credentials['api_key'][0]
- # get path to resources
- path_items = os.path.dirname(
- inspect.getfile(pype)
- ).split(os.path.sep)
- del path_items[-1]
- path_items.extend(['res', 'ftrack', 'sign_in_message.html'])
- message_filepath = os.path.sep.join(path_items)
- message_file = open(message_filepath, 'r')
- sign_in_message = message_file.read()
- message_file.close()
+
+ with open(self.message_filepath, "r") as message_file:
+ sign_in_message = message_file.read()
+
# formatting html code for python
- replacement = [('{', '{{'), ('}', '}}'), ('{{}}', '{}')]
- for r in (replacement):
- sign_in_message = sign_in_message.replace(*r)
+ replacements = (
+ ("{", "{{"),
+ ("}", "}}"),
+ ("{{}}", "{}")
+ )
+ for replacement in (replacements):
+ sign_in_message = sign_in_message.replace(*replacement)
message = sign_in_message.format(api_user)
else:
- message = '
{script_path}", "-a", + "-16", "-s", "-e ", f"-d {render_dir}", @@ -135,8 +137,10 @@ class ExtractCelactionDeadline(pyblish.api.InstancePlugin): # Optional, enable double-click to preview rendered # frames from Deadline Monitor - "OutputFilename0": output_filename_0.replace("\\", "/") + "OutputFilename0": output_filename_0.replace("\\", "/"), + # # Asset dependency to wait for at least the scene file to sync. + # "AssetDependency0": script_path }, "PluginInfo": { # Input diff --git a/pype/plugins/ftrack/publish/collect_ftrack_api.py b/pype/plugins/ftrack/publish/collect_ftrack_api.py index 151b8882a3..bbda6da3b0 100644 --- a/pype/plugins/ftrack/publish/collect_ftrack_api.py +++ b/pype/plugins/ftrack/publish/collect_ftrack_api.py @@ -96,6 +96,6 @@ class CollectFtrackApi(pyblish.api.ContextPlugin): task_entity = None self.log.warning("Task name is not set.") - context.data["ftrackProject"] = asset_entity + context.data["ftrackProject"] = project_entity context.data["ftrackEntity"] = asset_entity context.data["ftrackTask"] = task_entity diff --git a/pype/plugins/ftrack/publish/integrate_ftrack_api.py b/pype/plugins/ftrack/publish/integrate_ftrack_api.py index cd94b2a150..0c4c6d49b5 100644 --- a/pype/plugins/ftrack/publish/integrate_ftrack_api.py +++ b/pype/plugins/ftrack/publish/integrate_ftrack_api.py @@ -54,8 +54,52 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): self.log.debug(query) return query - def process(self, instance): + def _set_task_status(self, instance, task_entity, session): + project_entity = instance.context.data.get("ftrackProject") + if not project_entity: + self.log.info("Task status won't be set, project is not known.") + return + if not task_entity: + self.log.info("Task status won't be set, task is not known.") + return + + status_name = instance.context.data.get("ftrackStatus") + if not status_name: + self.log.info("Ftrack status name is not set.") + return + + self.log.debug( + "Ftrack status name will be (maybe) set to \"{}\"".format( + status_name + ) + ) + + project_schema = project_entity["project_schema"] + task_statuses = project_schema.get_statuses( + "Task", task_entity["type_id"] + ) + task_statuses_by_low_name = { + status["name"].lower(): status for status in task_statuses + } + status = task_statuses_by_low_name.get(status_name.lower()) + if not status: + self.log.warning(( + "Task status \"{}\" won't be set," + " status is now allowed on task type \"{}\"." + ).format(status_name, task_entity["type"]["name"])) + return + + self.log.info("Setting task status to \"{}\"".format(status_name)) + task_entity["status"] = status + try: + session.commit() + except Exception: + tp, value, tb = sys.exc_info() + session.rollback() + six.reraise(tp, value, tb) + + def process(self, instance): session = instance.context.data["ftrackSession"] if instance.data.get("ftrackTask"): task = instance.data["ftrackTask"] @@ -78,9 +122,11 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): info_msg += ", metadata: {metadata}." used_asset_versions = [] + + self._set_task_status(instance, task, session) + # Iterate over components and publish for data in instance.data.get("ftrackComponentsList", []): - # AssetType # Get existing entity. assettype_data = {"short": "upload"} @@ -94,9 +140,9 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): # Create a new entity if none exits. if not assettype_entity: assettype_entity = session.create("AssetType", assettype_data) - self.log.debug( - "Created new AssetType with data: ".format(assettype_data) - ) + self.log.debug("Created new AssetType with data: {}".format( + assettype_data + )) # Asset # Get existing entity. diff --git a/pype/plugins/ftrack/publish/integrate_hierarchy_ftrack.py b/pype/plugins/ftrack/publish/integrate_hierarchy_ftrack.py index a12fdfd36c..a0059c55a6 100644 --- a/pype/plugins/ftrack/publish/integrate_hierarchy_ftrack.py +++ b/pype/plugins/ftrack/publish/integrate_hierarchy_ftrack.py @@ -1,9 +1,13 @@ import sys - import six import pyblish.api from avalon import io +try: + from pype.modules.ftrack.lib.avalon_sync import CUST_ATTR_AUTO_SYNC +except Exception: + CUST_ATTR_AUTO_SYNC = "avalon_auto_sync" + class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): """ @@ -39,15 +43,32 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): if "hierarchyContext" not in context.data: return + self.session = self.context.data["ftrackSession"] + project_name = self.context.data["projectEntity"]["name"] + query = 'Project where full_name is "{}"'.format(project_name) + project = self.session.query(query).one() + auto_sync_state = project[ + "custom_attributes"][CUST_ATTR_AUTO_SYNC] + if not io.Session: io.install() self.ft_project = None - self.session = context.data["ftrackSession"] input_data = context.data["hierarchyContext"] - self.import_to_ftrack(input_data) + # disable termporarily ftrack project's autosyncing + if auto_sync_state: + self.auto_sync_off(project) + + try: + # import ftrack hierarchy + self.import_to_ftrack(input_data) + except Exception: + raise + finally: + if auto_sync_state: + self.auto_sync_on(project) def import_to_ftrack(self, input_data, parent=None): for entity_name in input_data: @@ -217,3 +238,28 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): six.reraise(tp, value, tb) return entity + + def auto_sync_off(self, project): + project["custom_attributes"][CUST_ATTR_AUTO_SYNC] = False + + self.log.info("Ftrack autosync swithed off") + + try: + self.session.commit() + except Exception: + tp, value, tb = sys.exc_info() + self.session.rollback() + raise + + def auto_sync_on(self, project): + + project["custom_attributes"][CUST_ATTR_AUTO_SYNC] = True + + self.log.info("Ftrack autosync swithed on") + + try: + self.session.commit() + except Exception: + tp, value, tb = sys.exc_info() + self.session.rollback() + raise diff --git a/pype/plugins/global/load/open_djv.py b/pype/plugins/global/load/open_djv.py index 650936a4dc..a500333875 100644 --- a/pype/plugins/global/load/open_djv.py +++ b/pype/plugins/global/load/open_djv.py @@ -1,34 +1,27 @@ import os import subprocess -import json -from pype.api import config from avalon import api -def get_families(): - families = [] - paths = config.get_presets().get("djv_view", {}).get("config", {}).get( - "djv_paths", [] - ) - for path in paths: +def existing_djv_path(): + djv_paths = os.environ.get("DJV_PATH") or "" + for path in djv_paths.split(os.pathsep): if os.path.exists(path): - families.append("*") - break - return families - - -def get_representation(): - return config.get_presets().get("djv_view", {}).get("config", {}).get( - 'file_ext', [] - ) + return path + return None class OpenInDJV(api.Loader): """Open Image Sequence with system default""" - config_data = config.get_presets().get("djv_view", {}).get("config", {}) - families = get_families() - representations = get_representation() + djv_path = existing_djv_path() + families = ["*"] if djv_path else [] + representations = [ + "cin", "dpx", "avi", "dv", "gif", "flv", "mkv", "mov", "mpg", "mpeg", + "mp4", "m4v", "mxf", "iff", "z", "ifl", "jpeg", "jpg", "jfif", "lut", + "1dl", "exr", "pic", "png", "ppm", "pnm", "pgm", "pbm", "rla", "rpf", + "sgi", "rgba", "rgb", "bw", "tga", "tiff", "tif", "img" + ] label = "Open in DJV" order = -10 @@ -36,14 +29,6 @@ class OpenInDJV(api.Loader): color = "orange" def load(self, context, name, namespace, data): - self.djv_path = None - paths = config.get_presets().get("djv_view", {}).get("config", {}).get( - "djv_paths", [] - ) - for path in paths: - if os.path.exists(path): - self.djv_path = path - break directory = os.path.dirname(self.fname) from avalon.vendor import clique diff --git a/pype/plugins/global/publish/integrate_new.py b/pype/plugins/global/publish/integrate_new.py index 6d5c693b79..1d50e24e86 100644 --- a/pype/plugins/global/publish/integrate_new.py +++ b/pype/plugins/global/publish/integrate_new.py @@ -83,6 +83,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "textures", "action", "harmony.template", + "harmony.palette", "editorial" ] exclude_families = ["clip"] @@ -515,12 +516,6 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): instance: the instance to integrate """ transfers = instance.data.get("transfers", list()) - - for src, dest in transfers: - if os.path.normpath(src) != os.path.normpath(dest): - self.copy_file(src, dest) - - transfers = instance.data.get("transfers", list()) for src, dest in transfers: self.copy_file(src, dest) @@ -558,12 +553,12 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): # copy file with speedcopy and check if size of files are simetrical while True: + import shutil try: copyfile(src, dst) - except (OSError, AttributeError) as e: - self.log.warning(e) - # try it again with shutil - import shutil + except shutil.SameFileError as sfe: + self.log.critical("files are the same {} to {}".format(src, dst)) + os.remove(dst) try: shutil.copyfile(src, dst) self.log.debug("Copying files with shutil...") @@ -607,7 +602,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "type": "subset", "name": subset_name, "data": { - "families": instance.data.get('families') + "families": instance.data.get("families", []) }, "parent": asset["_id"] }).inserted_id @@ -747,6 +742,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): value += 1 if value > highest_value: + matching_profiles = {} highest_value = value if value == highest_value: diff --git a/pype/plugins/global/publish/submit_publish_job.py b/pype/plugins/global/publish/submit_publish_job.py index 10c01886fa..9f89466c31 100644 --- a/pype/plugins/global/publish/submit_publish_job.py +++ b/pype/plugins/global/publish/submit_publish_job.py @@ -12,7 +12,15 @@ from avalon.vendor import requests, clique import pyblish.api -def _get_script(): +def _get_script(path): + + # pass input path if exists + if path: + if os.path.exists(path): + return str(path) + else: + raise + """Get path to the image sequence script.""" try: from pathlib import Path @@ -192,6 +200,38 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): families_transfer = ["render3d", "render2d", "ftrack", "slate"] plugin_python_version = "3.7" + # script path for publish_filesequence.py + publishing_script = None + + def _create_metadata_path(self, instance): + ins_data = instance.data + # Ensure output dir exists + output_dir = ins_data.get("publishRenderFolder", ins_data["outputDir"]) + + try: + if not os.path.isdir(output_dir): + os.makedirs(output_dir) + except OSError: + # directory is not available + self.log.warning("Path is unreachable: `{}`".format(output_dir)) + + metadata_filename = "{}_metadata.json".format(ins_data["subset"]) + + metadata_path = os.path.join(output_dir, metadata_filename) + + # Convert output dir to `{root}/rest/of/path/...` with Anatomy + success, roothless_mtdt_p = self.anatomy.find_root_template_from_path( + metadata_path) + if not success: + # `rootless_path` is not set to `output_dir` if none of roots match + self.log.warning(( + "Could not find root path for remapping \"{}\"." + " This may cause issues on farm." + ).format(output_dir)) + roothless_mtdt_p = metadata_path + + return (metadata_path, roothless_mtdt_p) + def _submit_deadline_post_job(self, instance, job): """Submit publish job to Deadline. @@ -205,17 +245,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): job_name = "Publish - {subset}".format(subset=subset) output_dir = instance.data["outputDir"] - # Convert output dir to `{root}/rest/of/path/...` with Anatomy - success, rootless_path = ( - self.anatomy.find_root_template_from_path(output_dir) - ) - if not success: - # `rootless_path` is not set to `output_dir` if none of roots match - self.log.warning(( - "Could not find root path for remapping \"{}\"." - " This may cause issues on farm." - ).format(output_dir)) - rootless_path = output_dir # Generate the payload for Deadline submission payload = { @@ -239,7 +268,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): }, "PluginInfo": { "Version": self.plugin_python_version, - "ScriptFile": _get_script(), + "ScriptFile": _get_script(self.publishing_script), "Arguments": "", "SingleFrameOnly": "True", }, @@ -249,11 +278,11 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # Transfer the environment from the original job to this dependent # job so they use the same environment - metadata_filename = "{}_metadata.json".format(subset) - metadata_path = os.path.join(rootless_path, metadata_filename) + metadata_path, roothless_metadata_path = self._create_metadata_path( + instance) environment = job["Props"].get("Env", {}) - environment["PYPE_METADATA_FILE"] = metadata_path + environment["PYPE_METADATA_FILE"] = roothless_metadata_path environment["AVALON_PROJECT"] = io.Session["AVALON_PROJECT"] environment["PYPE_LOG_NO_COLORS"] = "1" try: @@ -488,7 +517,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): if bake_render_path: preview = False - if "celaction" in self.hosts: + if "celaction" in pyblish.api.registered_hosts(): preview = True staging = os.path.dirname(list(collection)[0]) @@ -847,14 +876,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): } publish_job.update({"ftrack": ftrack}) - # Ensure output dir exists - output_dir = instance.data["outputDir"] - if not os.path.isdir(output_dir): - os.makedirs(output_dir) + metadata_path, roothless_metadata_path = self._create_metadata_path( + instance) - metadata_filename = "{}_metadata.json".format(subset) - - metadata_path = os.path.join(output_dir, metadata_filename) self.log.info("Writing json file: {}".format(metadata_path)) with open(metadata_path, "w") as f: json.dump(publish_job, f, indent=4, sort_keys=True) diff --git a/pype/plugins/global/publish/validate_containers.py b/pype/plugins/global/publish/validate_containers.py index 44cb5def3c..1bf4967ec2 100644 --- a/pype/plugins/global/publish/validate_containers.py +++ b/pype/plugins/global/publish/validate_containers.py @@ -19,7 +19,7 @@ class ValidateContainers(pyblish.api.ContextPlugin): label = "Validate Containers" order = pyblish.api.ValidatorOrder - hosts = ["maya", "houdini", "nuke"] + hosts = ["maya", "houdini", "nuke", "harmony", "photoshop"] optional = True actions = [ShowInventory] diff --git a/pype/plugins/harmony/load/load_imagesequence.py b/pype/plugins/harmony/load/load_imagesequence.py index 7862e027af..f81018d0fb 100644 --- a/pype/plugins/harmony/load/load_imagesequence.py +++ b/pype/plugins/harmony/load/load_imagesequence.py @@ -1,8 +1,10 @@ import os +import uuid import clique from avalon import api, harmony +import pype.lib copy_files = """function copyFile(srcFilename, dstFilename) { @@ -98,33 +100,63 @@ function import_files(args) transparencyModeAttr.setValue(SGITransparencyMode); if (extension == "psd") transparencyModeAttr.setValue(FlatPSDTransparencyMode); + if (extension == "jpg") + transparencyModeAttr.setValue(LayeredPSDTransparencyMode); node.linkAttr(read, "DRAWING.ELEMENT", uniqueColumnName); - // Create a drawing for each file. - for( var i =0; i <= files.length - 1; ++i) + if (files.length == 1) { - timing = start_frame + i // Create a drawing drawing, 'true' indicate that the file exists. - Drawing.create(elemId, timing, true); + Drawing.create(elemId, 1, true); // Get the actual path, in tmp folder. - var drawingFilePath = Drawing.filename(elemId, timing.toString()); - copyFile( files[i], drawingFilePath ); + var drawingFilePath = Drawing.filename(elemId, "1"); + copyFile(files[0], drawingFilePath); + // Expose the image for the entire frame range. + for( var i =0; i <= frame.numberOf() - 1; ++i) + { + timing = start_frame + i + column.setEntry(uniqueColumnName, 1, timing, "1"); + } + } else { + // Create a drawing for each file. + for( var i =0; i <= files.length - 1; ++i) + { + timing = start_frame + i + // Create a drawing drawing, 'true' indicate that the file exists. + Drawing.create(elemId, timing, true); + // Get the actual path, in tmp folder. + var drawingFilePath = Drawing.filename(elemId, timing.toString()); + copyFile( files[i], drawingFilePath ); - column.setEntry(uniqueColumnName, 1, timing, timing.toString()); + column.setEntry(uniqueColumnName, 1, timing, timing.toString()); + } } + + var green_color = new ColorRGBA(0, 255, 0, 255); + node.setColor(read, green_color); + return read; } import_files """ -replace_files = """function replace_files(args) +replace_files = """var PNGTransparencyMode = 0; //Premultiplied wih Black +var TGATransparencyMode = 0; //Premultiplied wih Black +var SGITransparencyMode = 0; //Premultiplied wih Black +var LayeredPSDTransparencyMode = 1; //Straight +var FlatPSDTransparencyMode = 2; //Premultiplied wih White + +function replace_files(args) { var files = args[0]; + MessageLog.trace(files); + MessageLog.trace(files.length); var _node = args[1]; var start_frame = args[2]; var _column = node.linkedColumn(_node, "DRAWING.ELEMENT"); + var elemId = column.getElementIdOfDrawing(_column); // Delete existing drawings. var timings = column.getDrawingTimings(_column); @@ -133,20 +165,62 @@ replace_files = """function replace_files(args) column.deleteDrawingAt(_column, parseInt(timings[i])); } - // Create new drawings. - for( var i =0; i <= files.length - 1; ++i) - { - timing = start_frame + i - // Create a drawing drawing, 'true' indicate that the file exists. - Drawing.create(node.getElementId(_node), timing, true); - // Get the actual path, in tmp folder. - var drawingFilePath = Drawing.filename( - node.getElementId(_node), timing.toString() - ); - copyFile( files[i], drawingFilePath ); - column.setEntry(_column, 1, timing, timing.toString()); + var filename = files[0]; + var pos = filename.lastIndexOf("."); + if( pos < 0 ) + return null; + var extension = filename.substr(pos+1).toLowerCase(); + + if(extension == "jpeg") + extension = "jpg"; + + var transparencyModeAttr = node.getAttr( + _node, frame.current(), "applyMatteToColor" + ); + if (extension == "png") + transparencyModeAttr.setValue(PNGTransparencyMode); + if (extension == "tga") + transparencyModeAttr.setValue(TGATransparencyMode); + if (extension == "sgi") + transparencyModeAttr.setValue(SGITransparencyMode); + if (extension == "psd") + transparencyModeAttr.setValue(FlatPSDTransparencyMode); + if (extension == "jpg") + transparencyModeAttr.setValue(LayeredPSDTransparencyMode); + + if (files.length == 1) + { + // Create a drawing drawing, 'true' indicate that the file exists. + Drawing.create(elemId, 1, true); + // Get the actual path, in tmp folder. + var drawingFilePath = Drawing.filename(elemId, "1"); + copyFile(files[0], drawingFilePath); + MessageLog.trace(files[0]); + MessageLog.trace(drawingFilePath); + // Expose the image for the entire frame range. + for( var i =0; i <= frame.numberOf() - 1; ++i) + { + timing = start_frame + i + column.setEntry(_column, 1, timing, "1"); + } + } else { + // Create a drawing for each file. + for( var i =0; i <= files.length - 1; ++i) + { + timing = start_frame + i + // Create a drawing drawing, 'true' indicate that the file exists. + Drawing.create(elemId, timing, true); + // Get the actual path, in tmp folder. + var drawingFilePath = Drawing.filename(elemId, timing.toString()); + copyFile( files[i], drawingFilePath ); + + column.setEntry(_column, 1, timing, timing.toString()); + } } + + var green_color = new ColorRGBA(0, 255, 0, 255); + node.setColor(_node, green_color); } replace_files """ @@ -156,8 +230,8 @@ class ImageSequenceLoader(api.Loader): """Load images Stores the imported asset in a container named after the asset. """ - families = ["shot", "render"] - representations = ["jpeg", "png"] + families = ["shot", "render", "image"] + representations = ["jpeg", "png", "jpg"] def load(self, context, name=None, namespace=None, data=None): @@ -165,20 +239,29 @@ class ImageSequenceLoader(api.Loader): os.listdir(os.path.dirname(self.fname)) ) files = [] - for f in list(collections[0]): + if collections: + for f in list(collections[0]): + files.append( + os.path.join( + os.path.dirname(self.fname), f + ).replace("\\", "/") + ) + else: files.append( - os.path.join(os.path.dirname(self.fname), f).replace("\\", "/") + os.path.join( + os.path.dirname(self.fname), remainder[0] + ).replace("\\", "/") ) + name = context["subset"]["name"] + name += "_{}".format(uuid.uuid4()) read_node = harmony.send( { "function": copy_files + import_files, - "args": ["Top", files, context["subset"]["name"], 1] + "args": ["Top", files, name, 1] } )["result"] - self[:] = [read_node] - return harmony.containerise( name, namespace, @@ -188,17 +271,25 @@ class ImageSequenceLoader(api.Loader): ) def update(self, container, representation): - node = container.pop("node") + node = harmony.find_node_by_name(container["name"], "READ") + path = api.get_representation_path(representation) collections, remainder = clique.assemble( - os.listdir( - os.path.dirname(api.get_representation_path(representation)) - ) + os.listdir(os.path.dirname(path)) ) files = [] - for f in list(collections[0]): + if collections: + for f in list(collections[0]): + files.append( + os.path.join( + os.path.dirname(path), f + ).replace("\\", "/") + ) + else: files.append( - os.path.join(os.path.dirname(self.fname), f).replace("\\", "/") + os.path.join( + os.path.dirname(path), remainder[0] + ).replace("\\", "/") ) harmony.send( @@ -208,12 +299,34 @@ class ImageSequenceLoader(api.Loader): } ) + # Colour node. + func = """function func(args){ + for( var i =0; i <= args[0].length - 1; ++i) + { + var red_color = new ColorRGBA(255, 0, 0, 255); + var green_color = new ColorRGBA(0, 255, 0, 255); + if (args[1] == "red"){ + node.setColor(args[0], red_color); + } + if (args[1] == "green"){ + node.setColor(args[0], green_color); + } + } + } + func + """ + if pype.lib.is_latest(representation): + harmony.send({"function": func, "args": [node, "green"]}) + else: + harmony.send({"function": func, "args": [node, "red"]}) + harmony.imprint( node, {"representation": str(representation["_id"])} ) def remove(self, container): - node = container.pop("node") + node = harmony.find_node_by_name(container["name"], "READ") + func = """function deleteNode(_node) { node.deleteNode(_node, true, true); diff --git a/pype/plugins/harmony/load/load_palette.py b/pype/plugins/harmony/load/load_palette.py new file mode 100644 index 0000000000..001758d5a8 --- /dev/null +++ b/pype/plugins/harmony/load/load_palette.py @@ -0,0 +1,66 @@ +import os +import shutil + +from avalon import api, harmony +from avalon.vendor import Qt + + +class ImportPaletteLoader(api.Loader): + """Import palettes.""" + + families = ["harmony.palette"] + representations = ["plt"] + label = "Import Palette" + + def load(self, context, name=None, namespace=None, data=None): + name = self.load_palette(context["representation"]) + + return harmony.containerise( + name, + namespace, + name, + context, + self.__class__.__name__ + ) + + def load_palette(self, representation): + subset_name = representation["context"]["subset"] + name = subset_name.replace("palette", "") + + # Overwrite palette on disk. + scene_path = harmony.send( + {"function": "scene.currentProjectPath"} + )["result"] + src = api.get_representation_path(representation) + dst = os.path.join( + scene_path, + "palette-library", + "{}.plt".format(name) + ) + shutil.copy(src, dst) + + harmony.save_scene() + + # Dont allow instances with the same name. + message_box = Qt.QtWidgets.QMessageBox() + message_box.setIcon(Qt.QtWidgets.QMessageBox.Warning) + msg = "Updated {}.".format(subset_name) + msg += " You need to reload the scene to see the changes." + message_box.setText(msg) + message_box.exec_() + + return name + + def remove(self, container): + harmony.remove(container["name"]) + + def switch(self, container, representation): + self.update(container, representation) + + def update(self, container, representation): + self.remove(container) + name = self.load_palette(representation) + + container["representation"] = str(representation["_id"]) + container["name"] = name + harmony.imprint(name, container) diff --git a/pype/plugins/harmony/load/load_template_workfile.py b/pype/plugins/harmony/load/load_template_workfile.py index a9dcd0c776..db67f20ff7 100644 --- a/pype/plugins/harmony/load/load_template_workfile.py +++ b/pype/plugins/harmony/load/load_template_workfile.py @@ -9,7 +9,7 @@ from avalon import api, harmony class ImportTemplateLoader(api.Loader): """Import templates.""" - families = ["harmony.template"] + families = ["harmony.template", "workfile"] representations = ["*"] label = "Import Template" @@ -40,5 +40,5 @@ class ImportWorkfileLoader(ImportTemplateLoader): """Import workfiles.""" families = ["workfile"] - representations = ["*"] + representations = ["zip"] label = "Import Workfile" diff --git a/pype/plugins/harmony/publish/collect_palettes.py b/pype/plugins/harmony/publish/collect_palettes.py new file mode 100644 index 0000000000..2a2c1066c0 --- /dev/null +++ b/pype/plugins/harmony/publish/collect_palettes.py @@ -0,0 +1,45 @@ +import os +import json + +import pyblish.api +from avalon import harmony + + +class CollectPalettes(pyblish.api.ContextPlugin): + """Gather palettes from scene when publishing templates.""" + + label = "Palettes" + order = pyblish.api.CollectorOrder + hosts = ["harmony"] + + def process(self, context): + func = """function func() + { + var palette_list = PaletteObjectManager.getScenePaletteList(); + + var palettes = {}; + for(var i=0; i < palette_list.numPalettes; ++i) + { + var palette = palette_list.getPaletteByIndex(i); + palettes[palette.getName()] = palette.id; + } + + return palettes; + } + func + """ + palettes = harmony.send({"function": func})["result"] + + for name, id in palettes.items(): + instance = context.create_instance(name) + instance.data.update({ + "id": id, + "family": "harmony.palette", + "asset": os.environ["AVALON_ASSET"], + "subset": "palette" + name + }) + self.log.info( + "Created instance:\n" + json.dumps( + instance.data, sort_keys=True, indent=4 + ) + ) diff --git a/pype/plugins/harmony/publish/extract_palette.py b/pype/plugins/harmony/publish/extract_palette.py new file mode 100644 index 0000000000..9bca005278 --- /dev/null +++ b/pype/plugins/harmony/publish/extract_palette.py @@ -0,0 +1,34 @@ +import os + +from avalon import harmony +import pype.api +import pype.hosts.harmony + + +class ExtractPalette(pype.api.Extractor): + """Extract palette.""" + + label = "Extract Palette" + hosts = ["harmony"] + families = ["harmony.palette"] + + def process(self, instance): + func = """function func(args) + { + var palette_list = PaletteObjectManager.getScenePaletteList(); + var palette = palette_list.getPaletteById(args[0]); + return (palette.getPath() + "/" + palette.getName() + ".plt"); + } + func + """ + palette_file = harmony.send( + {"function": func, "args": [instance.data["id"]]} + )["result"] + + representation = { + "name": "plt", + "ext": "plt", + "files": os.path.basename(palette_file), + "stagingDir": os.path.dirname(palette_file) + } + instance.data["representations"] = [representation] diff --git a/pype/plugins/harmony/publish/extract_render.py b/pype/plugins/harmony/publish/extract_render.py index 7ca83d3f0f..fe1352f9f9 100644 --- a/pype/plugins/harmony/publish/extract_render.py +++ b/pype/plugins/harmony/publish/extract_render.py @@ -111,13 +111,22 @@ class ExtractRender(pyblish.api.InstancePlugin): # Generate mov. mov_path = os.path.join(path, instance.data["name"] + ".mov") - args = [ - "ffmpeg", "-y", - "-i", audio_path, - "-i", - os.path.join(path, collection.head + "%04d" + collection.tail), - mov_path - ] + if os.path.isfile(audio_path): + args = [ + "ffmpeg", "-y", + "-i", audio_path, + "-i", + os.path.join(path, collection.head + "%04d" + collection.tail), + mov_path + ] + else: + args = [ + "ffmpeg", "-y", + "-i", + os.path.join(path, collection.head + "%04d" + collection.tail), + mov_path + ] + process = subprocess.Popen( args, stdout=subprocess.PIPE, diff --git a/pype/plugins/harmony/publish/validate_audio.py b/pype/plugins/harmony/publish/validate_audio.py new file mode 100644 index 0000000000..ba113e7610 --- /dev/null +++ b/pype/plugins/harmony/publish/validate_audio.py @@ -0,0 +1,37 @@ +import json +import os + +import pyblish.api + +import avalon.harmony +import pype.hosts.harmony + + +class ValidateAudio(pyblish.api.InstancePlugin): + """Ensures that there is an audio file in the scene. If you are sure that you want to send render without audio, you can disable this validator before clicking on "publish" """ + + order = pyblish.api.ValidatorOrder + label = "Validate Audio" + families = ["render"] + hosts = ["harmony"] + optional = True + + def process(self, instance): + # Collect scene data. + func = """function func(write_node) + { + return [ + sound.getSoundtrackAll().path() + ] + } + func + """ + result = avalon.harmony.send( + {"function": func, "args": [instance[0]]} + )["result"] + + audio_path = result[0] + + msg = "You are missing audio file:\n{}".format(audio_path) + + assert os.path.isfile(audio_path), msg diff --git a/pype/plugins/maya/load/load_image_plane.py b/pype/plugins/maya/load/load_image_plane.py index e95ea6cd8f..653a8d4128 100644 --- a/pype/plugins/maya/load/load_image_plane.py +++ b/pype/plugins/maya/load/load_image_plane.py @@ -50,8 +50,11 @@ class ImagePlaneLoader(api.Loader): camera = selection[0] - camera.displayResolution.set(1) - camera.farClipPlane.set(image_plane_depth * 10) + try: + camera.displayResolution.set(1) + camera.farClipPlane.set(image_plane_depth * 10) + except RuntimeError: + pass # Create image plane image_plane_transform, image_plane_shape = pc.imagePlane( diff --git a/pype/plugins/maya/publish/validate_transform_naming_suffix.py b/pype/plugins/maya/publish/validate_transform_naming_suffix.py index 17066f6b12..120123af4b 100644 --- a/pype/plugins/maya/publish/validate_transform_naming_suffix.py +++ b/pype/plugins/maya/publish/validate_transform_naming_suffix.py @@ -103,9 +103,7 @@ class ValidateTransformNamingSuffix(pyblish.api.InstancePlugin): instance (:class:`pyblish.api.Instance`): published instance. """ - invalid = self.get_invalid(instance, - self.SUFFIX_NAMING_TABLE, - self.ALLOW_IF_NOT_IN_SUFFIX_TABLE) + invalid = self.get_invalid(instance) if invalid: raise ValueError("Incorrectly named geometry " "transforms: {0}".format(invalid)) diff --git a/pype/plugins/nuke/publish/submit_nuke_deadline.py b/pype/plugins/nuke/publish/submit_nuke_deadline.py index 3731cd25f0..26d3f9b571 100644 --- a/pype/plugins/nuke/publish/submit_nuke_deadline.py +++ b/pype/plugins/nuke/publish/submit_nuke_deadline.py @@ -49,6 +49,24 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin): render_path = instance.data['path'] script_path = context.data["currentFile"] + for item in context: + if "workfile" in item.data["families"]: + msg = "Workfile (scene) must be published along" + assert item.data["publish"] is True, msg + + template_data = item.data.get("anatomyData") + rep = item.data.get("representations")[0].get("name") + template_data["representation"] = rep + template_data["ext"] = rep + template_data["comment"] = None + anatomy_filled = context.data["anatomy"].format(template_data) + template_filled = anatomy_filled["publish"]["path"] + script_path = os.path.normpath(template_filled) + + self.log.info( + "Using published scene for render {}".format(script_path) + ) + # exception for slate workflow if "slate" in instance.data["families"]: self._frame_start -= 1 @@ -120,7 +138,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin): chunk_size = self.deadline_chunk_size priority = instance.data.get("deadlinePriority") - if priority != 50: + if not priority: priority = self.deadline_priority payload = { diff --git a/pype/plugins/nuke/publish/validate_write_knobs.py b/pype/plugins/nuke/publish/validate_knobs.py similarity index 61% rename from pype/plugins/nuke/publish/validate_write_knobs.py rename to pype/plugins/nuke/publish/validate_knobs.py index 24572bedb3..22f0d344c9 100644 --- a/pype/plugins/nuke/publish/validate_write_knobs.py +++ b/pype/plugins/nuke/publish/validate_knobs.py @@ -4,14 +4,14 @@ import pyblish.api import pype.api -class ValidateNukeWriteKnobs(pyblish.api.ContextPlugin): +class ValidateKnobs(pyblish.api.ContextPlugin): """Ensure knobs are consistent. Knobs to validate and their values comes from the Example for presets in config: "presets/plugins/nuke/publish.json" preset, which needs this structure: - "ValidateNukeWriteKnobs": { + "ValidateKnobs": { "enabled": true, "knobs": { "family": { @@ -22,22 +22,31 @@ class ValidateNukeWriteKnobs(pyblish.api.ContextPlugin): """ order = pyblish.api.ValidatorOrder - label = "Validate Write Knobs" + label = "Validate Knobs" hosts = ["nuke"] actions = [pype.api.RepairContextAction] optional = True def process(self, context): - # Check for preset existence. - if not getattr(self, "knobs"): + nuke_presets = context.data["presets"].get("nuke") + + if not nuke_presets: + return + + publish_presets = nuke_presets.get("publish") + + if not publish_presets: + return + + plugin_preset = publish_presets.get("ValidateKnobs") + + if not plugin_preset: return - - self.log.debug("__ self.knobs: {}".format(self.knobs)) invalid = self.get_invalid(context, compute=True) if invalid: raise RuntimeError( - "Found knobs with invalid values: {}".format(invalid) + "Found knobs with invalid values:\n{}".format(invalid) ) @classmethod @@ -51,6 +60,8 @@ class ValidateNukeWriteKnobs(pyblish.api.ContextPlugin): @classmethod def get_invalid_knobs(cls, context): invalid_knobs = [] + publish_presets = context.data["presets"]["nuke"]["publish"] + knobs_preset = publish_presets["ValidateKnobs"]["knobs"] for instance in context: # Filter publisable instances. if not instance.data["publish"]: @@ -59,15 +70,15 @@ class ValidateNukeWriteKnobs(pyblish.api.ContextPlugin): # Filter families. families = [instance.data["family"]] families += instance.data.get("families", []) - families = list(set(families) & set(cls.knobs.keys())) + families = list(set(families) & set(knobs_preset.keys())) if not families: continue # Get all knobs to validate. knobs = {} for family in families: - for preset in cls.knobs[family]: - knobs.update({preset: cls.knobs[family][preset]}) + for preset in knobs_preset[family]: + knobs.update({preset: knobs_preset[family][preset]}) # Get invalid knobs. nodes = [] @@ -82,16 +93,20 @@ class ValidateNukeWriteKnobs(pyblish.api.ContextPlugin): for node in nodes: for knob in node.knobs(): - if knob in knobs.keys(): - expected = knobs[knob] - if node[knob].value() != expected: - invalid_knobs.append( - { - "knob": node[knob], - "expected": expected, - "current": node[knob].value() - } - ) + if knob not in knobs.keys(): + continue + + expected = knobs[knob] + if node[knob].value() != expected: + invalid_knobs.append( + { + "knob": node[knob], + "name": node[knob].name(), + "label": node[knob].label(), + "expected": expected, + "current": node[knob].value() + } + ) context.data["invalid_knobs"] = invalid_knobs return invalid_knobs diff --git a/pype/plugins/photoshop/create/create_image.py b/pype/plugins/photoshop/create/create_image.py index ff0a5dcb6c..5b2f9f7981 100644 --- a/pype/plugins/photoshop/create/create_image.py +++ b/pype/plugins/photoshop/create/create_image.py @@ -74,4 +74,5 @@ class CreateImage(api.Creator): groups.append(group) for group in groups: + self.data.update({"subset": "image" + group.Name}) photoshop.imprint(group, self.data) diff --git a/pype/plugins/photoshop/publish/collect_review.py b/pype/plugins/photoshop/publish/collect_review.py new file mode 100644 index 0000000000..30042d188b --- /dev/null +++ b/pype/plugins/photoshop/publish/collect_review.py @@ -0,0 +1,36 @@ +import os + +import pythoncom + +import pyblish.api + + +class CollectReview(pyblish.api.ContextPlugin): + """Gather the active document as review instance.""" + + label = "Review" + order = pyblish.api.CollectorOrder + hosts = ["photoshop"] + + def process(self, context): + # Necessary call when running in a different thread which pyblish-qml + # can be. + pythoncom.CoInitialize() + + family = "review" + task = os.getenv("AVALON_TASK", None) + subset = family + task.capitalize() + + file_path = context.data["currentFile"] + base_name = os.path.basename(file_path) + + instance = context.create_instance(subset) + instance.data.update({ + "subset": subset, + "label": base_name, + "name": base_name, + "family": family, + "families": ["ftrack"], + "representations": [], + "asset": os.environ["AVALON_ASSET"] + }) diff --git a/pype/plugins/photoshop/publish/extract_review.py b/pype/plugins/photoshop/publish/extract_review.py new file mode 100644 index 0000000000..d784dc0998 --- /dev/null +++ b/pype/plugins/photoshop/publish/extract_review.py @@ -0,0 +1,105 @@ +import os + +import pype.api +import pype.lib +from avalon import photoshop + + +class ExtractReview(pype.api.Extractor): + """Produce a flattened image file from all instances.""" + + label = "Extract Review" + hosts = ["photoshop"] + families = ["review"] + + def process(self, instance): + + staging_dir = self.staging_dir(instance) + self.log.info("Outputting image to {}".format(staging_dir)) + + layers = [] + for image_instance in instance.context: + if image_instance.data["family"] != "image": + continue + layers.append(image_instance[0]) + + # Perform extraction + output_image = "{} copy.jpg".format( + os.path.splitext(photoshop.app().ActiveDocument.Name)[0] + ) + with photoshop.maintained_visibility(): + # Hide all other layers. + extract_ids = [ + x.id for x in photoshop.get_layers_in_layers(layers) + ] + for layer in photoshop.get_layers_in_document(): + if layer.id in extract_ids: + layer.Visible = True + else: + layer.Visible = False + + photoshop.app().ActiveDocument.SaveAs( + staging_dir, photoshop.com_objects.JPEGSaveOptions(), True + ) + + ffmpeg_path = pype.lib.get_ffmpeg_tool_path("ffmpeg") + + instance.data["representations"].append({ + "name": "jpg", + "ext": "jpg", + "files": output_image, + "stagingDir": staging_dir + }) + instance.data["stagingDir"] = staging_dir + + # Generate thumbnail. + thumbnail_path = os.path.join(staging_dir, "thumbnail.jpg") + args = [ + ffmpeg_path, "-y", + "-i", os.path.join(staging_dir, output_image), + "-vf", "scale=300:-1", + "-vframes", "1", + thumbnail_path + ] + output = pype.lib._subprocess(args) + + self.log.debug(output) + + instance.data["representations"].append({ + "name": "thumbnail", + "ext": "jpg", + "files": os.path.basename(thumbnail_path), + "stagingDir": staging_dir, + "tags": ["thumbnail"] + }) + + # Generate mov. + mov_path = os.path.join(staging_dir, "review.mov") + args = [ + ffmpeg_path, "-y", + "-i", os.path.join(staging_dir, output_image), + "-vframes", "1", + mov_path + ] + output = pype.lib._subprocess(args) + + self.log.debug(output) + + instance.data["representations"].append({ + "name": "mov", + "ext": "mov", + "files": os.path.basename(mov_path), + "stagingDir": staging_dir, + "frameStart": 1, + "frameEnd": 1, + "fps": 25, + "preview": True, + "tags": ["review", "ftrackreview"] + }) + + # Required for extract_review plugin (L222 onwards). + instance.data["frameStart"] = 1 + instance.data["frameEnd"] = 1 + instance.data["fps"] = 25 + + self.log.info(f"Extracted {instance} to {staging_dir}") diff --git a/pype/plugins/photoshop/publish/validate_naming.py b/pype/plugins/photoshop/publish/validate_naming.py index 1d85ea99a0..51e00da352 100644 --- a/pype/plugins/photoshop/publish/validate_naming.py +++ b/pype/plugins/photoshop/publish/validate_naming.py @@ -1,5 +1,6 @@ import pyblish.api import pype.api +from avalon import photoshop class ValidateNamingRepair(pyblish.api.Action): @@ -22,7 +23,11 @@ class ValidateNamingRepair(pyblish.api.Action): instances = pyblish.api.instances_by_plugin(failed, plugin) for instance in instances: - instance[0].Name = instance.data["name"].replace(" ", "_") + name = instance.data["name"].replace(" ", "_") + instance[0].Name = name + data = photoshop.read(instance[0]) + data["subset"] = "image" + name + photoshop.imprint(instance[0], data) return True @@ -42,3 +47,6 @@ class ValidateNaming(pyblish.api.InstancePlugin): def process(self, instance): msg = "Name \"{}\" is not allowed.".format(instance.data["name"]) assert " " not in instance.data["name"], msg + + msg = "Subset \"{}\" is not allowed.".format(instance.data["subset"]) + assert " " not in instance.data["subset"], msg diff --git a/pype/plugins/premiere/publish/collect_frameranges.py b/pype/plugins/premiere/publish/collect_frameranges.py index ffcc1023b5..075f84e8e3 100644 --- a/pype/plugins/premiere/publish/collect_frameranges.py +++ b/pype/plugins/premiere/publish/collect_frameranges.py @@ -11,7 +11,7 @@ class CollectFrameranges(pyblish.api.InstancePlugin): """ label = "Collect Clip Frameranges" - order = pyblish.api.CollectorOrder + order = pyblish.api.CollectorOrder - 0.01 families = ['clip'] def process(self, instance): diff --git a/pype/plugins/premiere/publish/collect_instance_representations.py b/pype/plugins/premiere/publish/collect_instance_representations.py index f53c60ad64..b62b47c473 100644 --- a/pype/plugins/premiere/publish/collect_instance_representations.py +++ b/pype/plugins/premiere/publish/collect_instance_representations.py @@ -12,7 +12,7 @@ class CollectClipRepresentations(pyblish.api.InstancePlugin): """ label = "Collect Clip Representations" - order = pyblish.api.CollectorOrder + order = pyblish.api.CollectorOrder + 0.1 families = ['clip'] def process(self, instance): diff --git a/pype/plugins/premiere/publish/validate_auto_sync_off.py b/pype/plugins/premiere/publish/validate_auto_sync_off.py deleted file mode 100644 index b6429cfa05..0000000000 --- a/pype/plugins/premiere/publish/validate_auto_sync_off.py +++ /dev/null @@ -1,58 +0,0 @@ -import sys -import pyblish.api -import pype.api -import avalon.api -import six - - -class ValidateAutoSyncOff(pyblish.api.ContextPlugin): - """Ensure that autosync value in ftrack project is set to False. - - In case was set to True and event server with the sync to avalon event - is running will cause integration to avalon will be override. - - """ - - order = pyblish.api.ValidatorOrder - families = ['clip'] - label = 'Ftrack project\'s auto sync off' - actions = [pype.api.RepairAction] - - def process(self, context): - session = context.data["ftrackSession"] - project_name = avalon.api.Session["AVALON_PROJECT"] - query = 'Project where full_name is "{}"'.format(project_name) - project = session.query(query).one() - invalid = self.get_invalid(context) - - assert not invalid, ( - "Ftrack Project has 'Auto sync' set to On." - " That may cause issues during integration." - ) - - @staticmethod - def get_invalid(context): - session = context.data["ftrackSession"] - project_name = avalon.api.Session["AVALON_PROJECT"] - query = 'Project where full_name is "{}"'.format(project_name) - project = session.query(query).one() - - invalid = None - - if project.get('custom_attributes', {}).get( - 'avalon_auto_sync', False): - invalid = project - - return invalid - - @classmethod - def repair(cls, context): - session = context.data["ftrackSession"] - invalid = cls.get_invalid(context) - invalid['custom_attributes']['avalon_auto_sync'] = False - try: - session.commit() - except Exception: - tp, value, tb = sys.exc_info() - session.rollback() - six.reraise(tp, value, tb) diff --git a/pype/plugins/standalonepublisher/publish/collect_shots.py b/pype/plugins/standalonepublisher/publish/collect_shots.py index 853ba4e8de..4f682bd808 100644 --- a/pype/plugins/standalonepublisher/publish/collect_shots.py +++ b/pype/plugins/standalonepublisher/publish/collect_shots.py @@ -56,12 +56,18 @@ class CollectShots(pyblish.api.InstancePlugin): asset_entity = instance.context.data["assetEntity"] asset_name = asset_entity["name"] + # Ask user for sequence start. Usually 10:00:00:00. + sequence_start_frame = 900000 + # Project specific prefix naming. This needs to be replaced with some # options to be more flexible. asset_name = asset_name.split("_")[0] instances = [] for track in tracks: + track_start_frame = ( + abs(track.source_range.start_time.value) - sequence_start_frame + ) for child in track.each_child(): # Transitions are ignored, because Clips have the full frame @@ -69,12 +75,17 @@ class CollectShots(pyblish.api.InstancePlugin): if isinstance(child, otio.schema.transition.Transition): continue + if child.name is None: + continue + # Hardcoded to expect a shot name of "[name].[extension]" child_name = os.path.splitext(child.name)[0].lower() name = f"{asset_name}_{child_name}" - frame_start = child.range_in_parent().start_time.value - frame_end = child.range_in_parent().end_time_inclusive().value + frame_start = track_start_frame + frame_start += child.range_in_parent().start_time.value + frame_end = track_start_frame + frame_end += child.range_in_parent().end_time_inclusive().value label = f"{name} (framerange: {frame_start}-{frame_end})" instances.append( diff --git a/pype/resources/__init__.py b/pype/resources/__init__.py index 248614ae9d..ba882a84fb 100644 --- a/pype/resources/__init__.py +++ b/pype/resources/__init__.py @@ -14,3 +14,25 @@ def get_resource(*args): *args ) ) + + +def pype_icon_filepath(debug=None): + if debug is None: + debug = bool(os.getenv("PYPE_DEV")) + + if debug: + icon_file_name = "pype_icon_dev.png" + else: + icon_file_name = "pype_icon.png" + return get_resource("icons", icon_file_name) + + +def pype_splash_filepath(debug=None): + if debug is None: + debug = bool(os.getenv("PYPE_DEV")) + + if debug: + splash_file_name = "pype_splash_dev.png" + else: + splash_file_name = "pype_splash.png" + return get_resource("icons", splash_file_name) diff --git a/res/app_icons/Aport.png b/pype/resources/app_icons/Aport.png similarity index 100% rename from res/app_icons/Aport.png rename to pype/resources/app_icons/Aport.png diff --git a/res/app_icons/blender.png b/pype/resources/app_icons/blender.png similarity index 100% rename from res/app_icons/blender.png rename to pype/resources/app_icons/blender.png diff --git a/res/app_icons/celaction_local.png b/pype/resources/app_icons/celaction_local.png similarity index 100% rename from res/app_icons/celaction_local.png rename to pype/resources/app_icons/celaction_local.png diff --git a/res/app_icons/celaction_remotel.png b/pype/resources/app_icons/celaction_remotel.png similarity index 100% rename from res/app_icons/celaction_remotel.png rename to pype/resources/app_icons/celaction_remotel.png diff --git a/res/app_icons/clockify-white.png b/pype/resources/app_icons/clockify-white.png similarity index 100% rename from res/app_icons/clockify-white.png rename to pype/resources/app_icons/clockify-white.png diff --git a/res/app_icons/clockify.png b/pype/resources/app_icons/clockify.png similarity index 100% rename from res/app_icons/clockify.png rename to pype/resources/app_icons/clockify.png diff --git a/res/app_icons/djvView.png b/pype/resources/app_icons/djvView.png similarity index 100% rename from res/app_icons/djvView.png rename to pype/resources/app_icons/djvView.png diff --git a/res/app_icons/harmony.png b/pype/resources/app_icons/harmony.png similarity index 100% rename from res/app_icons/harmony.png rename to pype/resources/app_icons/harmony.png diff --git a/res/app_icons/houdini.png b/pype/resources/app_icons/houdini.png similarity index 100% rename from res/app_icons/houdini.png rename to pype/resources/app_icons/houdini.png diff --git a/res/app_icons/maya.png b/pype/resources/app_icons/maya.png similarity index 100% rename from res/app_icons/maya.png rename to pype/resources/app_icons/maya.png diff --git a/res/app_icons/nuke.png b/pype/resources/app_icons/nuke.png similarity index 100% rename from res/app_icons/nuke.png rename to pype/resources/app_icons/nuke.png diff --git a/res/app_icons/nukex.png b/pype/resources/app_icons/nukex.png similarity index 100% rename from res/app_icons/nukex.png rename to pype/resources/app_icons/nukex.png diff --git a/res/app_icons/photoshop.png b/pype/resources/app_icons/photoshop.png similarity index 100% rename from res/app_icons/photoshop.png rename to pype/resources/app_icons/photoshop.png diff --git a/res/app_icons/premiere.png b/pype/resources/app_icons/premiere.png similarity index 100% rename from res/app_icons/premiere.png rename to pype/resources/app_icons/premiere.png diff --git a/res/app_icons/python.png b/pype/resources/app_icons/python.png similarity index 100% rename from res/app_icons/python.png rename to pype/resources/app_icons/python.png diff --git a/res/app_icons/resolve.png b/pype/resources/app_icons/resolve.png similarity index 100% rename from res/app_icons/resolve.png rename to pype/resources/app_icons/resolve.png diff --git a/res/app_icons/storyboardpro.png b/pype/resources/app_icons/storyboardpro.png similarity index 100% rename from res/app_icons/storyboardpro.png rename to pype/resources/app_icons/storyboardpro.png diff --git a/res/app_icons/ue4.png b/pype/resources/app_icons/ue4.png similarity index 100% rename from res/app_icons/ue4.png rename to pype/resources/app_icons/ue4.png diff --git a/res/ftrack/action_icons/ActionAskWhereIRun.svg b/pype/resources/ftrack/action_icons/ActionAskWhereIRun.svg similarity index 100% rename from res/ftrack/action_icons/ActionAskWhereIRun.svg rename to pype/resources/ftrack/action_icons/ActionAskWhereIRun.svg diff --git a/res/ftrack/action_icons/AssetsRemover.svg b/pype/resources/ftrack/action_icons/AssetsRemover.svg similarity index 100% rename from res/ftrack/action_icons/AssetsRemover.svg rename to pype/resources/ftrack/action_icons/AssetsRemover.svg diff --git a/res/ftrack/action_icons/ComponentOpen.svg b/pype/resources/ftrack/action_icons/ComponentOpen.svg similarity index 100% rename from res/ftrack/action_icons/ComponentOpen.svg rename to pype/resources/ftrack/action_icons/ComponentOpen.svg diff --git a/res/ftrack/action_icons/CreateFolders.svg b/pype/resources/ftrack/action_icons/CreateFolders.svg similarity index 100% rename from res/ftrack/action_icons/CreateFolders.svg rename to pype/resources/ftrack/action_icons/CreateFolders.svg diff --git a/res/ftrack/action_icons/CreateProjectFolders.svg b/pype/resources/ftrack/action_icons/CreateProjectFolders.svg similarity index 100% rename from res/ftrack/action_icons/CreateProjectFolders.svg rename to pype/resources/ftrack/action_icons/CreateProjectFolders.svg diff --git a/res/ftrack/action_icons/DeleteAsset.svg b/pype/resources/ftrack/action_icons/DeleteAsset.svg similarity index 100% rename from res/ftrack/action_icons/DeleteAsset.svg rename to pype/resources/ftrack/action_icons/DeleteAsset.svg diff --git a/res/ftrack/action_icons/Delivery.svg b/pype/resources/ftrack/action_icons/Delivery.svg similarity index 100% rename from res/ftrack/action_icons/Delivery.svg rename to pype/resources/ftrack/action_icons/Delivery.svg diff --git a/res/ftrack/action_icons/MultipleNotes.svg b/pype/resources/ftrack/action_icons/MultipleNotes.svg similarity index 100% rename from res/ftrack/action_icons/MultipleNotes.svg rename to pype/resources/ftrack/action_icons/MultipleNotes.svg diff --git a/res/ftrack/action_icons/PrepareProject.svg b/pype/resources/ftrack/action_icons/PrepareProject.svg similarity index 100% rename from res/ftrack/action_icons/PrepareProject.svg rename to pype/resources/ftrack/action_icons/PrepareProject.svg diff --git a/res/ftrack/action_icons/PypeAdmin.svg b/pype/resources/ftrack/action_icons/PypeAdmin.svg similarity index 100% rename from res/ftrack/action_icons/PypeAdmin.svg rename to pype/resources/ftrack/action_icons/PypeAdmin.svg diff --git a/res/ftrack/action_icons/PypeDoctor.svg b/pype/resources/ftrack/action_icons/PypeDoctor.svg similarity index 100% rename from res/ftrack/action_icons/PypeDoctor.svg rename to pype/resources/ftrack/action_icons/PypeDoctor.svg diff --git a/res/ftrack/action_icons/PypeUpdate.svg b/pype/resources/ftrack/action_icons/PypeUpdate.svg similarity index 100% rename from res/ftrack/action_icons/PypeUpdate.svg rename to pype/resources/ftrack/action_icons/PypeUpdate.svg diff --git a/res/ftrack/action_icons/RV.png b/pype/resources/ftrack/action_icons/RV.png similarity index 100% rename from res/ftrack/action_icons/RV.png rename to pype/resources/ftrack/action_icons/RV.png diff --git a/res/ftrack/action_icons/SeedProject.svg b/pype/resources/ftrack/action_icons/SeedProject.svg similarity index 100% rename from res/ftrack/action_icons/SeedProject.svg rename to pype/resources/ftrack/action_icons/SeedProject.svg diff --git a/res/ftrack/action_icons/SyncHierarchicalAttrs.svg b/pype/resources/ftrack/action_icons/SyncHierarchicalAttrs.svg similarity index 100% rename from res/ftrack/action_icons/SyncHierarchicalAttrs.svg rename to pype/resources/ftrack/action_icons/SyncHierarchicalAttrs.svg diff --git a/res/ftrack/action_icons/SyncToAvalon.svg b/pype/resources/ftrack/action_icons/SyncToAvalon.svg similarity index 100% rename from res/ftrack/action_icons/SyncToAvalon.svg rename to pype/resources/ftrack/action_icons/SyncToAvalon.svg diff --git a/res/ftrack/action_icons/TestAction.svg b/pype/resources/ftrack/action_icons/TestAction.svg similarity index 100% rename from res/ftrack/action_icons/TestAction.svg rename to pype/resources/ftrack/action_icons/TestAction.svg diff --git a/res/ftrack/action_icons/Thumbnail.svg b/pype/resources/ftrack/action_icons/Thumbnail.svg similarity index 100% rename from res/ftrack/action_icons/Thumbnail.svg rename to pype/resources/ftrack/action_icons/Thumbnail.svg diff --git a/res/ftrack/sign_in_message.html b/pype/resources/ftrack/sign_in_message.html similarity index 100% rename from res/ftrack/sign_in_message.html rename to pype/resources/ftrack/sign_in_message.html diff --git a/pype/resources/circle_green.png b/pype/resources/icons/circle_green.png similarity index 100% rename from pype/resources/circle_green.png rename to pype/resources/icons/circle_green.png diff --git a/pype/resources/circle_orange.png b/pype/resources/icons/circle_orange.png similarity index 100% rename from pype/resources/circle_orange.png rename to pype/resources/icons/circle_orange.png diff --git a/pype/resources/circle_red.png b/pype/resources/icons/circle_red.png similarity index 100% rename from pype/resources/circle_red.png rename to pype/resources/icons/circle_red.png diff --git a/res/icons/folder-favorite.png b/pype/resources/icons/folder-favorite.png similarity index 100% rename from res/icons/folder-favorite.png rename to pype/resources/icons/folder-favorite.png diff --git a/res/icons/folder-favorite2.png b/pype/resources/icons/folder-favorite2.png similarity index 100% rename from res/icons/folder-favorite2.png rename to pype/resources/icons/folder-favorite2.png diff --git a/res/icons/folder-favorite3.png b/pype/resources/icons/folder-favorite3.png similarity index 100% rename from res/icons/folder-favorite3.png rename to pype/resources/icons/folder-favorite3.png diff --git a/res/icons/inventory.png b/pype/resources/icons/inventory.png similarity index 100% rename from res/icons/inventory.png rename to pype/resources/icons/inventory.png diff --git a/res/icons/loader.png b/pype/resources/icons/loader.png similarity index 100% rename from res/icons/loader.png rename to pype/resources/icons/loader.png diff --git a/res/icons/lookmanager.png b/pype/resources/icons/lookmanager.png similarity index 100% rename from res/icons/lookmanager.png rename to pype/resources/icons/lookmanager.png diff --git a/pype/resources/icon.png b/pype/resources/icons/pype_icon.png similarity index 100% rename from pype/resources/icon.png rename to pype/resources/icons/pype_icon.png diff --git a/pype/resources/icon_dev.png b/pype/resources/icons/pype_icon_dev.png similarity index 100% rename from pype/resources/icon_dev.png rename to pype/resources/icons/pype_icon_dev.png diff --git a/pype/resources/splash.png b/pype/resources/icons/pype_splash.png similarity index 100% rename from pype/resources/splash.png rename to pype/resources/icons/pype_splash.png diff --git a/pype/resources/splash_dev.png b/pype/resources/icons/pype_splash_dev.png similarity index 100% rename from pype/resources/splash_dev.png rename to pype/resources/icons/pype_splash_dev.png diff --git a/res/icons/workfiles.png b/pype/resources/icons/workfiles.png similarity index 100% rename from res/icons/workfiles.png rename to pype/resources/icons/workfiles.png diff --git a/pype/resources/working.svg b/pype/resources/icons/working.svg similarity index 100% rename from pype/resources/working.svg rename to pype/resources/icons/working.svg diff --git a/res/workspace.mel b/pype/resources/maya/workspace.mel similarity index 100% rename from res/workspace.mel rename to pype/resources/maya/workspace.mel diff --git a/pype/tools/pyblish_pype/constants.py b/pype/tools/pyblish_pype/constants.py index 5395d1fd0a..03536fb829 100644 --- a/pype/tools/pyblish_pype/constants.py +++ b/pype/tools/pyblish_pype/constants.py @@ -1,5 +1,7 @@ from Qt import QtCore +EXPANDER_WIDTH = 20 + def flags(*args, **kwargs): type_name = kwargs.pop("type_name", "Flags") diff --git a/pype/tools/pyblish_pype/control.py b/pype/tools/pyblish_pype/control.py index 5138b5cc4c..77badf71b6 100644 --- a/pype/tools/pyblish_pype/control.py +++ b/pype/tools/pyblish_pype/control.py @@ -183,7 +183,18 @@ class Controller(QtCore.QObject): plugins = pyblish.api.discover() targets = pyblish.logic.registered_targets() or ["default"] - self.plugins = pyblish.logic.plugins_by_targets(plugins, targets) + plugins_by_targets = pyblish.logic.plugins_by_targets(plugins, targets) + + _plugins = [] + for plugin in plugins_by_targets: + # Skip plugin if is not optional and not active + if ( + not getattr(plugin, "optional", False) + and not getattr(plugin, "active", True) + ): + continue + _plugins.append(plugin) + self.plugins = _plugins def on_published(self): if self.is_running: diff --git a/pype/tools/pyblish_pype/delegate.py b/pype/tools/pyblish_pype/delegate.py index e88835b81a..cb9123bf3a 100644 --- a/pype/tools/pyblish_pype/delegate.py +++ b/pype/tools/pyblish_pype/delegate.py @@ -5,7 +5,7 @@ from Qt import QtWidgets, QtGui, QtCore from . import model from .awesome import tags as awesome from .constants import ( - PluginStates, InstanceStates, PluginActionStates, Roles + PluginStates, InstanceStates, PluginActionStates, Roles, EXPANDER_WIDTH ) colors = { @@ -14,12 +14,16 @@ colors = { "ok": QtGui.QColor("#77AE24"), "active": QtGui.QColor("#99CEEE"), "idle": QtCore.Qt.white, - "font": QtGui.QColor("#DDD"), "inactive": QtGui.QColor("#888"), "hover": QtGui.QColor(255, 255, 255, 10), "selected": QtGui.QColor(255, 255, 255, 20), "outline": QtGui.QColor("#333"), - "group": QtGui.QColor("#333") + "group": QtGui.QColor("#333"), + "group-hover": QtGui.QColor("#3c3c3c"), + "group-selected-hover": QtGui.QColor("#555555"), + "expander-bg": QtGui.QColor("#222"), + "expander-hover": QtGui.QColor("#2d6c9f"), + "expander-selected-hover": QtGui.QColor("#3784c5") } scale_factors = {"darwin": 1.5} @@ -279,14 +283,169 @@ class InstanceItemDelegate(QtWidgets.QStyledItemDelegate): return QtCore.QSize(option.rect.width(), 20) -class OverviewGroupSection(QtWidgets.QStyledItemDelegate): - """Generic delegate for section header""" +class InstanceDelegate(QtWidgets.QStyledItemDelegate): + """Generic delegate for instance header""" - item_class = None + radius = 8.0 def __init__(self, parent): - super(OverviewGroupSection, self).__init__(parent) - self.item_delegate = self.item_class(parent) + super(InstanceDelegate, self).__init__(parent) + self.item_delegate = InstanceItemDelegate(parent) + + def paint(self, painter, option, index): + if index.data(Roles.TypeRole) in ( + model.InstanceType, model.PluginType + ): + self.item_delegate.paint(painter, option, index) + return + + self.group_item_paint(painter, option, index) + + def group_item_paint(self, painter, option, index): + """Paint text + _ + My label + """ + body_rect = QtCore.QRectF(option.rect) + bg_rect = QtCore.QRectF( + body_rect.left(), body_rect.top() + 1, + body_rect.width() - 5, body_rect.height() - 2 + ) + + expander_rect = QtCore.QRectF(bg_rect) + expander_rect.setWidth(EXPANDER_WIDTH) + + remainder_rect = QtCore.QRectF( + expander_rect.x() + expander_rect.width(), + expander_rect.y(), + bg_rect.width() - expander_rect.width(), + expander_rect.height() + ) + + width = float(expander_rect.width()) + height = float(expander_rect.height()) + + x_pos = expander_rect.x() + y_pos = expander_rect.y() + + x_radius = min(self.radius, width / 2) + y_radius = min(self.radius, height / 2) + x_radius2 = x_radius * 2 + y_radius2 = y_radius * 2 + + expander_path = QtGui.QPainterPath() + expander_path.moveTo(x_pos, y_pos + y_radius) + expander_path.arcTo( + x_pos, y_pos, + x_radius2, y_radius2, + 180.0, -90.0 + ) + expander_path.lineTo(x_pos + width, y_pos) + expander_path.lineTo(x_pos + width, y_pos + height) + expander_path.lineTo(x_pos + x_radius, y_pos + height) + expander_path.arcTo( + x_pos, y_pos + height - y_radius2, + x_radius2, y_radius2, + 270.0, -90.0 + ) + expander_path.closeSubpath() + + width = float(remainder_rect.width()) + height = float(remainder_rect.height()) + x_pos = remainder_rect.x() + y_pos = remainder_rect.y() + + x_radius = min(self.radius, width / 2) + y_radius = min(self.radius, height / 2) + x_radius2 = x_radius * 2 + y_radius2 = y_radius * 2 + + remainder_path = QtGui.QPainterPath() + remainder_path.moveTo(x_pos + width, y_pos + height - y_radius) + remainder_path.arcTo( + x_pos + width - x_radius2, y_pos + height - y_radius2, + x_radius2, y_radius2, + 0.0, -90.0 + ) + remainder_path.lineTo(x_pos, y_pos + height) + remainder_path.lineTo(x_pos, y_pos) + remainder_path.lineTo(x_pos + width - x_radius, y_pos) + remainder_path.arcTo( + x_pos + width - x_radius2, y_pos, + x_radius2, y_radius2, + 90.0, -90.0 + ) + remainder_path.closeSubpath() + + painter.fillPath(expander_path, colors["expander-bg"]) + painter.fillPath(remainder_path, colors["group"]) + + mouse_pos = option.widget.mapFromGlobal(QtGui.QCursor.pos()) + selected = option.state & QtWidgets.QStyle.State_Selected + hovered = option.state & QtWidgets.QStyle.State_MouseOver + + if selected and hovered: + if expander_rect.contains(mouse_pos): + painter.fillPath( + expander_path, colors["expander-selected-hover"] + ) + else: + painter.fillPath( + remainder_path, colors["group-selected-hover"] + ) + + elif hovered: + if expander_rect.contains(mouse_pos): + painter.fillPath(expander_path, colors["expander-hover"]) + else: + painter.fillPath(remainder_path, colors["group-hover"]) + + text_height = font_metrics["awesome6"].height() + adjust_value = (expander_rect.height() - text_height) / 2 + expander_rect.adjust( + adjust_value + 1.5, adjust_value - 0.5, + -adjust_value + 1.5, -adjust_value - 0.5 + ) + + offset = (remainder_rect.height() - font_metrics["h5"].height()) / 2 + label_rect = QtCore.QRectF(remainder_rect.adjusted( + 5, offset - 1, 0, 0 + )) + + expander_icon = icons["plus-sign"] + + expanded = self.parent().isExpanded(index) + if expanded: + expander_icon = icons["minus-sign"] + label = index.data(QtCore.Qt.DisplayRole) + label = font_metrics["h5"].elidedText( + label, QtCore.Qt.ElideRight, label_rect.width() + ) + + # Maintain reference to state, so we can restore it once we're done + painter.save() + + painter.setFont(fonts["awesome6"]) + painter.setPen(QtGui.QPen(colors["idle"])) + painter.drawText(expander_rect, QtCore.Qt.AlignCenter, expander_icon) + + # Draw label + painter.setFont(fonts["h5"]) + painter.drawText(label_rect, label) + + # Ok, we're done, tidy up. + painter.restore() + + def sizeHint(self, option, index): + return QtCore.QSize(option.rect.width(), 20) + + +class PluginDelegate(QtWidgets.QStyledItemDelegate): + """Generic delegate for plugin header""" + + def __init__(self, parent): + super(PluginDelegate, self).__init__(parent) + self.item_delegate = PluginItemDelegate(parent) def paint(self, painter, option, index): if index.data(Roles.TypeRole) in ( @@ -310,7 +469,14 @@ class OverviewGroupSection(QtWidgets.QStyledItemDelegate): radius = 8.0 bg_path = QtGui.QPainterPath() bg_path.addRoundedRect(bg_rect, radius, radius) - painter.fillPath(bg_path, colors["group"]) + hovered = option.state & QtWidgets.QStyle.State_MouseOver + selected = option.state & QtWidgets.QStyle.State_Selected + if hovered and selected: + painter.fillPath(bg_path, colors["group-selected-hover"]) + elif hovered: + painter.fillPath(bg_path, colors["group-hover"]) + else: + painter.fillPath(bg_path, colors["group"]) expander_rect = QtCore.QRectF(bg_rect) expander_rect.setWidth(expander_rect.height()) @@ -343,18 +509,12 @@ class OverviewGroupSection(QtWidgets.QStyledItemDelegate): painter.setFont(fonts["awesome6"]) painter.setPen(QtGui.QPen(colors["idle"])) - painter.drawText(expander_rect, expander_icon) + painter.drawText(expander_rect, QtCore.Qt.AlignCenter, expander_icon) # Draw label painter.setFont(fonts["h5"]) painter.drawText(label_rect, label) - if option.state & QtWidgets.QStyle.State_MouseOver: - painter.fillPath(bg_path, colors["hover"]) - - if option.state & QtWidgets.QStyle.State_Selected: - painter.fillPath(bg_path, colors["selected"]) - # Ok, we're done, tidy up. painter.restore() @@ -362,16 +522,6 @@ class OverviewGroupSection(QtWidgets.QStyledItemDelegate): return QtCore.QSize(option.rect.width(), 20) -class PluginDelegate(OverviewGroupSection): - """Generic delegate for model items in proxy tree view""" - item_class = PluginItemDelegate - - -class InstanceDelegate(OverviewGroupSection): - """Generic delegate for model items in proxy tree view""" - item_class = InstanceItemDelegate - - class ArtistDelegate(QtWidgets.QStyledItemDelegate): """Delegate used on Artist page""" diff --git a/pype/tools/pyblish_pype/model.py b/pype/tools/pyblish_pype/model.py index 203b512d12..9086003258 100644 --- a/pype/tools/pyblish_pype/model.py +++ b/pype/tools/pyblish_pype/model.py @@ -319,7 +319,7 @@ class PluginItem(QtGui.QStandardItem): return False self.plugin.active = value self.emitDataChanged() - return True + return elif role == Roles.PluginActionProgressRole: if isinstance(value, list): @@ -652,14 +652,14 @@ class InstanceItem(QtGui.QStandardItem): def setData(self, value, role=(QtCore.Qt.UserRole + 1)): if role == QtCore.Qt.CheckStateRole: if not self.data(Roles.IsEnabledRole): - return False + return self.instance.data["publish"] = value self.emitDataChanged() - return True + return if role == Roles.IsEnabledRole: if not self.instance.optional: - return False + return if role == Roles.PublishFlagsRole: if isinstance(value, list): @@ -692,12 +692,12 @@ class InstanceItem(QtGui.QStandardItem): self.instance._publish_states = value self.emitDataChanged() - return True + return if role == Roles.LogRecordsRole: self.instance._logs = value self.emitDataChanged() - return True + return return super(InstanceItem, self).setData(value, role) diff --git a/pype/tools/pyblish_pype/view.py b/pype/tools/pyblish_pype/view.py index 450f56421c..477303eae8 100644 --- a/pype/tools/pyblish_pype/view.py +++ b/pype/tools/pyblish_pype/view.py @@ -1,6 +1,6 @@ from Qt import QtCore, QtWidgets from . import model -from .constants import Roles +from .constants import Roles, EXPANDER_WIDTH # Imported when used widgets = None @@ -84,8 +84,6 @@ class OverviewView(QtWidgets.QTreeView): self.setRootIsDecorated(False) self.setIndentation(0) - self.clicked.connect(self.item_expand) - def event(self, event): if not event.type() == QtCore.QEvent.KeyPress: return super(OverviewView, self).event(event) @@ -113,6 +111,24 @@ class OverviewView(QtWidgets.QTreeView): def focusOutEvent(self, event): self.selectionModel().clear() + def mouseReleaseEvent(self, event): + if event.button() in (QtCore.Qt.LeftButton, QtCore.Qt.RightButton): + # Deselect all group labels + indexes = self.selectionModel().selectedIndexes() + for index in indexes: + if index.data(Roles.TypeRole) == model.GroupType: + self.selectionModel().select( + index, QtCore.QItemSelectionModel.Deselect + ) + + return super(OverviewView, self).mouseReleaseEvent(event) + + +class PluginView(OverviewView): + def __init__(self, *args, **kwargs): + super(PluginView, self).__init__(*args, **kwargs) + self.clicked.connect(self.item_expand) + def item_expand(self, index): if index.data(Roles.TypeRole) == model.GroupType: if self.isExpanded(index): @@ -125,23 +141,86 @@ class OverviewView(QtWidgets.QTreeView): indexes = self.selectionModel().selectedIndexes() if len(indexes) == 1: index = indexes[0] - # If instance or Plugin - if index.data(Roles.TypeRole) in ( - model.InstanceType, model.PluginType + pos_index = self.indexAt(event.pos()) + # If instance or Plugin and is selected + if ( + index == pos_index + and index.data(Roles.TypeRole) == model.PluginType ): if event.pos().x() < 20: self.toggled.emit(index, None) elif event.pos().x() > self.width() - 20: self.show_perspective.emit(index) - # Deselect all group labels - for index in indexes: - if index.data(Roles.TypeRole) == model.GroupType: - self.selectionModel().select( - index, QtCore.QItemSelectionModel.Deselect - ) + return super(PluginView, self).mouseReleaseEvent(event) - return super(OverviewView, self).mouseReleaseEvent(event) + +class InstanceView(OverviewView): + def __init__(self, parent=None): + super(InstanceView, self).__init__(parent) + self.viewport().setMouseTracking(True) + + def mouseMoveEvent(self, event): + index = self.indexAt(event.pos()) + if index.data(Roles.TypeRole) == model.GroupType: + self.update(index) + super(InstanceView, self).mouseMoveEvent(event) + + def item_expand(self, index, expand=None): + if expand is None: + expand = not self.isExpanded(index) + + if expand: + self.expand(index) + else: + self.collapse(index) + + def group_toggle(self, index): + model = index.model() + + chilren_indexes_checked = [] + chilren_indexes_unchecked = [] + for idx in range(model.rowCount(index)): + child_index = model.index(idx, 0, index) + if not child_index.data(Roles.IsEnabledRole): + continue + + if child_index.data(QtCore.Qt.CheckStateRole): + chilren_indexes_checked.append(child_index) + else: + chilren_indexes_unchecked.append(child_index) + + if chilren_indexes_checked: + to_change_indexes = chilren_indexes_checked + new_state = False + else: + to_change_indexes = chilren_indexes_unchecked + new_state = True + + for index in to_change_indexes: + model.setData(index, new_state, QtCore.Qt.CheckStateRole) + self.toggled.emit(index, new_state) + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + indexes = self.selectionModel().selectedIndexes() + if len(indexes) == 1: + index = indexes[0] + pos_index = self.indexAt(event.pos()) + if index == pos_index: + # If instance or Plugin + if index.data(Roles.TypeRole) == model.InstanceType: + if event.pos().x() < 20: + self.toggled.emit(index, None) + elif event.pos().x() > self.width() - 20: + self.show_perspective.emit(index) + else: + if event.pos().x() < EXPANDER_WIDTH: + self.item_expand(index) + else: + self.group_toggle(index) + self.item_expand(index, True) + return super(InstanceView, self).mouseReleaseEvent(event) class TerminalView(QtWidgets.QTreeView): diff --git a/pype/tools/pyblish_pype/window.py b/pype/tools/pyblish_pype/window.py index 3c7808496c..7d79e0e26c 100644 --- a/pype/tools/pyblish_pype/window.py +++ b/pype/tools/pyblish_pype/window.py @@ -160,14 +160,14 @@ class Window(QtWidgets.QDialog): # TODO add parent overview_page = QtWidgets.QWidget() - overview_instance_view = view.OverviewView(parent=overview_page) + overview_instance_view = view.InstanceView(parent=overview_page) overview_instance_delegate = delegate.InstanceDelegate( parent=overview_instance_view ) overview_instance_view.setItemDelegate(overview_instance_delegate) overview_instance_view.setModel(instance_model) - overview_plugin_view = view.OverviewView(parent=overview_page) + overview_plugin_view = view.PluginView(parent=overview_page) overview_plugin_delegate = delegate.PluginDelegate( parent=overview_plugin_view ) diff --git a/pype/tools/tray/modules_imports.json b/pype/tools/tray/modules_imports.json new file mode 100644 index 0000000000..e9526dcddb --- /dev/null +++ b/pype/tools/tray/modules_imports.json @@ -0,0 +1,58 @@ +[ + { + "title": "User settings", + "type": "module", + "import_path": "pype.modules.user", + "fromlist": ["pype", "modules"] + }, { + "title": "Ftrack", + "type": "module", + "import_path": "pype.modules.ftrack.tray", + "fromlist": ["pype", "modules", "ftrack"] + }, { + "title": "Muster", + "type": "module", + "import_path": "pype.modules.muster", + "fromlist": ["pype", "modules"] + }, { + "title": "Avalon", + "type": "module", + "import_path": "pype.modules.avalon_apps", + "fromlist": ["pype", "modules"] + }, { + "title": "Clockify", + "type": "module", + "import_path": "pype.modules.clockify", + "fromlist": ["pype", "modules"] + }, { + "title": "Standalone Publish", + "type": "module", + "import_path": "pype.modules.standalonepublish", + "fromlist": ["pype", "modules"] + }, { + "title": "Logging", + "type": "module", + "import_path": "pype.modules.logging.tray", + "fromlist": ["pype", "modules", "logging"] + }, { + "title": "Idle Manager", + "type": "module", + "import_path": "pype.modules.idle_manager", + "fromlist": ["pype","modules"] + }, { + "title": "Timers Manager", + "type": "module", + "import_path": "pype.modules.timers_manager", + "fromlist": ["pype","modules"] + }, { + "title": "Rest Api", + "type": "module", + "import_path": "pype.modules.rest_api", + "fromlist": ["pype","modules"] + }, { + "title": "Adobe Communicator", + "type": "module", + "import_path": "pype.modules.adobe_communicator", + "fromlist": ["pype", "modules"] + } +] diff --git a/pype/tools/tray/pype_tray.py b/pype/tools/tray/pype_tray.py index eec8f61cc4..9537b62581 100644 --- a/pype/tools/tray/pype_tray.py +++ b/pype/tools/tray/pype_tray.py @@ -3,8 +3,12 @@ import sys import platform from avalon import style from Qt import QtCore, QtGui, QtWidgets, QtSvg -from pype.resources import get_resource -from pype.api import config, Logger +from pype.api import config, Logger, resources +import pype.version +try: + import configparser +except Exception: + import ConfigParser as configparser class TrayManager: @@ -12,28 +16,43 @@ class TrayManager: Load submenus, actions, separators and modules into tray's context. """ - modules = {} - services = {} - services_submenu = None - - errors = [] - items = ( - config.get_presets(first_run=True) - .get('tray', {}) - .get('menu_items', []) - ) - available_sourcetypes = ['python', 'file'] + available_sourcetypes = ["python", "file"] def __init__(self, tray_widget, main_window): self.tray_widget = tray_widget self.main_window = main_window + self.log = Logger().get_logger(self.__class__.__name__) - self.icon_run = QtGui.QIcon(get_resource('circle_green.png')) - self.icon_stay = QtGui.QIcon(get_resource('circle_orange.png')) - self.icon_failed = QtGui.QIcon(get_resource('circle_red.png')) + self.modules = {} + self.services = {} + self.services_submenu = None - self.services_thread = None + self.errors = [] + + CURRENT_DIR = os.path.dirname(__file__) + self.modules_imports = config.load_json( + os.path.join(CURRENT_DIR, "modules_imports.json") + ) + presets = config.get_presets(first_run=True) + menu_items = presets["tray"]["menu_items"] + try: + self.modules_usage = menu_items["item_usage"] + except Exception: + self.modules_usage = {} + self.log.critical("Couldn't find modules usage data.") + + self.module_attributes = menu_items.get("attributes") or {} + + self.icon_run = QtGui.QIcon( + resources.get_resource("icons", "circle_green.png") + ) + self.icon_stay = QtGui.QIcon( + resources.get_resource("icons", "circle_orange.png") + ) + self.icon_failed = QtGui.QIcon( + resources.get_resource("icons", "circle_red.png") + ) def process_presets(self): """Add modules to tray by presets. @@ -46,42 +65,34 @@ class TrayManager: "item_usage": { "Statics Server": false } - }, { - "item_import": [{ - "title": "Ftrack", - "type": "module", - "import_path": "pype.ftrack.tray", - "fromlist": ["pype", "ftrack"] - }, { - "title": "Statics Server", - "type": "module", - "import_path": "pype.services.statics_server", - "fromlist": ["pype","services"] - }] } In this case `Statics Server` won't be used. """ - # Backwards compatible presets loading - if isinstance(self.items, list): - items = self.items - else: - items = [] - # Get booleans is module should be used - usages = self.items.get("item_usage") or {} - for item in self.items.get("item_import", []): - import_path = item.get("import_path") - title = item.get("title") - item_usage = usages.get(title) - if item_usage is None: - item_usage = usages.get(import_path, True) + items = [] + # Get booleans is module should be used + for item in self.modules_imports: + import_path = item.get("import_path") + title = item.get("title") - if item_usage: - items.append(item) - else: - if not title: - title = import_path - self.log.debug("{} - Module ignored".format(title)) + item_usage = self.modules_usage.get(title) + if item_usage is None: + item_usage = self.modules_usage.get(import_path, True) + + if not item_usage: + if not title: + title = import_path + self.log.info("{} - Module ignored".format(title)) + continue + + _attributes = self.module_attributes.get(title) + if _attributes is None: + _attributes = self.module_attributes.get(import_path) + + if _attributes: + item["attributes"] = _attributes + + items.append(item) if items: self.process_items(items, self.tray_widget.menu) @@ -94,6 +105,8 @@ class TrayManager: if items and self.services_submenu is not None: self.add_separator(self.tray_widget.menu) + self._add_version_item() + # Add Exit action to menu aExit = QtWidgets.QAction("&Exit", self.tray_widget) aExit.triggered.connect(self.tray_widget.exit) @@ -103,6 +116,34 @@ class TrayManager: self.connect_modules() self.start_modules() + def _add_version_item(self): + config_file_path = os.path.join( + os.environ["PYPE_SETUP_PATH"], "pypeapp", "config.ini" + ) + + default_config = {} + if os.path.exists(config_file_path): + config = configparser.ConfigParser() + config.read(config_file_path) + try: + default_config = config["CLIENT"] + except Exception: + pass + + subversion = default_config.get("subversion") + client_name = default_config.get("client_name") + + version_string = pype.version.__version__ + if subversion: + version_string += " ({})".format(subversion) + + if client_name: + version_string += ", {}".format(client_name) + + version_action = QtWidgets.QAction(version_string, self.tray_widget) + self.tray_widget.menu.addAction(version_action) + self.add_separator(self.tray_widget.menu) + def process_items(self, items, parent_menu): """ Loop through items and add them to parent_menu. @@ -158,11 +199,29 @@ class TrayManager: import_path = item.get('import_path', None) title = item.get('title', import_path) fromlist = item.get('fromlist', []) + attributes = item.get("attributes", {}) try: module = __import__( "{}".format(import_path), fromlist=fromlist ) + klass = getattr(module, "CLASS_DEFINIION", None) + if not klass and attributes: + self.log.error(( + "There are defined attributes for module \"{}\" but" + "module does not have defined \"CLASS_DEFINIION\"." + ).format(import_path)) + + elif klass and attributes: + for key, value in attributes.items(): + if hasattr(klass, key): + setattr(klass, key, value) + else: + self.log.error(( + "Module \"{}\" does not have attribute \"{}\"." + " Check your settings please." + ).format(import_path, key)) + obj = module.tray_init(self.tray_widget, self.main_window) name = obj.__class__.__name__ if hasattr(obj, 'tray_menu'): @@ -179,7 +238,7 @@ class TrayManager: obj.set_qaction(action, self.icon_failed) self.modules[name] = obj self.log.info("{} - Module imported".format(title)) - except ImportError as ie: + except Exception as exc: if self.services_submenu is None: self.services_submenu = QtWidgets.QMenu( 'Services', self.tray_widget.menu @@ -188,7 +247,7 @@ class TrayManager: action.setIcon(self.icon_failed) self.services_submenu.addAction(action) self.log.warning( - "{} - Module import Error: {}".format(title, str(ie)), + "{} - Module import Error: {}".format(title, str(exc)), exc_info=True ) return False @@ -333,12 +392,7 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon): :type parent: QtWidgets.QMainWindow """ def __init__(self, parent): - if os.getenv("PYPE_DEV"): - icon_file_name = "icon_dev.png" - else: - icon_file_name = "icon.png" - - self.icon = QtGui.QIcon(get_resource(icon_file_name)) + self.icon = QtGui.QIcon(resources.pype_icon_filepath()) QtWidgets.QSystemTrayIcon.__init__(self, self.icon, parent) @@ -402,7 +456,7 @@ class TrayMainWindow(QtWidgets.QMainWindow): self.trayIcon.show() def set_working_widget(self): - image_file = get_resource('working.svg') + image_file = resources.get_resource("icons", "working.svg") img_pix = QtGui.QPixmap(image_file) if image_file.endswith('.svg'): widget = QtSvg.QSvgWidget(image_file) @@ -492,11 +546,7 @@ class PypeTrayApplication(QtWidgets.QApplication): splash_widget.hide() def set_splash(self): - if os.getenv("PYPE_DEV"): - splash_file_name = "splash_dev.png" - else: - splash_file_name = "splash.png" - splash_pix = QtGui.QPixmap(get_resource(splash_file_name)) + splash_pix = QtGui.QPixmap(resources.pype_splash_filepath()) splash = QtWidgets.QSplashScreen(splash_pix) splash.setMask(splash_pix.mask()) splash.setEnabled(False) diff --git a/pype/version.py b/pype/version.py index 1c622223ba..7f6646a762 100644 --- a/pype/version.py +++ b/pype/version.py @@ -1 +1 @@ -__version__ = "2.10.0" +__version__ = "2.11.0" diff --git a/pype/widgets/message_window.py b/pype/widgets/message_window.py index 3532d2df44..41c709b933 100644 --- a/pype/widgets/message_window.py +++ b/pype/widgets/message_window.py @@ -52,6 +52,19 @@ def message(title=None, message=None, level="info", parent=None): app = parent if not app: app = QtWidgets.QApplication(sys.argv) + ex = Window(app, title, message, level) ex.show() + + # Move widget to center of screen + try: + desktop_rect = QtWidgets.QApplication.desktop().availableGeometry(ex) + center = desktop_rect.center() + ex.move( + center.x() - (ex.width() * 0.5), + center.y() - (ex.height() * 0.5) + ) + except Exception: + # skip all possible issues that may happen feature is not crutial + log.warning("Couldn't center message.", exc_info=True) # sys.exit(app.exec_()) diff --git a/res/icons/Thumbs.db b/res/icons/Thumbs.db deleted file mode 100644 index fa56c871f6..0000000000 Binary files a/res/icons/Thumbs.db and /dev/null differ