From 1f748ad0a259b03e27578968f67f1d3f451a409e Mon Sep 17 00:00:00 2001 From: antirotor Date: Tue, 25 Jun 2019 22:36:41 +0200 Subject: [PATCH 01/22] feat(pyblish): adding ability to filter and modify plugins based on presets --- pype/__init__.py | 3 +++ pype/lib.py | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/pype/__init__.py b/pype/__init__.py index 5a65e01776..7f189bb814 100644 --- a/pype/__init__.py +++ b/pype/__init__.py @@ -3,6 +3,7 @@ import os from pyblish import api as pyblish from avalon import api as avalon from Qt import QtWidgets +from .lib import filter_pyblish_plugins import logging log = logging.getLogger(__name__) @@ -23,6 +24,7 @@ LOAD_PATH = os.path.join(PLUGINS_DIR, "global", "load") def install(): log.info("Registering global plug-ins..") pyblish.register_plugin_path(PUBLISH_PATH) + pyblish.register_discovery_filter(filter_pyblish_plugins) avalon.register_plugin_path(avalon.Loader, LOAD_PATH) # pyblish-qml settings. @@ -42,5 +44,6 @@ def install(): def uninstall(): log.info("Deregistering global plug-ins..") pyblish.deregister_plugin_path(PUBLISH_PATH) + pyblish.deregister_discovery_filter(filter_pyblish_plugins) avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) log.info("Global plug-ins unregistred") diff --git a/pype/lib.py b/pype/lib.py index 648a26a8a3..8120a1456a 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -478,3 +478,30 @@ def get_presets_path(): path_items = [templates, 'presets'] filepath = os.path.sep.join(path_items) return filepath + + +def filter_pyblish_plugins(plugins): + """ + This servers as plugin filter / modifier for pyblish. It will load plugin + definitions from presets and filter those needed to be excluded. + + :param plugins: Dictionary of plugins produced by :mod:`pyblish-base` + `discover()` method. + :type plugins: Dict + """ + from pypeapp import config + + # load plugins + config_data = config.get_presets()['plugins']['config'] + + # iterate over plugins + for plugin in plugins[:]: + if config_data.get(plugin.__name__): + for option, value in config_data[plugin.__name__].items(): + if hasattr(plugin, option): + log.info('setting {}:{} on plugin {}'.format( + option, value, plugin.__name__)) + setattr(plugin, option, value) + if option == "enabled": + log.info('removing plugin {}'.format(plugin.__name__)) + plugins.remove(plugin) From 52fd865563072aedbe0eae6a893dd8fadff9641e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 28 Jun 2019 17:31:20 +0200 Subject: [PATCH 02/22] added custom db connector for own data table name --- pype/ftrack/lib/custom_db_connector.py | 207 +++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 pype/ftrack/lib/custom_db_connector.py diff --git a/pype/ftrack/lib/custom_db_connector.py b/pype/ftrack/lib/custom_db_connector.py new file mode 100644 index 0000000000..505ac96610 --- /dev/null +++ b/pype/ftrack/lib/custom_db_connector.py @@ -0,0 +1,207 @@ +""" +Wrapper around interactions with the database + +Copy of io module in avalon-core. + - In this case not working as singleton with api.Session! +""" + +import os +import time +import errno +import shutil +import logging +import tempfile +import functools +import contextlib + +import requests + +# Third-party dependencies +import pymongo +from pymongo.client_session import ClientSession + +def auto_reconnect(func): + """Handling auto reconnect in 3 retry times""" + @functools.wraps(func) + def decorated(*args, **kwargs): + object = args[0] + for retry in range(3): + try: + return func(*args, **kwargs) + except pymongo.errors.AutoReconnect: + object.log.error("Reconnecting..") + time.sleep(0.1) + else: + raise + + return decorated + + +class DbConnector: + + log = logging.getLogger(__name__) + timeout = 1000 + + def __init__(self, mongo_url, database_name, table_name): + 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._database_name = database_name + + self.active_table = table_name + + def install(self): + """Establish a persistent connection to the database""" + if self._is_installed: + return + + logging.basicConfig() + + self._mongo_client = pymongo.MongoClient( + self._mongo_url, + serverSelectionTimeoutMS=self.timeout + ) + + for retry in range(3): + try: + t1 = time.time() + self._mongo_client.server_info() + except Exception: + self.log.error("Retrying..") + time.sleep(1) + else: + break + + else: + raise IOError( + "ERROR: Couldn't connect to %s in " + "less than %.3f ms" % (self._mongo_url, timeout) + ) + + self.log.info("Connected to %s, delay %.3f s" % ( + self._mongo_url, time.time() - t1 + )) + + self._database = self._mongo_client[self._database_name] + self._is_installed = True + + def uninstall(self): + """Close any connection to the database""" + + try: + self._mongo_client.close() + except AttributeError: + pass + + self._mongo_client = None + self._database = None + self._is_installed = False + + def tables(self): + """List available tables + Returns: + list of table names + """ + collection_names = self.collections() + for table_name in collection_names: + if table_name in ("system.indexes",): + continue + yield table_name + + @auto_reconnect + def collections(self): + return self._database.collection_names() + + @auto_reconnect + def insert_one(self, item, session=None): + assert isinstance(item, dict), "item must be of type " + return self._database[self.active_table].insert_one( + item, + session=session + ) + + @auto_reconnect + def insert_many(self, items, ordered=True, session=None): + # check if all items are valid + assert isinstance(items, list), "`items` must be of type " + for item in items: + assert isinstance(item, dict), "`item` must be of type " + + return self._database[self.active_table].insert_many( + items, + ordered=ordered, + session=session + ) + + @auto_reconnect + def find(self, filter, projection=None, sort=None, session=None): + return self._database[self.active_table].find( + filter=filter, + projection=projection, + sort=sort, + session=session + ) + + @auto_reconnect + def find_one(self, filter, projection=None, sort=None, session=None): + assert isinstance(filter, dict), "filter must be " + + return self._database[self.active_table].find_one( + filter=filter, + projection=projection, + sort=sort, + session=session + ) + + @auto_reconnect + def replace_one(self, filter, replacement, session=None): + return self._database[self.active_table].replace_one( + filter, replacement, + session=session + ) + + @auto_reconnect + def update_one(self, filter, update, session=None): + return self._database[self.active_table].update_one( + filter, update, + session=session + ) + + @auto_reconnect + def update_many(self, filter, update, session=None): + return self._database[self.active_table].update_many( + filter, update, + session=session + ) + + @auto_reconnect + def distinct(self, *args, **kwargs): + return self._database[self.active_table].distinct( + *args, **kwargs + ) + + @auto_reconnect + def drop_collection(self, name_or_collection, session=None): + return self._database[self.active_table].drop( + name_or_collection, + session=session + ) + + @auto_reconnect + def delete_one(filter, collation=None, session=None): + return self._database[self.active_table].delete_one( + filter, + collation=collation, + session=session + ) + + @auto_reconnect + def delete_many(filter, collation=None, session=None): + return self._database[self.active_table].delete_many( + filter, + collation=collation, + session=session + ) From f2f2a8fcd81c790a77d1c83b13efe2941e20c155 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 28 Jun 2019 17:31:45 +0200 Subject: [PATCH 03/22] first working version, will crash if if replies are on notes --- pype/ftrack/actions/action_sync_notes.py | 240 +++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 pype/ftrack/actions/action_sync_notes.py diff --git a/pype/ftrack/actions/action_sync_notes.py b/pype/ftrack/actions/action_sync_notes.py new file mode 100644 index 0000000000..be5848644d --- /dev/null +++ b/pype/ftrack/actions/action_sync_notes.py @@ -0,0 +1,240 @@ +import os +import sys +import datetime +import argparse +import logging +import collections +import json +import re +import requests +import tempfile + +from pype.vendor import ftrack_api +from pype.ftrack import BaseAction +from pype.ftrack.lib.custom_db_connector import DbConnector, ClientSession +from avalon import io, inventory, schema + + +class SynchronizeNotes(BaseAction): + #: Action identifier. + identifier = 'sync.notes' + #: Action label. + label = 'Synchronize Notes' + #: Action description. + description = 'Synchronize notes from one Ftrack to another' + #: roles that are allowed to register this action + role_list = ['Pypeclub'] + + db_con = DbConnector( + mongo_url=os.environ["AVALON_MONGO"], + database_name='customDatabase', + table_name='notesTable' + ) + + id_key_src = 'fridge_ftrackID' + id_key_dst = 'kredenc_ftrackID' + date_store_key = 'last_stored_asset_version' + + def discover(self, session, entities, event): + ''' Validation ''' + if len(entities) == 0: + return False + + for entity in entities: + if entity.entity_type.lower() != 'assetversion': + return False + + return True + + def launch(self, session, entities, event): + + self.session_source = ftrack_api.Session( + server_url='', + api_key='', + api_user='' + ) + + self.session_for_components = ftrack_api.Session( + server_url=session.server_url, + api_key=session.api_key, + api_user=session.api_user + ) + + self.user = self.session_for_components.query( + 'User where username is "{}"'.format(self.session.api_user) + ).one() + + self.db_con.install() + + missing_id_entities = [] + for dst_entity in entities: + # Ignore entities withoud stored id from second ftrack + from_id = dst_entity['custom_attributes'].get(self.id_key_src) + if not from_id: + missing_id_entities.append(dst_entity) + continue + + av_query = 'AssetVersion where id is "{}"'.format(from_id) + src_entity = self.session_source.query(av_query).one() + src_notes = src_entity['notes'] + self.sync_notes(src_notes, dst_entity) + + self.db_con.uninstall() + + print(missing_id_entities) + + return True + + def sync_notes(self, src_notes, dst_entity): + # Sort notes by date time + src_notes = sorted(src_notes, key=lambda note: note['date']) + for src_note in src_notes: + # Find if exists in DB + db_note_entity = self.db_con.find_one({ + self.id_key_src: src_note['id'] + }) + # WARNING: expr `if not db_note_entity:` does not work! + + if db_note_entity is None: + # Create note if not found in DB + dst_note = self.create_note(dst_entity, src_note) + dst_id = dst_note['id'] + # Add references to DB for next sync + item = { + self.id_key_dst: dst_id, + self.id_key_src: src_note['id'], + 'content': src_note['content'], + 'entity_type': 'Note', + 'sync_date': str(datetime.date.today()) + } + self.db_con.insert_one(item) + else: + dst_id = db_note_entity[self.id_key_dst] + # for src_note in src_notes: + replies = src_note.get('replies') + if not replies: + continue + # db_note_entity = self.db_con.find_one({ + # self.id_key_src: src_note['id'] + # }) + dst_note = self.session.query( + 'Note where id is "{}"'.format(dst_id) + ).one() + self.sync_notes(replies, dst_note) + + def create_note(self, dst_entity, src_note): + + # dst_entity = self.session_for_components.query( + # '{} where id is "{}"'.format( + # dst_entity.entity_type, dst_entity['id'] + # ) + # ).one() + + note_key = 'notes' + if dst_entity.entity_type.lower() == 'note': + note_key = 'replies' + + # TODO Which date? Source note date? + note_date = src_note['date'] + + note_data = { + 'content': src_note['content'], + # 'date': note_date, + 'author': self.user + } + + if dst_entity.entity_type.lower() != 'note': + # Category + category = None + cat = src_note['category'] + if cat: + cat_name = cat['name'] + category = self.session.query( + 'NoteCategory where name is "{}"'.format(cat_name) + ).first() + + if category: + note_data['category'] = category + + # TODO Recipients? add assigned user? + # recipients = [] + + new_note = dst_entity.create_note(src_note['content'], self.user, category=category) + else: + new_note = dst_entity.create_reply(src_note['content'], self.user) + self.session.commit() + # recipient = self.session.create('Recipient', { + # 'note_id': new_note['id'], + # 'recipient': self.user, + # 'user': self.user, + # 'resource_id': dst_entity['id'] + # }) + # new_note['recipients'] = [recipient,] + + + # Components + if src_note['note_components']: + self.reupload_components(src_note, new_note) + + return new_note + + def reupload_components(self, src_note, dst_note): + # Download and collect source components + src_server_location = self.session_source.query( + 'Location where name is "ftrack.server"' + ).one() + + temp_folder = tempfile.mkdtemp('note_components') + + #download and store path to upload + paths_to_upload = [] + count = 0 + for note_component in src_note['note_components']: + count +=1 + download_url = src_server_location.get_url( + note_component['component'] + ) + + file_name = '{}{}{}'.format( + str(src_note['date'].format('YYYYMMDDHHmmss')), + "{:0>3}".format(count), + note_component['component']['file_type'] + ) + path = os.path.sep.join([temp_folder, file_name]) + + self.download_file(download_url, path) + paths_to_upload.append(path) + + # Create downloaded components and add to note + dst_server_location = self.session_for_components.query( + 'Location where name is "ftrack.server"' + ).one() + + for path in paths_to_upload: + component = self.session_for_components.create_component( + path, + data={'name': 'My file'}, + location=dst_server_location + ) + + # Attach the component to the note. + self.session_for_components.create( + 'NoteComponent', + {'component_id': component['id'], 'note_id': dst_note['id']} + ) + + self.session_for_components.commit() + + def download_file(self, url, path): + r = requests.get(url, stream=True).content + with open(path, 'wb') as f: + f.write(r) + + +def register(session, **kw): + '''Register plugin. Called when used as an plugin.''' + + if not isinstance(session, ftrack_api.session.Session): + return + + SynchronizeNotes(session).register() From c1de475c58c5a965621d171822e54f35cbe976d0 Mon Sep 17 00:00:00 2001 From: antirotor Date: Fri, 28 Jun 2019 19:01:10 +0200 Subject: [PATCH 04/22] changed plugin filter to load presets from directory structure, added tests --- pype/__init__.py | 24 +++++----- pype/lib.py | 26 ++++++---- pype/tests/__init__.py | 0 pype/tests/lib.py | 80 +++++++++++++++++++++++++++++++ pype/tests/test_pyblish_filter.py | 41 ++++++++++++++++ 5 files changed, 150 insertions(+), 21 deletions(-) create mode 100644 pype/tests/__init__.py create mode 100644 pype/tests/lib.py create mode 100644 pype/tests/test_pyblish_filter.py diff --git a/pype/__init__.py b/pype/__init__.py index 7f189bb814..43ca61e29a 100644 --- a/pype/__init__.py +++ b/pype/__init__.py @@ -2,7 +2,7 @@ import os from pyblish import api as pyblish from avalon import api as avalon -from Qt import QtWidgets +# from Qt import QtWidgets from .lib import filter_pyblish_plugins import logging @@ -28,17 +28,17 @@ def install(): avalon.register_plugin_path(avalon.Loader, LOAD_PATH) # pyblish-qml settings. - try: - __import__("pyblish_qml") - except ImportError as e: - log.error("Could not load pyblish-qml: %s " % e) - else: - from pyblish_qml import settings - app = QtWidgets.QApplication.instance() - screen_resolution = app.desktop().screenGeometry() - width, height = screen_resolution.width(), screen_resolution.height() - settings.WindowSize = (width / 3, height * 0.75) - settings.WindowPosition = (0, 0) + # try: + # __import__("pyblish_qml") + # except ImportError as e: + # log.error("Could not load pyblish-qml: %s " % e) + # else: + # from pyblish_qml import settings + # app = QtWidgets.QApplication.instance() + # screen_resolution = app.desktop().screenGeometry() + # width, height = screen_resolution.width(), screen_resolution.height() + # settings.WindowSize = (width / 3, height * 0.75) + # settings.WindowPosition = (0, 0) def uninstall(): diff --git a/pype/lib.py b/pype/lib.py index 8120a1456a..78658b498b 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -490,18 +490,26 @@ def filter_pyblish_plugins(plugins): :type plugins: Dict """ from pypeapp import config + from pyblish import api + + host = api.current_host() # load plugins config_data = config.get_presets()['plugins']['config'] # iterate over plugins for plugin in plugins[:]: - if config_data.get(plugin.__name__): - for option, value in config_data[plugin.__name__].items(): - if hasattr(plugin, option): - log.info('setting {}:{} on plugin {}'.format( - option, value, plugin.__name__)) - setattr(plugin, option, value) - if option == "enabled": - log.info('removing plugin {}'.format(plugin.__name__)) - plugins.remove(plugin) + try: + config_data = config.get_presets()['plugins'][host][plugin.__name__] # noqa: E501 + except KeyError: + continue + + for option, value in config_data.items(): + if option == "enabled" and value is False: + log.info('removing plugin {}'.format(plugin.__name__)) + plugins.remove(plugin) + else: + log.info('setting {}:{} on plugin {}'.format( + option, value, plugin.__name__)) + + setattr(plugin, option, value) diff --git a/pype/tests/__init__.py b/pype/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pype/tests/lib.py b/pype/tests/lib.py new file mode 100644 index 0000000000..85b9032836 --- /dev/null +++ b/pype/tests/lib.py @@ -0,0 +1,80 @@ +import os +import sys +import shutil +import tempfile +import contextlib + +import pyblish +import pyblish.cli +import pyblish.plugin +from pyblish.vendor import six + + +# Setup +HOST = 'python' +FAMILY = 'test.family' + +REGISTERED = pyblish.plugin.registered_paths() +PACKAGEPATH = pyblish.lib.main_package_path() +ENVIRONMENT = os.environ.get("PYBLISHPLUGINPATH", "") +PLUGINPATH = os.path.join(PACKAGEPATH, '..', 'tests', 'plugins') + + +def setup(): + pyblish.plugin.deregister_all_paths() + + +def setup_empty(): + """Disable all plug-ins""" + setup() + pyblish.plugin.deregister_all_plugins() + pyblish.plugin.deregister_all_paths() + pyblish.plugin.deregister_all_hosts() + pyblish.plugin.deregister_all_callbacks() + pyblish.plugin.deregister_all_targets() + pyblish.api.deregister_all_discovery_filters() + + +def teardown(): + """Restore previously REGISTERED paths""" + + pyblish.plugin.deregister_all_paths() + for path in REGISTERED: + pyblish.plugin.register_plugin_path(path) + + os.environ["PYBLISHPLUGINPATH"] = ENVIRONMENT + pyblish.api.deregister_all_plugins() + pyblish.api.deregister_all_hosts() + pyblish.api.deregister_all_discovery_filters() + pyblish.api.deregister_test() + pyblish.api.__init__() + + +@contextlib.contextmanager +def captured_stdout(): + """Temporarily reassign stdout to a local variable""" + try: + sys.stdout = six.StringIO() + yield sys.stdout + finally: + sys.stdout = sys.__stdout__ + + +@contextlib.contextmanager +def captured_stderr(): + """Temporarily reassign stderr to a local variable""" + try: + sys.stderr = six.StringIO() + yield sys.stderr + finally: + sys.stderr = sys.__stderr__ + + +@contextlib.contextmanager +def tempdir(): + """Provide path to temporary directory""" + try: + tempdir = tempfile.mkdtemp() + yield tempdir + finally: + shutil.rmtree(tempdir) diff --git a/pype/tests/test_pyblish_filter.py b/pype/tests/test_pyblish_filter.py new file mode 100644 index 0000000000..27fed50c40 --- /dev/null +++ b/pype/tests/test_pyblish_filter.py @@ -0,0 +1,41 @@ +from . import lib +import pyblish.api +import pyblish.util +import pyblish.plugin +from pype.lib import filter_pyblish_plugins +import os + + +def test_pyblish_plugin_filter(printer, monkeypatch): + """ + Test if pyblish filter can filter and modify plugins on-the-fly. + """ + + lib.setup_empty() + monkeypatch.setitem(os.environ, 'PYBLISHPLUGINPATH', '') + plugins = pyblish.api.registered_plugins() + printer("Test if we have no registered plugins") + assert len(plugins) == 0 + paths = pyblish.api.registered_paths() + printer("Test if we have no registered plugin paths") + print(paths) + + class MyTestPlugin(pyblish.api.InstancePlugin): + my_test_property = 1 + label = "Collect Renderable Camera(s)" + hosts = ["test"] + families = ["default"] + + pyblish.api.register_host("test") + pyblish.api.register_plugin(MyTestPlugin) + pyblish.api.register_discovery_filter(filter_pyblish_plugins) + plugins = pyblish.api.discover() + + printer("Test if only one plugin was discovered") + assert len(plugins) == 1 + printer("Test if properties are modified correctly") + assert plugins[0].label == "loaded from preset" + assert plugins[0].families == ["changed", "by", "preset"] + assert plugins[0].optional is True + + lib.teardown() From ed5751457918e911f9dcefcd30e94fb3957cffde Mon Sep 17 00:00:00 2001 From: antirotor Date: Fri, 28 Jun 2019 21:29:58 +0200 Subject: [PATCH 05/22] added more tests, added support for coverage --- .gitignore | 15 +++++++++++++++ pype/.coveragerc | 0 pype/tests/test_pyblish_filter.py | 21 ++++++++++++++++++++- 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 pype/.coveragerc diff --git a/.gitignore b/.gitignore index baf7b918e2..801760201e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,18 @@ __pycache__/ # Editor backup files # ####################### *~ + +# Unit test / coverage reports +############################## +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +/coverage +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ diff --git a/pype/.coveragerc b/pype/.coveragerc new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pype/tests/test_pyblish_filter.py b/pype/tests/test_pyblish_filter.py index 27fed50c40..8d747e63df 100644 --- a/pype/tests/test_pyblish_filter.py +++ b/pype/tests/test_pyblish_filter.py @@ -6,7 +6,7 @@ from pype.lib import filter_pyblish_plugins import os -def test_pyblish_plugin_filter(printer, monkeypatch): +def test_pyblish_plugin_filter_modifier(printer, monkeypatch): """ Test if pyblish filter can filter and modify plugins on-the-fly. """ @@ -39,3 +39,22 @@ def test_pyblish_plugin_filter(printer, monkeypatch): assert plugins[0].optional is True lib.teardown() + + +def test_pyblish_plugin_filter_removal(monkeypatch): + """ Test that plugin can be removed by filter """ + lib.setup_empty() + monkeypatch.setitem(os.environ, 'PYBLISHPLUGINPATH', '') + plugins = pyblish.api.registered_plugins() + + class MyTestRemovedPlugin(pyblish.api.InstancePlugin): + my_test_property = 1 + label = "Collect Renderable Camera(s)" + hosts = ["test"] + families = ["default"] + + pyblish.api.register_host("test") + pyblish.api.register_plugin(MyTestRemovedPlugin) + pyblish.api.register_discovery_filter(filter_pyblish_plugins) + plugins = pyblish.api.discover() + assert len(plugins) == 0 From f440f5cfcf0eb327da9e361541b2f0891ff18078 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 3 Jul 2019 10:36:36 +0200 Subject: [PATCH 06/22] import cleanup --- pype/ftrack/actions/action_sync_notes.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pype/ftrack/actions/action_sync_notes.py b/pype/ftrack/actions/action_sync_notes.py index be5848644d..b7b8ec872e 100644 --- a/pype/ftrack/actions/action_sync_notes.py +++ b/pype/ftrack/actions/action_sync_notes.py @@ -1,18 +1,13 @@ import os import sys +import time import datetime -import argparse -import logging -import collections -import json -import re import requests import tempfile from pype.vendor import ftrack_api from pype.ftrack import BaseAction from pype.ftrack.lib.custom_db_connector import DbConnector, ClientSession -from avalon import io, inventory, schema class SynchronizeNotes(BaseAction): From 8551017daf1a7bba28719929ef989054b04fe023 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 3 Jul 2019 10:37:29 +0200 Subject: [PATCH 07/22] added auto connect to sessions --- pype/ftrack/actions/action_sync_notes.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pype/ftrack/actions/action_sync_notes.py b/pype/ftrack/actions/action_sync_notes.py index b7b8ec872e..d394a2dd5e 100644 --- a/pype/ftrack/actions/action_sync_notes.py +++ b/pype/ftrack/actions/action_sync_notes.py @@ -46,13 +46,15 @@ class SynchronizeNotes(BaseAction): self.session_source = ftrack_api.Session( server_url='', api_key='', - api_user='' + api_user='', + auto_connect_event_hub=True ) self.session_for_components = ftrack_api.Session( server_url=session.server_url, api_key=session.api_key, - api_user=session.api_user + api_user=session.api_user, + auto_connect_event_hub=True ) self.user = self.session_for_components.query( From f48d527a6fa07ff94fc796afe68e8bef7659451f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 3 Jul 2019 10:40:12 +0200 Subject: [PATCH 08/22] after each reply is called session.reset() so between methods are sent only ids now - replies can be syncronized too --- pype/ftrack/actions/action_sync_notes.py | 100 ++++++++++------------- 1 file changed, 45 insertions(+), 55 deletions(-) diff --git a/pype/ftrack/actions/action_sync_notes.py b/pype/ftrack/actions/action_sync_notes.py index d394a2dd5e..05c53e2c79 100644 --- a/pype/ftrack/actions/action_sync_notes.py +++ b/pype/ftrack/actions/action_sync_notes.py @@ -64,27 +64,34 @@ class SynchronizeNotes(BaseAction): self.db_con.install() missing_id_entities = [] + to_sync_data = [] for dst_entity in entities: # Ignore entities withoud stored id from second ftrack from_id = dst_entity['custom_attributes'].get(self.id_key_src) if not from_id: - missing_id_entities.append(dst_entity) + missing_id_entities.append(dst_entity.get('name', dst_entity)) continue + to_sync_data.append((dst_entity.entity_type, dst_entity['id'])) + + for dst_entity_data in to_sync_data: av_query = 'AssetVersion where id is "{}"'.format(from_id) src_entity = self.session_source.query(av_query).one() src_notes = src_entity['notes'] - self.sync_notes(src_notes, dst_entity) + self.sync_notes(src_notes, dst_entity_data) self.db_con.uninstall() - print(missing_id_entities) + if missing_id_entities: + self.log.info('Entities without Avalon ID:') + self.log.info(missing_id_entities) return True - def sync_notes(self, src_notes, dst_entity): + def sync_notes(self, src_notes, dst_entity_data): # Sort notes by date time src_notes = sorted(src_notes, key=lambda note: note['date']) + for src_note in src_notes: # Find if exists in DB db_note_entity = self.db_con.find_one({ @@ -94,11 +101,12 @@ class SynchronizeNotes(BaseAction): if db_note_entity is None: # Create note if not found in DB - dst_note = self.create_note(dst_entity, src_note) - dst_id = dst_note['id'] + dst_note_id = self.create_note( + src_note, dst_entity_data + ) # Add references to DB for next sync item = { - self.id_key_dst: dst_id, + self.id_key_dst: dst_note_id, self.id_key_src: src_note['id'], 'content': src_note['content'], 'entity_type': 'Note', @@ -106,40 +114,21 @@ class SynchronizeNotes(BaseAction): } self.db_con.insert_one(item) else: - dst_id = db_note_entity[self.id_key_dst] - # for src_note in src_notes: + dst_note_id = db_note_entity[self.id_key_dst] + replies = src_note.get('replies') if not replies: continue - # db_note_entity = self.db_con.find_one({ - # self.id_key_src: src_note['id'] - # }) - dst_note = self.session.query( - 'Note where id is "{}"'.format(dst_id) - ).one() - self.sync_notes(replies, dst_note) - def create_note(self, dst_entity, src_note): + self.sync_notes(replies, ('Note', dst_note_id)) - # dst_entity = self.session_for_components.query( - # '{} where id is "{}"'.format( - # dst_entity.entity_type, dst_entity['id'] - # ) - # ).one() - - note_key = 'notes' - if dst_entity.entity_type.lower() == 'note': - note_key = 'replies' - - # TODO Which date? Source note date? - note_date = src_note['date'] - - note_data = { - 'content': src_note['content'], - # 'date': note_date, - 'author': self.user - } + def create_note(self, src_note, dst_entity_data): + # dst_entity_data - tuple(entity type, entity id) + dst_entity = self.session.query( + '{} where id is "{}"'.format(*dst_entity_data) + ).one() + is_reply = False if dst_entity.entity_type.lower() != 'note': # Category category = None @@ -150,32 +139,33 @@ class SynchronizeNotes(BaseAction): 'NoteCategory where name is "{}"'.format(cat_name) ).first() - if category: - note_data['category'] = category - - # TODO Recipients? add assigned user? - # recipients = [] - - new_note = dst_entity.create_note(src_note['content'], self.user, category=category) + new_note = dst_entity.create_note( + src_note['content'], self.user, category=category + ) else: - new_note = dst_entity.create_reply(src_note['content'], self.user) - self.session.commit() - # recipient = self.session.create('Recipient', { - # 'note_id': new_note['id'], - # 'recipient': self.user, - # 'user': self.user, - # 'resource_id': dst_entity['id'] - # }) - # new_note['recipients'] = [recipient,] + new_note = dst_entity.create_reply( + src_note['content'], self.user + ) + is_reply = True + # TODO Should we change date to match source Ftrack? + # new_note['data'] = src_note['date'] + + self.session.commit() + new_note_id = new_note['id'] # Components if src_note['note_components']: - self.reupload_components(src_note, new_note) + self.reupload_components(src_note, new_note_id) - return new_note + # Bug in ftrack_api, when reply is added session must be reset + if is_reply: + self.session.reset() + time.sleep(0.2) - def reupload_components(self, src_note, dst_note): + return new_note_id + + def reupload_components(self, src_note, dst_note_id): # Download and collect source components src_server_location = self.session_source.query( 'Location where name is "ftrack.server"' @@ -217,7 +207,7 @@ class SynchronizeNotes(BaseAction): # Attach the component to the note. self.session_for_components.create( 'NoteComponent', - {'component_id': component['id'], 'note_id': dst_note['id']} + {'component_id': component['id'], 'note_id': dst_note_id} ) self.session_for_components.commit() From 9c739666d28d8dad33243f1cef9c9fafb38cbc6d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 3 Jul 2019 10:52:39 +0200 Subject: [PATCH 09/22] renamed database and table name --- pype/ftrack/actions/action_sync_notes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pype/ftrack/actions/action_sync_notes.py b/pype/ftrack/actions/action_sync_notes.py index 05c53e2c79..57c6745c50 100644 --- a/pype/ftrack/actions/action_sync_notes.py +++ b/pype/ftrack/actions/action_sync_notes.py @@ -22,8 +22,8 @@ class SynchronizeNotes(BaseAction): db_con = DbConnector( mongo_url=os.environ["AVALON_MONGO"], - database_name='customDatabase', - table_name='notesTable' + database_name='notes_database', + table_name='notes_table' ) id_key_src = 'fridge_ftrackID' From 18394c203b49825d7f9ea836822c013373c58968 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 3 Jul 2019 11:23:13 +0200 Subject: [PATCH 10/22] code cleanup --- pype/ftrack/actions/action_sync_notes.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pype/ftrack/actions/action_sync_notes.py b/pype/ftrack/actions/action_sync_notes.py index 57c6745c50..40f17e5147 100644 --- a/pype/ftrack/actions/action_sync_notes.py +++ b/pype/ftrack/actions/action_sync_notes.py @@ -28,7 +28,6 @@ class SynchronizeNotes(BaseAction): id_key_src = 'fridge_ftrackID' id_key_dst = 'kredenc_ftrackID' - date_store_key = 'last_stored_asset_version' def discover(self, session, entities, event): ''' Validation ''' @@ -97,8 +96,8 @@ class SynchronizeNotes(BaseAction): db_note_entity = self.db_con.find_one({ self.id_key_src: src_note['id'] }) - # WARNING: expr `if not db_note_entity:` does not work! + # WARNING: expr `if not db_note_entity:` does not work! if db_note_entity is None: # Create note if not found in DB dst_note_id = self.create_note( From 2164a911ae9efe32d437a0931d70dd48ee9f27f2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 3 Jul 2019 13:13:46 +0200 Subject: [PATCH 11/22] creation date of note in destination ftrack match with source --- pype/ftrack/actions/action_sync_notes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pype/ftrack/actions/action_sync_notes.py b/pype/ftrack/actions/action_sync_notes.py index 40f17e5147..67771762c8 100644 --- a/pype/ftrack/actions/action_sync_notes.py +++ b/pype/ftrack/actions/action_sync_notes.py @@ -147,8 +147,8 @@ class SynchronizeNotes(BaseAction): ) is_reply = True - # TODO Should we change date to match source Ftrack? - # new_note['data'] = src_note['date'] + # QUESTION Should we change date to match source Ftrack? + new_note['date'] = src_note['date'] self.session.commit() new_note_id = new_note['id'] From e4442c0dcb938fcf9cbdff4a7417905e4bb43c02 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 4 Jul 2019 18:45:06 +0200 Subject: [PATCH 12/22] changed plugin integrate ftrack instances, now also adds components with direct path to file which are uploaded to ftrack --- .../publish/integrate_ftrack_instances.py | 73 +++++++++++++++---- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/pype/plugins/ftrack/publish/integrate_ftrack_instances.py b/pype/plugins/ftrack/publish/integrate_ftrack_instances.py index d351289dfe..cbb4e89998 100644 --- a/pype/plugins/ftrack/publish/integrate_ftrack_instances.py +++ b/pype/plugins/ftrack/publish/integrate_ftrack_instances.py @@ -30,6 +30,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): } def process(self, instance): + self.ftrack_locations = {} self.log.debug('instance {}'.format(instance)) if instance.data.get('version'): @@ -47,8 +48,9 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): self.log.debug('component {}'.format(comp)) if comp.get('thumbnail'): - location = ft_session.query( - 'Location where name is "ftrack.server"').one() + location = self.get_ftrack_location( + 'ftrack.server', ft_session + ) component_data = { "name": "thumbnail" # Default component name is "main". } @@ -74,8 +76,9 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): if not comp.get('frameRate'): comp['frameRate'] = instance.context.data['fps'] - location = ft_session.query( - 'Location where name is "ftrack.server"').one() + location = self.get_ftrack_location( + 'ftrack.server', ft_session + ) component_data = { # Default component name is "main". "name": "ftrackreview-mp4", @@ -89,28 +92,70 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): component_data = { "name": comp['name'] } - location = ft_session.query( - 'Location where name is "ftrack.unmanaged"').one() + location = self.get_ftrack_location( + 'ftrack.unmanaged', ft_session + ) comp['thumbnail'] = False self.log.debug('location {}'.format(location)) - componentList.append({"assettype_data": { - "short": asset_type, - }, + component_item = { + "assettype_data": { + "short": asset_type, + }, "asset_data": { - "name": instance.data["subset"], - }, + "name": instance.data["subset"], + }, "assetversion_data": { - "version": version_number, - }, + "version": version_number, + }, "component_data": component_data, "component_path": comp['published_path'], 'component_location': location, "component_overwrite": False, "thumbnail": comp['thumbnail'] } - ) + + componentList.append(component_item) + # Create copy with ftrack.unmanaged location if thumb or prev + if comp.get('thumbnail') or comp.get('preview'): + unmanaged_loc = self.get_ftrack_location( + 'ftrack.unmanaged', ft_session + ) + + component_data_src = component_data.copy() + name = component_data['name'] + '_src' + component_data_src['name'] = name + + component_item_src = { + "assettype_data": { + "short": asset_type, + }, + "asset_data": { + "name": instance.data["subset"], + }, + "assetversion_data": { + "version": version_number, + }, + "component_data": component_data_src, + "component_path": comp['published_path'], + 'component_location': unmanaged_loc, + "component_overwrite": False, + "thumbnail": False + } + + componentList.append(component_item_src) + self.log.debug('componentsList: {}'.format(str(componentList))) instance.data["ftrackComponentsList"] = componentList + + def get_ftrack_location(self, name, session): + if name in self.ftrack_locations: + return self.ftrack_locations[name] + + location = session.query( + 'Location where name is "{}"'.format(name) + ).one() + self.ftrack_locations[name] = location + return location From 0d10dd1c08be7c7df32de5e8e8baa1a8fdcc4bdb Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 4 Jul 2019 18:45:53 +0200 Subject: [PATCH 13/22] first version of synchronization versions not tested --- .../actions/action_sync_asset_versions.py | 674 ++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 pype/ftrack/actions/action_sync_asset_versions.py diff --git a/pype/ftrack/actions/action_sync_asset_versions.py b/pype/ftrack/actions/action_sync_asset_versions.py new file mode 100644 index 0000000000..2786850243 --- /dev/null +++ b/pype/ftrack/actions/action_sync_asset_versions.py @@ -0,0 +1,674 @@ +import os +import sys +import argparse +import json +import logging +import collections +import tempfile +import requests + +from pype.vendor import ftrack_api +from pype.ftrack import BaseAction + + +class SyncAssetVersions(BaseAction): + + #: Action identifier. + identifier = 'sync.asset.versions' + #: Action label. + label = 'Sync Asset Versions' + #: Action description. + description = 'Synchronize Asset versions to another Ftrack' + + # ENTER VALUES HERE (change values based on keys) + # Custom attribute storing ftrack id of destination server + id_key_src = 'fridge_ftrackID' + # Custom attribute storing ftrack id of source server + id_key_dst = 'fridge_ftrackID'#'kredenc_ftrackID' + # comp name mapping + comp_name_mapping = { + 'ftrackreview-mp4_src': 'ftrackreview-mp4', + 'thumbnail_src': 'thumbnail' + } + + comp_location_mapping = { + 'ftrack.server': [ + 'ftrackreview-mp4', + 'ftrackreview-mp4_src', + 'thumbnail', + 'thumbnail_src' + ], + 'ftrack.unmanaged': [] + } + + def discover(self, session, entities, event): + ''' Validation ''' + for entity in entities: + if entity.entity_type.lower() != 'assetversion': + return False + + return True + + def launch(self, session, entities, event): + self.dst_ftrack_locations = {} + self.interface_messages = {} + # stop if custom attribute for storing second ftrack id is missing + if self.id_key_src not in entities[0]['custom_attributes']: + msg = ( + 'Custom attribute "{}" does not exist on AssetVersion' + ).format(self.id_key_src) + self.log.error(msg) + + return { + 'success': False, + 'message': msg + } + + # ENTER VALUES HERE + self.dst_session = ftrack_api.Session( + server_url='', + api_key='', + api_user='', + auto_connect_event_hub=True + ) + + # NOTE Shared session has issues with location definition + self.session_for_components = ftrack_api.Session( + server_url=session.server_url, + api_key=session.api_key, + api_user=session.api_user, + auto_connect_event_hub=True + ) + + for entity in entities: + asset = entity['asset'] + parent = asset['parent'] + + # Check if asset version already has entity on destinaition Ftrack + # TODO ? skip if yes + # ? show to user - with interface/message/note + # + or ask if user want to override found version ???? + dst_ftrack_id = entity['custom_attributes'].get(self.id_key_src) + if dst_ftrack_id: + dst_ftrack_ent = self.dst_session.query( + 'AssetVersion where id = "{}"'.format(dst_ftrack_id) + ).first() + + if dst_ftrack_ent: + self.log.warning( + '"{}" - Already exists. Skipping'.format(asset['name']) + ) + continue + + # Find parent where Version will be uploaded + dst_parent_id = parent['custom_attributes'].get(self.id_key_src) + if not dst_parent_id: + self.log.warning(( + 'Entity: "{}" don\'t have stored Custom attribute "{}"' + ).format(parent['name'], self.id_key_src)) + continue + + dst_parent_entity = self.dst_session.query( + 'TypedContext where id = "{}"'.format(dst_parent_id) + ).first() + + if not dst_parent_entity: + msg = ( + 'Didn\'t found mirrored entity in destination Ftrack' + ' for "{}"' + ).format(parent['name']) + self.log.warning(msg) + continue + + component_list = self.prepare_data(entity) + id_stored = False + for comp_data in component_list: + dst_asset_ver_id = self.asset_version_creation( + dst_parent_entity, comp_data, entity + ) + + if id_stored: + continue + entity['custom_attributes'][self.id_key_src] = dst_asset_ver_id + session.commit() + id_stored = True + + self.dst_session.close() + self.session_for_components.close() + + self.dst_session = None + self.session_for_components = None + + return True + + def prepare_data(self, asset_version): + components_list = [] + + # Asset data + asset_type = asset_version['asset']['type'].get('short', 'upload') + assettype_data = {'short': asset_type} + + asset_data = {'name': asset_version['asset']['name']} + + # Asset version data + assetversion_data = {'version': asset_version['version']} + + # Component data + components_of_interest = {} + components_name = ('ftrackreview-mp4_src', 'thumbnail_src') + for name in components_name: + components_of_interest[name] = False + + for key in components_of_interest: + # Find component by name + for comp in asset_version['components']: + if comp['name'] == key: + components_of_interest[key] = True + break + # NOTE if component was found then continue + if components_of_interest[key]: + continue + + # Look for alternative component name set in mapping + new_key = None + if key in self.comp_name_mapping: + new_key = self.comp_name_mapping[key] + + if not new_key: + self.log.warning( + 'Asset version do not have components "{}" or "{}"'.format( + key, new_key + ) + ) + continue + + components_of_interest[new_key] = components_of_interest.pop(key) + + # Try to look for alternative name + for comp in asset_version['components']: + if comp['name'] == new_key: + components_of_interest[new_key] = True + break + + # Check if at least one component is transferable + have_comp_to_transfer = False + for value in components_of_interest.values(): + if value: + have_comp_to_transfer = True + break + + if not have_comp_to_transfer: + return components_list + + thumbnail_id = asset_version.get('thumbnail_id') + temp_folder = tempfile.mkdtemp('components') + + # Data for transfer components + for comp in asset_version['components']: + comp_name = comp['name'] + + if comp_name not in components_of_interest: + continue + + if not components_of_interest[comp_name]: + continue + + if comp_name in self.comp_name_mapping: + comp_name = self.comp_name_mapping[comp_name] + + is_thumbnail = False + for _comp in asset_version['components'] + if _comp['name'] == comp_name: + if _comp['id'] == thumbnail_id: + is_thumbnail = True + break + + location = comp['component_locations'][0]['location'] + if location['name'] == 'ftrack.unmanaged': + file_path = '' + try: + file_path = location.get_filesystem_path(comp) + except Exception: + pass + + file_path = os.path.normpath(file_path) + if not os.path.exists(file_path): + file_path = comp['component_locations'][0][ + 'resource_identifier' + ] + + file_path = os.path.normpath(file_path) + if not os.path.exists(file_path): + self.log.warning( + 'In component: "{}" can\'t access filepath: "{}"'.format( + comp['name'], file_path + ) + ) + continue + elif location['name'] == 'ftrack.unmanaged': + download_url = location.get_url(comp) + + file_name = '{}{}{}'.format( + asset_version['asset']['name'] + comp_name, + comp['file_type'] + ) + file_path = os.path.sep.join([temp_folder, file_name]) + + self.download_file(download_url, file_path) + + # Default value is ftrack.unmanaged + location_name = 'ftrack.unmanaged' + for name, keys in self.comp_location_mapping.items(): + if comp_name in keys: + location_name = name + break + dst_location = self.get_dst_location(location_name) + + # Metadata + metadata = {} + metadata.update(comp.get('metadata', {})) + + component_data = { + "name": comp_name, + "metadata": metadata + } + + data = { + 'assettype_data': assettype_data, + 'asset_data': asset_data, + 'assetversion_data': assetversion_data, + 'component_data': component_data, + 'component_overwrite': False, + 'thumbnail': is_thumbnail, + 'component_location': dst_location, + 'component_path': file_path + } + + components_list.append(data) + + return components_list + + def asset_version_creation(self, dst_parent_entity, data, src_entity): + assettype_data = data['assettype_data'] + self.log.debug("data: {}".format(data)) + + assettype_entity = self.dst_session.query( + self.query("AssetType", assettype_data) + ).first() + + # Create a new entity if none exits. + if not assettype_entity: + assettype_entity = self.dst_session.create( + "AssetType", assettype_data + ) + self.dst_session.commit() + self.log.debug( + "Created new AssetType with data: ".format(assettype_data) + ) + + # Asset + # Get existing entity. + asset_data = { + "name": src_entity['asset']['name'], + "type": assettype_entity, + "parent": dst_parent_entity + } + asset_data.update(data.get("asset_data", {})) + + asset_entity = self.dst_session.query( + self.query("Asset", asset_data) + ).first() + + self.log.info("asset entity: {}".format(asset_entity)) + + # Extracting metadata, and adding after entity creation. This is + # due to a ftrack_api bug where you can't add metadata on creation. + asset_metadata = asset_data.pop("metadata", {}) + + # Create a new entity if none exits. + info_msg = ( + 'Created new {entity_type} with data: {data}' + ", metadata: {metadata}." + ) + + if not asset_entity: + asset_entity = self.dst_session.create("Asset", asset_data) + self.dst_session.commit() + + self.log.debug( + info_msg.format( + entity_type="Asset", + data=asset_data, + metadata=asset_metadata + ) + ) + + # Adding metadata + existing_asset_metadata = asset_entity["metadata"] + existing_asset_metadata.update(asset_metadata) + asset_entity["metadata"] = existing_asset_metadata + + # AssetVersion + assetversion_data = { + 'version': 0, + 'asset': asset_entity + } + + # NOTE task is skipped (can't be identified in other ftrack) + # if task: + # assetversion_data['task'] = task + + # NOTE assetversion_data contains version number which is not correct + assetversion_data.update(data.get("assetversion_data", {})) + + assetversion_entity = self.dst_session.query( + self.query("AssetVersion", assetversion_data) + ).first() + + # Extracting metadata, and adding after entity creation. This is + # due to a ftrack_api bug where you can't add metadata on creation. + assetversion_metadata = assetversion_data.pop("metadata", {}) + + # Create a new entity if none exits. + if not assetversion_entity: + assetversion_entity = self.dst_session.create( + "AssetVersion", assetversion_data + ) + self.dst_session.commit() + + self.log.debug( + info_msg.format( + entity_type="AssetVersion", + data=assetversion_data, + metadata=assetversion_metadata + ) + ) + + # Check if custom attribute can of main Ftrack can be set + if self.id_key_dst not in assetversion_entity['custom_attributes']: + self.log.warning(( + 'Destination Asset Version do not have key "{}" in' + ' Custom attributes' + ).format(self.id_key_dst)) + return + + assetversion_entity['custom_attributes'][self.id_key_dst] = src_entity['id'] + + # Adding metadata + existing_assetversion_metadata = assetversion_entity["metadata"] + existing_assetversion_metadata.update(assetversion_metadata) + assetversion_entity["metadata"] = existing_assetversion_metadata + + # Have to commit the version and asset, because location can't + # determine the final location without. + self.dst_session.commit() + + # Component + # Get existing entity. + component_data = { + "name": "main", + "version": assetversion_entity + } + component_data.update(data.get("component_data", {})) + + component_entity = self.dst_session.query( + self.query("Component", component_data) + ).first() + + component_overwrite = data.get("component_overwrite", False) + + location = None + location_name = data.get("component_location") + if location_name: + location = self.dst_session.query( + 'Location where name is "{}"'.format(location_name) + ).first() + + if not location: + location = self.dst_session.pick_location() + + # Overwrite existing component data if requested. + if component_entity and component_overwrite: + + origin_location = self.dst_session.query( + "Location where name is \"ftrack.origin\"" + ).one() + + # Removing existing members from location + components = list(component_entity.get("members", [])) + components += [component_entity] + for component in components: + for loc in component["component_locations"]: + if location["id"] == loc["location_id"]: + location.remove_component( + component, recursive=False + ) + + # Deleting existing members on component entity + for member in component_entity.get("members", []): + self.dst_session.delete(member) + del(member) + + self.dst_session.commit() + + # Reset members in memory + if "members" in component_entity.keys(): + component_entity["members"] = [] + + # Add components to origin location + try: + collection = clique.parse(data["component_path"]) + except ValueError: + # Assume its a single file + # Changing file type + name, ext = os.path.splitext(data["component_path"]) + component_entity["file_type"] = ext + + origin_location.add_component( + component_entity, data["component_path"] + ) + else: + # Changing file type + component_entity["file_type"] = collection.format("{tail}") + + # Create member components for sequence. + for member_path in collection: + + size = 0 + try: + size = os.path.getsize(member_path) + except OSError: + pass + + name = collection.match(member_path).group("index") + + member_data = { + "name": name, + "container": component_entity, + "size": size, + "file_type": os.path.splitext(member_path)[-1] + } + + component = self.dst_session.create( + "FileComponent", member_data + ) + origin_location.add_component( + component, member_path, recursive=False + ) + component_entity["members"].append(component) + + # Add components to location. + location.add_component( + component_entity, origin_location, recursive=True + ) + + data["component"] = component_entity + msg = "Overwriting Component with path: {0}, data: {1}, " + msg += "location: {2}" + self.log.info( + msg.format( + data["component_path"], + component_data, + location + ) + ) + + # Extracting metadata, and adding after entity creation. This is + # due to a ftrack_api bug where you can't add metadata on creation. + component_metadata = component_data.pop("metadata", {}) + + # Create new component if none exists. + new_component = False + if not component_entity: + component_entity = assetversion_entity.create_component( + data["component_path"], + data=component_data, + location=location + ) + data["component"] = component_entity + msg = "Created new Component with path: {0}, data: {1}" + msg += ", metadata: {2}, location: {3}" + self.log.info( + msg.format( + data["component_path"], + component_data, + component_metadata, + location + ) + ) + new_component = True + + # Adding metadata + existing_component_metadata = component_entity["metadata"] + existing_component_metadata.update(component_metadata) + component_entity["metadata"] = existing_component_metadata + + # if component_data['name'] = 'ftrackreview-mp4-mp4': + # assetversion_entity["thumbnail_id"] + + # Setting assetversion thumbnail + if data.get("thumbnail", False): + assetversion_entity["thumbnail_id"] = component_entity["id"] + + # Inform user about no changes to the database. + if ( + component_entity and + not component_overwrite and + not new_component + ): + data["component"] = component_entity + self.log.info( + "Found existing component, and no request to overwrite. " + "Nothing has been changed." + ) + return + + # Commit changes. + self.dst_session.commit() + + return assetversion_entity['id'] + + def query(self, entitytype, data): + """ Generate a query expression from data supplied. + + If a value is not a string, we'll add the id of the entity to the + query. + + Args: + entitytype (str): The type of entity to query. + data (dict): The data to identify the entity. + exclusions (list): All keys to exclude from the query. + + Returns: + str: String query to use with "session.query" + """ + queries = [] + if sys.version_info[0] < 3: + for key, value in data.iteritems(): + if not isinstance(value, (basestring, int)): + self.log.info("value: {}".format(value)) + if "id" in value.keys(): + queries.append( + "{0}.id is \"{1}\"".format(key, value["id"]) + ) + else: + queries.append("{0} is \"{1}\"".format(key, value)) + else: + for key, value in data.items(): + if not isinstance(value, (str, int)): + self.log.info("value: {}".format(value)) + if "id" in value.keys(): + queries.append( + "{0}.id is \"{1}\"".format(key, value["id"]) + ) + else: + queries.append("{0} is \"{1}\"".format(key, value)) + + query = ( + entitytype + " where " + " and ".join(queries) + ) + return query + + def download_file(self, url, path): + r = requests.get(url, stream=True).content + with open(path, 'wb') as f: + f.write(r) + + def get_dst_location(self, name): + if name in self.dst_ftrack_locations: + return self.dst_ftrack_locations[name] + + location = self.dst_session.query( + 'Location where name is "{}"'.format(name) + ).one() + self.dst_ftrack_locations[name] = location + return location + + +def register(session, **kw): + '''Register plugin. Called when used as an plugin.''' + + if not isinstance(session, ftrack_api.session.Session): + return + + SyncAssetVersions(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:])) From 273321d2ae6a638eec7de6373cdc309fae3357a3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 8 Jul 2019 18:12:29 +0200 Subject: [PATCH 14/22] few minor bugfixed added ftrackreview-image to interest list --- .../actions/action_sync_asset_versions.py | 74 +++++++++++++------ 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/pype/ftrack/actions/action_sync_asset_versions.py b/pype/ftrack/actions/action_sync_asset_versions.py index 2786850243..039eda3ee1 100644 --- a/pype/ftrack/actions/action_sync_asset_versions.py +++ b/pype/ftrack/actions/action_sync_asset_versions.py @@ -24,10 +24,18 @@ class SyncAssetVersions(BaseAction): # Custom attribute storing ftrack id of destination server id_key_src = 'fridge_ftrackID' # Custom attribute storing ftrack id of source server - id_key_dst = 'fridge_ftrackID'#'kredenc_ftrackID' + id_key_dst = 'kredenc_ftrackID' + + components_name = ( + 'ftrackreview-mp4_src', + 'ftrackreview-image_src', + 'thumbnail_src' + ) + # comp name mapping comp_name_mapping = { 'ftrackreview-mp4_src': 'ftrackreview-mp4', + 'ftrackreview-image_src': 'ftrackreview-image', 'thumbnail_src': 'thumbnail' } @@ -35,6 +43,8 @@ class SyncAssetVersions(BaseAction): 'ftrack.server': [ 'ftrackreview-mp4', 'ftrackreview-mp4_src', + 'ftrackreview-image', + 'ftrackreview-image_src', 'thumbnail', 'thumbnail_src' ], @@ -120,7 +130,7 @@ class SyncAssetVersions(BaseAction): self.log.warning(msg) continue - component_list = self.prepare_data(entity) + component_list = self.prepare_data(entity['id']) id_stored = False for comp_data in component_list: dst_asset_ver_id = self.asset_version_creation( @@ -141,9 +151,11 @@ class SyncAssetVersions(BaseAction): return True - def prepare_data(self, asset_version): + def prepare_data(self, asset_version_id): components_list = [] - + asset_version = self.session_for_components.query( + 'AssetVersion where id is "{}"'.format(asset_version_id) + ).one() # Asset data asset_type = asset_version['asset']['type'].get('short', 'upload') assettype_data = {'short': asset_type} @@ -155,8 +167,7 @@ class SyncAssetVersions(BaseAction): # Component data components_of_interest = {} - components_name = ('ftrackreview-mp4_src', 'thumbnail_src') - for name in components_name: + for name in self.components_name: components_of_interest[name] = False for key in components_of_interest: @@ -217,14 +228,18 @@ class SyncAssetVersions(BaseAction): comp_name = self.comp_name_mapping[comp_name] is_thumbnail = False - for _comp in asset_version['components'] + for _comp in asset_version['components']: if _comp['name'] == comp_name: if _comp['id'] == thumbnail_id: is_thumbnail = True break - location = comp['component_locations'][0]['location'] - if location['name'] == 'ftrack.unmanaged': + locatiom_name = comp['component_locations'][0]['location']['name'] + location = self.session_for_components.query( + 'Location where name is "{}"'.format(locatiom_name) + ).one() + file_path = None + if locatiom_name == 'ftrack.unmanaged': file_path = '' try: file_path = location.get_filesystem_path(comp) @@ -245,11 +260,12 @@ class SyncAssetVersions(BaseAction): ) ) continue - elif location['name'] == 'ftrack.unmanaged': + + elif locatiom_name == 'ftrack.server': download_url = location.get_url(comp) file_name = '{}{}{}'.format( - asset_version['asset']['name'] + asset_version['asset']['name'], comp_name, comp['file_type'] ) @@ -257,8 +273,18 @@ class SyncAssetVersions(BaseAction): self.download_file(download_url, file_path) - # Default value is ftrack.unmanaged + if not file_path: + self.log.warning( + 'In component: "{}" is invalid file path'.format( + comp['name'] + ) + ) + continue + + # Default location name value is ftrack.unmanaged location_name = 'ftrack.unmanaged' + + # Try to find location where component will be created for name, keys in self.comp_location_mapping.items(): if comp_name in keys: location_name = name @@ -419,7 +445,7 @@ class SyncAssetVersions(BaseAction): component_overwrite = data.get("component_overwrite", False) location = None - location_name = data.get("component_location") + location_name = data.get("component_location", {}).get('name') if location_name: location = self.dst_session.query( 'Location where name is "{}"'.format(location_name) @@ -432,12 +458,12 @@ class SyncAssetVersions(BaseAction): if component_entity and component_overwrite: origin_location = self.dst_session.query( - "Location where name is \"ftrack.origin\"" + 'Location where name is "ftrack.origin"' ).one() # Removing existing members from location components = list(component_entity.get("members", [])) - components += [component_entity] + components += [component_entity,] for component in components: for loc in component["component_locations"]: if location["id"] == loc["location_id"]: @@ -527,16 +553,16 @@ class SyncAssetVersions(BaseAction): location=location ) data["component"] = component_entity - msg = "Created new Component with path: {0}, data: {1}" - msg += ", metadata: {2}, location: {3}" - self.log.info( - msg.format( - data["component_path"], - component_data, - component_metadata, - location - ) + msg = ( + "Created new Component with path: {}, data: {}" + ", metadata: {}, location: {}" ) + self.log.info(msg.format( + data["component_path"], + component_data, + component_metadata, + location['name'] + )) new_component = True # Adding metadata From 9865608c08147288559f24258e0963062e10c5dd Mon Sep 17 00:00:00 2001 From: antirotor Date: Mon, 8 Jul 2019 21:41:02 +0200 Subject: [PATCH 15/22] plugins overrides are now looked in presets/plugins/[host]/publish.json --- pype/lib.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pype/lib.py b/pype/lib.py index 78658b498b..e163cc14fc 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -494,13 +494,10 @@ def filter_pyblish_plugins(plugins): host = api.current_host() - # load plugins - config_data = config.get_presets()['plugins']['config'] - # iterate over plugins for plugin in plugins[:]: try: - config_data = config.get_presets()['plugins'][host][plugin.__name__] # noqa: E501 + config_data = config.get_presets()['plugins'][host]["publish"][plugin.__name__] # noqa: E501 except KeyError: continue From ab4cd2fcf10e0052740b09c33317d6fd78e8726b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 9 Jul 2019 11:31:52 +0200 Subject: [PATCH 16/22] added administrator and project manager to allowed roles --- pype/ftrack/actions/action_sync_notes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/ftrack/actions/action_sync_notes.py b/pype/ftrack/actions/action_sync_notes.py index 67771762c8..7e80ef636d 100644 --- a/pype/ftrack/actions/action_sync_notes.py +++ b/pype/ftrack/actions/action_sync_notes.py @@ -18,7 +18,7 @@ class SynchronizeNotes(BaseAction): #: Action description. description = 'Synchronize notes from one Ftrack to another' #: roles that are allowed to register this action - role_list = ['Pypeclub'] + role_list = ['Administrator', 'Project Manager', 'Pypeclub'] db_con = DbConnector( mongo_url=os.environ["AVALON_MONGO"], From f11a1b330132b08b10ad4e9ae3c5be0b64dd8de6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 9 Jul 2019 11:32:16 +0200 Subject: [PATCH 17/22] credentials for partnership ftrack are loaded from presets --- pype/ftrack/actions/action_sync_notes.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pype/ftrack/actions/action_sync_notes.py b/pype/ftrack/actions/action_sync_notes.py index 7e80ef636d..ee4604aab1 100644 --- a/pype/ftrack/actions/action_sync_notes.py +++ b/pype/ftrack/actions/action_sync_notes.py @@ -5,6 +5,7 @@ import datetime import requests import tempfile +from pypeapp import config from pype.vendor import ftrack_api from pype.ftrack import BaseAction from pype.ftrack.lib.custom_db_connector import DbConnector, ClientSession @@ -41,11 +42,14 @@ class SynchronizeNotes(BaseAction): return True def launch(self, session, entities, event): + source_credentials = config.get_presets()['ftrack'].get( + 'partnership_ftrack', {} + ) self.session_source = ftrack_api.Session( - server_url='', - api_key='', - api_user='', + server_url=source_credentials.get('server_url'), + api_key=source_credentials.get('api_key'), + api_user=source_credentials.get('api_user'), auto_connect_event_hub=True ) From cb763ac8703e4a28146f9510555df99c774ba4c4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 9 Jul 2019 11:46:19 +0200 Subject: [PATCH 18/22] added role list to sync asset version action --- pype/ftrack/actions/action_sync_asset_versions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pype/ftrack/actions/action_sync_asset_versions.py b/pype/ftrack/actions/action_sync_asset_versions.py index 039eda3ee1..755d10a8d5 100644 --- a/pype/ftrack/actions/action_sync_asset_versions.py +++ b/pype/ftrack/actions/action_sync_asset_versions.py @@ -19,6 +19,8 @@ class SyncAssetVersions(BaseAction): label = 'Sync Asset Versions' #: Action description. description = 'Synchronize Asset versions to another Ftrack' + #: roles that are allowed to register this action + role_list = ['Administrator', 'Project Manager', 'Pypeclub'] # ENTER VALUES HERE (change values based on keys) # Custom attribute storing ftrack id of destination server From 73e0d5ed61ef5e83fee4edbee1bdd344329fa202 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 9 Jul 2019 11:46:34 +0200 Subject: [PATCH 19/22] partnership ftrack credentials are loaded from presets --- pype/ftrack/actions/action_sync_asset_versions.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pype/ftrack/actions/action_sync_asset_versions.py b/pype/ftrack/actions/action_sync_asset_versions.py index 755d10a8d5..48c7a7d632 100644 --- a/pype/ftrack/actions/action_sync_asset_versions.py +++ b/pype/ftrack/actions/action_sync_asset_versions.py @@ -9,6 +9,7 @@ import requests from pype.vendor import ftrack_api from pype.ftrack import BaseAction +from pypeapp import config class SyncAssetVersions(BaseAction): @@ -76,11 +77,13 @@ class SyncAssetVersions(BaseAction): 'message': msg } - # ENTER VALUES HERE + source_credentials = config.get_presets()['ftrack'].get( + 'partnership_ftrack_cred', {} + ) self.dst_session = ftrack_api.Session( - server_url='', - api_key='', - api_user='', + server_url=source_credentials.get('server_url'), + api_key=source_credentials.get('api_key'), + api_user=source_credentials.get('api_user'), auto_connect_event_hub=True ) From 31ffee17b8a6c8541e80ab06a7b891a22ad37a8d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 9 Jul 2019 11:47:35 +0200 Subject: [PATCH 20/22] fixed key name in getting credentials from presets --- pype/ftrack/actions/action_sync_notes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/ftrack/actions/action_sync_notes.py b/pype/ftrack/actions/action_sync_notes.py index ee4604aab1..4c0788f858 100644 --- a/pype/ftrack/actions/action_sync_notes.py +++ b/pype/ftrack/actions/action_sync_notes.py @@ -43,7 +43,7 @@ class SynchronizeNotes(BaseAction): def launch(self, session, entities, event): source_credentials = config.get_presets()['ftrack'].get( - 'partnership_ftrack', {} + 'partnership_ftrack_cred', {} ) self.session_source = ftrack_api.Session( From 3de2dbcb2e7eab2aca74a1a0c371365fd29cdb1f Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Tue, 9 Jul 2019 11:42:27 +0100 Subject: [PATCH 21/22] Support starting at frame zero. --- pype/scripts/otio_burnin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/scripts/otio_burnin.py b/pype/scripts/otio_burnin.py index 390471c849..cc018a04ea 100644 --- a/pype/scripts/otio_burnin.py +++ b/pype/scripts/otio_burnin.py @@ -294,7 +294,7 @@ def burnins_from_data(input_path, output_path, data, overwrite=True): if ( bi_func in ['frame_numbers', 'timecode'] and - not start_frame + start_frame is None ): log.error( 'start_frame is not set in entered data!' From 93a1ce115c1d2dc5f73ce0fb4f5fa25863f9507b Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Tue, 9 Jul 2019 16:43:56 +0200 Subject: [PATCH 22/22] make mesh validators more flexible --- pype/plugins/maya/create/create_model.py | 2 +- .../maya/publish/validate_model_content.py | 2 +- .../validate_transform_naming_suffix.py | 24 ++++++++++--------- .../maya/publish/validate_transform_zero.py | 2 ++ 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/pype/plugins/maya/create/create_model.py b/pype/plugins/maya/create/create_model.py index a992d84585..f9ba229c89 100644 --- a/pype/plugins/maya/create/create_model.py +++ b/pype/plugins/maya/create/create_model.py @@ -8,7 +8,7 @@ class CreateModel(avalon.maya.Creator): label = "Model" family = "model" icon = "cube" - defaults = ["Main", "Proxy"] + defaults = [ "_MD", "_HD", "_LD", "Main", "Proxy",] def __init__(self, *args, **kwargs): super(CreateModel, self).__init__(*args, **kwargs) diff --git a/pype/plugins/maya/publish/validate_model_content.py b/pype/plugins/maya/publish/validate_model_content.py index e76e102672..2a34d606cb 100644 --- a/pype/plugins/maya/publish/validate_model_content.py +++ b/pype/plugins/maya/publish/validate_model_content.py @@ -38,7 +38,7 @@ class ValidateModelContent(pyblish.api.InstancePlugin): content_instance = list(set(content_instance + descendants)) # Ensure only valid node types - allowed = ('mesh', 'transform', 'nurbsCurve', 'nurbsSurface') + allowed = ('mesh', 'transform', 'nurbsCurve', 'nurbsSurface', 'locator') nodes = cmds.ls(content_instance, long=True) valid = cmds.ls(content_instance, long=True, type=allowed) invalid = set(nodes) - set(valid) diff --git a/pype/plugins/maya/publish/validate_transform_naming_suffix.py b/pype/plugins/maya/publish/validate_transform_naming_suffix.py index 0adba9b656..e9ddd120b5 100644 --- a/pype/plugins/maya/publish/validate_transform_naming_suffix.py +++ b/pype/plugins/maya/publish/validate_transform_naming_suffix.py @@ -4,13 +4,6 @@ import pyblish.api import pype.api import pype.maya.action -SUFFIX_NAMING_TABLE = {'mesh': ["_GEO", "_GES", "_GEP", "_OSD"], - 'nurbsCurve': ["_CRV"], - 'nurbsSurface': ["_NRB"], - None: ['_GRP']} - -ALLOW_IF_NOT_IN_SUFFIX_TABLE = True - class ValidateTransformNamingSuffix(pyblish.api.InstancePlugin): """Validates transform suffix based on the type of its children shapes. @@ -23,6 +16,7 @@ class ValidateTransformNamingSuffix(pyblish.api.InstancePlugin): _OSD (open subdiv smooth at rendertime) - nurbsCurve: _CRV - nurbsSurface: _NRB + - locator: _LOC - null/group: _GRP .. warning:: @@ -39,9 +33,16 @@ class ValidateTransformNamingSuffix(pyblish.api.InstancePlugin): version = (0, 1, 0) label = 'Suffix Naming Conventions' actions = [pype.maya.action.SelectInvalidAction] + SUFFIX_NAMING_TABLE = {'mesh': ["_GEO", "_GES", "_GEP", "_OSD"], + 'nurbsCurve': ["_CRV"], + 'nurbsSurface': ["_NRB"], + 'locator': ["_LOC"], + None: ['_GRP']} + + ALLOW_IF_NOT_IN_SUFFIX_TABLE = True @staticmethod - def is_valid_name(node_name, shape_type): + def is_valid_name(node_name, shape_type, SUFFIX_NAMING_TABLE, ALLOW_IF_NOT_IN_SUFFIX_TABLE): """Return whether node's name is correct. The correctness for a transform's suffix is dependent on what @@ -62,7 +63,7 @@ class ValidateTransformNamingSuffix(pyblish.api.InstancePlugin): return False @classmethod - def get_invalid(cls, instance): + def get_invalid(cls, instance, SUFFIX_NAMING_TABLE, ALLOW_IF_NOT_IN_SUFFIX_TABLE): transforms = cmds.ls(instance, type='transform', long=True) invalid = [] @@ -73,7 +74,7 @@ class ValidateTransformNamingSuffix(pyblish.api.InstancePlugin): noIntermediate=True) shape_type = cmds.nodeType(shapes[0]) if shapes else None - if not cls.is_valid_name(transform, shape_type): + if not cls.is_valid_name(transform, shape_type, SUFFIX_NAMING_TABLE, ALLOW_IF_NOT_IN_SUFFIX_TABLE): invalid.append(transform) return invalid @@ -81,7 +82,8 @@ class ValidateTransformNamingSuffix(pyblish.api.InstancePlugin): def process(self, instance): """Process all the nodes in the instance""" - invalid = self.get_invalid(instance) + + invalid = self.get_invalid(instance, self.SUFFIX_NAMING_TABLE, self.ALLOW_IF_NOT_IN_SUFFIX_TABLE) if invalid: raise ValueError("Incorrectly named geometry " "transforms: {0}".format(invalid)) diff --git a/pype/plugins/maya/publish/validate_transform_zero.py b/pype/plugins/maya/publish/validate_transform_zero.py index 0d2c3beade..9f735b3cdc 100644 --- a/pype/plugins/maya/publish/validate_transform_zero.py +++ b/pype/plugins/maya/publish/validate_transform_zero.py @@ -49,6 +49,8 @@ class ValidateTransformZero(pyblish.api.Validator): invalid = [] for transform in transforms: + if '_LOC' in transform: + continue mat = cmds.xform(transform, q=1, matrix=True, objectSpace=True) if not all(abs(x-y) < cls._tolerance for x, y in zip(cls._identity, mat)):