diff --git a/pype/api.py b/pype/api.py index 200ca9daec..5775bb3ce4 100644 --- a/pype/api.py +++ b/pype/api.py @@ -6,6 +6,12 @@ from pypeapp import ( execute ) +from pypeapp.lib.mongo import ( + decompose_url, + compose_url, + get_default_components +) + from .plugin import ( Extractor, @@ -44,6 +50,9 @@ __all__ = [ "project_overrides_dir_path", "config", "execute", + "decompose_url", + "compose_url", + "get_default_components", # plugin classes "Extractor", diff --git a/pype/lib.py b/pype/lib.py index 7c7a01d5cc..87808e53f5 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -17,6 +17,7 @@ import six import avalon.api from .api import config + log = logging.getLogger(__name__) diff --git a/pype/modules/adobe_communicator/lib/io_nonsingleton.py b/pype/modules/adobe_communicator/lib/io_nonsingleton.py index 6380e4eb23..da37c657c6 100644 --- a/pype/modules/adobe_communicator/lib/io_nonsingleton.py +++ b/pype/modules/adobe_communicator/lib/io_nonsingleton.py @@ -16,6 +16,7 @@ import contextlib from avalon import schema from avalon.vendor import requests +from avalon.io import extract_port_from_url # Third-party dependencies import pymongo @@ -72,8 +73,17 @@ class DbConnector(object): self.Session.update(self._from_environment()) timeout = int(self.Session["AVALON_TIMEOUT"]) - self._mongo_client = pymongo.MongoClient( - self.Session["AVALON_MONGO"], serverSelectionTimeoutMS=timeout) + mongo_url = self.Session["AVALON_MONGO"] + kwargs = { + "host": mongo_url, + "serverSelectionTimeoutMS": timeout + } + + port = extract_port_from_url(mongo_url) + if port is not None: + kwargs["port"] = int(port) + + self._mongo_client = pymongo.MongoClient(**kwargs) for retry in range(3): try: @@ -381,6 +391,10 @@ class DbConnector(object): if document is None: break + if document.get("type") == "master_version": + _document = self.find_one({"_id": document["version_id"]}) + document["data"] = _document["data"] + parents.append(document) return parents diff --git a/pype/modules/avalon_apps/rest_api.py b/pype/modules/avalon_apps/rest_api.py index a5dc326a8e..1cb9e544a7 100644 --- a/pype/modules/avalon_apps/rest_api.py +++ b/pype/modules/avalon_apps/rest_api.py @@ -4,17 +4,14 @@ import json import bson import bson.json_util from pype.modules.rest_api import RestApi, abort, CallbackResult -from pype.modules.ftrack.lib.custom_db_connector import DbConnector +from pype.modules.ftrack.lib.io_nonsingleton import DbConnector class AvalonRestApi(RestApi): - dbcon = DbConnector( - os.environ["AVALON_MONGO"], - os.environ["AVALON_DB"] - ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.dbcon = DbConnector() self.dbcon.install() @RestApi.route("/projects/", url_prefix="/avalon", methods="GET") diff --git a/pype/modules/ftrack/ftrack_server/event_server_cli.py b/pype/modules/ftrack/ftrack_server/event_server_cli.py index 5709a88297..73c7abfc5d 100644 --- a/pype/modules/ftrack/ftrack_server/event_server_cli.py +++ b/pype/modules/ftrack/ftrack_server/event_server_cli.py @@ -13,10 +13,12 @@ import time import uuid import ftrack_api +import pymongo from pype.modules.ftrack.lib import credentials from pype.modules.ftrack.ftrack_server.lib import ( - ftrack_events_mongo_settings, check_ftrack_url + check_ftrack_url, get_ftrack_event_mongo_info ) + import socket_thread @@ -30,22 +32,19 @@ class MongoPermissionsError(Exception): def check_mongo_url(host, port, log_error=False): """Checks if mongo server is responding""" - sock = None try: - sock = socket.create_connection( - (host, port), - timeout=1 - ) - return True - except socket.error as err: + client = pymongo.MongoClient(host=host, port=port) + # Force connection on a request as the connect=True parameter of + # MongoClient seems to be useless here + client.server_info() + except pymongo.errors.ServerSelectionTimeoutError as err: if log_error: print("Can't connect to MongoDB at {}:{} because: {}".format( host, port, err )) return False - finally: - if sock is not None: - sock.close() + + return True def validate_credentials(url, user, api): @@ -190,9 +189,10 @@ def main_loop(ftrack_url): os.environ["FTRACK_EVENT_SUB_ID"] = str(uuid.uuid1()) # Get mongo hostname and port for testing mongo connection - mongo_list = ftrack_events_mongo_settings() - mongo_hostname = mongo_list[0] - mongo_port = mongo_list[1] + + mongo_uri, mongo_port, database_name, collection_name = ( + get_ftrack_event_mongo_info() + ) # Current file file_path = os.path.dirname(os.path.realpath(__file__)) @@ -270,13 +270,12 @@ def main_loop(ftrack_url): ftrack_accessible = check_ftrack_url(ftrack_url) if not mongo_accessible: - mongo_accessible = check_mongo_url(mongo_hostname, mongo_port) + mongo_accessible = check_mongo_url(mongo_uri, mongo_port) # Run threads only if Ftrack is accessible if not ftrack_accessible or not mongo_accessible: if not mongo_accessible and not printed_mongo_error: - mongo_url = mongo_hostname + ":" + mongo_port - print("Can't access Mongo {}".format(mongo_url)) + print("Can't access Mongo {}".format(mongo_uri)) if not ftrack_accessible and not printed_ftrack_error: print("Can't access Ftrack {}".format(ftrack_url)) diff --git a/pype/modules/ftrack/ftrack_server/lib.py b/pype/modules/ftrack/ftrack_server/lib.py index 129cd7173a..327fab817d 100644 --- a/pype/modules/ftrack/ftrack_server/lib.py +++ b/pype/modules/ftrack/ftrack_server/lib.py @@ -18,12 +18,13 @@ import ftrack_api.operation import ftrack_api._centralized_storage_scenario import ftrack_api.event from ftrack_api.logging import LazyLogMessage as L -try: - from urllib.parse import urlparse, parse_qs -except ImportError: - from urlparse import urlparse, parse_qs -from pype.api import Logger +from pype.api import ( + Logger, + get_default_components, + decompose_url, + compose_url +) from pype.modules.ftrack.lib.custom_db_connector import DbConnector @@ -32,69 +33,29 @@ TOPIC_STATUS_SERVER = "pype.event.server.status" TOPIC_STATUS_SERVER_RESULT = "pype.event.server.status.result" -def ftrack_events_mongo_settings(): - host = None - port = None - username = None - password = None - collection = None - database = None - auth_db = "" - - if os.environ.get('FTRACK_EVENTS_MONGO_URL'): - result = urlparse(os.environ['FTRACK_EVENTS_MONGO_URL']) - - host = result.hostname - try: - port = result.port - except ValueError: - raise RuntimeError("invalid port specified") - username = result.username - password = result.password - try: - database = result.path.lstrip("/").split("/")[0] - collection = result.path.lstrip("/").split("/")[1] - except IndexError: - if not database: - raise RuntimeError("missing database name for logging") - try: - auth_db = parse_qs(result.query)['authSource'][0] - except KeyError: - # no auth db provided, mongo will use the one we are connecting to - pass - else: - host = os.environ.get('FTRACK_EVENTS_MONGO_HOST') - port = int(os.environ.get('FTRACK_EVENTS_MONGO_PORT', "0")) - database = os.environ.get('FTRACK_EVENTS_MONGO_DB') - username = os.environ.get('FTRACK_EVENTS_MONGO_USER') - password = os.environ.get('FTRACK_EVENTS_MONGO_PASSWORD') - collection = os.environ.get('FTRACK_EVENTS_MONGO_COL') - auth_db = os.environ.get('FTRACK_EVENTS_MONGO_AUTH_DB', 'avalon') - - return host, port, database, username, password, collection, auth_db - - def get_ftrack_event_mongo_info(): - host, port, database, username, password, collection, auth_db = ( - ftrack_events_mongo_settings() + database_name = ( + os.environ.get("FTRACK_EVENTS_MONGO_DB") or "pype" + ) + collection_name = ( + os.environ.get("FTRACK_EVENTS_MONGO_COL") or "ftrack_events" ) - user_pass = "" - if username and password: - user_pass = "{}:{}@".format(username, password) - socket_path = "{}:{}".format(host, port) + 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 - dab = "" - if database: - dab = "/{}".format(database) + if not _used_ftrack_url or components["database"] is None: + components["database"] = database_name + components["collection"] = collection_name - auth = "" - if auth_db: - auth = "?authSource={}".format(auth_db) + uri = compose_url(components) - url = "mongodb://{}{}{}{}".format(user_pass, socket_path, dab, auth) - - return url, database, collection + return uri, components["port"], database_name, collection_name def check_ftrack_url(url, log_errors=True): @@ -198,16 +159,17 @@ class StorerEventHub(SocketBaseEventHub): class ProcessEventHub(SocketBaseEventHub): hearbeat_msg = b"processor" - url, database, table_name = get_ftrack_event_mongo_info() + uri, port, database, table_name = get_ftrack_event_mongo_info() is_table_created = False pypelog = Logger().get_logger("Session Processor") def __init__(self, *args, **kwargs): self.dbcon = DbConnector( - mongo_url=self.url, - database_name=self.database, - table_name=self.table_name + self.uri, + self.port, + self.database, + self.table_name ) super(ProcessEventHub, self).__init__(*args, **kwargs) @@ -269,7 +231,7 @@ class ProcessEventHub(SocketBaseEventHub): def load_events(self): """Load not processed events sorted by stored date""" ago_date = datetime.datetime.now() - datetime.timedelta(days=3) - result = self.dbcon.delete_many({ + self.dbcon.delete_many({ "pype_data.stored": {"$lte": ago_date}, "pype_data.is_processed": True }) diff --git a/pype/modules/ftrack/ftrack_server/sub_event_storer.py b/pype/modules/ftrack/ftrack_server/sub_event_storer.py index c4d199407d..61b9aaf2c8 100644 --- a/pype/modules/ftrack/ftrack_server/sub_event_storer.py +++ b/pype/modules/ftrack/ftrack_server/sub_event_storer.py @@ -23,12 +23,8 @@ class SessionFactory: session = None -url, database, table_name = get_ftrack_event_mongo_info() -dbcon = DbConnector( - mongo_url=url, - database_name=database, - table_name=table_name -) +uri, port, database, table_name = get_ftrack_event_mongo_info() +dbcon = DbConnector(uri, port, database, 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 b307117127..a734b3f80a 100644 --- a/pype/modules/ftrack/lib/custom_db_connector.py +++ b/pype/modules/ftrack/lib/custom_db_connector.py @@ -12,6 +12,7 @@ import atexit # Third-party dependencies import pymongo +from pype.api import decompose_url class NotActiveTable(Exception): @@ -63,13 +64,29 @@ class DbConnector: log = logging.getLogger(__name__) timeout = 1000 - def __init__(self, mongo_url, database_name, table_name=None): + def __init__( + self, uri, port=None, database_name=None, table_name=None + ): self._mongo_client = None self._sentry_client = None self._sentry_logging_handler = None self._database = None self._is_installed = False - self._mongo_url = mongo_url + + self._uri = uri + components = decompose_url(uri) + 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) + ) + + self._port = port self._database_name = database_name self.active_table = table_name @@ -95,10 +112,16 @@ class DbConnector: atexit.register(self.uninstall) logging.basicConfig() - self._mongo_client = pymongo.MongoClient( - self._mongo_url, - serverSelectionTimeoutMS=self.timeout - ) + kwargs = { + "host": self._uri, + "serverSelectionTimeoutMS": self.timeout + } + if self._port is not None: + kwargs["port"] = self._port + + self._mongo_client = pymongo.MongoClient(**kwargs) + if self._port is None: + self._port = self._mongo_client.PORT for retry in range(3): try: @@ -113,11 +136,11 @@ class DbConnector: else: raise IOError( "ERROR: Couldn't connect to %s in " - "less than %.3f ms" % (self._mongo_url, self.timeout) + "less than %.3f ms" % (self._uri, self.timeout) ) self.log.info("Connected to %s, delay %.3f s" % ( - self._mongo_url, time.time() - t1 + self._uri, time.time() - t1 )) self._database = self._mongo_client[self._database_name] diff --git a/pype/modules/ftrack/lib/io_nonsingleton.py b/pype/modules/ftrack/lib/io_nonsingleton.py index 6380e4eb23..da37c657c6 100644 --- a/pype/modules/ftrack/lib/io_nonsingleton.py +++ b/pype/modules/ftrack/lib/io_nonsingleton.py @@ -16,6 +16,7 @@ import contextlib from avalon import schema from avalon.vendor import requests +from avalon.io import extract_port_from_url # Third-party dependencies import pymongo @@ -72,8 +73,17 @@ class DbConnector(object): self.Session.update(self._from_environment()) timeout = int(self.Session["AVALON_TIMEOUT"]) - self._mongo_client = pymongo.MongoClient( - self.Session["AVALON_MONGO"], serverSelectionTimeoutMS=timeout) + mongo_url = self.Session["AVALON_MONGO"] + kwargs = { + "host": mongo_url, + "serverSelectionTimeoutMS": timeout + } + + port = extract_port_from_url(mongo_url) + if port is not None: + kwargs["port"] = int(port) + + self._mongo_client = pymongo.MongoClient(**kwargs) for retry in range(3): try: @@ -381,6 +391,10 @@ class DbConnector(object): if document is None: break + if document.get("type") == "master_version": + _document = self.find_one({"_id": document["version_id"]}) + document["data"] = _document["data"] + parents.append(document) return parents diff --git a/pype/modules/logging/gui/models.py b/pype/modules/logging/gui/models.py index 484fd6dc69..ce1fa236a9 100644 --- a/pype/modules/logging/gui/models.py +++ b/pype/modules/logging/gui/models.py @@ -1,8 +1,7 @@ -import os import collections from Qt import QtCore from pype.api import Logger -from pypeapp.lib.log import _bootstrap_mongo_log +from pypeapp.lib.log import _bootstrap_mongo_log, LOG_COLLECTION_NAME log = Logger().get_logger("LogModel", "LoggingModule") @@ -41,11 +40,11 @@ class LogModel(QtCore.QAbstractItemModel): super(LogModel, self).__init__(parent) self._root_node = Node() - collection = os.environ.get('PYPE_LOG_MONGO_COL') - database = _bootstrap_mongo_log() self.dbcon = None - if collection in database.list_collection_names(): - self.dbcon = database[collection] + # Crash if connection is not possible to skip this module + database = _bootstrap_mongo_log() + if LOG_COLLECTION_NAME in database.list_collection_names(): + self.dbcon = database[LOG_COLLECTION_NAME] def add_log(self, log): node = Node(log) diff --git a/pype/modules/logging/tray/logging_module.py b/pype/modules/logging/tray/logging_module.py index 087a51f322..9b26d5d9bf 100644 --- a/pype/modules/logging/tray/logging_module.py +++ b/pype/modules/logging/tray/logging_module.py @@ -1,20 +1,23 @@ from Qt import QtWidgets - from pype.api import Logger - from ..gui.app import LogsWindow -log = Logger().get_logger("LoggingModule", "logging") - class LoggingModule: def __init__(self, main_parent=None, parent=None): self.parent = parent + self.log = Logger().get_logger(self.__class__.__name__, "logging") - self.window = LogsWindow() + try: + self.window = LogsWindow() + self.tray_menu = self._tray_menu + except Exception: + self.log.warning( + "Couldn't set Logging GUI due to error.", exc_info=True + ) # Definition of Tray menu - def tray_menu(self, parent_menu): + def _tray_menu(self, parent_menu): # Menu for Tray App menu = QtWidgets.QMenu('Logging', parent_menu) # menu.setProperty('submenu', 'on') diff --git a/pype/plugins/global/publish/collect_avalon_entities.py b/pype/plugins/global/publish/collect_avalon_entities.py index 51dd3d7b06..917172d40c 100644 --- a/pype/plugins/global/publish/collect_avalon_entities.py +++ b/pype/plugins/global/publish/collect_avalon_entities.py @@ -48,8 +48,18 @@ class CollectAvalonEntities(pyblish.api.ContextPlugin): data = asset_entity['data'] - context.data["frameStart"] = data.get("frameStart") - context.data["frameEnd"] = data.get("frameEnd") + frame_start = data.get("frameStart") + if frame_start is None: + frame_start = 1 + self.log.warning("Missing frame start. Defaulting to 1.") + + frame_end = data.get("frameEnd") + if frame_end is None: + frame_end = 2 + self.log.warning("Missing frame end. Defaulting to 2.") + + context.data["frameStart"] = frame_start + context.data["frameEnd"] = frame_end handles = data.get("handles") or 0 handle_start = data.get("handleStart") @@ -72,7 +82,7 @@ class CollectAvalonEntities(pyblish.api.ContextPlugin): context.data["handleStart"] = int(handle_start) context.data["handleEnd"] = int(handle_end) - frame_start_h = data.get("frameStart") - context.data["handleStart"] - frame_end_h = data.get("frameEnd") + context.data["handleEnd"] + frame_start_h = frame_start - context.data["handleStart"] + frame_end_h = frame_end + context.data["handleEnd"] context.data["frameStartHandle"] = frame_start_h context.data["frameEndHandle"] = frame_end_h diff --git a/pype/plugins/global/publish/submit_publish_job.py b/pype/plugins/global/publish/submit_publish_job.py index a05cc3721e..82de2ec099 100644 --- a/pype/plugins/global/publish/submit_publish_job.py +++ b/pype/plugins/global/publish/submit_publish_job.py @@ -14,7 +14,10 @@ import pyblish.api def _get_script(): """Get path to the image sequence script.""" - from pathlib import Path + try: + from pathlib import Path + except ImportError: + from pathlib2 import Path try: from pype.scripts import publish_filesequence @@ -26,6 +29,7 @@ def _get_script(): module_path = module_path[: -len(".pyc")] + ".py" path = Path(os.path.normpath(module_path)).resolve(strict=True) + assert path is not None, ("Cannot determine path") return str(path) @@ -840,7 +844,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # add audio to metadata file if available audio_file = context.data.get("audioFile") - if os.path.isfile(audio_file): + if audio_file and os.path.isfile(audio_file): publish_job.update({"audio": audio_file}) # pass Ftrack credentials in case of Muster diff --git a/pype/plugins/global/publish/validate_ffmpeg_installed.py b/pype/plugins/global/publish/validate_ffmpeg_installed.py index f6738e6de1..61127ff96e 100644 --- a/pype/plugins/global/publish/validate_ffmpeg_installed.py +++ b/pype/plugins/global/publish/validate_ffmpeg_installed.py @@ -8,12 +8,11 @@ except ImportError: import errno -class ValidateFFmpegInstalled(pyblish.api.Validator): +class ValidateFFmpegInstalled(pyblish.api.ContextPlugin): """Validate availability of ffmpeg tool in PATH""" order = pyblish.api.ValidatorOrder label = 'Validate ffmpeg installation' - families = ['review'] optional = True def is_tool(self, name): @@ -27,7 +26,7 @@ class ValidateFFmpegInstalled(pyblish.api.Validator): return False return True - def process(self, instance): + def process(self, context): ffmpeg_path = pype.lib.get_ffmpeg_tool_path("ffmpeg") self.log.info("ffmpeg path: `{}`".format(ffmpeg_path)) if self.is_tool(ffmpeg_path) is False: diff --git a/pype/plugins/harmony/load/load_audio.py b/pype/plugins/harmony/load/load_audio.py new file mode 100644 index 0000000000..a17af78964 --- /dev/null +++ b/pype/plugins/harmony/load/load_audio.py @@ -0,0 +1,42 @@ +from avalon import api, harmony + + +func = """ +function getUniqueColumnName( column_prefix ) +{ + var suffix = 0; + // finds if unique name for a column + var column_name = column_prefix; + while(suffix < 2000) + { + if(!column.type(column_name)) + break; + + suffix = suffix + 1; + column_name = column_prefix + "_" + suffix; + } + return column_name; +} + +function func(args) +{ + var uniqueColumnName = getUniqueColumnName(args[0]); + column.add(uniqueColumnName , "SOUND"); + column.importSound(uniqueColumnName, 1, args[1]); +} +func +""" + + +class ImportAudioLoader(api.Loader): + """Import audio.""" + + families = ["shot"] + representations = ["wav"] + label = "Import Audio" + + def load(self, context, name=None, namespace=None, data=None): + wav_file = api.get_representation_path(context["representation"]) + harmony.send( + {"function": func, "args": [context["subset"]["name"], wav_file]} + ) diff --git a/pype/plugins/harmony/load/load_template_workfile.py b/pype/plugins/harmony/load/load_template_workfile.py index 00d2e63c62..a9dcd0c776 100644 --- a/pype/plugins/harmony/load/load_template_workfile.py +++ b/pype/plugins/harmony/load/load_template_workfile.py @@ -14,18 +14,6 @@ class ImportTemplateLoader(api.Loader): label = "Import Template" def load(self, context, name=None, namespace=None, data=None): - # Make backdrops from metadata. - backdrops = context["representation"]["data"].get("backdrops", []) - - func = """function func(args) - { - Backdrop.addBackdrop("Top", args[0]); - } - func - """ - for backdrop in backdrops: - harmony.send({"function": func, "args": [backdrop]}) - # Import template. temp_dir = tempfile.mkdtemp() zip_file = api.get_representation_path(context["representation"]) @@ -33,19 +21,6 @@ class ImportTemplateLoader(api.Loader): with zipfile.ZipFile(zip_file, "r") as zip_ref: zip_ref.extractall(template_path) - func = """function func(args) - { - var template_path = args[0]; - var drag_object = copyPaste.copyFromTemplate( - template_path, 0, 0, copyPaste.getCurrentCreateOptions() - ); - copyPaste.pasteNewNodes( - drag_object, "", copyPaste.getCurrentPasteOptions() - ); - } - func - """ - func = """function func(args) { var template_path = args[0]; diff --git a/pype/plugins/harmony/publish/extract_render.py b/pype/plugins/harmony/publish/extract_render.py index 75d0d2ae36..7ca83d3f0f 100644 --- a/pype/plugins/harmony/publish/extract_render.py +++ b/pype/plugins/harmony/publish/extract_render.py @@ -28,7 +28,8 @@ class ExtractRender(pyblish.api.InstancePlugin): scene.currentScene(), scene.getFrameRate(), scene.getStartFrame(), - scene.getStopFrame() + scene.getStopFrame(), + sound.getSoundtrackAll().path() ] } func @@ -41,6 +42,7 @@ class ExtractRender(pyblish.api.InstancePlugin): frame_rate = result[3] frame_start = result[4] frame_end = result[5] + audio_path = result[6] # Set output path to temp folder. path = tempfile.mkdtemp() @@ -111,6 +113,7 @@ class ExtractRender(pyblish.api.InstancePlugin): 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 diff --git a/pype/plugins/harmony/publish/extract_save_scene.py b/pype/plugins/harmony/publish/extract_save_scene.py index 1733bdb95c..8b953580a7 100644 --- a/pype/plugins/harmony/publish/extract_save_scene.py +++ b/pype/plugins/harmony/publish/extract_save_scene.py @@ -9,5 +9,5 @@ class ExtractSaveScene(pyblish.api.ContextPlugin): order = pyblish.api.ExtractorOrder - 0.49 hosts = ["harmony"] - def process(self, instance): + def process(self, context): harmony.save_scene() diff --git a/pype/plugins/harmony/publish/extract_template.py b/pype/plugins/harmony/publish/extract_template.py index f7a5e34e67..1ba0befc54 100644 --- a/pype/plugins/harmony/publish/extract_template.py +++ b/pype/plugins/harmony/publish/extract_template.py @@ -2,7 +2,8 @@ import os import shutil import pype.api -from avalon import harmony +import avalon.harmony +import pype.hosts.harmony class ExtractTemplate(pype.api.Extractor): @@ -14,6 +15,7 @@ class ExtractTemplate(pype.api.Extractor): def process(self, instance): staging_dir = self.staging_dir(instance) + filepath = os.path.join(staging_dir, "{}.tpl".format(instance.name)) self.log.info("Outputting template to {}".format(staging_dir)) @@ -28,7 +30,7 @@ class ExtractTemplate(pype.api.Extractor): unique_backdrops = [backdrops[x] for x in set(backdrops.keys())] # Get non-connected nodes within backdrops. - all_nodes = harmony.send( + all_nodes = avalon.harmony.send( {"function": "node.subNodes", "args": ["Top"]} )["result"] for node in [x for x in all_nodes if x not in dependencies]: @@ -43,48 +45,9 @@ class ExtractTemplate(pype.api.Extractor): dependencies.remove(instance[0]) # Export template. - func = """function func(args) - { - // Add an extra node just so a new group can be created. - var temp_node = node.add("Top", "temp_note", "NOTE", 0, 0, 0); - var template_group = node.createGroup(temp_node, "temp_group"); - node.deleteNode( template_group + "/temp_note" ); - - // This will make Node View to focus on the new group. - selection.clearSelection(); - selection.addNodeToSelection(template_group); - Action.perform("onActionEnterGroup()", "Node View"); - - // Recreate backdrops in group. - for (var i = 0 ; i < args[0].length; i++) - { - Backdrop.addBackdrop(template_group, args[0][i]); - }; - - // Copy-paste the selected nodes into the new group. - var drag_object = copyPaste.copy(args[1], 1, frame.numberOf, ""); - copyPaste.pasteNewNodes(drag_object, template_group, ""); - - // Select all nodes within group and export as template. - Action.perform( "selectAll()", "Node View" ); - copyPaste.createTemplateFromSelection(args[2], args[3]); - - // Unfocus the group in Node view, delete all nodes and backdrops - // created during the process. - Action.perform("onActionUpToParent()", "Node View"); - node.deleteNode(template_group, true, true); - } - func - """ - harmony.send({ - "function": func, - "args": [ - unique_backdrops, - dependencies, - "{}.tpl".format(instance.name), - staging_dir - ] - }) + pype.hosts.harmony.export_template( + unique_backdrops, dependencies, filepath + ) # Prep representation. os.chdir(staging_dir) @@ -131,7 +94,7 @@ class ExtractTemplate(pype.api.Extractor): } func """ - return harmony.send( + return avalon.harmony.send( {"function": func, "args": [node]} )["result"] @@ -150,7 +113,7 @@ class ExtractTemplate(pype.api.Extractor): func """ - current_dependencies = harmony.send( + current_dependencies = avalon.harmony.send( {"function": func, "args": [node]} )["result"] diff --git a/pype/plugins/harmony/publish/extract_workfile.py b/pype/plugins/harmony/publish/extract_workfile.py index 7a0a7954dd..304b70e293 100644 --- a/pype/plugins/harmony/publish/extract_workfile.py +++ b/pype/plugins/harmony/publish/extract_workfile.py @@ -2,6 +2,8 @@ import os import shutil import pype.api +import avalon.harmony +import pype.hosts.harmony class ExtractWorkfile(pype.api.Extractor): @@ -12,17 +14,25 @@ class ExtractWorkfile(pype.api.Extractor): families = ["workfile"] def process(self, instance): - file_path = instance.context.data["currentFile"] + # Export template. + backdrops = avalon.harmony.send( + {"function": "Backdrop.backdrops", "args": ["Top"]} + )["result"] + nodes = avalon.harmony.send( + {"function": "node.subNodes", "args": ["Top"]} + )["result"] staging_dir = self.staging_dir(instance) + filepath = os.path.join(staging_dir, "{}.tpl".format(instance.name)) + pype.hosts.harmony.export_template(backdrops, nodes, filepath) + + # Prep representation. os.chdir(staging_dir) shutil.make_archive( - instance.name, + "{}".format(instance.name), "zip", - os.path.dirname(file_path) + os.path.join(staging_dir, "{}.tpl".format(instance.name)) ) - zip_path = os.path.join(staging_dir, instance.name + ".zip") - self.log.info(f"Output zip file: {zip_path}") representation = { "name": "tpl", diff --git a/pype/plugins/harmony/publish/increment_workfile.py b/pype/plugins/harmony/publish/increment_workfile.py index 29bae09df3..858e5fab0e 100644 --- a/pype/plugins/harmony/publish/increment_workfile.py +++ b/pype/plugins/harmony/publish/increment_workfile.py @@ -22,8 +22,7 @@ class IncrementWorkfile(pyblish.api.InstancePlugin): errored_plugins = get_errored_plugins_from_data(instance.context) if errored_plugins: raise RuntimeError( - "Skipping incrementing current file because submission to" - " deadline failed." + "Skipping incrementing current file because publishing failed." ) scene_dir = version_up( diff --git a/pype/plugins/harmony/publish/validate_scene_settings.py b/pype/plugins/harmony/publish/validate_scene_settings.py index 260d64c42b..aa9a70bd85 100644 --- a/pype/plugins/harmony/publish/validate_scene_settings.py +++ b/pype/plugins/harmony/publish/validate_scene_settings.py @@ -31,6 +31,12 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): def process(self, instance): expected_settings = pype.hosts.harmony.get_asset_settings() + # Harmony is expected to start at 1. + frame_start = expected_settings["frameStart"] + frame_end = expected_settings["frameEnd"] + expected_settings["frameEnd"] = frame_end - frame_start + 1 + expected_settings["frameStart"] = 1 + func = """function func() { return { diff --git a/pype/plugins/maya/create/create_render.py b/pype/plugins/maya/create/create_render.py index b6eb2e8daa..3b2048d8f0 100644 --- a/pype/plugins/maya/create/create_render.py +++ b/pype/plugins/maya/create/create_render.py @@ -179,7 +179,7 @@ class CreateRender(avalon.maya.Creator): self.data["framesPerTask"] = 1 self.data["whitelist"] = False self.data["machineList"] = "" - self.data["useMayaBatch"] = True + self.data["useMayaBatch"] = False self.data["vrayScene"] = False # Disable for now as this feature is not working yet # self.data["assScene"] = False diff --git a/pype/plugins/maya/publish/collect_render.py b/pype/plugins/maya/publish/collect_render.py index eed44fbefd..03b14f76bb 100644 --- a/pype/plugins/maya/publish/collect_render.py +++ b/pype/plugins/maya/publish/collect_render.py @@ -332,9 +332,9 @@ class CollectMayaRender(pyblish.api.ContextPlugin): options["extendFrames"] = extend_frames options["overrideExistingFrame"] = override_frames - maya_render_plugin = "MayaBatch" - if not attributes.get("useMayaBatch", True): - maya_render_plugin = "MayaCmd" + maya_render_plugin = "MayaPype" + if attributes.get("useMayaBatch", True): + maya_render_plugin = "MayaBatch" options["mayaRenderPlugin"] = maya_render_plugin diff --git a/pype/plugins/maya/publish/submit_maya_deadline.py b/pype/plugins/maya/publish/submit_maya_deadline.py index 5a8b2f6e5a..8750d88b90 100644 --- a/pype/plugins/maya/publish/submit_maya_deadline.py +++ b/pype/plugins/maya/publish/submit_maya_deadline.py @@ -41,7 +41,7 @@ payload_skeleton = { "BatchName": None, # Top-level group name "Name": None, # Job name, as seen in Monitor "UserName": None, - "Plugin": "MayaBatch", + "Plugin": "MayaPype", "Frames": "{start}-{end}x{step}", "Comment": None, }, @@ -274,7 +274,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): step=int(self._instance.data["byFrameStep"])) payload_skeleton["JobInfo"]["Plugin"] = self._instance.data.get( - "mayaRenderPlugin", "MayaBatch") + "mayaRenderPlugin", "MayaPype") payload_skeleton["JobInfo"]["BatchName"] = filename # Job name, as seen in Monitor @@ -311,12 +311,14 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): "AVALON_TASK", "PYPE_USERNAME", "PYPE_DEV", - "PYPE_LOG_NO_COLORS" + "PYPE_LOG_NO_COLORS", + "PYPE_SETUP_PATH" ] environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **api.Session) environment["PYPE_LOG_NO_COLORS"] = "1" + environment["PYPE_MAYA_VERSION"] = cmds.about(v=True) payload_skeleton["JobInfo"].update({ "EnvironmentKeyValue%d" % index: "{key}={value}".format( key=key, @@ -428,7 +430,8 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): int(self._instance.data["frameStartHandle"]), int(self._instance.data["frameEndHandle"])), - "Plugin": "MayaBatch", + "Plugin": self._instance.data.get( + "mayaRenderPlugin", "MayaPype"), "FramesPerTask": self._instance.data.get("framesPerTask", 1) } diff --git a/pype/plugins/photoshop/create/create_image.py b/pype/plugins/photoshop/create/create_image.py index a840dd13a7..ff0a5dcb6c 100644 --- a/pype/plugins/photoshop/create/create_image.py +++ b/pype/plugins/photoshop/create/create_image.py @@ -1,12 +1,77 @@ -from avalon import photoshop +from avalon import api, photoshop +from avalon.vendor import Qt -class CreateImage(photoshop.Creator): +class CreateImage(api.Creator): """Image folder for publish.""" name = "imageDefault" label = "Image" family = "image" - def __init__(self, *args, **kwargs): - super(CreateImage, self).__init__(*args, **kwargs) + def process(self): + groups = [] + layers = [] + create_group = False + group_constant = photoshop.get_com_objects().constants().psLayerSet + if (self.options or {}).get("useSelection"): + multiple_instances = False + selection = photoshop.get_selected_layers() + + if len(selection) > 1: + # Ask user whether to create one image or image per selected + # item. + msg_box = Qt.QtWidgets.QMessageBox() + msg_box.setIcon(Qt.QtWidgets.QMessageBox.Warning) + msg_box.setText( + "Multiple layers selected." + "\nDo you want to make one image per layer?" + ) + msg_box.setStandardButtons( + Qt.QtWidgets.QMessageBox.Yes | + Qt.QtWidgets.QMessageBox.No | + Qt.QtWidgets.QMessageBox.Cancel + ) + ret = msg_box.exec_() + if ret == Qt.QtWidgets.QMessageBox.Yes: + multiple_instances = True + elif ret == Qt.QtWidgets.QMessageBox.Cancel: + return + + if multiple_instances: + for item in selection: + if item.LayerType == group_constant: + groups.append(item) + else: + layers.append(item) + else: + group = photoshop.group_selected_layers() + group.Name = self.name + groups.append(group) + + elif len(selection) == 1: + # One selected item. Use group if its a LayerSet (group), else + # create a new group. + if selection[0].LayerType == group_constant: + groups.append(selection[0]) + else: + layers.append(selection[0]) + elif len(selection) == 0: + # No selection creates an empty group. + create_group = True + else: + create_group = True + + if create_group: + group = photoshop.app().ActiveDocument.LayerSets.Add() + group.Name = self.name + groups.append(group) + + for layer in layers: + photoshop.select_layers([layer]) + group = photoshop.group_selected_layers() + group.Name = layer.Name + groups.append(group) + + for group in groups: + photoshop.imprint(group, self.data) diff --git a/pype/plugins/photoshop/publish/increment_workfile.py b/pype/plugins/photoshop/publish/increment_workfile.py new file mode 100644 index 0000000000..ba9ab8606a --- /dev/null +++ b/pype/plugins/photoshop/publish/increment_workfile.py @@ -0,0 +1,29 @@ +import pyblish.api +from pype.action import get_errored_plugins_from_data +from pype.lib import version_up +from avalon import photoshop + + +class IncrementWorkfile(pyblish.api.InstancePlugin): + """Increment the current workfile. + + Saves the current scene with an increased version number. + """ + + label = "Increment Workfile" + order = pyblish.api.IntegratorOrder + 9.0 + hosts = ["photoshop"] + families = ["workfile"] + optional = True + + def process(self, instance): + errored_plugins = get_errored_plugins_from_data(instance.context) + if errored_plugins: + raise RuntimeError( + "Skipping incrementing current file because publishing failed." + ) + + scene_path = version_up(instance.context.data["currentFile"]) + photoshop.app().ActiveDocument.SaveAs(scene_path) + + self.log.info("Incremented workfile to: {}".format(scene_path)) diff --git a/pype/plugins/photoshop/publish/validate_naming.py b/pype/plugins/photoshop/publish/validate_naming.py new file mode 100644 index 0000000000..1d85ea99a0 --- /dev/null +++ b/pype/plugins/photoshop/publish/validate_naming.py @@ -0,0 +1,44 @@ +import pyblish.api +import pype.api + + +class ValidateNamingRepair(pyblish.api.Action): + """Repair the instance asset.""" + + label = "Repair" + icon = "wrench" + on = "failed" + + def process(self, context, plugin): + + # Get the errored instances + failed = [] + for result in context.data["results"]: + if (result["error"] is not None and result["instance"] is not None + and result["instance"] not in failed): + failed.append(result["instance"]) + + # Apply pyblish.logic to get the instances for the plug-in + instances = pyblish.api.instances_by_plugin(failed, plugin) + + for instance in instances: + instance[0].Name = instance.data["name"].replace(" ", "_") + + return True + + +class ValidateNaming(pyblish.api.InstancePlugin): + """Validate the instance name. + + Spaces in names are not allowed. Will be replace with underscores. + """ + + label = "Validate Naming" + hosts = ["photoshop"] + order = pype.api.ValidateContentsOrder + families = ["image"] + actions = [ValidateNamingRepair] + + def process(self, instance): + msg = "Name \"{}\" is not allowed.".format(instance.data["name"]) + assert " " not in instance.data["name"], msg diff --git a/pype/plugins/standalonepublisher/publish/collect_shots.py b/pype/plugins/standalonepublisher/publish/collect_shots.py index 80ee875add..853ba4e8de 100644 --- a/pype/plugins/standalonepublisher/publish/collect_shots.py +++ b/pype/plugins/standalonepublisher/publish/collect_shots.py @@ -60,14 +60,8 @@ class CollectShots(pyblish.api.InstancePlugin): # options to be more flexible. asset_name = asset_name.split("_")[0] - shot_number = 10 + instances = [] for track in tracks: - self.log.info(track) - - if "audio" in track.name.lower(): - continue - - instances = [] for child in track.each_child(): # Transitions are ignored, because Clips have the full frame @@ -75,10 +69,13 @@ class CollectShots(pyblish.api.InstancePlugin): if isinstance(child, otio.schema.transition.Transition): 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 - name = f"{asset_name}_sh{shot_number:04}" label = f"{name} (framerange: {frame_start}-{frame_end})" instances.append( instance.context.create_instance(**{ @@ -96,8 +93,6 @@ class CollectShots(pyblish.api.InstancePlugin): }) ) - shot_number += 10 - visual_hierarchy = [asset_entity] while True: visual_parent = io.find_one( diff --git a/pype/plugins/standalonepublisher/publish/extract_shot.py b/pype/plugins/standalonepublisher/publish/extract_shot.py index f2fc2b74cf..d58ddfe8d5 100644 --- a/pype/plugins/standalonepublisher/publish/extract_shot.py +++ b/pype/plugins/standalonepublisher/publish/extract_shot.py @@ -26,8 +26,9 @@ class ExtractShot(pype.api.Extractor): os.path.dirname(editorial_path), basename + ".mov" ) shot_mov = os.path.join(staging_dir, instance.data["name"] + ".mov") + ffmpeg_path = pype.lib.get_ffmpeg_tool_path("ffmpeg") args = [ - "ffmpeg", + ffmpeg_path, "-ss", str(instance.data["frameStart"] / fps), "-i", input_path, "-t", str( @@ -58,7 +59,7 @@ class ExtractShot(pype.api.Extractor): shot_jpegs = os.path.join( staging_dir, instance.data["name"] + ".%04d.jpeg" ) - args = ["ffmpeg", "-i", shot_mov, shot_jpegs] + args = [ffmpeg_path, "-i", shot_mov, shot_jpegs] self.log.info(f"Processing: {args}") output = pype.lib._subprocess(args) self.log.info(output) @@ -79,7 +80,7 @@ class ExtractShot(pype.api.Extractor): # Generate wav file. shot_wav = os.path.join(staging_dir, instance.data["name"] + ".wav") - args = ["ffmpeg", "-i", shot_mov, shot_wav] + args = [ffmpeg_path, "-i", shot_mov, shot_wav] self.log.info(f"Processing: {args}") output = pype.lib._subprocess(args) self.log.info(output) diff --git a/pype/plugins/standalonepublisher/publish/validate_shots.py b/pype/plugins/standalonepublisher/publish/validate_shots.py new file mode 100644 index 0000000000..3267af7685 --- /dev/null +++ b/pype/plugins/standalonepublisher/publish/validate_shots.py @@ -0,0 +1,23 @@ +import pyblish.api +import pype.api + + +class ValidateShots(pyblish.api.ContextPlugin): + """Validate there is a "mov" next to the editorial file.""" + + label = "Validate Shots" + hosts = ["standalonepublisher"] + order = pype.api.ValidateContentsOrder + + def process(self, context): + shot_names = [] + duplicate_names = [] + for instance in context: + name = instance.data["name"] + if name in shot_names: + duplicate_names.append(name) + else: + shot_names.append(name) + + msg = "There are duplicate shot names:\n{}".format(duplicate_names) + assert not duplicate_names, msg diff --git a/pype/tools/pyblish_pype/app.css b/pype/tools/pyblish_pype/app.css index b52d9efec8..3a2c05c1f3 100644 --- a/pype/tools/pyblish_pype/app.css +++ b/pype/tools/pyblish_pype/app.css @@ -491,3 +491,24 @@ QToolButton { #TerminalFilerBtn[type="log_critical"]:checked {color: rgb(255, 79, 117);} #TerminalFilerBtn[type="log_critical"] {color: rgba(255, 79, 117, 63);} + +#SuspendLogsBtn { + background: #444; + border: none; + border-top-right-radius: 7px; + border-bottom-right-radius: 7px; + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + font-family: "FontAwesome"; + font-size: 11pt; + color: white; + padding: 0px; +} + +#SuspendLogsBtn:hover { + background: #333; +} + +#SuspendLogsBtn:disabled { + background: #4c4c4c; +} diff --git a/pype/tools/pyblish_pype/control.py b/pype/tools/pyblish_pype/control.py index 12c8944642..5138b5cc4c 100644 --- a/pype/tools/pyblish_pype/control.py +++ b/pype/tools/pyblish_pype/control.py @@ -8,6 +8,7 @@ an active window manager; such as via Travis-CI. import os import sys import traceback +import inspect from Qt import QtCore @@ -60,11 +61,15 @@ class Controller(QtCore.QObject): # store OrderGroups - now it is a singleton order_groups = util.OrderGroups + # When instance is toggled + instance_toggled = QtCore.Signal(object, object, object) + def __init__(self, parent=None): super(Controller, self).__init__(parent) self.context = None self.plugins = {} self.optional_default = {} + self.instance_toggled.connect(self._on_instance_toggled) def reset_variables(self): # Data internal to the GUI itself @@ -81,7 +86,6 @@ class Controller(QtCore.QObject): # - passing collectors order disables plugin/instance toggle self.collectors_order = None self.collect_state = 0 - self.collected = False # - passing validators order disables validate button and gives ability # to know when to stop on validate button press @@ -415,3 +419,19 @@ class Controller(QtCore.QObject): for plugin in self.plugins: del(plugin) + + def _on_instance_toggled(self, instance, old_value, new_value): + callbacks = pyblish.api.registered_callbacks().get("instanceToggled") + if not callbacks: + return + + for callback in callbacks: + try: + callback(instance, old_value, new_value) + except Exception: + print( + "Callback for `instanceToggled` crashed. {}".format( + os.path.abspath(inspect.getfile(callback)) + ) + ) + traceback.print_exception(*sys.exc_info()) diff --git a/pype/tools/pyblish_pype/model.py b/pype/tools/pyblish_pype/model.py index 2c2661b5ec..203b512d12 100644 --- a/pype/tools/pyblish_pype/model.py +++ b/pype/tools/pyblish_pype/model.py @@ -32,7 +32,6 @@ from .awesome import tags as awesome import Qt from Qt import QtCore, QtGui from six import text_type -from six.moves import queue from .vendor import qtawesome from .constants import PluginStates, InstanceStates, GroupStates, Roles @@ -49,6 +48,7 @@ TerminalDetailType = QtGui.QStandardItem.UserType + 4 class QAwesomeTextIconFactory: icons = {} + @classmethod def icon(cls, icon_name): if icon_name not in cls.icons: @@ -58,6 +58,7 @@ class QAwesomeTextIconFactory: class QAwesomeIconFactory: icons = {} + @classmethod def icon(cls, icon_name, icon_color): if icon_name not in cls.icons: @@ -489,12 +490,8 @@ class PluginModel(QtGui.QStandardItemModel): new_records = result.get("records") or [] if not has_warning: for record in new_records: - if not hasattr(record, "levelname"): - continue - - if str(record.levelname).lower() in [ - "warning", "critical", "error" - ]: + level_no = record.get("levelno") + if level_no and level_no >= 30: new_flag_states[PluginStates.HasWarning] = True break @@ -788,12 +785,8 @@ class InstanceModel(QtGui.QStandardItemModel): new_records = result.get("records") or [] if not has_warning: for record in new_records: - if not hasattr(record, "levelname"): - continue - - if str(record.levelname).lower() in [ - "warning", "critical", "error" - ]: + level_no = record.get("levelno") + if level_no and level_no >= 30: new_flag_states[InstanceStates.HasWarning] = True break @@ -1009,7 +1002,7 @@ class ArtistProxy(QtCore.QAbstractProxyModel): return QtCore.QModelIndex() -class TerminalModel(QtGui.QStandardItemModel): +class TerminalDetailItem(QtGui.QStandardItem): key_label_record_map = ( ("instance", "Instance"), ("msg", "Message"), @@ -1022,6 +1015,57 @@ class TerminalModel(QtGui.QStandardItemModel): ("msecs", "Millis") ) + def __init__(self, record_item): + self.record_item = record_item + self.msg = None + msg = record_item.get("msg") + if msg is None: + msg = record_item["label"].split("\n")[0] + + super(TerminalDetailItem, self).__init__(msg) + + def data(self, role=QtCore.Qt.DisplayRole): + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + if self.msg is None: + self.msg = self.compute_detail_text(self.record_item) + return self.msg + return super(TerminalDetailItem, self).data(role) + + def compute_detail_text(self, item_data): + if item_data["type"] == "info": + return item_data["label"] + + html_text = "" + for key, title in self.key_label_record_map: + if key not in item_data: + continue + value = item_data[key] + text = ( + str(value) + .replace("<", "<") + .replace(">", ">") + .replace('\n', '
') + .replace(' ', ' ') + ) + + title_tag = ( + '{}: ' + ' color:#fff;\" >{}: ' + ).format(title) + + html_text += ( + '{}' + '{}' + ).format(title_tag, text) + + html_text = '{}
'.format( + html_text + ) + return html_text + + +class TerminalModel(QtGui.QStandardItemModel): item_icon_name = { "info": "fa.info", "record": "fa.circle", @@ -1053,38 +1097,38 @@ class TerminalModel(QtGui.QStandardItemModel): self.reset() def reset(self): - self.items_to_set_widget = queue.Queue() self.clear() - def prepare_records(self, result): + def prepare_records(self, result, suspend_logs): prepared_records = [] instance_name = None instance = result["instance"] if instance is not None: instance_name = instance.data["name"] - for record in result.get("records") or []: - if isinstance(record, dict): - record_item = record - else: - record_item = { - "label": text_type(record.msg), - "type": "record", - "levelno": record.levelno, - "threadName": record.threadName, - "name": record.name, - "filename": record.filename, - "pathname": record.pathname, - "lineno": record.lineno, - "msg": text_type(record.msg), - "msecs": record.msecs, - "levelname": record.levelname - } + if not suspend_logs: + for record in result.get("records") or []: + if isinstance(record, dict): + record_item = record + else: + record_item = { + "label": text_type(record.msg), + "type": "record", + "levelno": record.levelno, + "threadName": record.threadName, + "name": record.name, + "filename": record.filename, + "pathname": record.pathname, + "lineno": record.lineno, + "msg": text_type(record.msg), + "msecs": record.msecs, + "levelname": record.levelname + } - if instance_name is not None: - record_item["instance"] = instance_name + if instance_name is not None: + record_item["instance"] = instance_name - prepared_records.append(record_item) + prepared_records.append(record_item) error = result.get("error") if error: @@ -1140,49 +1184,14 @@ class TerminalModel(QtGui.QStandardItemModel): self.appendRow(top_item) - detail_text = self.prepare_detail_text(record_item) - detail_item = QtGui.QStandardItem(detail_text) + detail_item = TerminalDetailItem(record_item) detail_item.setData(TerminalDetailType, Roles.TypeRole) top_item.appendRow(detail_item) - self.items_to_set_widget.put(detail_item) def update_with_result(self, result): for record in result["records"]: self.append(record) - def prepare_detail_text(self, item_data): - if item_data["type"] == "info": - return item_data["label"] - - html_text = "" - for key, title in self.key_label_record_map: - if key not in item_data: - continue - value = item_data[key] - text = ( - str(value) - .replace("<", "<") - .replace(">", ">") - .replace('\n', '
') - .replace(' ', ' ') - ) - - title_tag = ( - '{}: ' - ' color:#fff;\" >{}: ' - ).format(title) - - html_text += ( - '{}' - '{}' - ).format(title_tag, text) - - html_text = '{}
'.format( - html_text - ) - return html_text - class TerminalProxy(QtCore.QSortFilterProxyModel): filter_buttons_checks = { diff --git a/pype/tools/pyblish_pype/view.py b/pype/tools/pyblish_pype/view.py index ada19bc7d9..450f56421c 100644 --- a/pype/tools/pyblish_pype/view.py +++ b/pype/tools/pyblish_pype/view.py @@ -1,6 +1,14 @@ from Qt import QtCore, QtWidgets from . import model from .constants import Roles +# Imported when used +widgets = None + + +def _import_widgets(): + global widgets + if widgets is None: + from . import widgets class ArtistView(QtWidgets.QListView): @@ -151,6 +159,8 @@ class TerminalView(QtWidgets.QTreeView): self.clicked.connect(self.item_expand) + _import_widgets() + def event(self, event): if not event.type() == QtCore.QEvent.KeyPress: return super(TerminalView, self).event(event) @@ -190,6 +200,23 @@ class TerminalView(QtWidgets.QTreeView): self.updateGeometry() self.scrollToBottom() + def expand(self, index): + """Wrapper to set widget for expanded index.""" + model = index.model() + row_count = model.rowCount(index) + is_new = False + for child_idx in range(row_count): + child_index = model.index(child_idx, index.column(), index) + widget = self.indexWidget(child_index) + if widget is None: + is_new = True + msg = child_index.data(QtCore.Qt.DisplayRole) + widget = widgets.TerminalDetail(msg) + self.setIndexWidget(child_index, widget) + super(TerminalView, self).expand(index) + if is_new: + self.updateGeometries() + def resizeEvent(self, event): super(self.__class__, self).resizeEvent(event) self.model().layoutChanged.emit() diff --git a/pype/tools/pyblish_pype/widgets.py b/pype/tools/pyblish_pype/widgets.py index e81633f7a3..880d4755ad 100644 --- a/pype/tools/pyblish_pype/widgets.py +++ b/pype/tools/pyblish_pype/widgets.py @@ -321,11 +321,6 @@ class PerspectiveWidget(QtWidgets.QWidget): data = {"records": records} self.terminal_model.reset() self.terminal_model.update_with_result(data) - while not self.terminal_model.items_to_set_widget.empty(): - item = self.terminal_model.items_to_set_widget.get() - widget = TerminalDetail(item.data(QtCore.Qt.DisplayRole)) - index = self.terminal_proxy.mapFromSource(item.index()) - self.terminal_view.setIndexWidget(index, widget) self.records.button_toggle_text.setText( "{} ({})".format(self.l_rec, len_records) diff --git a/pype/tools/pyblish_pype/window.py b/pype/tools/pyblish_pype/window.py index 21ac500f9c..3c7808496c 100644 --- a/pype/tools/pyblish_pype/window.py +++ b/pype/tools/pyblish_pype/window.py @@ -54,6 +54,7 @@ class Window(QtWidgets.QDialog): def __init__(self, controller, parent=None): super(Window, self).__init__(parent=parent) + self._suspend_logs = False # Use plastique style for specific ocations # TODO set style name via environment variable low_keys = { @@ -95,6 +96,18 @@ class Window(QtWidgets.QDialog): header_tab_terminal = QtWidgets.QRadioButton(header_tab_widget) header_spacer = QtWidgets.QWidget(header_tab_widget) + button_suspend_logs_widget = QtWidgets.QWidget() + button_suspend_logs_widget_layout = QtWidgets.QHBoxLayout( + button_suspend_logs_widget + ) + button_suspend_logs_widget_layout.setContentsMargins(0, 10, 0, 10) + button_suspend_logs = QtWidgets.QPushButton(header_widget) + button_suspend_logs.setFixedWidth(7) + button_suspend_logs.setSizePolicy( + QtWidgets.QSizePolicy.Preferred, + QtWidgets.QSizePolicy.Expanding + ) + button_suspend_logs_widget_layout.addWidget(button_suspend_logs) header_aditional_btns = QtWidgets.QWidget(header_tab_widget) aditional_btns_layout = QtWidgets.QHBoxLayout(header_aditional_btns) @@ -109,9 +122,11 @@ class Window(QtWidgets.QDialog): layout_tab.addWidget(header_tab_artist, 0) layout_tab.addWidget(header_tab_overview, 0) layout_tab.addWidget(header_tab_terminal, 0) + layout_tab.addWidget(button_suspend_logs_widget, 0) + # Compress items to the left layout_tab.addWidget(header_spacer, 1) - layout_tab.addWidget(header_aditional_btns, 1) + layout_tab.addWidget(header_aditional_btns, 0) layout = QtWidgets.QHBoxLayout(header_widget) layout.setContentsMargins(0, 0, 0, 0) @@ -226,6 +241,10 @@ class Window(QtWidgets.QDialog): footer_info = QtWidgets.QLabel(footer_widget) footer_spacer = QtWidgets.QWidget(footer_widget) + + footer_button_stop = QtWidgets.QPushButton( + awesome["stop"], footer_widget + ) footer_button_reset = QtWidgets.QPushButton( awesome["refresh"], footer_widget ) @@ -235,14 +254,12 @@ class Window(QtWidgets.QDialog): footer_button_play = QtWidgets.QPushButton( awesome["play"], footer_widget ) - footer_button_stop = QtWidgets.QPushButton( - awesome["stop"], footer_widget - ) layout = QtWidgets.QHBoxLayout() layout.setContentsMargins(5, 5, 5, 5) layout.addWidget(footer_info, 0) layout.addWidget(footer_spacer, 1) + layout.addWidget(footer_button_stop, 0) layout.addWidget(footer_button_reset, 0) layout.addWidget(footer_button_validate, 0) @@ -342,10 +359,11 @@ class Window(QtWidgets.QDialog): "TerminalView": terminal_view, # Buttons - "Play": footer_button_play, - "Validate": footer_button_validate, - "Reset": footer_button_reset, + "SuspendLogsBtn": button_suspend_logs, "Stop": footer_button_stop, + "Reset": footer_button_reset, + "Validate": footer_button_validate, + "Play": footer_button_play, # Misc "HeaderSpacer": header_spacer, @@ -370,10 +388,11 @@ class Window(QtWidgets.QDialog): overview_page, terminal_page, footer_widget, - footer_button_play, - footer_button_validate, + button_suspend_logs, footer_button_stop, footer_button_reset, + footer_button_validate, + footer_button_play, footer_spacer, closing_placeholder ): @@ -415,10 +434,11 @@ class Window(QtWidgets.QDialog): QtCore.Qt.DirectConnection ) - artist_view.toggled.connect(self.on_item_toggled) - overview_instance_view.toggled.connect(self.on_item_toggled) - overview_plugin_view.toggled.connect(self.on_item_toggled) + artist_view.toggled.connect(self.on_instance_toggle) + overview_instance_view.toggled.connect(self.on_instance_toggle) + overview_plugin_view.toggled.connect(self.on_plugin_toggle) + button_suspend_logs.clicked.connect(self.on_suspend_clicked) footer_button_stop.clicked.connect(self.on_stop_clicked) footer_button_reset.clicked.connect(self.on_reset_clicked) footer_button_validate.clicked.connect(self.on_validate_clicked) @@ -442,10 +462,11 @@ class Window(QtWidgets.QDialog): self.terminal_filters_widget = terminal_filters_widget self.footer_widget = footer_widget + self.button_suspend_logs = button_suspend_logs + self.footer_button_stop = footer_button_stop self.footer_button_reset = footer_button_reset self.footer_button_validate = footer_button_validate self.footer_button_play = footer_button_play - self.footer_button_stop = footer_button_stop self.overview_instance_view = overview_instance_view self.overview_plugin_view = overview_plugin_view @@ -537,7 +558,29 @@ class Window(QtWidgets.QDialog): ): instance_item.setData(enable_value, Roles.IsEnabledRole) - def on_item_toggled(self, index, state=None): + def on_instance_toggle(self, index, state=None): + """An item is requesting to be toggled""" + if not index.data(Roles.IsOptionalRole): + return self.info("This item is mandatory") + + if self.controller.collect_state != 1: + return self.info("Cannot toggle") + + current_state = index.data(QtCore.Qt.CheckStateRole) + if state is None: + state = not current_state + + instance_id = index.data(Roles.ObjectIdRole) + instance_item = self.instance_model.instance_items[instance_id] + instance_item.setData(state, QtCore.Qt.CheckStateRole) + + self.controller.instance_toggled.emit( + instance_item.instance, current_state, state + ) + + self.update_compatibility() + + def on_plugin_toggle(self, index, state=None): """An item is requesting to be toggled""" if not index.data(Roles.IsOptionalRole): return self.info("This item is mandatory") @@ -548,7 +591,10 @@ class Window(QtWidgets.QDialog): if state is None: state = not index.data(QtCore.Qt.CheckStateRole) - index.model().setData(index, state, QtCore.Qt.CheckStateRole) + plugin_id = index.data(Roles.ObjectIdRole) + plugin_item = self.plugin_model.plugin_items[plugin_id] + plugin_item.setData(state, QtCore.Qt.CheckStateRole) + self.update_compatibility() def on_tab_changed(self, target): @@ -587,6 +633,13 @@ class Window(QtWidgets.QDialog): self.footer_button_play.setEnabled(False) self.footer_button_stop.setEnabled(False) + def on_suspend_clicked(self): + self._suspend_logs = not self._suspend_logs + if self.state["current_page"] == "terminal": + self.on_tab_changed("overview") + + self.tabs["terminal"].setVisible(not self._suspend_logs) + def on_comment_entered(self): """The user has typed a comment.""" self.controller.context.data["comment"] = self.comment_box.text() @@ -701,14 +754,14 @@ class Window(QtWidgets.QDialog): self.on_tab_changed(self.state["current_page"]) self.update_compatibility() - self.footer_button_validate.setEnabled(True) - self.footer_button_reset.setEnabled(True) - self.footer_button_stop.setEnabled(False) - self.footer_button_play.setEnabled(True) - self.footer_button_play.setFocus() + self.button_suspend_logs.setEnabled(False) + + self.footer_button_validate.setEnabled(False) + self.footer_button_reset.setEnabled(False) + self.footer_button_stop.setEnabled(True) + self.footer_button_play.setEnabled(False) def on_passed_group(self, order): - for group_item in self.instance_model.group_items.values(): if self.overview_instance_view.isExpanded(group_item.index()): continue @@ -740,16 +793,28 @@ class Window(QtWidgets.QDialog): def on_was_stopped(self): errored = self.controller.errored - self.footer_button_play.setEnabled(not errored) - self.footer_button_validate.setEnabled( - not errored and not self.controller.validated - ) + if self.controller.collect_state == 0: + self.footer_button_play.setEnabled(False) + self.footer_button_validate.setEnabled(False) + else: + self.footer_button_play.setEnabled(not errored) + self.footer_button_validate.setEnabled( + not errored and not self.controller.validated + ) + self.footer_button_play.setFocus() + self.footer_button_reset.setEnabled(True) self.footer_button_stop.setEnabled(False) if errored: self.footer_widget.setProperty("success", 0) self.footer_widget.style().polish(self.footer_widget) + suspend_log_bool = ( + self.controller.collect_state == 1 + and not self.controller.stopped + ) + self.button_suspend_logs.setEnabled(suspend_log_bool) + def on_was_skipped(self, plugin): plugin_item = self.plugin_model.plugin_items[plugin.id] plugin_item.setData( @@ -809,17 +874,15 @@ class Window(QtWidgets.QDialog): if self.tabs["artist"].isChecked(): self.tabs["overview"].toggle() - result["records"] = self.terminal_model.prepare_records(result) + result["records"] = self.terminal_model.prepare_records( + result, + self._suspend_logs + ) plugin_item = self.plugin_model.update_with_result(result) instance_item = self.instance_model.update_with_result(result) self.terminal_model.update_with_result(result) - while not self.terminal_model.items_to_set_widget.empty(): - item = self.terminal_model.items_to_set_widget.get() - widget = widgets.TerminalDetail(item.data(QtCore.Qt.DisplayRole)) - index = self.terminal_proxy.mapFromSource(item.index()) - self.terminal_view.setIndexWidget(index, widget) self.update_compatibility() @@ -872,16 +935,19 @@ class Window(QtWidgets.QDialog): self.footer_button_validate.setEnabled(False) self.footer_button_play.setEnabled(False) + self.button_suspend_logs.setEnabled(False) + util.defer(5, self.controller.validate) def publish(self): self.info(self.tr("Preparing publish..")) - self.footer_button_stop.setEnabled(True) self.footer_button_reset.setEnabled(False) self.footer_button_validate.setEnabled(False) self.footer_button_play.setEnabled(False) + self.button_suspend_logs.setEnabled(False) + util.defer(5, self.controller.publish) def act(self, plugin_item, action): @@ -913,30 +979,24 @@ class Window(QtWidgets.QDialog): plugin_item = self.plugin_model.plugin_items[result["plugin"].id] action_state = plugin_item.data(Roles.PluginActionProgressRole) action_state |= PluginActionStates.HasFinished - result["records"] = self.terminal_model.prepare_records(result) + result["records"] = self.terminal_model.prepare_records( + result, + self._suspend_logs + ) - error = result.get("error") - if error: - records = result.get("records") or [] + if result.get("error"): action_state |= PluginActionStates.HasFailed - fname, line_no, func, exc = error.traceback - - records.append({ - "label": str(error), - "type": "error", - "filename": str(fname), - "lineno": str(line_no), - "func": str(func), - "traceback": error.formatted_traceback - }) - - result["records"] = records plugin_item.setData(action_state, Roles.PluginActionProgressRole) - self.plugin_model.update_with_result(result) - self.instance_model.update_with_result(result) self.terminal_model.update_with_result(result) + plugin_item = self.plugin_model.update_with_result(result) + instance_item = self.instance_model.update_with_result(result) + + if self.perspective_widget.isVisible(): + self.perspective_widget.update_context( + plugin_item, instance_item + ) def closeEvent(self, event): """Perform post-flight checks before closing diff --git a/pype/version.py b/pype/version.py index 334087f851..1c622223ba 100644 --- a/pype/version.py +++ b/pype/version.py @@ -1 +1 @@ -__version__ = "2.9.1" +__version__ = "2.10.0" diff --git a/schema/session-2.0.json b/schema/session-2.0.json index d37f2ac822..7ad2c63bcf 100644 --- a/schema/session-2.0.json +++ b/schema/session-2.0.json @@ -56,13 +56,6 @@ "pattern": "^\\w*$", "example": "maya2016" }, - "AVALON_MONGO": { - "description": "Address to the asset database", - "type": "string", - "pattern": "^mongodb://[\\w/@:.]*$", - "example": "mongodb://localhost:27017", - "default": "mongodb://localhost:27017" - }, "AVALON_DB": { "description": "Name of database", "type": "string",