From 889953af3166bbc5fa2798071c53bfa7667bc644 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 16 Dec 2021 21:18:33 +0100 Subject: [PATCH 001/483] tasks widget created model and proxy model with methods that can be overriden --- openpype/tools/utils/tasks_widget.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/openpype/tools/utils/tasks_widget.py b/openpype/tools/utils/tasks_widget.py index 419e77c780..699b1cf569 100644 --- a/openpype/tools/utils/tasks_widget.py +++ b/openpype/tools/utils/tasks_widget.py @@ -194,6 +194,8 @@ class TasksWidget(QtWidgets.QWidget): task_changed = QtCore.Signal() def __init__(self, dbcon, parent=None): + self._dbcon = dbcon + super(TasksWidget, self).__init__(parent) tasks_view = DeselectableTreeView(self) @@ -204,9 +206,8 @@ class TasksWidget(QtWidgets.QWidget): header_view = tasks_view.header() header_view.setSortIndicator(0, QtCore.Qt.AscendingOrder) - tasks_model = TasksModel(dbcon) - tasks_proxy = TasksProxyModel() - tasks_proxy.setSourceModel(tasks_model) + tasks_model = self._create_source_model() + tasks_proxy = self._create_proxy_model(tasks_model) tasks_view.setModel(tasks_proxy) layout = QtWidgets.QVBoxLayout(self) @@ -222,6 +223,14 @@ class TasksWidget(QtWidgets.QWidget): self._last_selected_task_name = None + def _create_source_model(self): + return TasksModel(self._dbcon) + + def _create_proxy_model(self, source_model): + proxy = TasksProxyModel() + proxy.setSourceModel(source_model) + return proxy + def refresh(self): self._tasks_model.refresh() From 33ae30d6ab25afa294ebd32aa4097bba03e8878e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 16 Dec 2021 21:19:12 +0100 Subject: [PATCH 002/483] model and proxy model in assets widget is created in methods which may be overriden --- openpype/tools/utils/assets_widget.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/openpype/tools/utils/assets_widget.py b/openpype/tools/utils/assets_widget.py index f310aafe89..1cb803c68a 100644 --- a/openpype/tools/utils/assets_widget.py +++ b/openpype/tools/utils/assets_widget.py @@ -582,11 +582,8 @@ class AssetsWidget(QtWidgets.QWidget): self.dbcon = dbcon # Tree View - model = AssetModel(dbcon=self.dbcon, parent=self) - proxy = RecursiveSortFilterProxyModel() - proxy.setSourceModel(model) - proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) - proxy.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + model = self._create_source_model() + proxy = self._create_proxy_model(model) view = AssetsView(self) view.setModel(proxy) @@ -628,7 +625,6 @@ class AssetsWidget(QtWidgets.QWidget): selection_model.selectionChanged.connect(self._on_selection_change) refresh_btn.clicked.connect(self.refresh) current_asset_btn.clicked.connect(self.set_current_session_asset) - model.refreshed.connect(self._on_model_refresh) view.doubleClicked.connect(self.double_clicked) self._current_asset_btn = current_asset_btn @@ -639,6 +635,18 @@ class AssetsWidget(QtWidgets.QWidget): self.model_selection = {} + def _create_source_model(self): + model = AssetModel(dbcon=self.dbcon, parent=self) + model.refreshed.connect(self._on_model_refresh) + return model + + def _create_proxy_model(self, source_model): + proxy = RecursiveSortFilterProxyModel() + proxy.setSourceModel(source_model) + proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + proxy.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + return proxy + @property def refreshing(self): return self._model.refreshing From 4525cf1be4f48e81c51c8aa33434a7e8762a34de Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 16 Dec 2021 21:19:51 +0100 Subject: [PATCH 003/483] filling of assets model is done in separated method --- openpype/tools/utils/assets_widget.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/openpype/tools/utils/assets_widget.py b/openpype/tools/utils/assets_widget.py index 1cb803c68a..1ae560bd2b 100644 --- a/openpype/tools/utils/assets_widget.py +++ b/openpype/tools/utils/assets_widget.py @@ -401,11 +401,18 @@ class AssetModel(QtGui.QStandardItemModel): self._clear_items() return + self._fill_assets(self._doc_payload) + + self.refreshed.emit(bool(self._items_by_asset_id)) + + self._stop_fetch_thread() + + def _fill_assets(self, asset_docs): # Collect asset documents as needed asset_ids = set() asset_docs_by_id = {} asset_ids_by_parents = collections.defaultdict(set) - for asset_doc in self._doc_payload: + for asset_doc in asset_docs: asset_id = asset_doc["_id"] asset_data = asset_doc.get("data") or {} parent_id = asset_data.get("visualParent") @@ -511,10 +518,6 @@ class AssetModel(QtGui.QStandardItemModel): except Exception: pass - self.refreshed.emit(bool(self._items_by_asset_id)) - - self._stop_fetch_thread() - def _threaded_fetch(self): asset_docs = self._fetch_asset_docs() if not self._refreshing: From c19f216128f7214735cf9f864e763e1651abb5fc Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 16 Dec 2021 21:20:17 +0100 Subject: [PATCH 004/483] assets model cares on it's own if should be cleared on refresh --- openpype/tools/utils/assets_widget.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/openpype/tools/utils/assets_widget.py b/openpype/tools/utils/assets_widget.py index 1ae560bd2b..9789078cb8 100644 --- a/openpype/tools/utils/assets_widget.py +++ b/openpype/tools/utils/assets_widget.py @@ -306,6 +306,8 @@ class AssetModel(QtGui.QStandardItemModel): self._items_with_color_by_id = {} self._items_by_asset_id = {} + self._last_project_name = None + @property def refreshing(self): return self._refreshing @@ -347,12 +349,11 @@ class AssetModel(QtGui.QStandardItemModel): return self.get_indexes_by_asset_ids(asset_ids) - def refresh(self, force=False, clear=False): + def refresh(self, force=False): """Refresh the data for the model. Args: force (bool): Stop currently running refresh start new refresh. - clear (bool): Clear model before refresh thread starts. """ # Skip fetch if there is already other thread fetching documents if self._refreshing: @@ -360,7 +361,13 @@ class AssetModel(QtGui.QStandardItemModel): return self.stop_refresh() - if clear: + project_name = self.dbcon.Session.get("AVALON_PROJECT") + clear_model = False + if project_name != self._last_project_name: + clear_model = True + self._last_project_name = project_name + + if clear_model: self._clear_items() # Fetch documents from mongo @@ -655,12 +662,7 @@ class AssetsWidget(QtWidgets.QWidget): return self._model.refreshing def refresh(self): - project_name = self.dbcon.Session.get("AVALON_PROJECT") - clear_model = False - if project_name != self._last_project_name: - clear_model = True - self._last_project_name = project_name - self._refresh_model(clear_model) + self._refresh_model() def stop_refresh(self): self._model.stop_refresh() @@ -706,14 +708,14 @@ class AssetsWidget(QtWidgets.QWidget): self._set_loading_state(loading=False, empty=not has_item) self.refreshed.emit() - def _refresh_model(self, clear=False): + def _refresh_model(self): # Store selection self._set_loading_state(loading=True, empty=True) # Trigger signal before refresh is called self.refresh_triggered.emit() # Refresh model - self._model.refresh(clear=clear) + self._model.refresh() def _set_loading_state(self, loading, empty): self._view.set_loading_state(loading, empty) From 06396fa038e57eb51280c1e33d58ef065e61f557 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 16 Dec 2021 21:42:55 +0100 Subject: [PATCH 005/483] Create model for whole launcher tool --- openpype/tools/launcher/models.py | 305 +++++++++++++++++++++++++++++- 1 file changed, 303 insertions(+), 2 deletions(-) diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index 427475cb4b..d1927e2667 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -3,6 +3,10 @@ import copy import logging import collections +from Qt import QtCore, QtGui +from openpype.lib import ApplicationManager +from openpype.tools.utils.lib import DynamicQThread + from . import lib from .constants import ( ACTION_ROLE, @@ -11,10 +15,8 @@ from .constants import ( ACTION_ID_ROLE ) from .actions import ApplicationAction -from Qt import QtCore, QtGui from avalon.vendor import qtawesome from avalon import style, api -from openpype.lib import ApplicationManager log = logging.getLogger(__name__) @@ -223,6 +225,305 @@ class ActionModel(QtGui.QStandardItemModel): ) +class LauncherModel(QtCore.QObject): + # Refresh interval of projects + refresh_interval = 10000 + + # Signals + # Current project has changed + project_changed = QtCore.Signal(str) + # Filters has changed (any) + filters_changed = QtCore.Signal() + + # Projects were refreshed + projects_refreshed = QtCore.Signal() + + # Signals ONLY for assets model! + # - other objects should listen to asset model signals + # Asset refresh started + assets_refresh_started = QtCore.Signal() + # Assets refresh finished + assets_refreshed = QtCore.Signal() + + # Refresh timer timeout + # - give ability to tell parent window that this timer still runs + timer_timeout = QtCore.Signal() + + # Duplication from AssetsModel with "data.tasks" + _asset_projection = { + "name": 1, + "parent": 1, + "data.visualParent": 1, + "data.label": 1, + "data.icon": 1, + "data.color": 1, + "data.tasks": 1 + } + + def __init__(self, dbcon): + super(LauncherModel, self).__init__() + # Refresh timer + # - should affect only projects + refresh_timer = QtCore.QTimer() + refresh_timer.setInterval(self.refresh_interval) + refresh_timer.timeout.connect(self._on_timeout) + + self._refresh_timer = refresh_timer + + # Launcher is active + self._active = False + + # Global data + self._dbcon = dbcon + # Available project names + self._project_names = set() + + # Context data + self._asset_docs = [] + self._asset_docs_by_id = {} + self._asset_filter_data_by_id = {} + self._assignees = set() + self._task_types = set() + + # Filters + self._asset_name_filter = "" + self._assignee_filters = set() + self._task_type_filters = set() + + # Last project for which were assets queried + self._last_project_name = None + # Asset refresh thread is running + self._refreshing_assets = False + # Asset refresh thread + self._asset_refresh_thread = None + + def _on_timeout(self): + """Refresh timer timeout.""" + if self._active: + self.timer_timeout.emit() + self.refresh_projects() + + def set_active(self, active): + """Window change active state.""" + self._active = active + + def start_refresh_timer(self, trigger=False): + """Start refresh timer.""" + self._refresh_timer.start() + if trigger: + self._on_timeout() + + def stop_refresh_timer(self): + """Stop refresh timer.""" + self._refresh_timer.stop() + + @property + def project_name(self): + """Current project name.""" + return self._dbcon.Session.get("AVALON_PROJECT") + + @property + def refreshing_assets(self): + """Refreshing thread is running.""" + return self._refreshing_assets + + @property + def asset_docs(self): + """Access to asset docs.""" + return self._asset_docs + + @property + def project_names(self): + """Available project names.""" + return self._project_names + + @property + def asset_filter_data_by_id(self): + """Prepared filter data by asset id.""" + return self._asset_filter_data_by_id + + @property + def assignees(self): + """All assignees for all assets in current project.""" + return self._assignees + + @property + def task_types(self): + """All task types for all assets in current project. + + TODO: This could be maybe taken from project document where are all + task types... + """ + return self._task_types + + @property + def task_type_filters(self): + """Currently set task type filters.""" + return self._task_type_filters + + @property + def assignee_filters(self): + """Currently set assignee filters.""" + return self._assignee_filters + + @property + def asset_name_filter(self): + """Asset name filter (can be used as regex filter).""" + return self._asset_name_filter + + def get_asset_doc(self, asset_id): + """Get single asset document by id.""" + return self._asset_docs_by_id.get(asset_id) + + def set_project_name(self, project_name): + """Change project name and refresh asset documents.""" + if project_name == self.project_name: + return + self._dbcon.Session["AVALON_PROJECT"] = project_name + self.project_changed.emit(project_name) + + self.refresh_assets(force=True) + + def refresh(self): + """Trigger refresh of whole model.""" + self.refresh_projects() + self.refresh_assets(force=False) + + def refresh_projects(self): + """Refresh projects.""" + current_project = self.project_name + project_names = set() + for project_doc in self._dbcon.projects(only_active=True): + project_names.add(project_doc["name"]) + + self._project_names = project_names + self.projects_refreshed.emit() + if ( + current_project is not None + and current_project not in project_names + ): + self.set_project_name(None) + + def _set_asset_docs(self, asset_docs=None): + """Set asset documents and all related data. + + Method extract and prepare data needed for assets and tasks widget and + prepare filtering data. + """ + if asset_docs is None: + asset_docs = [] + + all_task_types = set() + all_assignees = set() + asset_docs_by_id = {} + asset_filter_data_by_id = {} + for asset_doc in asset_docs: + task_types = set() + assignees = set() + asset_id = asset_doc["_id"] + asset_docs_by_id[asset_id] = asset_doc + asset_tasks = asset_doc.get("data", {}).get("tasks") + asset_filter_data_by_id[asset_id] = { + "assignees": assignees, + "task_types": task_types + } + if not asset_tasks: + continue + + for task_data in asset_tasks.values(): + task_assignees = set() + _task_assignees = task_data.get("assignees") + if _task_assignees: + for assignee in _task_assignees: + task_assignees.add(assignee["username"]) + + task_type = task_data.get("type") + if task_assignees: + assignees |= set(task_assignees) + if task_type: + task_types.add(task_type) + + all_task_types |= task_types + all_assignees |= assignees + + self._asset_docs_by_id = asset_docs_by_id + self._asset_docs = asset_docs + self._asset_filter_data_by_id = asset_filter_data_by_id + self._assignees = all_assignees + self._task_types = all_task_types + + self.assets_refreshed.emit() + + def set_task_type_filter(self, task_types): + """Change task type filter. + + Args: + task_types (set): Set of task types that should be visible. + Pass empty set to turn filter off. + """ + self._task_type_filters = task_types + self.filters_changed.emit() + + def set_assignee_filter(self, assignees): + """Change assignees filter. + + Args: + assignees (set): Set of assignees that should be visible. + Pass empty set to turn filter off. + """ + self._assignee_filters = assignees + self.filters_changed.emit() + + def set_asset_name_filter(self, text_filter): + """Change asset name filter. + + Args: + text_filter (str): Asset name filter. Pass empty string to + turn filter off. + """ + self._asset_name_filter = text_filter + self.filters_changed.emit() + + def refresh_assets(self, force=True): + """Refresh assets.""" + self.assets_refresh_started.emit() + + if self.project_name is None: + self._set_asset_docs() + return + + if ( + not force + and self._last_project_name == self.project_name + ): + return + + self._stop_fetch_thread() + + self._refreshing_assets = True + self._last_project_name = self.project_name + self._asset_refresh_thread = DynamicQThread(self._refresh_assets) + self._asset_refresh_thread.start() + + def _stop_fetch_thread(self): + self._refreshing_assets = False + if self._asset_refresh_thread is not None: + while self._asset_refresh_thread.isRunning(): + time.sleep(0.01) + self._asset_refresh_thread = None + + def _refresh_assets(self): + asset_docs = list(self._dbcon.find( + {"type": "asset"}, + self._asset_projection + )) + time.sleep(5) + if not self._refreshing_assets: + return + self._refreshing_assets = False + self._set_asset_docs(asset_docs) + + class ProjectModel(QtGui.QStandardItemModel): """List of projects""" From 557de8c3aa51858e40145bbf9ea4b9e14d0da687 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 16 Dec 2021 21:43:40 +0100 Subject: [PATCH 006/483] create slightly modified task model --- openpype/tools/launcher/models.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index d1927e2667..1c61bd5012 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -4,8 +4,13 @@ import logging import collections from Qt import QtCore, QtGui +from avalon.vendor import qtawesome +from avalon import style, api from openpype.lib import ApplicationManager from openpype.tools.utils.lib import DynamicQThread +from openpype.tools.utils.tasks_widget import ( + TasksModel, +) from . import lib from .constants import ( @@ -15,8 +20,6 @@ from .constants import ( ACTION_ID_ROLE ) from .actions import ApplicationAction -from avalon.vendor import qtawesome -from avalon import style, api log = logging.getLogger(__name__) @@ -524,6 +527,18 @@ class LauncherModel(QtCore.QObject): self._set_asset_docs(asset_docs) +class LauncherTaskModel(TasksModel): + def __init__(self, launcher_model, *args, **kwargs): + self._launcher_model = launcher_model + super(LauncherTaskModel, self).__init__(*args, **kwargs) + + def set_asset_id(self, asset_id): + asset_doc = None + if self._context_is_valid(): + asset_doc = self._launcher_model.get_asset_doc(asset_id) + self._set_asset(asset_doc) + + class ProjectModel(QtGui.QStandardItemModel): """List of projects""" From c874adfcb6e16e9d6bb239ea225c0184f2799849 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 16 Dec 2021 21:47:11 +0100 Subject: [PATCH 007/483] tasks widget also can know about assignees --- openpype/tools/utils/tasks_widget.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openpype/tools/utils/tasks_widget.py b/openpype/tools/utils/tasks_widget.py index 699b1cf569..204719e739 100644 --- a/openpype/tools/utils/tasks_widget.py +++ b/openpype/tools/utils/tasks_widget.py @@ -9,6 +9,7 @@ from .views import DeselectableTreeView TASK_NAME_ROLE = QtCore.Qt.UserRole + 1 TASK_TYPE_ROLE = QtCore.Qt.UserRole + 2 TASK_ORDER_ROLE = QtCore.Qt.UserRole + 3 +TASK_ASSIGNEE_ROLE = QtCore.Qt.UserRole + 4 class TasksModel(QtGui.QStandardItemModel): @@ -144,11 +145,19 @@ class TasksModel(QtGui.QStandardItemModel): task_type_icon = task_type_info.get("icon") icon = self._get_icon(task_icon, task_type_icon) + task_assignees = set() + assignees_data = task_info.get("assignees") or [] + for assignee in assignees_data: + username = assignee.get("username") + if username: + task_assignees.add(username) + label = "{} ({})".format(task_name, task_type or "type N/A") item = QtGui.QStandardItem(label) item.setData(task_name, TASK_NAME_ROLE) item.setData(task_type, TASK_TYPE_ROLE) item.setData(task_order, TASK_ORDER_ROLE) + item.setData(task_assignees, TASK_ASSIGNEE_ROLE) item.setData(icon, QtCore.Qt.DecorationRole) item.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable) items.append(item) From 6d55d5d11a5342c360f912eaa8f82eefa763c78d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 16 Dec 2021 21:49:44 +0100 Subject: [PATCH 008/483] created task proxy model with more filtering --- openpype/tools/launcher/models.py | 45 +++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index 1c61bd5012..c4836fb1af 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -10,6 +10,9 @@ from openpype.lib import ApplicationManager from openpype.tools.utils.lib import DynamicQThread from openpype.tools.utils.tasks_widget import ( TasksModel, + TasksProxyModel, + TASK_TYPE_ROLE, + TASK_ASSIGNEE_ROLE ) from . import lib @@ -527,6 +530,48 @@ class LauncherModel(QtCore.QObject): self._set_asset_docs(asset_docs) +class LauncherTasksProxyModel(TasksProxyModel): + """Tasks proxy model with more filtering. + + TODO: + This can be (with few modifications) used in default tasks widget too. + """ + def __init__(self, launcher_model, *args, **kwargs): + self._launcher_model = launcher_model + super(LauncherTasksProxyModel, self).__init__(*args, **kwargs) + + launcher_model.filters_changed.connect(self._on_filter_change) + + self._task_types_filter = set() + self._assignee_filter = set() + + def _on_filter_change(self): + self._task_types_filter = self._launcher_model.task_type_filters + self._assignee_filter = self._launcher_model.assignee_filters + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent): + if not self._task_types_filter and not self._assignee_filter: + return True + + model = self.sourceModel() + source_index = model.index(row, self.filterKeyColumn(), parent) + if not source_index.isValid(): + return False + + # Check current index itself + if self._task_types_filter: + task_type = model.data(source_index, TASK_TYPE_ROLE) + if task_type not in self._task_types_filter: + return False + + if self._assignee_filter: + assignee = model.data(source_index, TASK_ASSIGNEE_ROLE) + if not self._assignee_filter.intersection(assignee): + return False + return True + + class LauncherTaskModel(TasksModel): def __init__(self, launcher_model, *args, **kwargs): self._launcher_model = launcher_model From e749a8ee53671b3cadb7a7e85cfbd9a76a3b0057 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 16 Dec 2021 21:50:23 +0100 Subject: [PATCH 009/483] added modified assets model --- openpype/tools/launcher/models.py | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index c4836fb1af..13ca95fe43 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -8,6 +8,10 @@ from avalon.vendor import qtawesome from avalon import style, api from openpype.lib import ApplicationManager from openpype.tools.utils.lib import DynamicQThread +from openpype.tools.utils.assets_widget import ( + AssetModel, + ASSET_NAME_ROLE +) from openpype.tools.utils.tasks_widget import ( TasksModel, TasksProxyModel, @@ -26,6 +30,10 @@ from .actions import ApplicationAction log = logging.getLogger(__name__) +# Must be different than roles in default asset model +ASSET_TASK_TYPES_ROLE = QtCore.Qt.UserRole + 10 +ASSET_ASSIGNEE_ROLE = QtCore.Qt.UserRole + 11 + class ActionModel(QtGui.QStandardItemModel): def __init__(self, dbcon, parent=None): @@ -584,6 +592,54 @@ class LauncherTaskModel(TasksModel): self._set_asset(asset_doc) +class LauncherAssetsModel(AssetModel): + def __init__(self, launcher_model, dbcon, parent=None): + self._launcher_model = launcher_model + # Make sure that variable is available (even if is in AssetModel) + self._last_project_name = None + + super(LauncherAssetsModel, self).__init__(dbcon, parent) + + launcher_model.project_changed.connect(self._on_project_change) + launcher_model.assets_refresh_started.connect( + self._on_launcher_refresh_start + ) + launcher_model.assets_refreshed.connect(self._on_launcher_refresh) + + def _on_launcher_refresh_start(self): + self._refreshing = True + project_name = self._launcher_model.project_name + if self._last_project_name != project_name: + self._clear_items() + self._last_project_name = project_name + + def _on_launcher_refresh(self): + self._fill_assets(self._launcher_model.asset_docs) + self._refreshing = False + self.refreshed.emit(bool(self._items_by_asset_id)) + + def _fill_assets(self, *args, **kwargs): + super(LauncherAssetsModel, self)._fill_assets(*args, **kwargs) + asset_filter_data_by_id = self._launcher_model.asset_filter_data_by_id + for asset_id, item in self._items_by_asset_id.items(): + filter_data = asset_filter_data_by_id.get(asset_id) + + assignees = filter_data["assignees"] + task_types = filter_data["task_types"] + + item.setData(assignees, ASSET_ASSIGNEE_ROLE) + item.setData(task_types, ASSET_TASK_TYPES_ROLE) + + def _on_project_change(self): + self._clear_items() + + def refresh(self, *args, **kwargs): + raise ValueError("This is a bug!") + + def stop_refresh(self, *args, **kwargs): + raise ValueError("This is a bug!") + + class ProjectModel(QtGui.QStandardItemModel): """List of projects""" From 577a82ca7b67b923d64a6799e509219a9a2a758e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 16 Dec 2021 21:50:48 +0100 Subject: [PATCH 010/483] created new recursive assets proxy with more filters --- openpype/tools/launcher/models.py | 60 +++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index 13ca95fe43..dc06ec4e28 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -1,3 +1,4 @@ +import re import uuid import copy import logging @@ -592,6 +593,65 @@ class LauncherTaskModel(TasksModel): self._set_asset(asset_doc) +class AssetRecursiveSortFilterModel(QtCore.QSortFilterProxyModel): + def __init__(self, launcher_model, *args, **kwargs): + self._launcher_model = launcher_model + + super(AssetRecursiveSortFilterModel, self).__init__(*args, **kwargs) + + launcher_model.filters_changed.connect(self._on_filter_change) + self._name_filter = "" + self._task_types_filter = set() + self._assignee_filter = set() + + def _on_filter_change(self): + self._name_filter = self._launcher_model.asset_name_filter + self._task_types_filter = self._launcher_model.task_type_filters + self._assignee_filter = self._launcher_model.assignee_filters + self.invalidateFilter() + + """Filters to the regex if any of the children matches allow parent""" + def filterAcceptsRow(self, row, parent): + if ( + not self._name_filter + and not self._task_types_filter + and not self._assignee_filter + ): + return True + + model = self.sourceModel() + source_index = model.index(row, self.filterKeyColumn(), parent) + if not source_index.isValid(): + return False + + # Check current index itself + valid = True + if self._name_filter: + name = model.data(source_index, ASSET_NAME_ROLE) + if not re.search(self._name_filter, name, re.IGNORECASE): + valid = False + + if valid and self._task_types_filter: + task_types = model.data(source_index, ASSET_TASK_TYPES_ROLE) + if not self._task_types_filter.intersection(task_types): + valid = False + + if valid and self._assignee_filter: + assignee = model.data(source_index, ASSET_ASSIGNEE_ROLE) + if not self._assignee_filter.intersection(assignee): + valid = False + + if valid: + return True + + # Check children + rows = model.rowCount(source_index) + for child_row in range(rows): + if self.filterAcceptsRow(child_row, source_index): + return True + return False + + class LauncherAssetsModel(AssetModel): def __init__(self, launcher_model, dbcon, parent=None): self._launcher_model = launcher_model From ccf3b1de219a2a8c5fe55c1e6f644e76442b08a8 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 16 Dec 2021 21:51:53 +0100 Subject: [PATCH 011/483] modified ProjectModel to use launcher model --- openpype/tools/launcher/models.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index dc06ec4e28..f518ac78a7 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -3,6 +3,7 @@ import uuid import copy import logging import collections +import time from Qt import QtCore, QtGui from avalon.vendor import qtawesome @@ -524,6 +525,7 @@ class LauncherModel(QtCore.QObject): self._refreshing_assets = False if self._asset_refresh_thread is not None: while self._asset_refresh_thread.isRunning(): + # TODO this is blocking UI should be done in a different way time.sleep(0.01) self._asset_refresh_thread = None @@ -532,7 +534,6 @@ class LauncherModel(QtCore.QObject): {"type": "asset"}, self._asset_projection )) - time.sleep(5) if not self._refreshing_assets: return self._refreshing_assets = False @@ -703,18 +704,17 @@ class LauncherAssetsModel(AssetModel): class ProjectModel(QtGui.QStandardItemModel): """List of projects""" - def __init__(self, dbcon, parent=None): + def __init__(self, launcher_model, parent=None): super(ProjectModel, self).__init__(parent=parent) - self.dbcon = dbcon + self._launcher_model = launcher_model self.project_icon = qtawesome.icon("fa.map", color="white") self._project_names = set() - def refresh(self): - project_names = set() - for project_doc in self.get_projects(): - project_names.add(project_doc["name"]) + launcher_model.projects_refreshed.connect(self._on_refresh) + def _on_refresh(self): + project_names = set(self._launcher_model.project_names) origin_project_names = set(self._project_names) self._project_names = project_names @@ -757,7 +757,3 @@ class ProjectModel(QtGui.QStandardItemModel): items.append(item) self.invisibleRootItem().insertRows(row, items) - - def get_projects(self): - return sorted(self.dbcon.projects(only_active=True), - key=lambda x: x["name"]) From 258dfbffd9e8c923a07e65cca0d3dae22d654b1f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 16 Dec 2021 21:54:45 +0100 Subject: [PATCH 012/483] ProjectBar and ActionBar are using launcher model --- openpype/tools/launcher/widgets.py | 35 +++++++++++++++++------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/openpype/tools/launcher/widgets.py b/openpype/tools/launcher/widgets.py index edda8d08b5..33118e03be 100644 --- a/openpype/tools/launcher/widgets.py +++ b/openpype/tools/launcher/widgets.py @@ -4,10 +4,14 @@ import collections from Qt import QtWidgets, QtCore, QtGui from avalon.vendor import qtawesome +from openpype.tools.flickcharm import FlickCharm + from .delegates import ActionDelegate from . import lib -from .models import ActionModel -from openpype.tools.flickcharm import FlickCharm +from .models import ( + ActionModel, + ProjectModel, +) from .constants import ( ACTION_ROLE, GROUP_ROLE, @@ -20,15 +24,15 @@ from .constants import ( class ProjectBar(QtWidgets.QWidget): - def __init__(self, project_handler, parent=None): + def __init__(self, launcher_model, parent=None): super(ProjectBar, self).__init__(parent) project_combobox = QtWidgets.QComboBox(self) # Change delegate so stylysheets are applied project_delegate = QtWidgets.QStyledItemDelegate(project_combobox) project_combobox.setItemDelegate(project_delegate) - - project_combobox.setModel(project_handler.model) + model = ProjectModel(launcher_model) + project_combobox.setModel(model) project_combobox.setRootModelIndex(QtCore.QModelIndex()) layout = QtWidgets.QHBoxLayout(self) @@ -40,16 +44,17 @@ class ProjectBar(QtWidgets.QWidget): QtWidgets.QSizePolicy.Maximum ) - self.project_handler = project_handler + self._launcher_model = launcher_model self.project_delegate = project_delegate self.project_combobox = project_combobox + self._model = model # Signals self.project_combobox.currentIndexChanged.connect(self.on_index_change) - project_handler.project_changed.connect(self._on_project_change) + launcher_model.project_changed.connect(self._on_project_change) # Set current project by default if it's set. - project_name = project_handler.current_project + project_name = launcher_model.project_name if project_name: self.set_project(project_name) @@ -65,7 +70,7 @@ class ProjectBar(QtWidgets.QWidget): index = self.project_combobox.findText(project_name) if index < 0: # Try refresh combobox model - self.project_handler.refresh_model() + self._launcher_model.refresh_projects() index = self.project_combobox.findText(project_name) if index >= 0: @@ -76,7 +81,7 @@ class ProjectBar(QtWidgets.QWidget): return project_name = self.get_current_project() - self.project_handler.set_project(project_name) + self._launcher_model.set_project_name(project_name) class ActionBar(QtWidgets.QWidget): @@ -84,10 +89,10 @@ class ActionBar(QtWidgets.QWidget): action_clicked = QtCore.Signal(object) - def __init__(self, project_handler, dbcon, parent=None): + def __init__(self, launcher_model, dbcon, parent=None): super(ActionBar, self).__init__(parent) - self.project_handler = project_handler + self._launcher_model = launcher_model self.dbcon = dbcon view = QtWidgets.QListView(self) @@ -133,7 +138,7 @@ class ActionBar(QtWidgets.QWidget): self.set_row_height(1) - project_handler.projects_refreshed.connect(self._on_projects_refresh) + launcher_model.projects_refreshed.connect(self._on_projects_refresh) view.clicked.connect(self.on_clicked) def discover_actions(self): @@ -172,7 +177,7 @@ class ActionBar(QtWidgets.QWidget): def _start_animation(self, index): # Offset refresh timout - self.project_handler.start_timer() + self.launcher_model.start_refresh_timer() action_id = index.data(ACTION_ID_ROLE) item = self.model.items_by_id.get(action_id) if item: @@ -194,7 +199,7 @@ class ActionBar(QtWidgets.QWidget): return # Offset refresh timout - self.project_handler.start_timer() + self.launcher_model.start_refresh_timer() actions = index.data(ACTION_ROLE) From cde8c69cbabca6b9471cc30996b7a4a8000cfb06 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 16 Dec 2021 21:55:12 +0100 Subject: [PATCH 013/483] modify tasks widget to use different models --- openpype/tools/launcher/widgets.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/openpype/tools/launcher/widgets.py b/openpype/tools/launcher/widgets.py index 33118e03be..4408219628 100644 --- a/openpype/tools/launcher/widgets.py +++ b/openpype/tools/launcher/widgets.py @@ -5,12 +5,15 @@ from Qt import QtWidgets, QtCore, QtGui from avalon.vendor import qtawesome from openpype.tools.flickcharm import FlickCharm +from openpype.tools.utils.tasks_widget import TasksWidget from .delegates import ActionDelegate from . import lib from .models import ( ActionModel, ProjectModel, + LauncherTaskModel, + LauncherTasksProxyModel ) from .constants import ( ACTION_ROLE, @@ -84,6 +87,21 @@ class ProjectBar(QtWidgets.QWidget): self._launcher_model.set_project_name(project_name) +class LauncherTaskWidget(TasksWidget): + def __init__(self, launcher_model, *args, **kwargs): + self._launcher_model = launcher_model + + super(LauncherTaskWidget, self).__init__(*args, **kwargs) + + def _create_source_model(self): + return LauncherTaskModel(self._launcher_model, self._dbcon) + + def _create_proxy_model(self, source_model): + proxy = LauncherTasksProxyModel(self._launcher_model) + proxy.setSourceModel(source_model) + return proxy + + class ActionBar(QtWidgets.QWidget): """Launcher interface""" From a232f8bc58b918e1e31741ea06c715b2ee97b992 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 16 Dec 2021 21:55:33 +0100 Subject: [PATCH 014/483] modified assets widget to use new models nad modified what is called when --- openpype/tools/launcher/widgets.py | 51 ++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/openpype/tools/launcher/widgets.py b/openpype/tools/launcher/widgets.py index 4408219628..397b29a1b0 100644 --- a/openpype/tools/launcher/widgets.py +++ b/openpype/tools/launcher/widgets.py @@ -5,6 +5,7 @@ from Qt import QtWidgets, QtCore, QtGui from avalon.vendor import qtawesome from openpype.tools.flickcharm import FlickCharm +from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget from openpype.tools.utils.tasks_widget import TasksWidget from .delegates import ActionDelegate @@ -12,6 +13,8 @@ from . import lib from .models import ( ActionModel, ProjectModel, + LauncherAssetsModel, + AssetRecursiveSortFilterModel, LauncherTaskModel, LauncherTasksProxyModel ) @@ -102,6 +105,54 @@ class LauncherTaskWidget(TasksWidget): return proxy +class LauncherAssetsWidget(SingleSelectAssetsWidget): + def __init__(self, launcher_model, *args, **kwargs): + self._launcher_model = launcher_model + + super(LauncherAssetsWidget, self).__init__(*args, **kwargs) + + launcher_model.assets_refresh_started.connect(self._on_refresh_start) + + self.set_current_asset_btn_visibility(False) + + def _on_refresh_start(self): + self._set_loading_state(loading=True, empty=True) + self.refresh_triggered.emit() + + @property + def refreshing(self): + return self._model.refreshing + + def refresh(self): + self._launcher_model.refresh_assets(force=True) + + def stop_refresh(self): + raise ValueError("bug stop_refresh called") + + def _refresh_model(self, clear=False): + raise ValueError("bug _refresh_model called") + + def _create_source_model(self): + model = LauncherAssetsModel(self._launcher_model, self.dbcon) + model.refreshed.connect(self._on_model_refresh) + return model + + def _create_proxy_model(self, source_model): + proxy = AssetRecursiveSortFilterModel(self._launcher_model) + proxy.setSourceModel(source_model) + proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + proxy.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + return proxy + + def _on_model_refresh(self, has_item): + self._proxy.sort(0) + self._set_loading_state(loading=False, empty=not has_item) + self.refreshed.emit() + + def _on_filter_text_change(self, new_text): + self._launcher_model.set_asset_name_filter(new_text) + + class ActionBar(QtWidgets.QWidget): """Launcher interface""" From 27d067fbdf66a2f61ef005236b2fadde4e758829 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 16 Dec 2021 21:56:47 +0100 Subject: [PATCH 015/483] project panel is using launcher model --- openpype/tools/launcher/window.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/openpype/tools/launcher/window.py b/openpype/tools/launcher/window.py index a8f65894f2..9b5a21ebc2 100644 --- a/openpype/tools/launcher/window.py +++ b/openpype/tools/launcher/window.py @@ -89,15 +89,15 @@ class ProjectIconView(QtWidgets.QListView): class ProjectsPanel(QtWidgets.QWidget): """Projects Page""" - def __init__(self, project_handler, parent=None): + def __init__(self, launcher_model, parent=None): super(ProjectsPanel, self).__init__(parent=parent) view = ProjectIconView(parent=self) view.setSelectionMode(QtWidgets.QListView.NoSelection) flick = FlickCharm(parent=self) flick.activateOn(view) - - view.setModel(project_handler.model) + model = ProjectModel(launcher_model) + view.setModel(model) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -105,13 +105,14 @@ class ProjectsPanel(QtWidgets.QWidget): view.clicked.connect(self.on_clicked) + self._model = model self.view = view - self.project_handler = project_handler + self._launcher_model = launcher_model def on_clicked(self, index): if index.isValid(): project_name = index.data(QtCore.Qt.DisplayRole) - self.project_handler.set_project(project_name) + self._launcher_model.set_project_name(project_name) class AssetsPanel(QtWidgets.QWidget): @@ -119,7 +120,7 @@ class AssetsPanel(QtWidgets.QWidget): back_clicked = QtCore.Signal() session_changed = QtCore.Signal() - def __init__(self, project_handler, dbcon, parent=None): + def __init__(self, launcher_model, dbcon, parent=None): super(AssetsPanel, self).__init__(parent=parent) self.dbcon = dbcon @@ -129,7 +130,7 @@ class AssetsPanel(QtWidgets.QWidget): btn_back = QtWidgets.QPushButton(self) btn_back.setIcon(btn_back_icon) - project_bar = ProjectBar(project_handler, self) + project_bar = ProjectBar(launcher_model, self) project_bar_layout = QtWidgets.QHBoxLayout() project_bar_layout.setContentsMargins(0, 0, 0, 0) From 9b044ffd828f3c8015fdb031e67d371599c9606d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 16 Dec 2021 21:57:07 +0100 Subject: [PATCH 016/483] use new widgets and models in launcher window --- openpype/tools/launcher/window.py | 52 ++++++++++++++++--------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/openpype/tools/launcher/window.py b/openpype/tools/launcher/window.py index 9b5a21ebc2..b5b6368865 100644 --- a/openpype/tools/launcher/window.py +++ b/openpype/tools/launcher/window.py @@ -8,17 +8,19 @@ from avalon.api import AvalonMongoDB from openpype import style from openpype.api import resources -from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget -from openpype.tools.utils.tasks_widget import TasksWidget - from avalon.vendor import qtawesome -from .models import ProjectModel -from .lib import get_action_label, ProjectHandler +from .models import ( + LauncherModel, + ProjectModel +) +from .lib import get_action_label from .widgets import ( ProjectBar, ActionBar, ActionHistory, - SlidePageWidget + SlidePageWidget, + LauncherAssetsWidget, + LauncherTaskWidget ) from openpype.tools.flickcharm import FlickCharm @@ -139,12 +141,14 @@ class AssetsPanel(QtWidgets.QWidget): project_bar_layout.addWidget(project_bar) # Assets widget - assets_widget = SingleSelectAssetsWidget(dbcon=self.dbcon, parent=self) + assets_widget = LauncherAssetsWidget( + launcher_model, dbcon=self.dbcon, parent=self + ) # Make assets view flickable assets_widget.activate_flick_charm() # Tasks widget - tasks_widget = TasksWidget(self.dbcon, self) + tasks_widget = LauncherTaskWidget(launcher_model, self.dbcon, self) # Body body = QtWidgets.QSplitter(self) @@ -166,19 +170,20 @@ class AssetsPanel(QtWidgets.QWidget): layout.addWidget(body) # signals - project_handler.project_changed.connect(self._on_project_changed) + launcher_model.project_changed.connect(self._on_project_changed) assets_widget.selection_changed.connect(self._on_asset_changed) assets_widget.refreshed.connect(self._on_asset_changed) tasks_widget.task_changed.connect(self._on_task_change) btn_back.clicked.connect(self.back_clicked) - self.project_handler = project_handler self.project_bar = project_bar self.assets_widget = assets_widget self._tasks_widget = tasks_widget self._btn_back = btn_back + self._launcher_model = launcher_model + def select_asset(self, asset_name): self.assets_widget.select_asset_by_name(asset_name) @@ -197,8 +202,6 @@ class AssetsPanel(QtWidgets.QWidget): def _on_project_changed(self): self.session_changed.emit() - self.assets_widget.refresh() - def _on_asset_changed(self): """Callback on asset selection changed @@ -251,18 +254,17 @@ class LauncherWindow(QtWidgets.QDialog): | QtCore.Qt.WindowCloseButtonHint ) - project_model = ProjectModel(self.dbcon) - project_handler = ProjectHandler(self.dbcon, project_model) + launcher_model = LauncherModel(self.dbcon) - project_panel = ProjectsPanel(project_handler) - asset_panel = AssetsPanel(project_handler, self.dbcon) + project_panel = ProjectsPanel(launcher_model) + asset_panel = AssetsPanel(launcher_model, self.dbcon) page_slider = SlidePageWidget() page_slider.addWidget(project_panel) page_slider.addWidget(asset_panel) # actions - actions_bar = ActionBar(project_handler, self.dbcon, self) + actions_bar = ActionBar(launcher_model, self.dbcon, self) # statusbar message_label = QtWidgets.QLabel(self) @@ -304,8 +306,8 @@ class LauncherWindow(QtWidgets.QDialog): # signals actions_bar.action_clicked.connect(self.on_action_clicked) action_history.trigger_history.connect(self.on_history_action) - project_handler.project_changed.connect(self.on_project_change) - project_handler.timer_timeout.connect(self._on_refresh_timeout) + launcher_model.project_changed.connect(self.on_project_change) + launcher_model.timer_timeout.connect(self._on_refresh_timeout) asset_panel.back_clicked.connect(self.on_back_clicked) asset_panel.session_changed.connect(self.on_session_changed) @@ -315,7 +317,7 @@ class LauncherWindow(QtWidgets.QDialog): self._message_timer = message_timer - self.project_handler = project_handler + self._launcher_model = launcher_model self._message_label = message_label self.project_panel = project_panel @@ -325,19 +327,19 @@ class LauncherWindow(QtWidgets.QDialog): self.page_slider = page_slider def showEvent(self, event): - self.project_handler.set_active(True) - self.project_handler.start_timer(True) + self._launcher_model.set_active(True) + self._launcher_model.start_refresh_timer(True) super(LauncherWindow, self).showEvent(event) def _on_refresh_timeout(self): # Stop timer if widget is not visible if not self.isVisible(): - self.project_handler.stop_timer() + self._launcher_model.stop_refresh_timer() def changeEvent(self, event): if event.type() == QtCore.QEvent.ActivationChange: - self.project_handler.set_active(self.isActiveWindow()) + self._launcher_model.set_active(self.isActiveWindow()) super(LauncherWindow, self).changeEvent(event) def set_page(self, page): @@ -372,7 +374,7 @@ class LauncherWindow(QtWidgets.QDialog): self.discover_actions() def on_back_clicked(self): - self.project_handler.set_project(None) + self._launcher_model.set_project_name(None) self.set_page(0) self.discover_actions() From c776ee07ba6f7e357f12ef4f53d829182130d11e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 16 Dec 2021 21:57:14 +0100 Subject: [PATCH 017/483] removed unused project handler --- openpype/tools/launcher/lib.py | 71 ---------------------------------- 1 file changed, 71 deletions(-) diff --git a/openpype/tools/launcher/lib.py b/openpype/tools/launcher/lib.py index d6374f49d2..a7c686a7d0 100644 --- a/openpype/tools/launcher/lib.py +++ b/openpype/tools/launcher/lib.py @@ -23,77 +23,6 @@ ICON_CACHE = {} NOT_FOUND = type("NotFound", (object, ), {}) -class ProjectHandler(QtCore.QObject): - """Handler of project model and current project in Launcher tool. - - Helps to organize two separate widgets handling current project selection. - - It is easier to trigger project change callbacks from one place than from - multiple differect places without proper handling or sequence changes. - - Args: - dbcon(AvalonMongoDB): Mongo connection with Session. - model(ProjectModel): Object of projects model which is shared across - all widgets using projects. Arg dbcon should be used as source for - the model. - """ - # Project list will be refreshed each 10000 msecs - # - this is not part of helper implementation but should be used by widgets - # that may require reshing of projects - refresh_interval = 10000 - - # Signal emmited when project has changed - project_changed = QtCore.Signal(str) - projects_refreshed = QtCore.Signal() - timer_timeout = QtCore.Signal() - - def __init__(self, dbcon, model): - super(ProjectHandler, self).__init__() - self._active = False - # Store project model for usage - self.model = model - # Store dbcon - self.dbcon = dbcon - - self.current_project = dbcon.Session.get("AVALON_PROJECT") - - refresh_timer = QtCore.QTimer() - refresh_timer.setInterval(self.refresh_interval) - refresh_timer.timeout.connect(self._on_timeout) - - self.refresh_timer = refresh_timer - - def _on_timeout(self): - if self._active: - self.timer_timeout.emit() - self.refresh_model() - - def set_active(self, active): - self._active = active - - def start_timer(self, trigger=False): - self.refresh_timer.start() - if trigger: - self._on_timeout() - - def stop_timer(self): - self.refresh_timer.stop() - - def set_project(self, project_name): - # Change current project of this handler - self.current_project = project_name - # Change session project to take effect for other widgets using the - # dbcon object. - self.dbcon.Session["AVALON_PROJECT"] = project_name - - # Trigger change signal when everything is updated to new project - self.project_changed.emit(project_name) - - def refresh_model(self): - self.model.refresh() - self.projects_refreshed.emit() - - def get_action_icon(action): icon_name = action.icon if not icon_name: From 392dd02ba94e7f1da1247c39e4daaba3999c362b Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Tue, 21 Dec 2021 12:51:11 +0000 Subject: [PATCH 018/483] xml batch 1 --- .../help/validate_abc_primitive_to_detail.xml | 15 ++++++ .../help/validate_alembic_face_sets.xml | 22 +++++++++ .../help/validate_alembic_input_node.xml | 21 +++++++++ .../help/validate_animation_settings.xml | 31 ++++++++++++ .../publish/help/validate_vdb_input_node.xml | 22 +++++++++ .../plugins/publish/valiate_vdb_input_node.py | 47 ------------------- .../publish/validate_context_with_error.py | 1 + 7 files changed, 112 insertions(+), 47 deletions(-) create mode 100644 openpype/hosts/houdini/plugins/publish/help/validate_abc_primitive_to_detail.xml create mode 100644 openpype/hosts/houdini/plugins/publish/help/validate_alembic_face_sets.xml create mode 100644 openpype/hosts/houdini/plugins/publish/help/validate_alembic_input_node.xml create mode 100644 openpype/hosts/houdini/plugins/publish/help/validate_animation_settings.xml create mode 100644 openpype/hosts/houdini/plugins/publish/help/validate_vdb_input_node.xml delete mode 100644 openpype/hosts/houdini/plugins/publish/valiate_vdb_input_node.py diff --git a/openpype/hosts/houdini/plugins/publish/help/validate_abc_primitive_to_detail.xml b/openpype/hosts/houdini/plugins/publish/help/validate_abc_primitive_to_detail.xml new file mode 100644 index 0000000000..0e2aa6c1f4 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/help/validate_abc_primitive_to_detail.xml @@ -0,0 +1,15 @@ + + + +Primitive to Detail +## Invalid Primitive to Detail Attributes + +Primitives with inconsistent primitive to detail attributes were found. + +{message} + + + + + + \ No newline at end of file diff --git a/openpype/hosts/houdini/plugins/publish/help/validate_alembic_face_sets.xml b/openpype/hosts/houdini/plugins/publish/help/validate_alembic_face_sets.xml new file mode 100644 index 0000000000..7bc149d7c3 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/help/validate_alembic_face_sets.xml @@ -0,0 +1,22 @@ + + + +Alembic ROP Face Sets +## Invalid Alembic ROP Face Sets + +When groups are saved as Face Sets with the Alembic these show up +as shadingEngine connections in Maya - however, with animated groups +these connections in Maya won't work as expected, it won't update per +frame. Additionally, it can break shader assignments in some cases +where it requires to first break this connection to allow a shader to +be assigned. + +It is allowed to include Face Sets, so only an issue is logged to +identify that it could introduce issues down the pipeline. + + + + + + + \ No newline at end of file diff --git a/openpype/hosts/houdini/plugins/publish/help/validate_alembic_input_node.xml b/openpype/hosts/houdini/plugins/publish/help/validate_alembic_input_node.xml new file mode 100644 index 0000000000..5be722ccb2 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/help/validate_alembic_input_node.xml @@ -0,0 +1,21 @@ + + + +Alembic input +## Invalid Alembic input + +The node connected to the output is incorrect. +It contains primitive types that are not supported for alembic output. + +Problematic primitive is of type {primitive_type} + + + + + +The connected node cannot be of the following types for Alembic: + - VDB + - Volume + + + \ No newline at end of file diff --git a/openpype/hosts/houdini/plugins/publish/help/validate_animation_settings.xml b/openpype/hosts/houdini/plugins/publish/help/validate_animation_settings.xml new file mode 100644 index 0000000000..8a2a396783 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/help/validate_animation_settings.xml @@ -0,0 +1,31 @@ + + + +Frame token in output +## Frame range is missing frame token + +This validator will check the output parameter of the node if +the Valid Frame Range is not set to 'Render Current Frame' + +No frame token found in {nodepath} + +### How to repair? +Your you need to add `$F4` or similar frame based token to your path. +**Example:** + Good: 'my_vbd_cache.$F4.vdb' + Bad: 'my_vbd_cache.vdb' + + + + + + +If you render out a frame range it is mandatory to have the +frame token - '$F4' or similar - to ensure that each frame gets +written. If this is not the case you will override the same file +every time a frame is written out. + + + + + \ No newline at end of file diff --git a/openpype/hosts/houdini/plugins/publish/help/validate_vdb_input_node.xml b/openpype/hosts/houdini/plugins/publish/help/validate_vdb_input_node.xml new file mode 100644 index 0000000000..8cc186a183 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/help/validate_vdb_input_node.xml @@ -0,0 +1,22 @@ + + + +VDB input node +## Invalid VDB input node + +Validate that the node connected to the output node is of type VDB. + +Regardless of the amount of VDBs created the output will need to have an +equal amount of VDBs, points, primitives and vertices + +A VDB is an inherited type of Prim, holds the following data: + - Primitives: 1 + - Points: 1 + - Vertices: 1 + - VDBs: 1 + + + + + + \ No newline at end of file diff --git a/openpype/hosts/houdini/plugins/publish/valiate_vdb_input_node.py b/openpype/hosts/houdini/plugins/publish/valiate_vdb_input_node.py deleted file mode 100644 index 0ae1bc94eb..0000000000 --- a/openpype/hosts/houdini/plugins/publish/valiate_vdb_input_node.py +++ /dev/null @@ -1,47 +0,0 @@ -import pyblish.api -import openpype.api - - -class ValidateVDBInputNode(pyblish.api.InstancePlugin): - """Validate that the node connected to the output node is of type VDB. - - Regardless of the amount of VDBs create the output will need to have an - equal amount of VDBs, points, primitives and vertices - - A VDB is an inherited type of Prim, holds the following data: - - Primitives: 1 - - Points: 1 - - Vertices: 1 - - VDBs: 1 - - """ - - order = openpype.api.ValidateContentsOrder + 0.1 - families = ["vdbcache"] - hosts = ["houdini"] - label = "Validate Input Node (VDB)" - - def process(self, instance): - invalid = self.get_invalid(instance) - if invalid: - raise RuntimeError( - "Node connected to the output node is not" "of type VDB!" - ) - - @classmethod - def get_invalid(cls, instance): - - node = instance.data["output_node"] - - prims = node.geometry().prims() - nr_of_prims = len(prims) - - nr_of_points = len(node.geometry().points()) - if nr_of_points != nr_of_prims: - cls.log.error("The number of primitives and points do not match") - return [instance] - - for prim in prims: - if prim.numVertices() != 1: - cls.log.error("Found primitive with more than 1 vertex!") - return [instance] diff --git a/openpype/hosts/testhost/plugins/publish/validate_context_with_error.py b/openpype/hosts/testhost/plugins/publish/validate_context_with_error.py index 46e996a569..20fb47513e 100644 --- a/openpype/hosts/testhost/plugins/publish/validate_context_with_error.py +++ b/openpype/hosts/testhost/plugins/publish/validate_context_with_error.py @@ -2,6 +2,7 @@ import pyblish.api from openpype.pipeline import PublishValidationError + class ValidateInstanceAssetRepair(pyblish.api.Action): """Repair the instance asset.""" From dda5ddaa98537b60d02e5ca80a48b5208af9a788 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 21 Dec 2021 14:09:09 +0100 Subject: [PATCH 019/483] Implemented change of validators to new publisher style for AE --- .../publish/help/validate_instance_asset.xml | 21 +++++++++++ .../publish/help/validate_scene_settings.xml | 36 +++++++++++++++++++ .../publish/validate_instance_asset.py | 12 +++---- .../publish/validate_scene_settings.py | 27 +++++++++++--- 4 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 openpype/hosts/aftereffects/plugins/publish/help/validate_instance_asset.xml create mode 100644 openpype/hosts/aftereffects/plugins/publish/help/validate_scene_settings.xml diff --git a/openpype/hosts/aftereffects/plugins/publish/help/validate_instance_asset.xml b/openpype/hosts/aftereffects/plugins/publish/help/validate_instance_asset.xml new file mode 100644 index 0000000000..580b0e552d --- /dev/null +++ b/openpype/hosts/aftereffects/plugins/publish/help/validate_instance_asset.xml @@ -0,0 +1,21 @@ + + + +Subset context + +## Invalid subset context + +Context of the given subset doesn't match your current scene. + +### How to repair? + +You can fix this with "repair" button on the right. + + +### __Detailed Info__ (optional) + +This might happen if you are reuse old workfile and open it in different context. + (Eg. you created subset "renderCompositingDefault" from asset "Robot' in "your_project_Robot_compositing.aep", now you opened this workfile in a context "Sloth" but existing subset for "Robot" asset stayed in the workfile.) + + + \ No newline at end of file diff --git a/openpype/hosts/aftereffects/plugins/publish/help/validate_scene_settings.xml b/openpype/hosts/aftereffects/plugins/publish/help/validate_scene_settings.xml new file mode 100644 index 0000000000..603ab4805d --- /dev/null +++ b/openpype/hosts/aftereffects/plugins/publish/help/validate_scene_settings.xml @@ -0,0 +1,36 @@ + + + +Scene setting + +## Invalid scene setting found + +One of the settings in a scene doesn't match to asset settings in database. + + Invalid setting: + {invalid_setting_str} + +### How to repair? + +Change {invalid_keys_str} in the scene OR change them in asset database if they are wrong there. + + +### __Detailed Info__ (optional) + +This error is shown when for example resolution in the scene doesn't match to resolution set on the asset in the database. + Either value in the database or in the scene is wrong. + + + +Scene file doesn't exist + +## Scene file doesn't exist + +Collected scene {scene_url} doesn't exist. + +### How to repair? + +Re-save file, start publish from the beginning again. + + + \ No newline at end of file diff --git a/openpype/hosts/aftereffects/plugins/publish/validate_instance_asset.py b/openpype/hosts/aftereffects/plugins/publish/validate_instance_asset.py index eff89adcb3..2c8c1b4312 100644 --- a/openpype/hosts/aftereffects/plugins/publish/validate_instance_asset.py +++ b/openpype/hosts/aftereffects/plugins/publish/validate_instance_asset.py @@ -2,6 +2,7 @@ from avalon import api import pyblish.api import openpype.api from avalon import aftereffects +from openpype.pipeline import PublishValidationError class ValidateInstanceAssetRepair(pyblish.api.Action): @@ -29,7 +30,6 @@ class ValidateInstanceAssetRepair(pyblish.api.Action): data["asset"] = api.Session["AVALON_ASSET"] stub.imprint(instance[0], data) - class ValidateInstanceAsset(pyblish.api.InstancePlugin): """Validate the instance asset is the current selected context asset. @@ -53,9 +53,9 @@ class ValidateInstanceAsset(pyblish.api.InstancePlugin): current_asset = api.Session["AVALON_ASSET"] msg = ( f"Instance asset {instance_asset} is not the same " - f"as current context {current_asset}. PLEASE DO:\n" - f"Repair with 'A' action to use '{current_asset}'.\n" - f"If that's not correct value, close workfile and " - f"reopen via Workfiles!" + f"as current context {current_asset}." ) - assert instance_asset == current_asset, msg + + # assert instance_asset == current_asset, msg + if instance_asset != current_asset: + raise PublishValidationError(msg, "Subset context", DESCRIPTION) \ No newline at end of file diff --git a/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py index 7fba11957c..50e55599e2 100644 --- a/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py +++ b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py @@ -7,6 +7,7 @@ import pyblish.api from avalon import aftereffects +from openpype.pipeline import PublishXmlValidationError import openpype.hosts.aftereffects.api as api stub = aftereffects.stub() @@ -103,12 +104,14 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): self.log.info("current_settings:: {}".format(current_settings)) invalid_settings = [] + invalid_keys = set() for key, value in expected_settings.items(): if value != current_settings[key]: invalid_settings.append( "{} expected: {} found: {}".format(key, value, current_settings[key]) ) + invalid_keys.add(key) if ((expected_settings.get("handleStart") or expected_settings.get("handleEnd")) @@ -120,7 +123,23 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): msg = "Found invalid settings:\n{}".format( "\n".join(invalid_settings) ) - assert not invalid_settings, msg - assert os.path.exists(instance.data.get("source")), ( - "Scene file not found (saved under wrong name)" - ) + + if invalid_settings: + invalid_keys_str = ",".join(invalid_keys) + formatting_data = { + "invalid_setting_str": msg, + "invalid_keys_str": invalid_keys_str + } + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data) + + if not os.path.exists(instance.data.get("source")): + scene_url = instance.data.get("source") + msg = "Scene file {} not found (saved under wrong name)".format( + scene_url + ) + formatting_data = { + "scene_url": scene_url + } + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data) From 8a67dba15d67ecc8092b9e53efd65a0f4d79b143 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 21 Dec 2021 14:18:26 +0100 Subject: [PATCH 020/483] Fix formatting --- .../plugins/publish/help/validate_instance_asset.xml | 2 +- .../plugins/publish/help/validate_scene_settings.xml | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/help/validate_instance_asset.xml b/openpype/hosts/aftereffects/plugins/publish/help/validate_instance_asset.xml index 580b0e552d..13f03a9b9a 100644 --- a/openpype/hosts/aftereffects/plugins/publish/help/validate_instance_asset.xml +++ b/openpype/hosts/aftereffects/plugins/publish/help/validate_instance_asset.xml @@ -15,7 +15,7 @@ You can fix this with "repair" button on the right. ### __Detailed Info__ (optional) This might happen if you are reuse old workfile and open it in different context. - (Eg. you created subset "renderCompositingDefault" from asset "Robot' in "your_project_Robot_compositing.aep", now you opened this workfile in a context "Sloth" but existing subset for "Robot" asset stayed in the workfile.) +(Eg. you created subset "renderCompositingDefault" from asset "Robot' in "your_project_Robot_compositing.aep", now you opened this workfile in a context "Sloth" but existing subset for "Robot" asset stayed in the workfile.) \ No newline at end of file diff --git a/openpype/hosts/aftereffects/plugins/publish/help/validate_scene_settings.xml b/openpype/hosts/aftereffects/plugins/publish/help/validate_scene_settings.xml index 603ab4805d..983dde42ce 100644 --- a/openpype/hosts/aftereffects/plugins/publish/help/validate_scene_settings.xml +++ b/openpype/hosts/aftereffects/plugins/publish/help/validate_scene_settings.xml @@ -6,22 +6,21 @@ ## Invalid scene setting found One of the settings in a scene doesn't match to asset settings in database. - - Invalid setting: - {invalid_setting_str} +Invalid setting: +{invalid_setting_str} ### How to repair? -Change {invalid_keys_str} in the scene OR change them in asset database if they are wrong there. +Change {invalid_keys_str} setting in the scene OR change them in asset database if they are wrong there. ### __Detailed Info__ (optional) This error is shown when for example resolution in the scene doesn't match to resolution set on the asset in the database. - Either value in the database or in the scene is wrong. +Either value in the database or in the scene is wrong. - + Scene file doesn't exist ## Scene file doesn't exist From 3e195d58ccc16c33498159a364ca758ad604d9cf Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 21 Dec 2021 14:37:41 +0100 Subject: [PATCH 021/483] Fix formatting --- .../plugins/publish/help/validate_scene_settings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/help/validate_scene_settings.xml b/openpype/hosts/aftereffects/plugins/publish/help/validate_scene_settings.xml index 983dde42ce..6dc51d9953 100644 --- a/openpype/hosts/aftereffects/plugins/publish/help/validate_scene_settings.xml +++ b/openpype/hosts/aftereffects/plugins/publish/help/validate_scene_settings.xml @@ -6,12 +6,12 @@ ## Invalid scene setting found One of the settings in a scene doesn't match to asset settings in database. -Invalid setting: + {invalid_setting_str} ### How to repair? -Change {invalid_keys_str} setting in the scene OR change them in asset database if they are wrong there. +Change values for {invalid_keys_str} in the scene OR change them in the asset database if they are wrong there. ### __Detailed Info__ (optional) From a14aaaf9b8e4feb4245bae8a4e07aa7a91043d33 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 21 Dec 2021 14:48:08 +0100 Subject: [PATCH 022/483] Fix formatting --- .../plugins/publish/help/validate_scene_settings.xml | 2 +- .../plugins/publish/validate_instance_asset.py | 5 ++--- .../plugins/publish/validate_scene_settings.py | 8 ++++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/help/validate_scene_settings.xml b/openpype/hosts/aftereffects/plugins/publish/help/validate_scene_settings.xml index 6dc51d9953..36fa90456e 100644 --- a/openpype/hosts/aftereffects/plugins/publish/help/validate_scene_settings.xml +++ b/openpype/hosts/aftereffects/plugins/publish/help/validate_scene_settings.xml @@ -20,7 +20,7 @@ This error is shown when for example resolution in the scene doesn't match to re Either value in the database or in the scene is wrong. - + Scene file doesn't exist ## Scene file doesn't exist diff --git a/openpype/hosts/aftereffects/plugins/publish/validate_instance_asset.py b/openpype/hosts/aftereffects/plugins/publish/validate_instance_asset.py index 2c8c1b4312..491e07b6c4 100644 --- a/openpype/hosts/aftereffects/plugins/publish/validate_instance_asset.py +++ b/openpype/hosts/aftereffects/plugins/publish/validate_instance_asset.py @@ -2,7 +2,7 @@ from avalon import api import pyblish.api import openpype.api from avalon import aftereffects -from openpype.pipeline import PublishValidationError +from openpype.pipeline import PublishXmlValidationError class ValidateInstanceAssetRepair(pyblish.api.Action): @@ -56,6 +56,5 @@ class ValidateInstanceAsset(pyblish.api.InstancePlugin): f"as current context {current_asset}." ) - # assert instance_asset == current_asset, msg if instance_asset != current_asset: - raise PublishValidationError(msg, "Subset context", DESCRIPTION) \ No newline at end of file + raise PublishXmlValidationError(self, msg) diff --git a/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py index 50e55599e2..0e7a54005a 100644 --- a/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py +++ b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py @@ -126,8 +126,12 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): if invalid_settings: invalid_keys_str = ",".join(invalid_keys) + break_str = "
" + invalid_setting_str = "Found invalid settings:
{}".\ + format(break_str.join(invalid_settings)) + formatting_data = { - "invalid_setting_str": msg, + "invalid_setting_str": invalid_setting_str, "invalid_keys_str": invalid_keys_str } raise PublishXmlValidationError(self, msg, @@ -141,5 +145,5 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): formatting_data = { "scene_url": scene_url } - raise PublishXmlValidationError(self, msg, + raise PublishXmlValidationError(self, msg, key="file_not_found", formatting_data=formatting_data) From 28040a2f6a4ae863578edf49baebd7bd5a85a514 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 21 Dec 2021 14:55:14 +0100 Subject: [PATCH 023/483] exception description can be different per instance under title --- .../publisher/widgets/validations_widget.py | 83 ++++++++++++++----- 1 file changed, 64 insertions(+), 19 deletions(-) diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index 09e56d64cc..9f550725a5 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -10,6 +10,9 @@ from .widgets import ( ClickableFrame, IconValuePixmapLabel ) +from ..constants import ( + INSTANCE_ID_ROLE +) class ValidationErrorInstanceList(QtWidgets.QListView): @@ -47,6 +50,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): if there is a list (Valdation error may happen on context). """ selected = QtCore.Signal(int) + instance_changed = QtCore.Signal(int) def __init__(self, index, error_info, parent): super(ValidationErrorTitleWidget, self).__init__(parent) @@ -72,24 +76,37 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): title_frame_layout.addWidget(label_widget) instances_model = QtGui.QStandardItemModel() - instances = error_info["instances"] + error_info = error_info["error_info"] + + help_text_by_instance_id = {} context_validation = False if ( - not instances - or (len(instances) == 1 and instances[0] is None) + not error_info + or (len(error_info) == 1 and error_info[0][0] is None) ): context_validation = True toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow) + help_text_by_instance_id[None] = error_info[0][1] else: items = [] - for instance in instances: + for instance, exception in error_info: label = instance.data.get("label") or instance.data.get("name") item = QtGui.QStandardItem(label) item.setFlags( QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable ) - item.setData(instance.id) + item.setData(instance.id, INSTANCE_ID_ROLE) items.append(item) + dsc = exception.description + detail = exception.detail + if detail: + dsc += "

{}".format(detail) + + help_text = dsc + if commonmark: + help_text = commonmark.commonmark(dsc) + + help_text_by_instance_id[instance.id] = help_text instances_model.invisibleRootItem().appendRows(items) @@ -114,6 +131,10 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): if not context_validation: toggle_instance_btn.clicked.connect(self._on_toggle_btn_click) + instances_view.selectionModel().selectionChanged.connect( + self._on_seleciton_change + ) + self._title_frame = title_frame self._toggle_instance_btn = toggle_instance_btn @@ -121,6 +142,9 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): self._instances_model = instances_model self._instances_view = instances_view + self._context_validation = context_validation + self._help_text_by_instance_id = help_text_by_instance_id + def _mouse_release_callback(self): """Mark this widget as selected on click.""" self.set_selected(True) @@ -145,6 +169,17 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): self._title_frame.setProperty("selected", value) self._title_frame.style().polish(self._title_frame) + def current_desctiption_text(self): + if self._context_validation: + return self._help_text_by_instance_id[None] + index = self._instances_view.currentIndex() + # TODO make sure instance is selected + if not index.isValid(): + index = self._instances_model.index(0, 0) + + indence_id = index.data(INSTANCE_ID_ROLE) + return self._help_text_by_instance_id[indence_id] + def set_selected(self, selected=None): """Change selected state of widget.""" if selected is None: @@ -167,6 +202,9 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): else: self._toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) + def _on_seleciton_change(self): + self.instance_changed.emit(self._index) + class ActionButton(ClickableFrame): """Plugin's action callback button. @@ -440,28 +478,28 @@ class ValidationsWidget(QtWidgets.QWidget): errors_by_title = [] for plugin_info in errors: titles = [] - exception_by_title = {} - instances_by_title = {} + error_info_by_title = {} for error_info in plugin_info["errors"]: exception = error_info["exception"] title = exception.title if title not in titles: titles.append(title) - instances_by_title[title] = [] - exception_by_title[title] = exception - instances_by_title[title].append(error_info["instance"]) + error_info_by_title[title] = [] + error_info_by_title[title].append( + (error_info["instance"], exception) + ) for title in titles: errors_by_title.append({ "plugin": plugin_info["plugin"], - "exception": exception_by_title[title], - "instances": instances_by_title[title] + "error_info": error_info_by_title[title] }) for idx, item in enumerate(errors_by_title): widget = ValidationErrorTitleWidget(idx, item, self) widget.selected.connect(self._on_select) + widget.instance_changed.connect(self._on_instance_change) self._errors_layout.addWidget(widget) self._title_widgets[idx] = widget self._error_info[idx] = item @@ -480,11 +518,18 @@ class ValidationsWidget(QtWidgets.QWidget): self._previous_select = self._title_widgets[index] error_item = self._error_info[index] - - dsc = error_item["exception"].description - if commonmark: - html = commonmark.commonmark(dsc) - self._error_details_input.setHtml(html) - else: - self._error_details_input.setMarkdown(dsc) self._actions_widget.set_plugin(error_item["plugin"]) + + self._update_description() + + def _on_instance_change(self, index): + if self._previous_select and self._previous_select.index != index: + return + self._update_description() + + def _update_description(self): + description = self._previous_select.current_desctiption_text() + if commonmark: + self._error_details_input.setHtml(description) + else: + self._error_details_input.setMarkdown(description) From 72cdaecef46e0fecb72f880b5a0681fc7f4f6ca9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 21 Dec 2021 15:02:13 +0100 Subject: [PATCH 024/483] fix context exception handling --- .../publisher/widgets/validations_widget.py | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index 9f550725a5..ba4df2eb8e 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -68,8 +68,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) toggle_instance_btn.setMaximumWidth(14) - exception = error_info["exception"] - label_widget = QtWidgets.QLabel(exception.title, title_frame) + label_widget = QtWidgets.QLabel(error_info["title"], title_frame) title_frame_layout = QtWidgets.QHBoxLayout(title_frame) title_frame_layout.addWidget(toggle_instance_btn) @@ -86,7 +85,8 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): ): context_validation = True toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow) - help_text_by_instance_id[None] = error_info[0][1] + description = self._prepare_description(error_info[0][1]) + help_text_by_instance_id[None] = description else: items = [] for instance, exception in error_info: @@ -97,16 +97,8 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): ) item.setData(instance.id, INSTANCE_ID_ROLE) items.append(item) - dsc = exception.description - detail = exception.detail - if detail: - dsc += "

{}".format(detail) - - help_text = dsc - if commonmark: - help_text = commonmark.commonmark(dsc) - - help_text_by_instance_id[instance.id] = help_text + description = self._prepare_description(exception) + help_text_by_instance_id[instance.id] = description instances_model.invisibleRootItem().appendRows(items) @@ -145,6 +137,17 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): self._context_validation = context_validation self._help_text_by_instance_id = help_text_by_instance_id + def _prepare_description(self, exception): + dsc = exception.description + detail = exception.detail + if detail: + dsc += "

{}".format(detail) + + description = dsc + if commonmark: + description = commonmark.commonmark(dsc) + return description + def _mouse_release_callback(self): """Mark this widget as selected on click.""" self.set_selected(True) @@ -493,7 +496,8 @@ class ValidationsWidget(QtWidgets.QWidget): for title in titles: errors_by_title.append({ "plugin": plugin_info["plugin"], - "error_info": error_info_by_title[title] + "error_info": error_info_by_title[title], + "title": title }) for idx, item in enumerate(errors_by_title): From 4759b6db371c9724748be4099ce19f5e191bc3c1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 11:00:50 +0100 Subject: [PATCH 025/483] raise PublishXmlValidationError in validate asset name --- .../publish/help/validate_asset_name.xml | 22 ++++++++++++++++++ .../plugins/publish/validate_asset_name.py | 23 ++++++++++++++----- 2 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 openpype/hosts/tvpaint/plugins/publish/help/validate_asset_name.xml diff --git a/openpype/hosts/tvpaint/plugins/publish/help/validate_asset_name.xml b/openpype/hosts/tvpaint/plugins/publish/help/validate_asset_name.xml new file mode 100644 index 0000000000..ed8e36b1d9 --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/help/validate_asset_name.xml @@ -0,0 +1,22 @@ + + + +Subset context +## Invalid subset context + +Context of the given subset doesn't match your current scene. + +### How to repair? + +Yout can fix with "Repair" button on the right. This will use '{expected_asset}' asset name and overwrite '{found_asset}' asset name in scene metadata. + +After that restart publishing with Reload button. + + +### How could this happen? + +The subset was created in different scene with different context +or the scene file was copy pasted from different context. + + + diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py b/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py index 4ce8d5347d..199b9a3b19 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py @@ -1,5 +1,6 @@ import pyblish.api from avalon.tvpaint import pipeline +from openpype.pipeline import PublishXmlValidationError class FixAssetNames(pyblish.api.Action): @@ -27,7 +28,7 @@ class FixAssetNames(pyblish.api.Action): pipeline._write_instances(new_instance_items) -class ValidateMissingLayers(pyblish.api.ContextPlugin): +class ValidateAssetNames(pyblish.api.ContextPlugin): """Validate assset name present on instance. Asset name on instance should be the same as context's. @@ -48,8 +49,18 @@ class ValidateMissingLayers(pyblish.api.ContextPlugin): instance_label = ( instance.data.get("label") or instance.data["name"] ) - raise AssertionError(( - "Different asset name on instance then context's." - " Instance \"{}\" has asset name: \"{}\"" - " Context asset name is: \"{}\"" - ).format(instance_label, asset_name, context_asset_name)) + + raise PublishXmlValidationError( + self, + ( + "Different asset name on instance then context's." + " Instance \"{}\" has asset name: \"{}\"" + " Context asset name is: \"{}\"" + ).format( + instance_label, asset_name, context_asset_name + ), + formatting_data={ + "expected_asset": context_asset_name, + "found_asset": asset_name + } + ) From f13923d31ccae0a0455fb36bb09dd225c6b4e0e3 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 11:04:10 +0100 Subject: [PATCH 026/483] raise PublishXmlValidationError in validate duplicated layer names --- .../help/validate_duplicated_layer_names.xml | 22 +++++++++++++++++++ .../validate_duplicated_layer_names.py | 15 +++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 openpype/hosts/tvpaint/plugins/publish/help/validate_duplicated_layer_names.xml diff --git a/openpype/hosts/tvpaint/plugins/publish/help/validate_duplicated_layer_names.xml b/openpype/hosts/tvpaint/plugins/publish/help/validate_duplicated_layer_names.xml new file mode 100644 index 0000000000..5d798544c0 --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/help/validate_duplicated_layer_names.xml @@ -0,0 +1,22 @@ + + + +Layer names +## Duplicated layer names + +Can't determine which layers should be published because there are duplicated layer names in the scene. + +### Duplicated layer names + +{layer_names} + +*Check layer names for all subsets in list on left side.* + +### How to repair? + +Hide/rename/remove layers that should not be published. + +If all of them should be published then you have duplicated subset names in the scene. In that case you have to recrete them and use different variant name. + + + diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_duplicated_layer_names.py b/openpype/hosts/tvpaint/plugins/publish/validate_duplicated_layer_names.py index efccf19ef9..9f61bdbcd0 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_duplicated_layer_names.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_duplicated_layer_names.py @@ -1,4 +1,5 @@ import pyblish.api +from openpype.pipeline import PublishXmlValidationError class ValidateLayersGroup(pyblish.api.InstancePlugin): @@ -30,14 +31,20 @@ class ValidateLayersGroup(pyblish.api.InstancePlugin): "\"{}\"".format(layer_name) for layer_name in duplicated_layer_names ]) - - # Raise an error - raise AssertionError( + detail_lines = [ + "- {}".format(layer_name) + for layer_name in set(duplicated_layer_names) + ] + raise PublishXmlValidationError( + self, ( "Layers have duplicated names for instance {}." # Description what's wrong " There are layers with same name and one of them is marked" " for publishing so it is not possible to know which should" " be published. Please look for layers with names: {}" - ).format(instance.data["label"], layers_msg) + ).format(instance.data["label"], layers_msg), + formatting_data={ + "layer_names": "
".join(detail_lines) + } ) From 3417dc716af9c1081a51ab9c132bcded0330623d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 11:12:42 +0100 Subject: [PATCH 027/483] raise PublishXmlValidationError in validate layers visibility --- .../help/validate_layers_visibility.xml | 20 +++++++++++++++++ .../publish/validate_layers_visibility.py | 22 ++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml diff --git a/openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml b/openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml new file mode 100644 index 0000000000..fc69d5fd7b --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml @@ -0,0 +1,20 @@ + + + +Layers visiblity +## All layers are not visible + +All layers for subset "{instance_name}" are hidden. + +### Layer names for **{instance_name}** + +{layer_names} + +*Check layer names for all subsets in list on left side.* + +### How to repair? + +Make sure that at least one layer in the scene is visible or disable the subset before hitting publish button after refresh. + + + diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_layers_visibility.py b/openpype/hosts/tvpaint/plugins/publish/validate_layers_visibility.py index 74ef34169e..7ea0587b8f 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_layers_visibility.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_layers_visibility.py @@ -1,6 +1,8 @@ import pyblish.api +from openpype.pipeline import PublishXmlValidationError +# TODO @iLLiCiTiT add repair action to disable instances? class ValidateLayersVisiblity(pyblish.api.InstancePlugin): """Validate existence of renderPass layers.""" @@ -9,8 +11,26 @@ class ValidateLayersVisiblity(pyblish.api.InstancePlugin): families = ["review", "renderPass", "renderLayer"] def process(self, instance): + layer_names = set() for layer in instance.data["layers"]: + layer_names.add(layer["name"]) if layer["visible"]: return - raise AssertionError("All layers of instance are not visible.") + instance_label = ( + instance.data.get("label") or instance.data["name"] + ) + + raise PublishXmlValidationError( + self, + "All layers of instance \"{}\" are not visible.".format( + instance_label + ), + formatting_data={ + "instance_name": instance_label, + "layer_names": "
".join([ + "- {}".format(layer_name) + for layer_name in layer_names + ]) + } + ) From fba2191226388f73c3dbf5e7c20773e58cb1259c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 11:57:31 +0100 Subject: [PATCH 028/483] raise PublishXmlValidationError in validate marks --- .../publish/help/validate_asset_name.xml | 2 +- .../plugins/publish/help/validate_marks.xml | 21 ++++++++++ .../tvpaint/plugins/publish/validate_marks.py | 38 ++++++++++++++++--- 3 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 openpype/hosts/tvpaint/plugins/publish/help/validate_marks.xml diff --git a/openpype/hosts/tvpaint/plugins/publish/help/validate_asset_name.xml b/openpype/hosts/tvpaint/plugins/publish/help/validate_asset_name.xml index ed8e36b1d9..33a9ca4247 100644 --- a/openpype/hosts/tvpaint/plugins/publish/help/validate_asset_name.xml +++ b/openpype/hosts/tvpaint/plugins/publish/help/validate_asset_name.xml @@ -8,7 +8,7 @@ Context of the given subset doesn't match your current scene. ### How to repair? -Yout can fix with "Repair" button on the right. This will use '{expected_asset}' asset name and overwrite '{found_asset}' asset name in scene metadata. +Yout can fix this with "Repair" button on the right. This will use '{expected_asset}' asset name and overwrite '{found_asset}' asset name in scene metadata. After that restart publishing with Reload button.
diff --git a/openpype/hosts/tvpaint/plugins/publish/help/validate_marks.xml b/openpype/hosts/tvpaint/plugins/publish/help/validate_marks.xml new file mode 100644 index 0000000000..f0e01ebaa7 --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/help/validate_marks.xml @@ -0,0 +1,21 @@ + + + +Frame range +## Invalid render frame range + +Scene frame range which will be rendered is defined by MarkIn and MarkOut. Expected frame range is {expected_frame_range} and current frame range is {current_frame_range}. + +It is also required that MarkIn and MarkOut are enabled in the scene. Their color is highlighted on timeline when are enabled. + +- MarkIn is {mark_in_enable_state} +- MarkOut is {mark_out_enable_state} + +### How to repair? + +Yout can fix this with "Repair" button on the right. That will change MarkOut to {expected_mark_out}. + +Or you can manually modify MarkIn and MarkOut in the scene timeline. + + + diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_marks.py b/openpype/hosts/tvpaint/plugins/publish/validate_marks.py index e2ef81e4a4..5f569d3ba7 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_marks.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_marks.py @@ -2,6 +2,7 @@ import json import pyblish.api from avalon.tvpaint import lib +from openpype.pipeline import PublishXmlValidationError class ValidateMarksRepair(pyblish.api.Action): @@ -73,9 +74,34 @@ class ValidateMarks(pyblish.api.ContextPlugin): "expected": expected_data[k] } - if invalid: - raise AssertionError( - "Marks does not match database:\n{}".format( - json.dumps(invalid, sort_keys=True, indent=4) - ) - ) + # Validation ends + if not invalid: + return + + current_frame_range = ( + (current_data["markOut"] - current_data["markIn"]) + 1 + ) + expected_frame_range = ( + (expected_data["markOut"] - expected_data["markIn"]) + 1 + ) + mark_in_enable_state = "disabled" + if current_data["markInState"]: + mark_in_enable_state = "enabled" + + mark_out_enable_state = "disabled" + if current_data["markOutState"]: + mark_out_enable_state = "enabled" + + raise PublishXmlValidationError( + self, + "Marks does not match database:\n{}".format( + json.dumps(invalid, sort_keys=True, indent=4) + ), + formatting_data={ + "current_frame_range": str(current_frame_range), + "expected_frame_range": str(expected_frame_range), + "mark_in_enable_state": mark_in_enable_state, + "mark_out_enable_state": mark_out_enable_state, + "expected_mark_out": expected_data["markOut"] + } + ) From d17de8492da6c9c4aba019dab129ecb8f4a88088 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 12:59:09 +0100 Subject: [PATCH 029/483] raise PublishXmlValidationError in validate missing layer names --- .../help/validate_missing_layer_names.xml | 18 ++++++++++++++++++ .../publish/validate_missing_layer_names.py | 17 +++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 openpype/hosts/tvpaint/plugins/publish/help/validate_missing_layer_names.xml diff --git a/openpype/hosts/tvpaint/plugins/publish/help/validate_missing_layer_names.xml b/openpype/hosts/tvpaint/plugins/publish/help/validate_missing_layer_names.xml new file mode 100644 index 0000000000..e96e7c5044 --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/help/validate_missing_layer_names.xml @@ -0,0 +1,18 @@ + + + +Missing layers +## Missing layers for render pass + +Render pass subset "{instance_name}" has stored layer names that belong to it's rendering scope but layers were not found in scene. + +### Missing layer names + +{layer_names} + +### How to repair? + +Find layers that belong to subset {instance_name} and rename them back to expected layer names or remove the subset and create new with right layers. + + + diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_missing_layer_names.py b/openpype/hosts/tvpaint/plugins/publish/validate_missing_layer_names.py index db9d354fcd..294ce6cf4f 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_missing_layer_names.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_missing_layer_names.py @@ -1,4 +1,5 @@ import pyblish.api +from openpype.pipeline import PublishXmlValidationError class ValidateMissingLayers(pyblish.api.InstancePlugin): @@ -30,13 +31,25 @@ class ValidateMissingLayers(pyblish.api.InstancePlugin): "\"{}\"".format(layer_name) for layer_name in missing_layer_names ]) + instance_label = ( + instance.data.get("label") or instance.data["name"] + ) + description_layer_names = "
".join([ + "- {}".format(layer_name) + for layer_name in missing_layer_names + ]) # Raise an error - raise AssertionError( + raise PublishXmlValidationError( + self, ( "Layers were not found by name for instance \"{}\"." # Description what's wrong " Layer names marked for publishing are not available" " in layers list. Missing layer names: {}" - ).format(instance.data["label"], layers_msg) + ).format(instance.data["label"], layers_msg), + formatting_data={ + "instance_name": instance_label, + "layer_names": description_layer_names + } ) From db5d03c9023235352b3935853b555cda70e9885e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 13:02:02 +0100 Subject: [PATCH 030/483] renamed validate_project_settings to validate_scene_settings --- ...te_project_settings.py => validate_scene_settings.py} | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) rename openpype/hosts/tvpaint/plugins/publish/{validate_project_settings.py => validate_scene_settings.py} (78%) diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_project_settings.py b/openpype/hosts/tvpaint/plugins/publish/validate_scene_settings.py similarity index 78% rename from openpype/hosts/tvpaint/plugins/publish/validate_project_settings.py rename to openpype/hosts/tvpaint/plugins/publish/validate_scene_settings.py index 84c03a9857..7efa146c54 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_project_settings.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_scene_settings.py @@ -3,11 +3,10 @@ import json import pyblish.api -class ValidateProjectSettings(pyblish.api.ContextPlugin): - """Validate project settings against database. - """ +class ValidateSceneSettings(pyblish.api.ContextPlugin): + """Validate scene settings against database.""" - label = "Validate Project Settings" + label = "Validate Scene Settings" order = pyblish.api.ValidatorOrder optional = True @@ -28,7 +27,7 @@ class ValidateProjectSettings(pyblish.api.ContextPlugin): if invalid: raise AssertionError( - "Project settings does not match database:\n{}".format( + "Scene settings does not match database:\n{}".format( json.dumps(invalid, sort_keys=True, indent=4) ) ) From 6b6961a84a6e8b4a74eca4b4119d8ea4a7c41c8e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 13:02:32 +0100 Subject: [PATCH 031/483] raise PublishXmlValidationError in validate scene settings --- .../publish/help/validate_scene_settings.xml | 26 +++++++++++++++ .../publish/validate_scene_settings.py | 32 ++++++++++++++----- 2 files changed, 50 insertions(+), 8 deletions(-) create mode 100644 openpype/hosts/tvpaint/plugins/publish/help/validate_scene_settings.xml diff --git a/openpype/hosts/tvpaint/plugins/publish/help/validate_scene_settings.xml b/openpype/hosts/tvpaint/plugins/publish/help/validate_scene_settings.xml new file mode 100644 index 0000000000..f741c71456 --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/help/validate_scene_settings.xml @@ -0,0 +1,26 @@ + + + +Scene settings +## Invalid scene settings + +Scene settings do not match to expected values. + +**FPS** +- Expected value: {expected_fps} +- Current value: {current_fps} + +**Resolution** +- Expected value: {expected_width}x{expected_height} +- Current value: {current_width}x{current_height} + +**Pixel ratio** +- Expected value: {expected_pixel_ratio} +- Current value: {current_pixel_ratio} + +### How to repair? + +FPS and Pixel ratio can be modified in scene setting. Wrong resolution can be fixed with changing resolution of scene but due to TVPaint limitations it is possible that you will need to create new scene. + + + diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_scene_settings.py b/openpype/hosts/tvpaint/plugins/publish/validate_scene_settings.py index 7efa146c54..d235215ac9 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_scene_settings.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_scene_settings.py @@ -1,9 +1,11 @@ import json import pyblish.api +from openpype.pipeline import PublishXmlValidationError -class ValidateSceneSettings(pyblish.api.ContextPlugin): +# TODO @iLliCiTiT add fix action for fps +class ValidateProjectSettings(pyblish.api.ContextPlugin): """Validate scene settings against database.""" label = "Validate Scene Settings" @@ -11,6 +13,7 @@ class ValidateSceneSettings(pyblish.api.ContextPlugin): optional = True def process(self, context): + expected_data = context.data["assetEntity"]["data"] scene_data = { "fps": context.data.get("sceneFps"), "resolutionWidth": context.data.get("sceneWidth"), @@ -19,15 +22,28 @@ class ValidateSceneSettings(pyblish.api.ContextPlugin): } invalid = {} for k in scene_data.keys(): - expected_value = context.data["assetEntity"]["data"][k] + expected_value = expected_data[k] if scene_data[k] != expected_value: invalid[k] = { "current": scene_data[k], "expected": expected_value } - if invalid: - raise AssertionError( - "Scene settings does not match database:\n{}".format( - json.dumps(invalid, sort_keys=True, indent=4) - ) - ) + if not invalid: + return + + raise PublishXmlValidationError( + self, + "Scene settings does not match database:\n{}".format( + json.dumps(invalid, sort_keys=True, indent=4) + ), + formatting_data={ + "expected_fps": expected_data["fps"], + "current_fps": scene_data["fps"], + "expected_width": expected_data["resolutionWidth"], + "expected_height": expected_data["resolutionHeight"], + "current_width": scene_data["resolutionWidth"], + "current_height": scene_data["resolutionWidth"], + "expected_pixel_ratio": expected_data["pixelAspect"], + "current_pixel_ratio": scene_data["pixelAspect"] + } + ) From f99036eb1dbb74aa5a435d8f1f962303e170aecc Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 13:27:56 +0100 Subject: [PATCH 032/483] raise PublishXmlValidationError in validate pass groups --- .../help/validate_render_pass_group.xml | 14 +++++++ .../publish/validate_render_pass_group.py | 40 +++++++++++++------ 2 files changed, 41 insertions(+), 13 deletions(-) create mode 100644 openpype/hosts/tvpaint/plugins/publish/help/validate_render_pass_group.xml diff --git a/openpype/hosts/tvpaint/plugins/publish/help/validate_render_pass_group.xml b/openpype/hosts/tvpaint/plugins/publish/help/validate_render_pass_group.xml new file mode 100644 index 0000000000..df7bdf36e5 --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/help/validate_render_pass_group.xml @@ -0,0 +1,14 @@ + + + +Render pass group +## Invalid group of Render Pass layers + +Layers of Render Pass {instance_name} belong to Render Group which is defined by TVPaint color group {expected_group}. But the layers are not in the group. + +### How to repair? + +Change the color group to {expected_group} on layers {layer_names}. + + + diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_render_pass_group.py b/openpype/hosts/tvpaint/plugins/publish/validate_render_pass_group.py index 5047b8d729..0fbfca6c56 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_render_pass_group.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_render_pass_group.py @@ -1,5 +1,6 @@ import collections import pyblish.api +from openpype.pipeline import PublishXmlValidationError class ValidateLayersGroup(pyblish.api.InstancePlugin): @@ -26,11 +27,13 @@ class ValidateLayersGroup(pyblish.api.InstancePlugin): layer_names = instance.data["layer_names"] # Check if all layers from render pass are in right group invalid_layers_by_group_id = collections.defaultdict(list) + invalid_layer_names = set() for layer_name in layer_names: layer = layers_by_name.get(layer_name) _group_id = layer["group_id"] if _group_id != group_id: invalid_layers_by_group_id[_group_id].append(layer) + invalid_layer_names.add(layer_name) # Everything is OK and skip exception if not invalid_layers_by_group_id: @@ -61,16 +64,27 @@ class ValidateLayersGroup(pyblish.api.InstancePlugin): ) # Raise an error - raise AssertionError(( - # Short message - "Layers in wrong group." - # Description what's wrong - " Layers from render pass \"{}\" must be in group {} (id: {})." - # Detailed message - " Layers in wrong group: {}" - ).format( - instance.data["label"], - correct_group["name"], - correct_group["group_id"], - " | ".join(per_group_msgs) - )) + raise PublishXmlValidationError( + self, + ( + # Short message + "Layers in wrong group." + # Description what's wrong + " Layers from render pass \"{}\" must be in group {} (id: {})." + # Detailed message + " Layers in wrong group: {}" + ).format( + instance.data["label"], + correct_group["name"], + correct_group["group_id"], + " | ".join(per_group_msgs) + ), + formatting_data={ + "instance_name": ( + instance.data.get("label") or instance.data["name"] + ), + "expected_group": correct_group["name"], + "layer_names": ", ".join(invalid_layer_names) + + } + ) From df9e30eb7f8074d77a1701cc6bf2470ad82df487 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 13:33:11 +0100 Subject: [PATCH 033/483] raise PublishXmlValidationError in validate start frame --- .../plugins/publish/help/validate_start_frame.xml | 14 ++++++++++++++ .../plugins/publish/validate_start_frame.py | 12 +++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 openpype/hosts/tvpaint/plugins/publish/help/validate_start_frame.xml diff --git a/openpype/hosts/tvpaint/plugins/publish/help/validate_start_frame.xml b/openpype/hosts/tvpaint/plugins/publish/help/validate_start_frame.xml new file mode 100644 index 0000000000..9052abf66c --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/help/validate_start_frame.xml @@ -0,0 +1,14 @@ + + + +First frame +## MarkIn is not set to 0 + +MarkIn in your scene must start from 0 fram index but MarkIn is set to {current_start_frame}. + +### How to repair? + +You can modify MarkIn manually or hit the "Repair" button on the right which will change MarkIn to 0 (does not change MarkOut). + + + diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py b/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py index d769d47736..48efd91055 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py @@ -1,5 +1,6 @@ import pyblish.api from avalon.tvpaint import lib +from openpype.pipeline import PublishXmlValidationError class RepairStartFrame(pyblish.api.Action): @@ -24,4 +25,13 @@ class ValidateStartFrame(pyblish.api.ContextPlugin): def process(self, context): start_frame = lib.execute_george("tv_startframe") - assert int(start_frame) == 0, "Start frame has to be frame 0." + if start_frame == 0: + return + + raise PublishXmlValidationError( + self, + "Start frame has to be frame 0.", + formatting_data={ + "current_start_frame": start_frame + } + ) From 4c11ae83ee2843d1dc4bd4ac341d54137f3c3161 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 13:43:13 +0100 Subject: [PATCH 034/483] raise PublishXmlValidationError in validate workfile metadata --- .../help/validate_workfile_metadata.xml | 19 +++++++++++++++++++ .../publish/validate_workfile_metadata.py | 9 +++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_metadata.xml diff --git a/openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_metadata.xml b/openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_metadata.xml new file mode 100644 index 0000000000..7397f6ef0b --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_metadata.xml @@ -0,0 +1,19 @@ + + + +Missing metadata +## Your scene miss context metadata + +Your scene does not contain metadata about {missing_metadata}. + +### How to repair? + +Resave the scene using Workfiles tool or hit the "Repair" button on the right. + + +### How this could happend? + +You're using scene file that was not created using Workfiles tool. + + + diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_workfile_metadata.py b/openpype/hosts/tvpaint/plugins/publish/validate_workfile_metadata.py index 757da3294a..553d9af4e8 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_workfile_metadata.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_workfile_metadata.py @@ -1,5 +1,6 @@ import pyblish.api from avalon.tvpaint import save_file +from openpype.pipeline import PublishXmlValidationError class ValidateWorkfileMetadataRepair(pyblish.api.Action): @@ -42,8 +43,12 @@ class ValidateWorkfileMetadata(pyblish.api.ContextPlugin): missing_keys.append(key) if missing_keys: - raise AssertionError( + raise PublishXmlValidationError( + self, "Current workfile is missing metadata about {}.".format( ", ".join(missing_keys) - ) + ), + formatting_data={ + "missing_metadata": ", ".join(missing_keys) + } ) From d7f6db8d38d6df7010f886802367863b045dd130 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 22 Dec 2021 13:56:18 +0100 Subject: [PATCH 035/483] Added new style validators for New Publisher for Harmony --- .../plugins/publish/help/validate_audio.xml | 15 ++++++++ .../publish/help/validate_instances.xml | 25 +++++++++++++ .../publish/help/validate_scene_settings.xml | 35 +++++++++++++++++++ .../harmony/plugins/publish/validate_audio.py | 9 ++++- .../plugins/publish/validate_instances.py | 14 ++++++-- .../publish/validate_scene_settings.py | 32 ++++++++++++++--- 6 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 openpype/hosts/harmony/plugins/publish/help/validate_audio.xml create mode 100644 openpype/hosts/harmony/plugins/publish/help/validate_instances.xml create mode 100644 openpype/hosts/harmony/plugins/publish/help/validate_scene_settings.xml diff --git a/openpype/hosts/harmony/plugins/publish/help/validate_audio.xml b/openpype/hosts/harmony/plugins/publish/help/validate_audio.xml new file mode 100644 index 0000000000..e9a183c675 --- /dev/null +++ b/openpype/hosts/harmony/plugins/publish/help/validate_audio.xml @@ -0,0 +1,15 @@ + + + +Missing audio file + +## Cannot locate linked audio file + +Audio file at {audio_url} cannot be found. + +### How to repair? + +Copy audio file to the highlighted location or remove audio link in the workfile. + + + \ No newline at end of file diff --git a/openpype/hosts/harmony/plugins/publish/help/validate_instances.xml b/openpype/hosts/harmony/plugins/publish/help/validate_instances.xml new file mode 100644 index 0000000000..3b040e8ea8 --- /dev/null +++ b/openpype/hosts/harmony/plugins/publish/help/validate_instances.xml @@ -0,0 +1,25 @@ + + + +Subset context + +## Invalid subset context + +Asset name found '{found}' in subsets, expected '{expected}'. + +### How to repair? + +You can fix this with `Repair` button on the right. This will use '{expected}' asset name and overwrite '{found}' asset name in scene metadata. + +After that restart `Publish` with a `Reload button`. + +If this is unwanted, close workfile and open again, that way different asset value would be used for context information. + + +### __Detailed Info__ (optional) + +This might happen if you are reuse old workfile and open it in different context. +(Eg. you created subset "renderCompositingDefault" from asset "Robot' in "your_project_Robot_compositing.aep", now you opened this workfile in a context "Sloth" but existing subset for "Robot" asset stayed in the workfile.) + + + \ No newline at end of file diff --git a/openpype/hosts/harmony/plugins/publish/help/validate_scene_settings.xml b/openpype/hosts/harmony/plugins/publish/help/validate_scene_settings.xml new file mode 100644 index 0000000000..36fa90456e --- /dev/null +++ b/openpype/hosts/harmony/plugins/publish/help/validate_scene_settings.xml @@ -0,0 +1,35 @@ + + + +Scene setting + +## Invalid scene setting found + +One of the settings in a scene doesn't match to asset settings in database. + +{invalid_setting_str} + +### How to repair? + +Change values for {invalid_keys_str} in the scene OR change them in the asset database if they are wrong there. + + +### __Detailed Info__ (optional) + +This error is shown when for example resolution in the scene doesn't match to resolution set on the asset in the database. +Either value in the database or in the scene is wrong. + + + +Scene file doesn't exist + +## Scene file doesn't exist + +Collected scene {scene_url} doesn't exist. + +### How to repair? + +Re-save file, start publish from the beginning again. + + + \ No newline at end of file diff --git a/openpype/hosts/harmony/plugins/publish/validate_audio.py b/openpype/hosts/harmony/plugins/publish/validate_audio.py index c043b31ca6..9322968a9d 100644 --- a/openpype/hosts/harmony/plugins/publish/validate_audio.py +++ b/openpype/hosts/harmony/plugins/publish/validate_audio.py @@ -4,6 +4,8 @@ import pyblish.api from avalon import harmony +from openpype.pipeline import PublishXmlValidationError + class ValidateAudio(pyblish.api.InstancePlugin): """Ensures that there is an audio file in the scene. @@ -42,4 +44,9 @@ class ValidateAudio(pyblish.api.InstancePlugin): msg = "You are missing audio file:\n{}".format(audio_path) - assert os.path.isfile(audio_path), msg + formatting_data = { + "audio_url": audio_path + } + if os.path.isfile(audio_path): + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data) diff --git a/openpype/hosts/harmony/plugins/publish/validate_instances.py b/openpype/hosts/harmony/plugins/publish/validate_instances.py index 78073a1978..9fb46dec49 100644 --- a/openpype/hosts/harmony/plugins/publish/validate_instances.py +++ b/openpype/hosts/harmony/plugins/publish/validate_instances.py @@ -1,8 +1,10 @@ import os +from avalon import harmony import pyblish.api import openpype.api -from avalon import harmony + +from openpype.pipeline import PublishXmlValidationError class ValidateInstanceRepair(pyblish.api.Action): @@ -45,4 +47,12 @@ class ValidateInstance(pyblish.api.InstancePlugin): "Instance asset is not the same as current asset:" f"\nInstance: {instance_asset}\nCurrent: {current_asset}" ) - assert instance_asset == current_asset, msg + + formatting_data = { + "found": instance_asset, + "expected": current_asset + } + if instance_asset != current_asset: + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data) + diff --git a/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py b/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py index 0371e80095..e10adb885c 100644 --- a/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py +++ b/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py @@ -7,7 +7,9 @@ import re import pyblish.api from avalon import harmony + import openpype.hosts.harmony +from openpype.pipeline import PublishXmlValidationError class ValidateSceneSettingsRepair(pyblish.api.Action): @@ -102,6 +104,7 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): self.log.debug("current scene settings {}".format(current_settings)) invalid_settings = [] + invalid_keys = set() for key, value in expected_settings.items(): if value != current_settings[key]: invalid_settings.append({ @@ -109,6 +112,7 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): "expected": value, "current": current_settings[key] }) + invalid_keys.add(key) if ((expected_settings["handleStart"] or expected_settings["handleEnd"]) @@ -120,10 +124,30 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): msg = "Found invalid settings:\n{}".format( json.dumps(invalid_settings, sort_keys=True, indent=4) ) - assert not invalid_settings, msg - assert os.path.exists(instance.context.data.get("scenePath")), ( - "Scene file not found (saved under wrong name)" - ) + + if invalid_settings: + invalid_keys_str = ",".join(invalid_keys) + break_str = "
" + invalid_setting_str = "Found invalid settings:
{}".\ + format(break_str.join(invalid_settings)) + + formatting_data = { + "invalid_setting_str": invalid_setting_str, + "invalid_keys_str": invalid_keys_str + } + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data) + + scene_url = instance.context.data.get("scenePath") + if not os.path.exists(scene_url): + msg = "Scene file {} not found (saved under wrong name)".format( + scene_url + ) + formatting_data = { + "scene_url": scene_url + } + raise PublishXmlValidationError(self, msg, key="file_not_found", + formatting_data=formatting_data) def _update_frames(expected_settings): From b9763e0e21342e11909563447047af201e1d70cc Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 14:13:03 +0100 Subject: [PATCH 036/483] raise PublishXmlValidationError in validate workfile project name --- .../help/validate_workfile_project_name.xml | 24 ++++++++++++++ .../publish/validate_workfile_project_name.py | 33 ++++++++++++------- 2 files changed, 45 insertions(+), 12 deletions(-) create mode 100644 openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_project_name.xml diff --git a/openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_project_name.xml b/openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_project_name.xml new file mode 100644 index 0000000000..c4ffafc8b5 --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_project_name.xml @@ -0,0 +1,24 @@ + + + +Project name +## Your scene is from different project + +It is not possible to publish into project "{workfile_project_name}" when TVPaint was opened with project "{env_project_name}" in context. + +### How to repair? + +If the workfile belongs to project "{env_project_name}" then use Workfiles tool to resave it. + +Otherwise close TVPaint and launch it again from project you want to publish in. + + +### How this could happend? + +You've opened workfile from different project. You've opened TVPaint on a task from "{env_project_name}" then you've opened TVPaint again on task from "{workfile_project_name}" without closing the TVPaint. Because TVPaint can run only once the project didn't change. + +### Why it is important? +Because project may affect how TVPaint works or change publishing behavior it is dangerous to allow change project context in many ways. For example publishing will not run as expected. + + + diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_workfile_project_name.py b/openpype/hosts/tvpaint/plugins/publish/validate_workfile_project_name.py index cc664d8030..36230ae38b 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_workfile_project_name.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_workfile_project_name.py @@ -1,5 +1,6 @@ import os import pyblish.api +from openpype.pipeline import PublishXmlValidationError class ValidateWorkfileProjectName(pyblish.api.ContextPlugin): @@ -31,15 +32,23 @@ class ValidateWorkfileProjectName(pyblish.api.ContextPlugin): return # Raise an error - raise AssertionError(( - # Short message - "Workfile from different Project ({})." - # Description what's wrong - " It is not possible to publish when TVPaint was launched in" - "context of different project. Current context project is \"{}\"." - " Launch TVPaint in context of project \"{}\" and then publish." - ).format( - workfile_project_name, - env_project_name, - workfile_project_name, - )) + raise AssertionError( + self, + ( + # Short message + "Workfile from different Project ({})." + # Description what's wrong + " It is not possible to publish when TVPaint was launched in" + "context of different project. Current context project is" + " \"{}\". Launch TVPaint in context of project \"{}\"" + " and then publish." + ).format( + workfile_project_name, + env_project_name, + workfile_project_name, + ), + formatting_data={ + "workfile_project_name": workfile_project_name, + "expected_project_name": env_project_name + } + ) From 9221d47b060695debe4bd39e226bcfdbe1dbe41f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 15:53:47 +0100 Subject: [PATCH 037/483] fix used exception --- .../tvpaint/plugins/publish/validate_workfile_project_name.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_workfile_project_name.py b/openpype/hosts/tvpaint/plugins/publish/validate_workfile_project_name.py index 36230ae38b..0f25f2f7be 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_workfile_project_name.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_workfile_project_name.py @@ -32,7 +32,7 @@ class ValidateWorkfileProjectName(pyblish.api.ContextPlugin): return # Raise an error - raise AssertionError( + raise PublishXmlValidationError( self, ( # Short message From 99feae84f28926a175eea31e1006705704314835 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 16:00:03 +0100 Subject: [PATCH 038/483] make sure all previous widget stay hidden --- openpype/tools/publisher/widgets/validations_widget.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index ba4df2eb8e..90bb4b062b 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -272,6 +272,7 @@ class ValidateActionsWidget(QtWidgets.QFrame): item = self._content_layout.takeAt(0) widget = item.widget() if widget: + widget.setVisible(False) widget.deleteLater() self._actions_mapping = {} From f6a7d3f51ab8b8f275615c4ac277c6813891a898 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 16:00:15 +0100 Subject: [PATCH 039/483] action icon is optional --- openpype/tools/publisher/widgets/validations_widget.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index 90bb4b062b..003d78fa56 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -226,13 +226,15 @@ class ActionButton(ClickableFrame): action_label = action.label or action.__name__ action_icon = getattr(action, "icon", None) label_widget = QtWidgets.QLabel(action_label, self) + icon_label = None if action_icon: icon_label = IconValuePixmapLabel(action_icon, self) layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(5, 0, 5, 0) layout.addWidget(label_widget, 1) - layout.addWidget(icon_label, 0) + if icon_label is not None: + layout.addWidget(icon_label, 0) self.setSizePolicy( QtWidgets.QSizePolicy.Minimum, From 2813317a17587a0d290547ec68d2af98132de5ba Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 20 Dec 2021 19:25:20 +0100 Subject: [PATCH 040/483] remove check of attr_plugins --- openpype/pipeline/create/context.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 7b0f50b1dc..2d748dd74f 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -306,8 +306,6 @@ class PublishAttributes: self._plugin_names_order = [] self._missing_plugins = [] self.attr_plugins = attr_plugins or [] - if not attr_plugins: - return origin_data = self._origin_data data = self._data From 43b5cc802d73c0af908216b674a8c214972c944a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 16:12:21 +0100 Subject: [PATCH 041/483] Change widget to Frame to make sure it has background with PySide --- openpype/tools/publisher/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index bb58813e55..b83c491c95 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -79,7 +79,7 @@ class PublisherWindow(QtWidgets.QDialog): # Content # Subset widget - subset_frame = QtWidgets.QWidget(self) + subset_frame = QtWidgets.QFrame(self) subset_views_widget = BorderedLabelWidget( "Subsets to publish", subset_frame From 3e683eadd0f5f133d99499bb0ce4a92935ad51eb Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 16:15:31 +0100 Subject: [PATCH 042/483] changed validation widget to frame to make sure it has background --- .../tools/publisher/widgets/validations_widget.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index 003d78fa56..c9e5283d5f 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -414,8 +414,8 @@ class ValidationsWidget(QtWidgets.QWidget): errors_scroll.setWidget(errors_widget) - error_details_widget = QtWidgets.QWidget(self) - error_details_input = QtWidgets.QTextEdit(error_details_widget) + error_details_frame = QtWidgets.QFrame(self) + error_details_input = QtWidgets.QTextEdit(error_details_frame) error_details_input.setObjectName("InfoText") error_details_input.setTextInteractionFlags( QtCore.Qt.TextBrowserInteraction @@ -424,7 +424,7 @@ class ValidationsWidget(QtWidgets.QWidget): actions_widget = ValidateActionsWidget(controller, self) actions_widget.setFixedWidth(140) - error_details_layout = QtWidgets.QHBoxLayout(error_details_widget) + error_details_layout = QtWidgets.QHBoxLayout(error_details_frame) error_details_layout.addWidget(error_details_input, 1) error_details_layout.addWidget(actions_widget, 0) @@ -433,7 +433,7 @@ class ValidationsWidget(QtWidgets.QWidget): content_layout.setContentsMargins(0, 0, 0, 0) content_layout.addWidget(errors_scroll, 0) - content_layout.addWidget(error_details_widget, 1) + content_layout.addWidget(error_details_frame, 1) top_label = QtWidgets.QLabel("Publish validation report", self) top_label.setObjectName("PublishInfoMainLabel") @@ -447,7 +447,7 @@ class ValidationsWidget(QtWidgets.QWidget): self._top_label = top_label self._errors_widget = errors_widget self._errors_layout = errors_layout - self._error_details_widget = error_details_widget + self._error_details_frame = error_details_frame self._error_details_input = error_details_input self._actions_widget = actions_widget @@ -467,7 +467,7 @@ class ValidationsWidget(QtWidgets.QWidget): widget.deleteLater() self._top_label.setVisible(False) - self._error_details_widget.setVisible(False) + self._error_details_frame.setVisible(False) self._errors_widget.setVisible(False) self._actions_widget.setVisible(False) @@ -478,7 +478,7 @@ class ValidationsWidget(QtWidgets.QWidget): return self._top_label.setVisible(True) - self._error_details_widget.setVisible(True) + self._error_details_frame.setVisible(True) self._errors_widget.setVisible(True) errors_by_title = [] From 8dff901f8d810b9f8363dbca7053e94788d741fd Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 22 Dec 2021 16:41:52 +0000 Subject: [PATCH 043/483] more xml validator messages --- ..._settings.xml => validate_frame_token.xml} | 12 +-- .../publish/help/validate_vdb_input_node.xml | 22 ------ .../publish/help/validate_vdb_output_node.xml | 48 ++++++++++++ .../publish/validate_animation_settings.py | 51 ------------ .../plugins/publish/validate_frame_token.py | 17 ++-- .../plugins/publish/validate_output_node.py | 77 ------------------- .../publish/validate_sop_output_node.py | 2 +- .../publish/validate_vdb_input_node.py | 47 ----------- .../publish/validate_vdb_output_node.py | 66 +++++++++++----- 9 files changed, 113 insertions(+), 229 deletions(-) rename openpype/hosts/houdini/plugins/publish/help/{validate_animation_settings.xml => validate_frame_token.xml} (74%) delete mode 100644 openpype/hosts/houdini/plugins/publish/help/validate_vdb_input_node.xml create mode 100644 openpype/hosts/houdini/plugins/publish/help/validate_vdb_output_node.xml delete mode 100644 openpype/hosts/houdini/plugins/publish/validate_animation_settings.py delete mode 100644 openpype/hosts/houdini/plugins/publish/validate_output_node.py delete mode 100644 openpype/hosts/houdini/plugins/publish/validate_vdb_input_node.py diff --git a/openpype/hosts/houdini/plugins/publish/help/validate_animation_settings.xml b/openpype/hosts/houdini/plugins/publish/help/validate_frame_token.xml similarity index 74% rename from openpype/hosts/houdini/plugins/publish/help/validate_animation_settings.xml rename to openpype/hosts/houdini/plugins/publish/help/validate_frame_token.xml index 8a2a396783..925113362a 100644 --- a/openpype/hosts/houdini/plugins/publish/help/validate_animation_settings.xml +++ b/openpype/hosts/houdini/plugins/publish/help/validate_frame_token.xml @@ -1,22 +1,22 @@ -Frame token in output -## Frame range is missing frame token +Output frame token +## Output path is missing frame token This validator will check the output parameter of the node if the Valid Frame Range is not set to 'Render Current Frame' -No frame token found in {nodepath} +No frame token found in: **{nodepath}** ### How to repair? -Your you need to add `$F4` or similar frame based token to your path. + +You need to add `$F4` or similar frame based token to your path. + **Example:** Good: 'my_vbd_cache.$F4.vdb' Bad: 'my_vbd_cache.vdb' - - diff --git a/openpype/hosts/houdini/plugins/publish/help/validate_vdb_input_node.xml b/openpype/hosts/houdini/plugins/publish/help/validate_vdb_input_node.xml deleted file mode 100644 index 8cc186a183..0000000000 --- a/openpype/hosts/houdini/plugins/publish/help/validate_vdb_input_node.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - -VDB input node -## Invalid VDB input node - -Validate that the node connected to the output node is of type VDB. - -Regardless of the amount of VDBs created the output will need to have an -equal amount of VDBs, points, primitives and vertices - -A VDB is an inherited type of Prim, holds the following data: - - Primitives: 1 - - Points: 1 - - Vertices: 1 - - VDBs: 1 - - - - - - \ No newline at end of file diff --git a/openpype/hosts/houdini/plugins/publish/help/validate_vdb_output_node.xml b/openpype/hosts/houdini/plugins/publish/help/validate_vdb_output_node.xml new file mode 100644 index 0000000000..822d1836c1 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/help/validate_vdb_output_node.xml @@ -0,0 +1,48 @@ + + + +VDB output node +## Invalid VDB output nodes + +Validate that the node connected to the output node is of type VDB. + +Regardless of the amount of VDBs created the output will need to have an +equal amount of VDBs, points, primitives and vertices + +A VDB is an inherited type of Prim, holds the following data: + +- Primitives: 1 +- Points: 1 +- Vertices: 1 +- VDBs: 1 + + + + + + + +No SOP path +## No SOP Path in output node + +SOP Output node in '{node}' does not exist. Ensure a valid SOP output path is set. + + + + + + + +Wrong SOP path +## Wrong SOP Path in output node + +Output node {nodepath} is not a SOP node. +SOP Path must point to a SOP node, +instead found category type: {categoryname} + + + + + + + \ No newline at end of file diff --git a/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py b/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py deleted file mode 100644 index 5eb8f93d03..0000000000 --- a/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py +++ /dev/null @@ -1,51 +0,0 @@ -import pyblish.api - -from openpype.hosts.houdini.api import lib - - -class ValidateAnimationSettings(pyblish.api.InstancePlugin): - """Validate if the unexpanded string contains the frame ('$F') token - - This validator will only check the output parameter of the node if - the Valid Frame Range is not set to 'Render Current Frame' - - Rules: - If you render out a frame range it is mandatory to have the - frame token - '$F4' or similar - to ensure that each frame gets - written. If this is not the case you will override the same file - every time a frame is written out. - - Examples: - Good: 'my_vbd_cache.$F4.vdb' - Bad: 'my_vbd_cache.vdb' - - """ - - order = pyblish.api.ValidatorOrder - label = "Validate Frame Settings" - families = ["vdbcache"] - - def process(self, instance): - - invalid = self.get_invalid(instance) - if invalid: - raise RuntimeError( - "Output settings do no match for '%s'" % instance - ) - - @classmethod - def get_invalid(cls, instance): - - node = instance[0] - - # Check trange parm, 0 means Render Current Frame - frame_range = node.evalParm("trange") - if frame_range == 0: - return [] - - output_parm = lib.get_output_parameter(node) - unexpanded_str = output_parm.unexpandedString() - - if "$F" not in unexpanded_str: - cls.log.error("No frame token found in '%s'" % node.path()) - return [instance] diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_token.py b/openpype/hosts/houdini/plugins/publish/validate_frame_token.py index 76b5910576..f66238f159 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_token.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_token.py @@ -1,12 +1,12 @@ import pyblish.api from openpype.hosts.houdini.api import lib - +from openpype.pipeline import PublishXmlValidationError class ValidateFrameToken(pyblish.api.InstancePlugin): - """Validate if the unexpanded string contains the frame ('$F') token. + """Validate if the unexpanded string contains the frame ('$F') token - This validator will *only* check the output parameter of the node if + This validator will only check the output parameter of the node if the Valid Frame Range is not set to 'Render Current Frame' Rules: @@ -28,9 +28,14 @@ class ValidateFrameToken(pyblish.api.InstancePlugin): def process(self, instance): invalid = self.get_invalid(instance) + data = { + "nodepath": instance + } if invalid: - raise RuntimeError( - "Output settings do no match for '%s'" % instance + raise PublishXmlValidationError( + self, + "Output path for '%s' is missing $F4 token" % instance, + formatting_data=data ) @classmethod @@ -47,5 +52,5 @@ class ValidateFrameToken(pyblish.api.InstancePlugin): unexpanded_str = output_parm.unexpandedString() if "$F" not in unexpanded_str: - cls.log.error("No frame token found in '%s'" % node.path()) + # cls.log.info("No frame token found in '%s'" % node.path()) return [instance] diff --git a/openpype/hosts/houdini/plugins/publish/validate_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_output_node.py deleted file mode 100644 index 0b60ab5c48..0000000000 --- a/openpype/hosts/houdini/plugins/publish/validate_output_node.py +++ /dev/null @@ -1,77 +0,0 @@ -import pyblish.api - - -class ValidateOutputNode(pyblish.api.InstancePlugin): - """Validate the instance SOP Output Node. - - This will ensure: - - The SOP Path is set. - - The SOP Path refers to an existing object. - - The SOP Path node is a SOP node. - - The SOP Path node has at least one input connection (has an input) - - The SOP Path has geometry data. - - """ - - order = pyblish.api.ValidatorOrder - families = ["pointcache", "vdbcache"] - hosts = ["houdini"] - label = "Validate Output Node" - - def process(self, instance): - - invalid = self.get_invalid(instance) - if invalid: - raise RuntimeError( - "Output node(s) `%s` are incorrect. " - "See plug-in log for details." % invalid - ) - - @classmethod - def get_invalid(cls, instance): - - import hou - - output_node = instance.data["output_node"] - - if output_node is None: - node = instance[0] - cls.log.error( - "SOP Output node in '%s' does not exist. " - "Ensure a valid SOP output path is set." % node.path() - ) - - return [node.path()] - - # Output node must be a Sop node. - if not isinstance(output_node, hou.SopNode): - cls.log.error( - "Output node %s is not a SOP node. " - "SOP Path must point to a SOP node, " - "instead found category type: %s" - % (output_node.path(), output_node.type().category().name()) - ) - return [output_node.path()] - - # For the sake of completeness also assert the category type - # is Sop to avoid potential edge case scenarios even though - # the isinstance check above should be stricter than this category - assert output_node.type().category().name() == "Sop", ( - "Output node %s is not of category Sop. This is a bug.." - % output_node.path() - ) - - # Check if output node has incoming connections - if not output_node.inputConnections(): - cls.log.error( - "Output node `%s` has no incoming connections" - % output_node.path() - ) - return [output_node.path()] - - # Ensure the output node has at least Geometry data - if not output_node.geometry(): - cls.log.error( - "Output node `%s` has no geometry data." % output_node.path() - ) - return [output_node.path()] diff --git a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py index a5a07b1b1a..a37d376919 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py @@ -14,7 +14,7 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin): """ order = pyblish.api.ValidatorOrder - families = ["pointcache", "vdbcache"] + families = ["pointcache"] hosts = ["houdini"] label = "Validate Output Node" diff --git a/openpype/hosts/houdini/plugins/publish/validate_vdb_input_node.py b/openpype/hosts/houdini/plugins/publish/validate_vdb_input_node.py deleted file mode 100644 index 0ae1bc94eb..0000000000 --- a/openpype/hosts/houdini/plugins/publish/validate_vdb_input_node.py +++ /dev/null @@ -1,47 +0,0 @@ -import pyblish.api -import openpype.api - - -class ValidateVDBInputNode(pyblish.api.InstancePlugin): - """Validate that the node connected to the output node is of type VDB. - - Regardless of the amount of VDBs create the output will need to have an - equal amount of VDBs, points, primitives and vertices - - A VDB is an inherited type of Prim, holds the following data: - - Primitives: 1 - - Points: 1 - - Vertices: 1 - - VDBs: 1 - - """ - - order = openpype.api.ValidateContentsOrder + 0.1 - families = ["vdbcache"] - hosts = ["houdini"] - label = "Validate Input Node (VDB)" - - def process(self, instance): - invalid = self.get_invalid(instance) - if invalid: - raise RuntimeError( - "Node connected to the output node is not" "of type VDB!" - ) - - @classmethod - def get_invalid(cls, instance): - - node = instance.data["output_node"] - - prims = node.geometry().prims() - nr_of_prims = len(prims) - - nr_of_points = len(node.geometry().points()) - if nr_of_points != nr_of_prims: - cls.log.error("The number of primitives and points do not match") - return [instance] - - for prim in prims: - if prim.numVertices() != 1: - cls.log.error("Found primitive with more than 1 vertex!") - return [instance] diff --git a/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py index 1ba840b71d..f6e54f3ae2 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py @@ -1,8 +1,7 @@ import pyblish.api import openpype.api +from openpype.pipeline import PublishXmlValidationError import hou - - class ValidateVDBOutputNode(pyblish.api.InstancePlugin): """Validate that the node connected to the output node is of type VDB. @@ -23,32 +22,61 @@ class ValidateVDBOutputNode(pyblish.api.InstancePlugin): label = "Validate Output Node (VDB)" def process(self, instance): + + data = { + "node": instance + } + + output_node = instance.data["output_node"] + if output_node is None: + raise PublishXmlValidationError( + self, + "SOP Output node in '{node}' does not exist. Ensure a valid " + "SOP output path is set.".format(**data), + key="noSOP", + formatting_data=data + ) + + # Output node must be a Sop node. + if not isinstance(output_node, hou.SopNode): + data = { + "nodepath": output_node.path(), + "categoryname": output_node.type().category().name() + } + raise PublishXmlValidationError( + self, + "Output node {nodepath} is not a SOP node. SOP Path must" + "point to a SOP node, instead found category" + "type: {categoryname}".format(**data), + key="wrongSOP", + formatting_data=data + ) + return [node.path()] + invalid = self.get_invalid(instance) + if invalid: - raise RuntimeError( - "Node connected to the output node is not" " of type VDB!" + raise PublishXmlValidationError( + self, + "Output node(s) `{}` are incorrect. See plug-in" + "log for details.".format(invalid), + formatting_data=data ) @classmethod def get_invalid(cls, instance): - node = instance.data["output_node"] - if node is None: - cls.log.error( - "SOP path is not correctly set on " - "ROP node '%s'." % instance[0].path() - ) - return [instance] + output_node = instance.data["output_node"] frame = instance.data.get("frameStart", 0) - geometry = node.geometryAtFrame(frame) + geometry = output_node.geometryAtFrame(frame) if geometry is None: - # No geometry data on this node, maybe the node hasn't cooked? - cls.log.error( + # No geometry data on this output_node, maybe the node hasn't cooked? + cls.log.debug( "SOP node has no geometry data. " - "Is it cooked? %s" % node.path() + "Is it cooked? %s" % output_node.path() ) - return [node] + return [output_node] prims = geometry.prims() nr_of_prims = len(prims) @@ -57,17 +85,17 @@ class ValidateVDBOutputNode(pyblish.api.InstancePlugin): invalid_prim = False for prim in prims: if not isinstance(prim, hou.VDB): - cls.log.error("Found non-VDB primitive: %s" % prim) + cls.log.debug("Found non-VDB primitive: %s" % prim) invalid_prim = True if invalid_prim: return [instance] nr_of_points = len(geometry.points()) if nr_of_points != nr_of_prims: - cls.log.error("The number of primitives and points do not match") + cls.log.debug("The number of primitives and points do not match") return [instance] for prim in prims: if prim.numVertices() != 1: - cls.log.error("Found primitive with more than 1 vertex!") + cls.log.debug("Found primitive with more than 1 vertex!") return [instance] From df717bfb8c038bec50f4a949c9f9c2b356ca7a41 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 22 Dec 2021 18:53:25 +0100 Subject: [PATCH 044/483] Update openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml Co-authored-by: Milan Kolar --- .../tvpaint/plugins/publish/help/validate_layers_visibility.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml b/openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml index fc69d5fd7b..2eaed22a19 100644 --- a/openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml +++ b/openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml @@ -10,7 +10,7 @@ All layers for subset "{instance_name}" are hidden. {layer_names} -*Check layer names for all subsets in list on left side.* +*Check layer names for all subsets in the list on the left side.* ### How to repair? From f06bfd7c861c32ac41fd63bb86e9b5bf3c68f19b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 19:34:02 +0100 Subject: [PATCH 045/483] changed fixed width to min width --- openpype/tools/publisher/widgets/validations_widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index c9e5283d5f..fc78f93856 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -407,7 +407,7 @@ class ValidationsWidget(QtWidgets.QWidget): errors_scroll.setWidgetResizable(True) errors_widget = QtWidgets.QWidget(errors_scroll) - errors_widget.setFixedWidth(200) + errors_widget.setMinimumWidth(200) errors_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) errors_layout = QtWidgets.QVBoxLayout(errors_widget) errors_layout.setContentsMargins(0, 0, 0, 0) @@ -422,7 +422,7 @@ class ValidationsWidget(QtWidgets.QWidget): ) actions_widget = ValidateActionsWidget(controller, self) - actions_widget.setFixedWidth(140) + actions_widget.setMinimumWidth(140) error_details_layout = QtWidgets.QHBoxLayout(error_details_frame) error_details_layout.addWidget(error_details_input, 1) From ca5f1dbcba0c7d93c0840cf4d9bf0ca941534f5d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 21:15:20 +0100 Subject: [PATCH 046/483] make sure items has label in tooltip --- openpype/tools/publisher/widgets/validations_widget.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index fc78f93856..028e6a2ea3 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -95,6 +95,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): item.setFlags( QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable ) + item.setData(label, QtCore.Qt.ToolTipRole) item.setData(instance.id, INSTANCE_ID_ROLE) items.append(item) description = self._prepare_description(exception) From 197a5054cd8463892ffa3bb3f2de7931d4f101ec Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 21:15:30 +0100 Subject: [PATCH 047/483] disable horizontal scroll --- openpype/tools/publisher/widgets/validations_widget.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index 028e6a2ea3..a3f2c2069a 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -25,6 +25,7 @@ class ValidationErrorInstanceList(QtWidgets.QListView): self.setObjectName("ValidationErrorInstanceList") + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setSelectionMode(QtWidgets.QListView.ExtendedSelection) def minimumSizeHint(self): From 64bb579c816d746a67a68322fd2c3a4f642e468d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 22 Dec 2021 21:39:44 +0100 Subject: [PATCH 048/483] resize title by longest instance name --- .../publisher/widgets/validations_widget.py | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index a3f2c2069a..c013b0b2e0 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -29,16 +29,16 @@ class ValidationErrorInstanceList(QtWidgets.QListView): self.setSelectionMode(QtWidgets.QListView.ExtendedSelection) def minimumSizeHint(self): - result = super(ValidationErrorInstanceList, self).minimumSizeHint() - result.setHeight(self.sizeHint().height()) - return result + return self.sizeHint() def sizeHint(self): + result = super(ValidationErrorInstanceList, self).sizeHint() row_count = self.model().rowCount() height = 0 if row_count > 0: height = self.sizeHintForRow(0) * row_count - return QtCore.QSize(self.width(), height) + result.setHeight(height) + return result class ValidationErrorTitleWidget(QtWidgets.QWidget): @@ -133,12 +133,30 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): self._toggle_instance_btn = toggle_instance_btn + self._view_layout = view_layout + self._instances_model = instances_model self._instances_view = instances_view self._context_validation = context_validation self._help_text_by_instance_id = help_text_by_instance_id + def sizeHint(self): + result = super().sizeHint() + expected_width = 0 + for idx in range(self._view_layout.count()): + expected_width += self._view_layout.itemAt(idx).sizeHint().width() + + if expected_width < 200: + expected_width = 200 + + if result.width() < expected_width: + result.setWidth(expected_width) + return result + + def minimumSizeHint(self): + return self.sizeHint() + def _prepare_description(self, exception): dsc = exception.description detail = exception.detail @@ -409,7 +427,6 @@ class ValidationsWidget(QtWidgets.QWidget): errors_scroll.setWidgetResizable(True) errors_widget = QtWidgets.QWidget(errors_scroll) - errors_widget.setMinimumWidth(200) errors_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) errors_layout = QtWidgets.QVBoxLayout(errors_widget) errors_layout.setContentsMargins(0, 0, 0, 0) @@ -518,6 +535,8 @@ class ValidationsWidget(QtWidgets.QWidget): if self._title_widgets: self._title_widgets[0].set_selected(True) + self.updateGeometry() + def _on_select(self, index): if self._previous_select: if self._previous_select.index == index: From 7c6d63f8930a6338b194ae339012c532914ca5d3 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 23 Dec 2021 12:38:00 +0100 Subject: [PATCH 049/483] Added new style validators for New Publisher for Standalone Publisher --- .../publish/help/validate_frame_ranges.xml | 15 ++++++++ .../publish/help/validate_shot_duplicates.xml | 15 ++++++++ .../plugins/publish/help/validate_sources.xml | 16 +++++++++ .../publish/help/validate_task_existence.xml | 16 +++++++++ .../publish/help/validate_texture_batch.xml | 15 ++++++++ .../help/validate_texture_has_workfile.xml | 15 ++++++++ .../publish/help/validate_texture_name.xml | 32 +++++++++++++++++ .../help/validate_texture_versions.xml | 35 +++++++++++++++++++ .../help/validate_texture_workfiles.xml | 23 ++++++++++++ .../plugins/publish/validate_frame_ranges.py | 18 +++++++--- .../publish/validate_shot_duplicates.py | 9 +++-- .../plugins/publish/validate_sources.py | 18 +++++++--- .../publish/validate_task_existence.py | 9 ++++- .../plugins/publish/validate_texture_batch.py | 8 +++-- .../publish/validate_texture_has_workfile.py | 6 +++- .../plugins/publish/validate_texture_name.py | 21 ++++++++--- .../publish/validate_texture_versions.py | 15 ++++++-- .../publish/validate_texture_workfiles.py | 17 ++++++--- 18 files changed, 275 insertions(+), 28 deletions(-) create mode 100644 openpype/hosts/standalonepublisher/plugins/publish/help/validate_frame_ranges.xml create mode 100644 openpype/hosts/standalonepublisher/plugins/publish/help/validate_shot_duplicates.xml create mode 100644 openpype/hosts/standalonepublisher/plugins/publish/help/validate_sources.xml create mode 100644 openpype/hosts/standalonepublisher/plugins/publish/help/validate_task_existence.xml create mode 100644 openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_batch.xml create mode 100644 openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_has_workfile.xml create mode 100644 openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_name.xml create mode 100644 openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_versions.xml create mode 100644 openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_workfiles.xml diff --git a/openpype/hosts/standalonepublisher/plugins/publish/help/validate_frame_ranges.xml b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_frame_ranges.xml new file mode 100644 index 0000000000..933df1c7c5 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_frame_ranges.xml @@ -0,0 +1,15 @@ + + + +Invalid frame range + +## Invalid frame range + +Expected duration or '{duration}' frames set in database, workfile contains only '{found}' frames. + +### How to repair? + +Modify configuration in the database or tweak frame range in the workfile. + + + \ No newline at end of file diff --git a/openpype/hosts/standalonepublisher/plugins/publish/help/validate_shot_duplicates.xml b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_shot_duplicates.xml new file mode 100644 index 0000000000..77b8727162 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_shot_duplicates.xml @@ -0,0 +1,15 @@ + + + +Duplicate shots + +## Duplicate shot names + +Process contains duplicated shot names '{duplicates_str}'. + +### How to repair? + +Remove shot duplicates. + + + \ No newline at end of file diff --git a/openpype/hosts/standalonepublisher/plugins/publish/help/validate_sources.xml b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_sources.xml new file mode 100644 index 0000000000..d527d2173e --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_sources.xml @@ -0,0 +1,16 @@ + + + +Files not found + +## Source files not found + +Process contains duplicated shot names: +'{files_not_found}' + +### How to repair? + +Add missing files or run Publish again to collect new publishable files. + + + \ No newline at end of file diff --git a/openpype/hosts/standalonepublisher/plugins/publish/help/validate_task_existence.xml b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_task_existence.xml new file mode 100644 index 0000000000..a943f560d0 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_task_existence.xml @@ -0,0 +1,16 @@ + + + +Task not found + +## Task not found in database + +Process contains tasks that don't exist in database: +'{task_not_found}' + +### How to repair? + +Remove set task or add task into database into proper place. + + + \ No newline at end of file diff --git a/openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_batch.xml b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_batch.xml new file mode 100644 index 0000000000..a645df8d02 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_batch.xml @@ -0,0 +1,15 @@ + + + +No texture files found + +## Batch doesn't contain texture files + +Batch must contain at least one texture file. + +### How to repair? + +Add texture file to the batch or check name if it follows naming convention to match texture files to the batch. + + + \ No newline at end of file diff --git a/openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_has_workfile.xml b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_has_workfile.xml new file mode 100644 index 0000000000..077987a96d --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_has_workfile.xml @@ -0,0 +1,15 @@ + + + +No workfile found + +## Batch should contain workfile + +It is expected that published contains workfile that served as a source for textures. + +### How to repair? + +Add workfile to the batch, or disable this validator if you do not want workfile published. + + + \ No newline at end of file diff --git a/openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_name.xml b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_name.xml new file mode 100644 index 0000000000..2610917736 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_name.xml @@ -0,0 +1,32 @@ + + + +Asset name not found + +## Couldn't parse asset name from a file + +Unable to parse asset name from '{file_name}'. File name doesn't match configured naming convention. + +### How to repair? + +Check Settings: project_settings/standalonepublisher/publish/CollectTextures for naming convention. + + +### __Detailed Info__ (optional) + +This error happens when parsing cannot figure out name of asset texture files belong under. + + + +Missing keys + +## Texture file name is missing some required keys + +Texture '{file_name}' is missing values for {missing_str} keys. + +### How to repair? + +Fix name of texture file and Publish again. + + + diff --git a/openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_versions.xml b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_versions.xml new file mode 100644 index 0000000000..1e536e604f --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_versions.xml @@ -0,0 +1,35 @@ + + + +Texture version + +## Texture version mismatch with workfile + +Workfile '{file_name}' version doesn't match with '{version}' of a texture. + +### How to repair? + +Rename either workfile or texture to contain matching versions + + +### __Detailed Info__ (optional) + +This might happen if you are trying to publish textures for older version of workfile (or the other way). +(Eg. publishing 'workfile_v001' and 'texture_file_v002') + + + +Too many versions + +## Too many versions published at same time + +It is currently expected to publish only batch with single version. + +Found {found} versions. + +### How to repair? + +Please remove files with different version and split publishing into multiple steps. + + + diff --git a/openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_workfiles.xml b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_workfiles.xml new file mode 100644 index 0000000000..8187eb0bc8 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_workfiles.xml @@ -0,0 +1,23 @@ + + + +No secondary workfile + +## No secondary workfile found + +Current process expects that primary workfile (for example with a extension '{extension}') will contain also 'secondary' workfile. + +Secondary workfile for '{file_name}' wasn't found. + +### How to repair? + +Attach secondary workfile or disable this validator and Publish again. + + +### __Detailed Info__ (optional) + +This process was implemented for a possible use case of first workfile coming from Mari, secondary workfile for textures from Substance. +Publish should contain both if primary workfile is present. + + + diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_frame_ranges.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_frame_ranges.py index 943cb73b98..c7a2e755b6 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_frame_ranges.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_frame_ranges.py @@ -1,8 +1,10 @@ import re import pyblish.api + import openpype.api from openpype import lib +from openpype.pipeline import PublishXmlValidationError class ValidateFrameRange(pyblish.api.InstancePlugin): @@ -48,9 +50,15 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): files = [files] frames = len(files) - err_msg = "Frame duration from DB:'{}' ". format(int(duration)) +\ - " doesn't match number of files:'{}'".format(frames) +\ - " Please change frame range for Asset or limit no. of files" - assert frames == duration, err_msg + msg = "Frame duration from DB:'{}' ". format(int(duration)) +\ + " doesn't match number of files:'{}'".format(frames) +\ + " Please change frame range for Asset or limit no. of files" - self.log.debug("Valid ranges {} - {}".format(int(duration), frames)) + formatting_data = {"duration": duration, + "found": frames} + if frames == duration: + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data) + + self.log.debug("Valid ranges expected '{}' - found '{}'". + format(int(duration), frames)) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_shot_duplicates.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_shot_duplicates.py index 85ec9379ce..0f957acad6 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_shot_duplicates.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_shot_duplicates.py @@ -1,6 +1,7 @@ import pyblish.api -import openpype.api +import openpype.api +from openpype.pipeline import PublishXmlValidationError class ValidateShotDuplicates(pyblish.api.ContextPlugin): """Validating no duplicate names are in context.""" @@ -20,4 +21,8 @@ class ValidateShotDuplicates(pyblish.api.ContextPlugin): shot_names.append(name) msg = "There are duplicate shot names:\n{}".format(duplicate_names) - assert not duplicate_names, msg + + formatting_data = {"duplicate_str": ','.join(duplicate_names)} + if duplicate_names: + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py index eec675e97f..316f58988f 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py @@ -1,8 +1,10 @@ -import pyblish.api -import openpype.api - import os +import pyblish.api + +import openpype.api +from openpype.pipeline import PublishXmlValidationError + class ValidateSources(pyblish.api.InstancePlugin): """Validates source files. @@ -11,7 +13,6 @@ class ValidateSources(pyblish.api.InstancePlugin): got deleted between starting of SP and now. """ - order = openpype.api.ValidateContentsOrder label = "Check source files" @@ -22,6 +23,7 @@ class ValidateSources(pyblish.api.InstancePlugin): def process(self, instance): self.log.info("instance {}".format(instance.data)) + missing_files = set() for repre in instance.data.get("representations") or []: files = [] if isinstance(repre["files"], str): @@ -34,4 +36,10 @@ class ValidateSources(pyblish.api.InstancePlugin): file_name) if not os.path.exists(source_file): - raise ValueError("File {} not found".format(source_file)) + missing_files.add(source_file) + + msg = "Files '{}' not found".format(','.join(missing_files)) + formatting_data = {"files_not_found": ' - {}'.join(missing_files)} + if missing_files: + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_task_existence.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_task_existence.py index e3b2ae1646..825092c81b 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_task_existence.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_task_existence.py @@ -1,6 +1,8 @@ import pyblish.api from avalon import io +from openpype.pipeline import PublishXmlValidationError + class ValidateTaskExistence(pyblish.api.ContextPlugin): """Validating tasks on instances are filled and existing.""" @@ -53,4 +55,9 @@ class ValidateTaskExistence(pyblish.api.ContextPlugin): "Asset: \"{}\" Task: \"{}\"".format(*missing_pair) ) - raise AssertionError(msg.format("\n".join(pair_msgs))) + msg = msg.format("\n".join(pair_msgs)) + + formatting_data = {"task_not_found": ' - {}'.join(pair_msgs)} + if pair_msgs: + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py index d592a4a059..d66fb257bb 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py @@ -1,6 +1,8 @@ import pyblish.api import openpype.api +from openpype.pipeline import PublishXmlValidationError + class ValidateTextureBatch(pyblish.api.InstancePlugin): """Validates that some texture files are present.""" @@ -15,8 +17,10 @@ class ValidateTextureBatch(pyblish.api.InstancePlugin): present = False for instance in instance.context: if instance.data["family"] == "textures": - self.log.info("Some textures present.") + self.log.info("At least some textures present.") return - assert present, "No textures found in published batch!" + msg = "No textures found in published batch!" + if not present: + raise PublishXmlValidationError(self, msg) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_has_workfile.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_has_workfile.py index 7cd540668c..0e67464f59 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_has_workfile.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_has_workfile.py @@ -1,5 +1,7 @@ import pyblish.api + import openpype.api +from openpype.pipeline import PublishXmlValidationError class ValidateTextureHasWorkfile(pyblish.api.InstancePlugin): @@ -17,4 +19,6 @@ class ValidateTextureHasWorkfile(pyblish.api.InstancePlugin): def process(self, instance): wfile = instance.data["versionData"].get("workfile") - assert wfile, "Textures are missing attached workfile" + msg = "Textures are missing attached workfile" + if not wfile: + raise PublishXmlValidationError(self, msg) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py index f210be3631..751ad917ca 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py @@ -1,6 +1,7 @@ import pyblish.api -import openpype.api +import openpype.api +from openpype.pipeline import PublishXmlValidationError class ValidateTextureBatchNaming(pyblish.api.InstancePlugin): """Validates that all instances had properly formatted name.""" @@ -16,12 +17,16 @@ class ValidateTextureBatchNaming(pyblish.api.InstancePlugin): if isinstance(file_name, list): file_name = file_name[0] - msg = "Couldnt find asset name in '{}'\n".format(file_name) + \ + msg = "Couldn't find asset name in '{}'\n".format(file_name) + \ "File name doesn't follow configured pattern.\n" + \ "Please rename the file." - assert "NOT_AVAIL" not in instance.data["asset_build"], msg - instance.data.pop("asset_build") + formatting_data = {"file_name": file_name} + if "NOT_AVAIL" in instance.data["asset_build"]: + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data) + + instance.data.pop("asset_build") # not needed anymore if instance.data["family"] == "textures": file_name = instance.data["representations"][0]["files"][0] @@ -47,4 +52,10 @@ class ValidateTextureBatchNaming(pyblish.api.InstancePlugin): "Name of the texture file doesn't match expected pattern.\n" + \ "Please rename file(s) {}".format(file_name) - assert not missing_key_values, msg + missing_str = ','.join(["'{}'".format(key) + for key in missing_key_values]) + formatting_data = {"file_name": file_name, + "missing_str": missing_str} + if missing_key_values: + raise PublishXmlValidationError(self, msg, key="missing_values", + formatting_data=formatting_data) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_versions.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_versions.py index 90d0e8e512..84d9def895 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_versions.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_versions.py @@ -1,5 +1,7 @@ import pyblish.api + import openpype.api +from openpype.pipeline import PublishXmlValidationError class ValidateTextureBatchVersions(pyblish.api.InstancePlugin): @@ -25,14 +27,21 @@ class ValidateTextureBatchVersions(pyblish.api.InstancePlugin): self.log.info("No workfile present for textures") return - msg = "Not matching version: texture v{:03d} - workfile {}" - assert version_str in wfile, \ + if version_str not in wfile: + msg = "Not matching version: texture v{:03d} - workfile {}" msg.format( instance.data["version"], wfile ) + raise PublishXmlValidationError(self, msg) present_versions = set() for instance in instance.context: present_versions.add(instance.data["version"]) - assert len(present_versions) == 1, "Too many versions in a batch!" + if len(present_versions) != 1: + msg = "Too many versions in a batch!" + found = ','.join(["'{}'".format(val) for val in present_versions]) + formatting_data = {"found": found} + + raise PublishXmlValidationError(self, msg, key="too_many", + formatting_data=formatting_data) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py index 25bb5aea4a..fa492a80d8 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py @@ -1,11 +1,13 @@ import pyblish.api + import openpype.api +from openpype.pipeline import PublishXmlValidationError class ValidateTextureBatchWorkfiles(pyblish.api.InstancePlugin): """Validates that textures workfile has collected resources (optional). - Collected recourses means secondary workfiles (in most cases). + Collected resources means secondary workfiles (in most cases). """ label = "Validate Texture Workfile Has Resources" @@ -24,6 +26,13 @@ class ValidateTextureBatchWorkfiles(pyblish.api.InstancePlugin): self.log.warning("Only secondary workfile present!") return - msg = "No secondary workfiles present for workfile {}".\ - format(instance.data["name"]) - assert instance.data.get("resources"), msg + if not instance.data.get("resources"): + msg = "No secondary workfile present for workfile '{}'". \ + format(instance.data["name"]) + ext = self.main_workfile_extensions[0] + formatting_data = {"file_name": instance.data["name"], + "extension": ext} + + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data + ) From d7b6582cd38f1dd5a74036778ad22bb36b0dc54e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 24 Dec 2021 15:11:53 +0100 Subject: [PATCH 050/483] Fix Maya 2022 Python 3 compatibility: types.BooleanType and types.ListType don't exist in Py3+ --- .../hosts/maya/plugins/publish/validate_ass_relative_paths.py | 4 ++-- .../maya/plugins/publish/validate_vray_referenced_aovs.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py b/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py index 3625d4ab32..5fb9bd98b1 100644 --- a/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py +++ b/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py @@ -110,9 +110,9 @@ class ValidateAssRelativePaths(pyblish.api.InstancePlugin): Maya API will return a list of values, which need to be properly handled to evaluate properly. """ - if isinstance(attr_val, types.BooleanType): + if isinstance(attr_val, bool): return attr_val - elif isinstance(attr_val, (types.ListType, types.GeneratorType)): + elif isinstance(attr_val, (list, types.GeneratorType)): return any(attr_val) else: return bool(attr_val) diff --git a/openpype/hosts/maya/plugins/publish/validate_vray_referenced_aovs.py b/openpype/hosts/maya/plugins/publish/validate_vray_referenced_aovs.py index 6cfbd4049b..7a48c29b7d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_vray_referenced_aovs.py +++ b/openpype/hosts/maya/plugins/publish/validate_vray_referenced_aovs.py @@ -82,9 +82,9 @@ class ValidateVrayReferencedAOVs(pyblish.api.InstancePlugin): bool: cast Maya attribute to Pythons boolean value. """ - if isinstance(attr_val, types.BooleanType): + if isinstance(attr_val, bool): return attr_val - elif isinstance(attr_val, (types.ListType, types.GeneratorType)): + elif isinstance(attr_val, (list, types.GeneratorType)): return any(attr_val) else: return bool(attr_val) From cb99b0acf791c970aeef7c04c1f01487f8e9f260 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 12:37:46 +0100 Subject: [PATCH 051/483] Ensure `server_aliases` is of type `list` instead of `dict_keys` in Py3+ This resolves an issue where otherwise `lib.imprint` will fail on `self.data` for this Creator plug-in --- openpype/hosts/maya/plugins/create/create_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 85919d1166..9e94996734 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -254,7 +254,7 @@ class CreateRender(plugin.Creator): # get pools pool_names = [] - self.server_aliases = self.deadline_servers.keys() + self.server_aliases = list(self.deadline_servers.keys()) self.data["deadlineServers"] = self.server_aliases self.data["suspendPublishJob"] = False self.data["review"] = True From 74cfde55ec44b70bbc8628e0b4762e114877f89c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 13:18:12 +0100 Subject: [PATCH 052/483] Fix `dict_keys` object is not subscriptable (Py3+) --- openpype/hosts/maya/plugins/publish/collect_render.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index ac1e495f08..745954e032 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -234,13 +234,14 @@ class CollectMayaRender(pyblish.api.ContextPlugin): publish_meta_path = None for aov in exp_files: full_paths = [] - for file in aov[aov.keys()[0]]: + aov_first_key = list(aov.keys())[0] + for file in aov[aov_first_key]: full_path = os.path.join(workspace, default_render_file, file) full_path = full_path.replace("\\", "/") full_paths.append(full_path) publish_meta_path = os.path.dirname(full_path) - aov_dict[aov.keys()[0]] = full_paths + aov_dict[aov_first_key] = full_paths frame_start_render = int(self.get_render_attribute( "startFrame", layer=layer_name)) From d09065576421d207e2f3e2cb7ab7b80b36fae3a3 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 14:52:59 +0100 Subject: [PATCH 053/483] Support OpenPype icon override in Maya Toolbox in Maya 2022+ - Also refactor import maya.cmds as `mc` to `cmds` to match with other code in the OpenPype code base --- openpype/hosts/maya/api/customize.py | 57 +++++++++++++++++----------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/maya/api/customize.py b/openpype/hosts/maya/api/customize.py index 8474262626..83f481f56e 100644 --- a/openpype/hosts/maya/api/customize.py +++ b/openpype/hosts/maya/api/customize.py @@ -5,7 +5,7 @@ import logging from functools import partial -import maya.cmds as mc +import maya.cmds as cmds import maya.mel as mel from avalon.maya import pipeline @@ -31,9 +31,9 @@ def override_component_mask_commands(): log.info("Installing override_component_mask_commands..") # Get all object mask buttons - buttons = mc.formLayout("objectMaskIcons", - query=True, - childArray=True) + buttons = cmds.formLayout("objectMaskIcons", + query=True, + childArray=True) # Skip the triangle list item buttons = [btn for btn in buttons if btn != "objPickMenuLayout"] @@ -44,14 +44,14 @@ def override_component_mask_commands(): # toggle the others based on whether any of the buttons # was remaining active after the toggle, if not then # enable all - if mc.getModifiers() == 4: # = CTRL + if cmds.getModifiers() == 4: # = CTRL state = True - active = [mc.iconTextCheckBox(btn, query=True, value=True) for btn - in buttons] + active = [cmds.iconTextCheckBox(btn, query=True, value=True) + for btn in buttons] if any(active): - mc.selectType(allObjects=False) + cmds.selectType(allObjects=False) else: - mc.selectType(allObjects=True) + cmds.selectType(allObjects=True) # Replace #1 with the current button state cmd = raw_command.replace(" #1", " {}".format(int(state))) @@ -64,13 +64,13 @@ def override_component_mask_commands(): # try to implement the fix. (This also allows us to # "uninstall" the behavior later) if btn not in COMPONENT_MASK_ORIGINAL: - original = mc.iconTextCheckBox(btn, query=True, cc=True) + original = cmds.iconTextCheckBox(btn, query=True, cc=True) COMPONENT_MASK_ORIGINAL[btn] = original # Assign the special callback original = COMPONENT_MASK_ORIGINAL[btn] new_fn = partial(on_changed_callback, original) - mc.iconTextCheckBox(btn, edit=True, cc=new_fn) + cmds.iconTextCheckBox(btn, edit=True, cc=new_fn) def override_toolbox_ui(): @@ -78,18 +78,29 @@ def override_toolbox_ui(): icons = resources.get_resource("icons") # Ensure the maya web icon on toolbox exists - web_button = "ToolBox|MainToolboxLayout|mayaWebButton" - if not mc.iconTextButton(web_button, query=True, exists=True): + maya_version = int(cmds.about(version=True)) + if maya_version >= 2022: + # Maya 2022+ has an updated toolbox with a different web + # button name and type + web_button = "ToolBox|MainToolboxLayout|mayaHomeToolboxButton" + button_fn = cmds.iconTextStaticLabel + else: + web_button = "ToolBox|MainToolboxLayout|mayaWebButton" + button_fn = cmds.iconTextButton + + if not button_fn(web_button, query=True, exists=True): + # Button does not exist + log.warning("Can't find Maya Home/Web button to override toolbox ui..") return - mc.iconTextButton(web_button, edit=True, visible=False) + button_fn(web_button, edit=True, visible=False) # real = 32, but 36 with padding - according to toolbox mel script icon_size = 36 parent = web_button.rsplit("|", 1)[0] # Ensure the parent is a formLayout - if not mc.objectTypeUI(parent) == "formLayout": + if not cmds.objectTypeUI(parent) == "formLayout": return # Create our controls @@ -106,7 +117,7 @@ def override_toolbox_ui(): if look_assigner is not None: controls.append( - mc.iconTextButton( + cmds.iconTextButton( "pype_toolbox_lookmanager", annotation="Look Manager", label="Look Manager", @@ -120,7 +131,7 @@ def override_toolbox_ui(): ) controls.append( - mc.iconTextButton( + cmds.iconTextButton( "pype_toolbox_workfiles", annotation="Work Files", label="Work Files", @@ -136,7 +147,7 @@ def override_toolbox_ui(): ) controls.append( - mc.iconTextButton( + cmds.iconTextButton( "pype_toolbox_loader", annotation="Loader", label="Loader", @@ -152,7 +163,7 @@ def override_toolbox_ui(): ) controls.append( - mc.iconTextButton( + cmds.iconTextButton( "pype_toolbox_manager", annotation="Inventory", label="Inventory", @@ -173,7 +184,7 @@ def override_toolbox_ui(): for i, control in enumerate(controls): previous = controls[i - 1] if i > 0 else web_button - mc.formLayout(parent, edit=True, - attachControl=[control, "bottom", 0, previous], - attachForm=([control, "left", 1], - [control, "right", 1])) + cmds.formLayout(parent, edit=True, + attachControl=[control, "bottom", 0, previous], + attachForm=([control, "left", 1], + [control, "right", 1])) From 19d2faac7ec6cd0dd793eae68e54563ed7b33c50 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 15:19:24 +0100 Subject: [PATCH 054/483] Fix "Set Frame Range" loaders to retrieve the correct data from the version --- openpype/hosts/houdini/plugins/load/actions.py | 13 ++++++------- openpype/hosts/maya/plugins/load/actions.py | 5 ++--- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/houdini/plugins/load/actions.py b/openpype/hosts/houdini/plugins/load/actions.py index 6e9410ff58..acdb998c16 100644 --- a/openpype/hosts/houdini/plugins/load/actions.py +++ b/openpype/hosts/houdini/plugins/load/actions.py @@ -29,8 +29,8 @@ class SetFrameRangeLoader(api.Loader): version = context["version"] version_data = version.get("data", {}) - start = version_data.get("startFrame", None) - end = version_data.get("endFrame", None) + start = version_data.get("frameStart", None) + end = version_data.get("frameEnd", None) if start is None or end is None: print( @@ -67,8 +67,8 @@ class SetFrameRangeWithHandlesLoader(api.Loader): version = context["version"] version_data = version.get("data", {}) - start = version_data.get("startFrame", None) - end = version_data.get("endFrame", None) + start = version_data.get("frameStart", None) + end = version_data.get("frameEnd", None) if start is None or end is None: print( @@ -78,9 +78,8 @@ class SetFrameRangeWithHandlesLoader(api.Loader): return # Include handles - handles = version_data.get("handles", 0) - start -= handles - end += handles + start -= version_data.get("handleStart", 0) + end += version_data.get("handleEnd", 0) hou.playbar.setFrameRange(start, end) hou.playbar.setPlaybackRange(start, end) diff --git a/openpype/hosts/maya/plugins/load/actions.py b/openpype/hosts/maya/plugins/load/actions.py index 1a9adf6142..f55aa80b9e 100644 --- a/openpype/hosts/maya/plugins/load/actions.py +++ b/openpype/hosts/maya/plugins/load/actions.py @@ -68,9 +68,8 @@ class SetFrameRangeWithHandlesLoader(api.Loader): return # Include handles - handles = version_data.get("handles", 0) - start -= handles - end += handles + start -= version_data.get("handleStart", 0) + end += version_data.get("handleEnd", 0) cmds.playbackOptions(minTime=start, maxTime=end, From 7cc091054957a9bc3cd6cdcdac4963ddc899aeb1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 18:19:33 +0100 Subject: [PATCH 055/483] Python 3 compatibility + correctly find maketx.exe on Windows --- openpype/hosts/maya/api/lib.py | 9 ++++++++- openpype/hosts/maya/plugins/publish/extract_look.py | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 52ebcaff64..6dc27b459d 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -5,6 +5,7 @@ import os import platform import uuid import math +import sys import json import logging @@ -130,7 +131,13 @@ def float_round(num, places=0, direction=ceil): def pairwise(iterable): """s -> (s0,s1), (s2,s3), (s4, s5), ...""" a = iter(iterable) - return itertools.izip(a, a) + + if sys.version_info[0] == 2: + izip = itertools.izip + else: + izip = zip + + return izip(a, a) def unique(name): diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 953539f65c..3b3f17aa7f 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -4,6 +4,7 @@ import os import sys import json import tempfile +import platform import contextlib import subprocess from collections import OrderedDict @@ -58,6 +59,11 @@ def maketx(source, destination, *args): from openpype.lib import get_oiio_tools_path maketx_path = get_oiio_tools_path("maketx") + + if platform.system().lower() == "windows": + # Ensure .exe extension + maketx_path += ".exe" + if not os.path.exists(maketx_path): print( "OIIO tool not found in {}".format(maketx_path)) @@ -212,7 +218,7 @@ class ExtractLook(openpype.api.Extractor): self.log.info("Extract sets (%s) ..." % _scene_type) lookdata = instance.data["lookData"] relationships = lookdata["relationships"] - sets = relationships.keys() + sets = list(relationships.keys()) if not sets: self.log.info("No sets found") return From 4d7ff9d54274272c89ebd0438ac489b318037edd Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 09:20:30 +0100 Subject: [PATCH 056/483] Fix `xrange` doesn't exist in Py3+ --- .../maya/plugins/publish/validate_mesh_overlapping_uvs.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py index 57cf0803a4..c06f48f5be 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py @@ -2,10 +2,16 @@ import pyblish.api import openpype.api import openpype.hosts.maya.api.action import math +import sys import maya.api.OpenMaya as om import pymel.core as pm +if sys.version_info[0] != 2: + # Py3+ does not have `xrange` so we mimic it to allow to use it in Py2 + xrange = range + + class GetOverlappingUVs(object): def _createBoundingCircle(self, meshfn): From bd726f8966481af7bcebbb3011cc4bb36836c4cd Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 09:23:46 +0100 Subject: [PATCH 057/483] Fix `xrange` doesn't exist in Py3+ (use `six` instead) --- .../maya/plugins/publish/validate_mesh_overlapping_uvs.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py index c06f48f5be..3c1bf3cddc 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py @@ -6,10 +6,7 @@ import sys import maya.api.OpenMaya as om import pymel.core as pm - -if sys.version_info[0] != 2: - # Py3+ does not have `xrange` so we mimic it to allow to use it in Py2 - xrange = range +from six.moves import xrange class GetOverlappingUVs(object): From cf9899b0385c2824ba97973b86bd4153a4e8b496 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 14:19:59 +0100 Subject: [PATCH 058/483] Remove unused import of 'sys' --- .../hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py index 3c1bf3cddc..c4e823fcba 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py @@ -2,7 +2,6 @@ import pyblish.api import openpype.api import openpype.hosts.maya.api.action import math -import sys import maya.api.OpenMaya as om import pymel.core as pm From 2b36eea3258d4d1ba69d1d1a0a5864037fd89f24 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 14:39:35 +0100 Subject: [PATCH 059/483] Avoid 'dict_keys' issue in Py3+ --- openpype/hosts/maya/plugins/publish/collect_look.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index d39750e917..b6a76f1e21 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -320,7 +320,7 @@ class CollectLook(pyblish.api.InstancePlugin): # Collect file nodes used by shading engines (if we have any) files = [] - look_sets = sets.keys() + look_sets = list(sets.keys()) shader_attrs = [ "surfaceShader", "volumeShader", From fa77a4934909b529ad38b79f9fa176fac8913c29 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 4 Jan 2022 14:12:21 +0100 Subject: [PATCH 060/483] Simplify logic that falls back to first valid name. - This fixes an issue with Maya 2022.0 where the maya web button was not yet renamed - Tested in Maya 2019.3.1, Maya 2020.4 and Maya 2022.1 --- openpype/hosts/maya/api/customize.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/maya/api/customize.py b/openpype/hosts/maya/api/customize.py index 83f481f56e..3ee9475035 100644 --- a/openpype/hosts/maya/api/customize.py +++ b/openpype/hosts/maya/api/customize.py @@ -78,22 +78,22 @@ def override_toolbox_ui(): icons = resources.get_resource("icons") # Ensure the maya web icon on toolbox exists - maya_version = int(cmds.about(version=True)) - if maya_version >= 2022: - # Maya 2022+ has an updated toolbox with a different web - # button name and type - web_button = "ToolBox|MainToolboxLayout|mayaHomeToolboxButton" - button_fn = cmds.iconTextStaticLabel + button_names = [ + # Maya 2022.1+ with maya.cmds.iconTextStaticLabel + "ToolBox|MainToolboxLayout|mayaHomeToolboxButton", + # Older with maya.cmds.iconTextButton + "ToolBox|MainToolboxLayout|mayaWebButton" + ] + for name in button_names: + if cmds.control(name, query=True, exists=True): + web_button = name + break else: - web_button = "ToolBox|MainToolboxLayout|mayaWebButton" - button_fn = cmds.iconTextButton - - if not button_fn(web_button, query=True, exists=True): # Button does not exist log.warning("Can't find Maya Home/Web button to override toolbox ui..") return - button_fn(web_button, edit=True, visible=False) + cmds.control(web_button, edit=True, visible=False) # real = 32, but 36 with padding - according to toolbox mel script icon_size = 36 From d3eca27140e7068dc9a05f1336992da8b9060f56 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 02:07:49 +0100 Subject: [PATCH 061/483] Avoid python version check and use `from six.moves import zip` --- openpype/hosts/maya/api/lib.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 6dc27b459d..b5bbf122ea 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -9,7 +9,6 @@ import sys import json import logging -import itertools import contextlib from collections import OrderedDict, defaultdict from math import ceil @@ -130,14 +129,10 @@ def float_round(num, places=0, direction=ceil): def pairwise(iterable): """s -> (s0,s1), (s2,s3), (s4, s5), ...""" + from six.moves import zip + a = iter(iterable) - - if sys.version_info[0] == 2: - izip = itertools.izip - else: - izip = zip - - return izip(a, a) + return zip(a, a) def unique(name): From eca18fa5bbff712ba379c2946fb500f99da72b47 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 02:09:56 +0100 Subject: [PATCH 062/483] Fix Py3 compatibility, refactor itertools.izip_longest --- .../hosts/maya/plugins/publish/extract_camera_mayaScene.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py index 888dc636b2..fdd36cf0b4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py @@ -44,7 +44,8 @@ def grouper(iterable, n, fillvalue=None): """ args = [iter(iterable)] * n - return itertools.izip_longest(fillvalue=fillvalue, *args) + from six.moves import zip_longest + return zip_longest(fillvalue=fillvalue, *args) def unlock(plug): From cb5d3e29eaa4b1280ebdd9a5f19e8dcbef739411 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 02:11:24 +0100 Subject: [PATCH 063/483] Remove unused import sys --- openpype/hosts/maya/api/lib.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index b5bbf122ea..21d5e581a5 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -5,7 +5,6 @@ import os import platform import uuid import math -import sys import json import logging From 3b63abfa6a2b8b54e061834b3d42b5ee013a9627 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 02:17:25 +0100 Subject: [PATCH 064/483] Remove "(Testing Only)" from defaults for Maya 2022 --- openpype/settings/defaults/system_settings/applications.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 1cbe09f576..f855117c07 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -93,7 +93,7 @@ } }, "__dynamic_keys_labels__": { - "2022": "2022 (Testing Only)" + "2022": "2022" } } }, From cee20eb256eecb083988c5834c474c845f247396 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 11:25:33 +0100 Subject: [PATCH 065/483] Refactor other references to 'startFrame'/'endFrame' to 'frameStart'/'frameEnd' --- openpype/hosts/fusion/scripts/fusion_switch_shot.py | 2 +- .../plugins/publish/validate_abc_primitive_to_detail.py | 2 +- .../houdini/plugins/publish/validate_alembic_input_node.py | 2 +- .../plugins/publish/submit_houdini_render_deadline.py | 4 ++-- .../deadline/plugins/publish/submit_publish_job.py | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/fusion/scripts/fusion_switch_shot.py b/openpype/hosts/fusion/scripts/fusion_switch_shot.py index 05b577c8ba..fd4128d840 100644 --- a/openpype/hosts/fusion/scripts/fusion_switch_shot.py +++ b/openpype/hosts/fusion/scripts/fusion_switch_shot.py @@ -176,7 +176,7 @@ def update_frame_range(comp, representations): versions = list(versions) versions = [v for v in versions - if v["data"].get("startFrame", None) is not None] + if v["data"].get("frameStart", None) is not None] if not versions: log.warning("No versions loaded to match frame range to.\n") diff --git a/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py b/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py index 8fe1b44b7a..3e17d3e8de 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py +++ b/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py @@ -65,7 +65,7 @@ class ValidateAbcPrimitiveToDetail(pyblish.api.InstancePlugin): cls.log.debug("Checking with path attribute: %s" % path_attr) # Check if the primitive attribute exists - frame = instance.data.get("startFrame", 0) + frame = instance.data.get("frameStart", 0) geo = output.geometryAtFrame(frame) # If there are no primitives on the start frame then it might be diff --git a/openpype/hosts/houdini/plugins/publish/validate_alembic_input_node.py b/openpype/hosts/houdini/plugins/publish/validate_alembic_input_node.py index 17c9da837a..8d7e3b611f 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_alembic_input_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_alembic_input_node.py @@ -38,7 +38,7 @@ class ValidateAlembicInputNode(pyblish.api.InstancePlugin): cls.log.warning("No geometry output node found, skipping check..") return - frame = instance.data.get("startFrame", 0) + frame = instance.data.get("frameStart", 0) geo = node.geometryAtFrame(frame) invalid = False diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_render_deadline.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_render_deadline.py index fa146c0d30..ace2aabb85 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -50,8 +50,8 @@ class HoudiniSubmitRenderDeadline(pyblish.api.InstancePlugin): # StartFrame to EndFrame by byFrameStep frames = "{start}-{end}x{step}".format( - start=int(instance.data["startFrame"]), - end=int(instance.data["endFrame"]), + start=int(instance.data["frameStart"]), + end=int(instance.data["frameEnd"]), step=int(instance.data["byFrameStep"]), ) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py index 516bd755d0..a5ccbe9cf9 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py @@ -311,8 +311,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): import speedcopy self.log.info("Preparing to copy ...") - start = instance.data.get("startFrame") - end = instance.data.get("endFrame") + start = instance.data.get("frameStart") + end = instance.data.get("frameEnd") # get latest version of subset # this will stop if subset wasn't published yet From 7621f02a47995ead84a102793c9584a2b45f21dc Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 12:35:25 +0100 Subject: [PATCH 066/483] Refactor "startFrame" -> "frameStart" --- .../plugins/publish/validate_primitive_hierarchy_paths.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py index 3c15532be8..1eb36763bb 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py +++ b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py @@ -51,7 +51,7 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): cls.log.debug("Checking for attribute: %s" % path_attr) # Check if the primitive attribute exists - frame = instance.data.get("startFrame", 0) + frame = instance.data.get("frameStart", 0) geo = output.geometryAtFrame(frame) # If there are no primitives on the current frame then we can't From 7bf02646bc6896ff96a3c07ffff737d269924266 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 13:38:19 +0100 Subject: [PATCH 067/483] Maya: Refactor `handles` to correctly extract handles with the frame range in export using `handleStart` and `handleEnd` as input --- openpype/hosts/maya/api/lib.py | 3 ++- .../maya/plugins/publish/extract_animation.py | 8 ++------ .../hosts/maya/plugins/publish/extract_ass.py | 8 ++------ .../plugins/publish/extract_camera_alembic.py | 19 ++++--------------- .../publish/extract_camera_mayaScene.py | 18 ++++-------------- .../hosts/maya/plugins/publish/extract_fbx.py | 8 ++------ .../maya/plugins/publish/extract_vrayproxy.py | 13 +++++++++---- .../plugins/publish/extract_yeti_cache.py | 4 ++-- 8 files changed, 27 insertions(+), 54 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 52ebcaff64..d003203959 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -297,7 +297,8 @@ def collect_animation_data(): data = OrderedDict() data["frameStart"] = start data["frameEnd"] = end - data["handles"] = 0 + data["handleStart"] = 0 + data["handleEnd"] = 0 data["step"] = 1.0 data["fps"] = fps diff --git a/openpype/hosts/maya/plugins/publish/extract_animation.py b/openpype/hosts/maya/plugins/publish/extract_animation.py index 7ecc40a68d..e0ed4411a8 100644 --- a/openpype/hosts/maya/plugins/publish/extract_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_animation.py @@ -35,12 +35,8 @@ class ExtractAnimation(openpype.api.Extractor): fullPath=True) or [] # Collect the start and end including handles - start = instance.data["frameStart"] - end = instance.data["frameEnd"] - handles = instance.data.get("handles", 0) or 0 - if handles: - start -= handles - end += handles + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] self.log.info("Extracting animation..") dirname = self.staging_dir(instance) diff --git a/openpype/hosts/maya/plugins/publish/extract_ass.py b/openpype/hosts/maya/plugins/publish/extract_ass.py index 7461ccdf78..9025709178 100644 --- a/openpype/hosts/maya/plugins/publish/extract_ass.py +++ b/openpype/hosts/maya/plugins/publish/extract_ass.py @@ -38,13 +38,9 @@ class ExtractAssStandin(openpype.api.Extractor): self.log.info("Extracting ass sequence") # Collect the start and end including handles - start = instance.data.get("frameStart", 1) - end = instance.data.get("frameEnd", 1) - handles = instance.data.get("handles", 0) + start = instance.data.get("frameStartHandle", 1) + end = instance.data.get("frameEndHandle", 1) step = instance.data.get("step", 0) - if handles: - start -= handles - end += handles exported_files = cmds.arnoldExportAss(filename=file_path, selected=True, diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py index 8950ed6254..b6f1826098 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py @@ -23,17 +23,9 @@ class ExtractCameraAlembic(openpype.api.Extractor): def process(self, instance): - # get settings - framerange = [instance.data.get("frameStart", 1), - instance.data.get("frameEnd", 1)] - handle_start = instance.data.get("handleStart", 0) - handle_end = instance.data.get("handleEnd", 0) - - # TODO: deprecated attribute "handles" - - if handle_start is None: - handle_start = instance.data.get("handles", 0) - handle_end = instance.data.get("handles", 0) + # Collect the start and end including handles + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] step = instance.data.get("step", 1.0) bake_to_worldspace = instance.data("bakeToWorldSpace", True) @@ -63,10 +55,7 @@ class ExtractCameraAlembic(openpype.api.Extractor): job_str = ' -selection -dataFormat "ogawa" ' job_str += ' -attrPrefix cb' - job_str += ' -frameRange {0} {1} '.format(framerange[0] - - handle_start, - framerange[1] - + handle_end) + job_str += ' -frameRange {0} {1} '.format(start, end) job_str += ' -step {0} '.format(step) if bake_to_worldspace: diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py index 888dc636b2..bac00dc711 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py @@ -118,19 +118,9 @@ class ExtractCameraMayaScene(openpype.api.Extractor): # no preset found pass - framerange = [instance.data.get("frameStart", 1), - instance.data.get("frameEnd", 1)] - handle_start = instance.data.get("handleStart", 0) - handle_end = instance.data.get("handleEnd", 0) - - # TODO: deprecated attribute "handles" - - if handle_start is None: - handle_start = instance.data.get("handles", 0) - handle_end = instance.data.get("handles", 0) - - range_with_handles = [framerange[0] - handle_start, - framerange[1] + handle_end] + # Collect the start and end including handles + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] step = instance.data.get("step", 1.0) bake_to_worldspace = instance.data("bakeToWorldSpace", True) @@ -165,7 +155,7 @@ class ExtractCameraMayaScene(openpype.api.Extractor): "Performing camera bakes: {}".format(transform)) baked = lib.bake_to_world_space( transform, - frame_range=range_with_handles, + frame_range=[start, end], step=step ) baked_shapes = cmds.ls(baked, diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx.py b/openpype/hosts/maya/plugins/publish/extract_fbx.py index 720a61b0a7..4a92e31ccb 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx.py @@ -166,12 +166,8 @@ class ExtractFBX(openpype.api.Extractor): self.log.info("Export options: {0}".format(options)) # Collect the start and end including handles - start = instance.data["frameStart"] - end = instance.data["frameEnd"] - handles = instance.data.get("handles", 0) - if handles: - start -= handles - end += handles + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] options['bakeComplexStart'] = start options['bakeComplexEnd'] = end diff --git a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py index 7103601b85..8bfbbd525d 100644 --- a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py @@ -28,14 +28,19 @@ class ExtractVRayProxy(openpype.api.Extractor): if not anim_on: # Remove animation information because it is not required for # non-animated subsets - instance.data.pop("frameStart", None) - instance.data.pop("frameEnd", None) + keys = ["frameStart", "frameEnd", + "handleStart", "handleEnd", + "frameStartHandle", "frameEndHandle", + # Backwards compatibility + "handles"] + for key in keys: + instance.data.pop(key, None) start_frame = 1 end_frame = 1 else: - start_frame = instance.data["frameStart"] - end_frame = instance.data["frameEnd"] + start_frame = instance.data["frameStartHandle"] + end_frame = instance.data["frameEndHandle"] vertex_colors = instance.data.get("vertexColors", False) diff --git a/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py b/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py index 05fe79ecc5..0d85708789 100644 --- a/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py +++ b/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py @@ -29,8 +29,8 @@ class ExtractYetiCache(openpype.api.Extractor): data_file = os.path.join(dirname, "yeti.fursettings") # Collect information for writing cache - start_frame = instance.data.get("frameStart") - end_frame = instance.data.get("frameEnd") + start_frame = instance.data.get("frameStartHandle") + end_frame = instance.data.get("frameEndHandle") preroll = instance.data.get("preroll") if preroll > 0: start_frame -= preroll From e00e26d3eb41652859a825b93f72e2b31aa0b154 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 14:46:06 +0100 Subject: [PATCH 068/483] Refactor 'handles' to 'handleStart' & 'handleEnd' with backwards compatibility --- .../modules/ftrack_lib.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py index 26b197ee1d..7a0efe079e 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py @@ -138,12 +138,24 @@ class FtrackComponentCreator: if name == "ftrackreview-mp4": duration = data["duration"] - handles = data["handles"] + + handle_start = data.get("handleStart", None) + handle_end = data.get("handleEnd", None) + if handle_start is not None: + duration += handle_start + if handle_end is not None: + duration += handle_end + if handle_start is None and handle_end is None: + # Backwards compatibility; old style 'handles' + # We multiply by two because old-style handles defined + # both the handle start and handle end + duration += data.get("handles", 0) * 2 + fps = data["fps"] component_data["metadata"] = { 'ftr_meta': json.dumps({ 'frameIn': int(0), - 'frameOut': int(duration + (handles * 2)), + 'frameOut': int(duration), 'frameRate': float(fps) }) } From 830086516a772fc967379ba553961f16eda6bc43 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 11:04:01 +0100 Subject: [PATCH 069/483] modified text of layers visibility validator exception --- .../plugins/publish/help/validate_layers_visibility.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml b/openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml index 2eaed22a19..e7be735888 100644 --- a/openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml +++ b/openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml @@ -4,7 +4,7 @@ Layers visiblity ## All layers are not visible -All layers for subset "{instance_name}" are hidden. +Layers visibility was changed during publishing which caused that all layers for subset "{instance_name}" are hidden. ### Layer names for **{instance_name}** @@ -14,7 +14,7 @@ All layers for subset "{instance_name}" are hidden. ### How to repair? -Make sure that at least one layer in the scene is visible or disable the subset before hitting publish button after refresh. +Reset publishing and do not change visibility of layers after hitting publish button. From 6fd45d99f9bcee675a3590d36396ca9b5acaea02 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 Jan 2022 18:10:17 +0100 Subject: [PATCH 070/483] Fix - wrong expression --- .../plugins/publish/validate_frame_ranges.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_frame_ranges.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_frame_ranges.py index c7a2e755b6..005157af62 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_frame_ranges.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_frame_ranges.py @@ -56,7 +56,7 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): formatting_data = {"duration": duration, "found": frames} - if frames == duration: + if frames != duration: raise PublishXmlValidationError(self, msg, formatting_data=formatting_data) From edd0fb1ce9e06f374994ca37c401fffa30a29f9e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 13 Jan 2022 11:04:47 +0100 Subject: [PATCH 071/483] Fix - typo in key --- .../plugins/publish/validate_shot_duplicates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_shot_duplicates.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_shot_duplicates.py index 0f957acad6..fe655f6b74 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_shot_duplicates.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_shot_duplicates.py @@ -22,7 +22,7 @@ class ValidateShotDuplicates(pyblish.api.ContextPlugin): msg = "There are duplicate shot names:\n{}".format(duplicate_names) - formatting_data = {"duplicate_str": ','.join(duplicate_names)} + formatting_data = {"duplicates_str": ','.join(duplicate_names)} if duplicate_names: raise PublishXmlValidationError(self, msg, formatting_data=formatting_data) From ef695cb153e6bfd7676f08f294b7b75ce999fadb Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 13 Jan 2022 11:09:03 +0100 Subject: [PATCH 072/483] Added new style validation for check for editorial resources --- .../help/validate_editorial_resources.xml | 17 +++++++++++++++++ .../publish/validate_editorial_resources.py | 7 +++++-- 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 openpype/hosts/standalonepublisher/plugins/publish/help/validate_editorial_resources.xml diff --git a/openpype/hosts/standalonepublisher/plugins/publish/help/validate_editorial_resources.xml b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_editorial_resources.xml new file mode 100644 index 0000000000..803de6bf11 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_editorial_resources.xml @@ -0,0 +1,17 @@ + + + +Missing source video file + +## No attached video file found + +Process expects presence of source video file with same name prefix as an editorial file in same folder. +(example `simple_editorial_setup_Layer1.edl` expects `simple_editorial_setup.mp4` in same folder) + + +### How to repair? + +Copy source video file to the folder next to `.edl` file. (On a disk, do not put it into Standalone Publisher.) + + + diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_editorial_resources.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_editorial_resources.py index 6759b87ceb..7987bbc2d9 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_editorial_resources.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_editorial_resources.py @@ -1,5 +1,6 @@ import pyblish.api import openpype.api +from openpype.pipeline import PublishXmlValidationError class ValidateEditorialResources(pyblish.api.InstancePlugin): @@ -19,5 +20,7 @@ class ValidateEditorialResources(pyblish.api.InstancePlugin): f"Instance: {instance}, Families: " f"{[instance.data['family']] + instance.data['families']}") check_file = instance.data["editorialSourcePath"] - msg = f"Missing \"{check_file}\"." - assert check_file, msg + msg = f"Missing source video file." + + if not check_file: + raise PublishXmlValidationError(self, msg) From 5a5a172c3051a789ce0adbe94ab1e1c428fecea7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 17 Jan 2022 15:31:21 +0100 Subject: [PATCH 073/483] Update openpype/hosts/standalonepublisher/plugins/publish/validate_editorial_resources.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../plugins/publish/validate_editorial_resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_editorial_resources.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_editorial_resources.py index 7987bbc2d9..afb828474d 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_editorial_resources.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_editorial_resources.py @@ -20,7 +20,7 @@ class ValidateEditorialResources(pyblish.api.InstancePlugin): f"Instance: {instance}, Families: " f"{[instance.data['family']] + instance.data['families']}") check_file = instance.data["editorialSourcePath"] - msg = f"Missing source video file." + msg = "Missing source video file." if not check_file: raise PublishXmlValidationError(self, msg) From 40978d7ed488e3a1aab2f2ae9344804cd897eb22 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 23 Jan 2022 14:30:00 +0100 Subject: [PATCH 074/483] Clarify logic of falling back to first server url --- openpype/hosts/maya/plugins/create/create_render.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 9e94996734..f1a8acadf4 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -287,15 +287,12 @@ class CreateRender(plugin.Creator): raise RuntimeError("Both Deadline and Muster are enabled") if deadline_enabled: - # if default server is not between selected, use first one for - # initial list of pools. try: deadline_url = self.deadline_servers["default"] except KeyError: - deadline_url = [ - self.deadline_servers[k] - for k in self.deadline_servers.keys() - ][0] + # if 'default' server is not between selected, + # use first one for initial list of pools. + deadline_url = next(iter(self.deadline_servers.values())) pool_names = self._get_deadline_pools(deadline_url) From 33ac3186c776160a313a3b82c6124057dbcce8b7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 25 Jan 2022 15:18:41 +0100 Subject: [PATCH 075/483] fix job killer action --- .../event_handlers_user/action_job_killer.py | 175 ++++++++++-------- 1 file changed, 96 insertions(+), 79 deletions(-) diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_job_killer.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_job_killer.py index af24e0280d..f489c0c54c 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_job_killer.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_job_killer.py @@ -3,111 +3,128 @@ from openpype_modules.ftrack.lib import BaseAction, statics_icon class JobKiller(BaseAction): - '''Edit meta data action.''' + """Kill jobs that are marked as running.""" - #: Action identifier. - identifier = 'job.killer' - #: Action label. + identifier = "job.killer" label = "OpenPype Admin" - variant = '- Job Killer' - #: Action description. - description = 'Killing selected running jobs' - #: roles that are allowed to register this action + variant = "- Job Killer" + description = "Killing selected running jobs" icon = statics_icon("ftrack", "action_icons", "OpenPypeAdmin.svg") settings_key = "job_killer" def discover(self, session, entities, event): - ''' Validation ''' + """Check if action is available for user role.""" return self.valid_roles(session, entities, event) def interface(self, session, entities, event): - if not event['data'].get('values', {}): - title = 'Select jobs to kill' - - jobs = session.query( - 'select id, status from Job' - ' where status in ("queued", "running")' - ).all() - - items = [] - - item_splitter = {'type': 'label', 'value': '---'} - for job in jobs: - try: - data = json.loads(job['data']) - desctiption = data['description'] - except Exception: - desctiption = '*No description*' - user = job['user']['username'] - created = job['created_at'].strftime('%d.%m.%Y %H:%M:%S') - label = '{} - {} - {}'.format( - desctiption, created, user - ) - item_label = { - 'type': 'label', - 'value': label - } - item = { - 'name': job['id'], - 'type': 'boolean', - 'value': False - } - if len(items) > 0: - items.append(item_splitter) - items.append(item_label) - items.append(item) - - if len(items) == 0: - return { - 'success': False, - 'message': 'Didn\'t found any running jobs' - } - else: - return { - 'items': items, - 'title': title - } - - def launch(self, session, entities, event): - """ GET JOB """ - if 'values' not in event['data']: + if event["data"].get("values"): return - values = event['data']['values'] - if len(values) <= 0: + title = "Select jobs to kill" + + jobs = session.query( + "select id, user_id, status, created_at, data from Job" + " where status in (\"queued\", \"running\")" + ).all() + if not jobs: return { - 'success': True, - 'message': 'No jobs to kill!' + "success": True, + "message": "Didn't found any running jobs" } - jobs = [] - job_ids = [] - for k, v in values.items(): - if v is True: - job_ids.append(k) + # Collect user ids from jobs + user_ids = set() + for job in jobs: + user_id = job["user_id"] + if user_id: + user_ids.add(user_id) + + # Store usernames by their ids + usernames_by_id = {} + if user_ids: + users = session.query( + "select id, username from User where id in ({})".format( + self.join_query_keys(user_ids) + ) + ).all() + for user in users: + usernames_by_id[user["id"]] = user["username"] + + items = [] + for job in jobs: + try: + data = json.loads(job["data"]) + desctiption = data["description"] + except Exception: + desctiption = "*No description*" + user_id = job["user_id"] + username = usernames_by_id.get(user_id) or "Unknown user" + created = job["created_at"].strftime('%d.%m.%Y %H:%M:%S') + label = "{} - {} - {}".format( + username, desctiption, created + ) + item_label = { + "type": "label", + "value": label + } + item = { + "name": job["id"], + "type": "boolean", + "value": False + } + if len(items) > 0: + items.append({"type": "label", "value": "---"}) + items.append(item_label) + items.append(item) + + return { + "items": items, + "title": title + } + + def launch(self, session, entities, event): + if "values" not in event["data"]: + return + + values = event["data"]["values"] + if len(values) < 1: + return { + "success": True, + "message": "No jobs to kill!" + } + + job_ids = set() + for job_id, kill_job in values.items(): + if kill_job: + job_ids.add(job_id) + + jobs = session.query( + "select id, status from Job where id in ({})".format( + self.join_query_keys(job_ids) + ) + ).all() - for id in job_ids: - query = 'Job where id is "{}"'.format(id) - jobs.append(session.query(query).one()) # Update all the queried jobs, setting the status to failed. for job in jobs: try: origin_status = job["status"] - job['status'] = 'failed' - session.commit() self.log.debug(( 'Changing Job ({}) status: {} -> failed' - ).format(job['id'], origin_status)) + ).format(job["id"], origin_status)) + + job["status"] = "failed" + session.commit() + except Exception: session.rollback() self.log.warning(( - 'Changing Job ({}) has failed' - ).format(job['id'])) + "Changing Job ({}) has failed" + ).format(job["id"])) - self.log.info('All running jobs were killed Successfully!') + self.log.info("All selected jobs were killed Successfully!") return { - 'success': True, - 'message': 'All running jobs were killed Successfully!' + "success": True, + "message": "All selected jobs were killed Successfully!" } From c7559b602fbbf3b1fcfe94509859020cbbaedba0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Jan 2022 17:39:36 +0100 Subject: [PATCH 076/483] Draft to support Color Management v2 preferences in Maya 2022+ --- openpype/hosts/maya/api/lib.py | 75 ++++++++++++++++--- .../defaults/project_anatomy/imageio.json | 11 +++ .../schemas/schema_anatomy_imageio.json | 42 ++++++++++- 3 files changed, 115 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 21d5e581a5..6578d423e6 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -2780,7 +2780,27 @@ def set_colorspace(): """ project_name = os.getenv("AVALON_PROJECT") imageio = get_anatomy_settings(project_name)["imageio"]["maya"] - root_dict = imageio["colorManagementPreference"] + + # Maya 2022+ introduces new OCIO v2 color management settings that + # can override the old color managenement preferences. OpenPype has + # separate settings for both so we fall back when necessary. + use_ocio_v2 = imageio["colorManagementPreference_v2"]["enabled"] + required_maya_version = 2022 + maya_version = int(cmds.about(version=True)) + maya_supports_ocio_v2 = maya_version >= required_maya_version + if use_ocio_v2 and not maya_supports_ocio_v2: + # Fallback to legacy behavior with a warning + log.warning("Color Management Preference v2 is enabled but not " + "supported by current Maya version: {} (< {}). Falling " + "back to legacy settings.".format( + maya_version, required_maya_version) + ) + use_ocio_v2 = False + + if use_ocio_v2: + root_dict = imageio["colorManagementPreference_v2"] + else: + root_dict = imageio["colorManagementPreference"] if not isinstance(root_dict, dict): msg = "set_colorspace(): argument should be dictionary" @@ -2788,11 +2808,12 @@ def set_colorspace(): log.debug(">> root_dict: {}".format(root_dict)) - # first enable color management + # enable color management cmds.colorManagementPrefs(e=True, cmEnabled=True) cmds.colorManagementPrefs(e=True, ocioRulesEnabled=True) - # second set config path + # set config path + custom_ocio_config = False if root_dict.get("configFilePath"): unresolved_path = root_dict["configFilePath"] ocio_paths = unresolved_path[platform.system().lower()] @@ -2809,13 +2830,47 @@ def set_colorspace(): cmds.colorManagementPrefs(e=True, cmConfigFileEnabled=True) log.debug("maya '{}' changed to: {}".format( "configFilePath", resolved_path)) - root_dict.pop("configFilePath") + custom_ocio_config = True else: cmds.colorManagementPrefs(e=True, cmConfigFileEnabled=False) - cmds.colorManagementPrefs(e=True, configFilePath="" ) + cmds.colorManagementPrefs(e=True, configFilePath="") - # third set rendering space and view transform - renderSpace = root_dict["renderSpace"] - cmds.colorManagementPrefs(e=True, renderingSpaceName=renderSpace) - viewTransform = root_dict["viewTransform"] - cmds.colorManagementPrefs(e=True, viewTransformName=viewTransform) + # If no custom OCIO config file was set we make sure that Maya 2022+ + # either chooses between Maya's newer default v2 or legacy config based + # on OpenPype setting to use ocio v2 or not. + if maya_supports_ocio_v2 and not custom_ocio_config: + if use_ocio_v2: + # Use Maya 2022+ default OCIO v2 config + log.info("Setting default Maya OCIO v2 config") + cmds.colorManagementPrefs(edit=True, configFilePath="") + else: + # Set the Maya default config file path + log.info("Setting default Maya OCIO v1 legacy config") + cmds.colorManagementPrefs(edit=True, configFilePath="legacy") + + # set color spaces for rendering space and view transforms + def _colormanage(**kwargs): + """Wrapper around `cmds.colorManagementPrefs`. + + This logs errors instead of raising an error so color management + settings get applied as much as possible. + + """ + assert len(kwargs) == 1, "Must receive one keyword argument" + try: + cmds.colorManagementPrefs(edit=True, **kwargs) + log.debug("Setting Color Management Preference: {}".format(kwargs)) + except RuntimeError as exc: + log.error(exc) + + if use_ocio_v2: + _colormanage(renderingSpaceName=root_dict["renderSpace"]) + _colormanage(displayName=root_dict["displayName"]) + _colormanage(viewName=root_dict["viewName"]) + else: + _colormanage(renderingSpaceName=root_dict["renderSpace"]) + if maya_supports_ocio_v2: + _colormanage(viewName=root_dict["viewTransform"]) + _colormanage(displayName="legacy") + else: + _colormanage(viewTransformName=root_dict["viewTransform"]) diff --git a/openpype/settings/defaults/project_anatomy/imageio.json b/openpype/settings/defaults/project_anatomy/imageio.json index 09ab398c37..1065ac58b2 100644 --- a/openpype/settings/defaults/project_anatomy/imageio.json +++ b/openpype/settings/defaults/project_anatomy/imageio.json @@ -177,6 +177,17 @@ } }, "maya": { + "colorManagementPreference_v2": { + "enabled": true, + "configFilePath": { + "windows": [], + "darwin": [], + "linux": [] + }, + "renderSpace": "ACEScg", + "viewName": "ACES 1.0 SDR-video", + "displayName": "sRGB" + }, "colorManagementPreference": { "configFilePath": { "windows": [], diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json index 380ea4a83d..fe37a450f3 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json @@ -377,11 +377,47 @@ "type": "dict", "label": "Maya", "children": [ + { + "key": "colorManagementPreference_v2", + "type": "dict", + "label": "Color Management Preference v2 (Maya 2022+)", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Use Color Management Preference v2" + }, + { + "type": "path", + "key": "configFilePath", + "label": "OCIO Config File Path", + "multiplatform": true, + "multipath": true + }, + { + "type": "text", + "key": "renderSpace", + "label": "Rendering Space" + }, + { + "type": "text", + "key": "displayName", + "label": "Display" + }, + { + "type": "text", + "key": "viewName", + "label": "View" + } + ] + }, { "key": "colorManagementPreference", "type": "dict", - "label": "Color Managment Preference", - "collapsible": false, + "label": "Color Management Preference (legacy)", + "collapsible": true, "children": [ { "type": "path", @@ -401,7 +437,7 @@ "label": "Viewer Transform" } ] - } + } ] } ] From f8d534f14a86393b5e3c809435111309a91c8a4b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 2 Feb 2022 12:50:30 +0100 Subject: [PATCH 077/483] Tweak Deadline Module ValidateExpectedFiles - Improve reported missing files by logging them sorted - Always check for use override, since updated Job frame list might be longer than initial frame list. - Check only for missing files instead of difference in files - allow other files to exist - Add a raised error if `file_name_template` could not be parsed - since that would be problematic if it happens? --- .../validate_expected_and_rendered_files.py | 70 +++++++++++-------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index 719c7dfe3e..cca8f06d9c 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -30,42 +30,54 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): staging_dir = repre["stagingDir"] existing_files = self._get_existing_files(staging_dir) - expected_non_existent = expected_files.difference( - existing_files) - if len(expected_non_existent) != 0: - self.log.info("Some expected files missing {}".format( - expected_non_existent)) + if self.allow_user_override: + # We always check for user override because the user might have + # also overridden the Job frame list to be longer than the + # originally submitted frame range + # todo: We should first check if Job frame range was overridden + # at all so we don't unnecessarily override anything + file_name_template, frame_placeholder = \ + self._get_file_name_template_and_placeholder( + expected_files) - if self.allow_user_override: - file_name_template, frame_placeholder = \ - self._get_file_name_template_and_placeholder( - expected_files) + if not file_name_template: + raise RuntimeError("Unable to retrieve file_name template" + "from files: {}".format(expected_files)) - if not file_name_template: - return + job_expected_files = self._get_job_expected_files( + file_name_template, + frame_placeholder, + frame_list) - real_expected_rendered = self._get_real_render_expected( - file_name_template, - frame_placeholder, - frame_list) + job_files_diff = job_expected_files.difference(expected_files) + if job_files_diff: + self.log.debug("Detected difference in expected output " + "files from Deadline job. Assuming an " + "updated frame list by the user. " + "Difference: {}".format( + sorted(job_files_diff))) - real_expected_non_existent = \ - real_expected_rendered.difference(existing_files) - if len(real_expected_non_existent) != 0: - raise RuntimeError("Still missing some files {}". - format(real_expected_non_existent)) - self.log.info("Update range from actual job range") + # Update the representation expected files + self.log.info("Update range from actual job range " + "to frame list: {}".format(frame_list)) repre["files"] = sorted(list(real_expected_rendered)) - else: - raise RuntimeError("Some expected files missing {}".format( - expected_non_existent)) + + # Update the expected files + expected_files = job_expected_files + + # We don't use set.difference because we do allow other existing + # files to be in the folder that we might not want to use. + missing = expected_files - existing_files + if missing: + raise RuntimeError("Missing expected files: {}".format( + sorted(missing))) def _get_frame_list(self, original_job_id): """ Returns list of frame ranges from all render job. - Render job might be requeried so job_id in metadata.json is invalid - GlobalJobPreload injects current ids to RENDER_JOB_IDS. + Render job might be re-queried so job_id in metadata.json is + invalid GlobalJobPreload injects current ids to RENDER_JOB_IDS. Args: original_job_id (str) @@ -87,8 +99,10 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): return all_frame_lists - def _get_real_render_expected(self, file_name_template, frame_placeholder, - frame_list): + def _get_job_expected_files(self, + file_name_template, + frame_placeholder, + frame_list): """ Calculates list of names of expected rendered files. From 5eca6a35385c74001676f561220cfc70abf342ce Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 2 Feb 2022 13:06:54 +0100 Subject: [PATCH 078/483] Tweak cosmetics for the Hound --- .../publish/validate_expected_and_rendered_files.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index cca8f06d9c..f4828435c2 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -51,11 +51,11 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): job_files_diff = job_expected_files.difference(expected_files) if job_files_diff: - self.log.debug("Detected difference in expected output " - "files from Deadline job. Assuming an " - "updated frame list by the user. " - "Difference: {}".format( - sorted(job_files_diff))) + self.log.debug( + "Detected difference in expected output files from " + "Deadline job. Assuming an updated frame list by the " + "user. Difference: {}".format(sorted(job_files_diff)) + ) # Update the representation expected files self.log.info("Update range from actual job range " From c8410674c28e50b348bf64dde738e85cd0fa6f18 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 2 Feb 2022 14:24:42 +0100 Subject: [PATCH 079/483] Remove redundant `list()` inside `sorted()` --- .../plugins/publish/validate_expected_and_rendered_files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index f4828435c2..d814cd6ae6 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -60,7 +60,7 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): # Update the representation expected files self.log.info("Update range from actual job range " "to frame list: {}".format(frame_list)) - repre["files"] = sorted(list(real_expected_rendered)) + repre["files"] = sorted(real_expected_rendered) # Update the expected files expected_files = job_expected_files From 307cfc92fdff72d0a67e84766dfa3a44762eba09 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 2 Feb 2022 14:29:38 +0100 Subject: [PATCH 080/483] Fix: Refactor variable name --- .../plugins/publish/validate_expected_and_rendered_files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index d814cd6ae6..896c7dbbc3 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -60,7 +60,7 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): # Update the representation expected files self.log.info("Update range from actual job range " "to frame list: {}".format(frame_list)) - repre["files"] = sorted(real_expected_rendered) + repre["files"] = sorted(job_expected_files) # Update the expected files expected_files = job_expected_files From 68d3f9ec3e53ba2f7c4c1efbfa2609f0903fe9ec Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 2 Feb 2022 18:25:38 +0100 Subject: [PATCH 081/483] fix imports --- openpype/hosts/maya/plugins/load/load_vrayscene.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 465dab2a76..61be634a42 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -1,6 +1,6 @@ from avalon.maya import lib from avalon import api -from openpype.api import config +from openpype.api import get_project_settings import os import maya.cmds as cmds @@ -19,7 +19,7 @@ class VRaySceneLoader(api.Loader): def load(self, context, name, namespace, data): from avalon.maya.pipeline import containerise - from openpype.hosts.maya.lib import namespaced + from openpype.hosts.maya.api.lib import namespaced try: family = context["representation"]["context"]["family"] @@ -47,8 +47,8 @@ class VRaySceneLoader(api.Loader): return # colour the group node - presets = config.get_presets(project=os.environ['AVALON_PROJECT']) - colors = presets['plugins']['maya']['load']['colors'] + settings = get_project_settings(os.environ['AVALON_PROJECT']) + colors = settings['maya']['load']['colors'] c = colors.get(family) if c is not None: cmds.setAttr("{0}.useOutlinerColor".format(group_node), 1) From c42d9a1b566876949c91fa8864568825e4d7f1d6 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 2 Feb 2022 22:26:22 +0100 Subject: [PATCH 082/483] Remove unused function and fix docstrings - Fix docstring that had invalid information - Refactor 'out_dir' to 'staging_dir' for consistency of what the variable refers to --- .../validate_expected_and_rendered_files.py | 68 +++++++++---------- 1 file changed, 32 insertions(+), 36 deletions(-) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index 896c7dbbc3..2566374d2b 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -73,16 +73,15 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): sorted(missing))) def _get_frame_list(self, original_job_id): - """ - Returns list of frame ranges from all render job. + """Returns list of frame ranges from all render job. - Render job might be re-queried so job_id in metadata.json is - invalid GlobalJobPreload injects current ids to RENDER_JOB_IDS. + Render job might be re-queried so job_id in metadata.json is + invalid GlobalJobPreload injects current ids to RENDER_JOB_IDS. - Args: - original_job_id (str) - Returns: - (list) + Args: + original_job_id (str) + Returns: + (list) """ all_frame_lists = [] render_job_ids = os.environ.get("RENDER_JOB_IDS") @@ -103,11 +102,11 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): file_name_template, frame_placeholder, frame_list): - """ - Calculates list of names of expected rendered files. + """Calculates list of names of expected rendered files. + + Might be different from expected files from submission if user + explicitly and manually changed the frame list on the Deadline job. - Might be different from job expected files if user explicitly and - manually change frame list on Deadline job. """ real_expected_rendered = set() src_padding_exp = "%0{}d".format(len(frame_placeholder)) @@ -137,11 +136,11 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): return file_name_template, frame_placeholder def _get_job_info(self, job_id): - """ - Calls DL for actual job info for 'job_id' + """Calls DL for actual job info for 'job_id' + + Might be different than job info saved in metadata.json if user + manually changes job pre/during rendering. - Might be different than job info saved in metadata.json if user - manually changes job pre/during rendering. """ # get default deadline webservice url from deadline module deadline_url = self.instance.context.data["defaultDeadline"] @@ -154,8 +153,8 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): try: response = requests_get(url) except requests.exceptions.ConnectionError: - print("Deadline is not accessible at {}".format(deadline_url)) - # self.log("Deadline is not accessible at {}".format(deadline_url)) + self.log.error("Deadline is not accessible at " + "{}".format(deadline_url)) return {} if not response.ok: @@ -169,29 +168,26 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): return json_content.pop() return {} - def _parse_metadata_json(self, json_path): - if not os.path.exists(json_path): - msg = "Metadata file {} doesn't exist".format(json_path) - raise RuntimeError(msg) - - with open(json_path) as fp: - try: - return json.load(fp) - except Exception as exc: - self.log.error( - "Error loading json: " - "{} - Exception: {}".format(json_path, exc) - ) - - def _get_existing_files(self, out_dir): - """Returns set of existing file names from 'out_dir'""" + def _get_existing_files(self, staging_dir): + """Returns set of existing file names from 'staging_dir'""" existing_files = set() - for file_name in os.listdir(out_dir): + for file_name in os.listdir(staging_dir): existing_files.add(file_name) return existing_files def _get_expected_files(self, repre): - """Returns set of file names from metadata.json""" + """Returns set of file names in representation['files'] + + The representations are collected from `CollectRenderedFiles` using + the metadata.json file submitted along with the render job. + + Args: + repre (dict): The representation containing 'files' + + Returns: + set: Set of expected file_names in the staging directory. + + """ expected_files = set() files = repre["files"] From ee614102796f3dbe32da48bcd4a78713180bbbed Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 2 Feb 2022 22:29:24 +0100 Subject: [PATCH 083/483] Tweak docstring info --- .../plugins/publish/validate_expected_and_rendered_files.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index 2566374d2b..ef2c2b03da 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -75,8 +75,8 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): def _get_frame_list(self, original_job_id): """Returns list of frame ranges from all render job. - Render job might be re-queried so job_id in metadata.json is - invalid GlobalJobPreload injects current ids to RENDER_JOB_IDS. + Render job might be re-submitted so job_id in metadata.json could be + invalid. GlobalJobPreload injects current job id to RENDER_JOB_IDS. Args: original_job_id (str) From 6fd4e7cf381e2e3601c256d1fd62787ed23a0ae7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 3 Feb 2022 13:12:56 +0100 Subject: [PATCH 084/483] Lock shape nodes --- openpype/hosts/maya/plugins/load/load_vrayscene.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 61be634a42..6cad4f3e1e 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -131,6 +131,10 @@ class VRaySceneLoader(api.Loader): cmds.setAttr("{}.FilePath".format(vray_scene), filename, type="string") + # Lock the shape nodes so the user cannot delete these + cmds.lockNode(mesh, lock=True) + cmds.lockNode(vray_scene, lock=True) + # Create important connections cmds.connectAttr("time1.outTime", "{0}.inputTime".format(trans)) From ed2908353d3a1b1bdbf242167f48abcc88bc745f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 3 Feb 2022 13:21:03 +0100 Subject: [PATCH 085/483] Don't create redundant extra group --- openpype/hosts/maya/plugins/load/load_vrayscene.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 6cad4f3e1e..40d7bd6403 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -39,8 +39,8 @@ class VRaySceneLoader(api.Loader): with lib.maintained_selection(): cmds.namespace(addNamespace=namespace) with namespaced(namespace, new=False): - nodes, group_node = self.create_vray_scene(name, - filename=self.fname) + nodes, root_node = self.create_vray_scene(name, + filename=self.fname) self[:] = nodes if not nodes: @@ -51,8 +51,8 @@ class VRaySceneLoader(api.Loader): colors = settings['maya']['load']['colors'] c = colors.get(family) if c is not None: - cmds.setAttr("{0}.useOutlinerColor".format(group_node), 1) - cmds.setAttr("{0}.outlinerColor".format(group_node), + cmds.setAttr("{0}.useOutlinerColor".format(root_node), 1) + cmds.setAttr("{0}.outlinerColor".format(root_node), (float(c[0])/255), (float(c[1])/255), (float(c[2])/255) @@ -142,11 +142,9 @@ class VRaySceneLoader(api.Loader): # Connect mesh to initialShadingGroup cmds.sets([mesh], forceElement="initialShadingGroup") - group_node = cmds.group(empty=True, name="{}_GRP".format(name)) - cmds.parent(trans, group_node) - nodes = [trans, vray_scene, mesh, group_node] + nodes = [trans, vray_scene, mesh] # Fix: Force refresh so the mesh shows correctly after creation cmds.refresh() - return nodes, group_node + return nodes, trans From b0aa53b52c31f3552f16680c4177b539414c8ee7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 3 Feb 2022 13:21:42 +0100 Subject: [PATCH 086/483] Remove redundant string format --- openpype/hosts/maya/plugins/load/load_vrayscene.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 40d7bd6403..3c0edac9a8 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -120,7 +120,7 @@ class VRaySceneLoader(api.Loader): mesh_node_name = "VRayScene_{}".format(name) trans = cmds.createNode( - "transform", name="{}".format(mesh_node_name)) + "transform", name=mesh_node_name) mesh = cmds.createNode( "mesh", name="{}_Shape".format(mesh_node_name), parent=trans) vray_scene = cmds.createNode( From e7e8235be1dab0aecdc0295903e9025fab0acb6b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 3 Feb 2022 13:28:29 +0100 Subject: [PATCH 087/483] Add V-Ray Scene to Maya Loaded Subsets Outliner Colors settings --- openpype/settings/defaults/project_settings/maya.json | 6 ++++++ .../schemas/projects_schema/schemas/schema_maya_load.json | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 52b8db058c..2712aeb1b2 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -575,6 +575,12 @@ 12, 255 ], + "vrayscene_layer": [ + 255, + 150, + 12, + 255 + ], "yeticache": [ 99, 206, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json index 7c87644817..6b2315abc0 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json @@ -75,6 +75,11 @@ "label": "Vray Proxy:", "key": "vrayproxy" }, + { + "type": "color", + "label": "Vray Scene:", + "key": "vrayscene_layer" + }, { "type": "color", "label": "Yeti Cache:", From 5269510ed9707d40b7b740770e5e61babd3fe116 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 3 Feb 2022 13:38:09 +0100 Subject: [PATCH 088/483] Create and parent the V-Ray Scene first to transform so that outliner shows VRayScene icon on transform --- openpype/hosts/maya/plugins/load/load_vrayscene.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 3c0edac9a8..5a67ab859d 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -121,10 +121,10 @@ class VRaySceneLoader(api.Loader): trans = cmds.createNode( "transform", name=mesh_node_name) - mesh = cmds.createNode( - "mesh", name="{}_Shape".format(mesh_node_name), parent=trans) vray_scene = cmds.createNode( "VRayScene", name="{}_VRSCN".format(mesh_node_name), parent=trans) + mesh = cmds.createNode( + "mesh", name="{}_Shape".format(mesh_node_name), parent=trans) cmds.connectAttr( "{}.outMesh".format(vray_scene), "{}.inMesh".format(mesh)) From 0724f70eeed1d9276b70a53893aaadfedbb6a526 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Thu, 20 Jan 2022 18:10:08 +0100 Subject: [PATCH 089/483] add sequence image in photoshop --- .../plugins/publish/extract_review.py | 141 ++++++++++++------ .../defaults/project_settings/photoshop.json | 3 +- .../schema_project_photoshop.json | 5 + 3 files changed, 103 insertions(+), 46 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 1ad442279a..57ad573aae 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -1,4 +1,5 @@ import os +import shutil import openpype.api import openpype.lib @@ -7,7 +8,7 @@ from openpype.hosts.photoshop import api as photoshop class ExtractReview(openpype.api.Extractor): """ - Produce a flattened image file from all 'image' instances. + Produce a flattened or sequence image file from all 'image' instances. If no 'image' instance is created, it produces flattened image from all visible layers. @@ -20,54 +21,43 @@ class ExtractReview(openpype.api.Extractor): # Extract Options jpg_options = None mov_options = None + make_image_sequence = None def process(self, instance): - staging_dir = self.staging_dir(instance) - self.log.info("Outputting image to {}".format(staging_dir)) + self.staging_dir = self.staging_dir(instance) + self.log.info("Outputting image to {}".format(self.staging_dir)) - stub = photoshop.stub() + self.stub = photoshop.stub() + self.output_seq_filename = os.path.splitext( + self.stub.get_active_document_name())[0] + ".%04d.jpg" - layers = [] - for image_instance in instance.context: - if image_instance.data["family"] != "image": - continue - layers.append(image_instance[0]) - - # Perform extraction - output_image = "{}.jpg".format( - os.path.splitext(stub.get_active_document_name())[0] - ) - output_image_path = os.path.join(staging_dir, output_image) - with photoshop.maintained_visibility(): - if layers: - # Hide all other layers. - extract_ids = set([ll.id for ll in stub. - get_layers_in_layers(layers)]) - self.log.debug("extract_ids {}".format(extract_ids)) - for layer in stub.get_layers(): - # limit unnecessary calls to client - if layer.visible and layer.id not in extract_ids: - stub.set_visible(layer.id, False) - - stub.saveAs(output_image_path, 'jpg', True) + new_img_list = src_img_list = [] + if self.make_image_sequence: + src_img_list = self._get_image_path_from_instances(instance) + if self.make_image_sequence and src_img_list: + new_img_list = self._copy_image_to_staging_dir(src_img_list) + else: + layers = self._get_layers_from_instance(instance) + new_img_list = self._saves_flattened_layers(layers) + instance.data["representations"].append({ + "name": "jpg", + "ext": "jpg", + "files": new_img_list, + "stagingDir": self.staging_dir, + "tags": self.jpg_options['tags'] + }) ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") - instance.data["representations"].append({ - "name": "jpg", - "ext": "jpg", - "files": output_image, - "stagingDir": staging_dir, - "tags": self.jpg_options['tags'] - }) - instance.data["stagingDir"] = staging_dir + instance.data["stagingDir"] = self.staging_dir # Generate thumbnail. - thumbnail_path = os.path.join(staging_dir, "thumbnail.jpg") + thumbnail_path = os.path.join(self.staging_dir, "thumbnail.jpg") + self.log.info(f"Generate thumbnail {thumbnail_path}") args = [ ffmpeg_path, "-y", - "-i", output_image_path, + "-i", os.path.join(self.staging_dir, self.output_seq_filename), "-vf", "scale=300:-1", "-vframes", "1", thumbnail_path @@ -78,17 +68,20 @@ class ExtractReview(openpype.api.Extractor): "name": "thumbnail", "ext": "jpg", "files": os.path.basename(thumbnail_path), - "stagingDir": staging_dir, + "stagingDir": self.staging_dir, "tags": ["thumbnail"] }) + # Generate mov. - mov_path = os.path.join(staging_dir, "review.mov") + mov_path = os.path.join(self.staging_dir, "review.mov") + self.log.info(f"Generate mov review: {mov_path}") + img_number = len(new_img_list) args = [ ffmpeg_path, "-y", - "-i", output_image_path, + "-i", os.path.join(self.staging_dir, self.output_seq_filename), "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", - "-vframes", "1", + "-vframes", str(img_number), mov_path ] output = openpype.lib.run_subprocess(args) @@ -97,9 +90,9 @@ class ExtractReview(openpype.api.Extractor): "name": "mov", "ext": "mov", "files": os.path.basename(mov_path), - "stagingDir": staging_dir, + "stagingDir":self.staging_dir, "frameStart": 1, - "frameEnd": 1, + "frameEnd": img_number, "fps": 25, "preview": True, "tags": self.mov_options['tags'] @@ -107,7 +100,65 @@ class ExtractReview(openpype.api.Extractor): # Required for extract_review plugin (L222 onwards). instance.data["frameStart"] = 1 - instance.data["frameEnd"] = 1 + instance.data["frameEnd"] = img_number instance.data["fps"] = 25 - self.log.info(f"Extracted {instance} to {staging_dir}") + self.log.info(f"Extracted {instance} to {self.staging_dir}") + + def _get_image_path_from_instances(self, instance): + img_list = [] + + for instance in instance.context: + if instance.data["family"] != "image": + continue + + for rep in instance.data["representations"]: + img_path = os.path.join( + rep["stagingDir"], + rep["files"] + ) + img_list.append(img_path) + + return img_list + + def _copy_image_to_staging_dir(self, img_list): + copy_files = [] + for i, img_src in enumerate(img_list): + img_filename = self.output_seq_filename %i + img_dst = os.path.join(self.staging_dir, img_filename) + + self.log.debug( + "Copying file .. {} -> {}".format(img_src, img_dst) + ) + shutil.copy(img_src, img_dst) + copy_files.append(img_filename) + + return copy_files + + def _get_layers_from_instance(self, instance): + layers = [] + for image_instance in instance.context: + if image_instance.data["family"] != "image": + continue + layers.append(image_instance[0]) + + return layers + + def _saves_flattened_layers(self, layers): + img_filename = self.output_seq_filename %0 + output_image_path = os.path.join(self.staging_dir, img_filename) + + with photoshop.maintained_visibility(): + if layers: + # Hide all other layers. + extract_ids = set([ll.id for ll in self.stub. + get_layers_in_layers(layers)]) + self.log.debug("extract_ids {}".format(extract_ids)) + for layer in self.stub.get_layers(): + # limit unnecessary calls to client + if layer.visible and layer.id not in extract_ids: + self.stub.set_visible(layer.id, False) + + self.stub.saveAs(output_image_path, 'jpg', True) + + return img_filename diff --git a/openpype/settings/defaults/project_settings/photoshop.json b/openpype/settings/defaults/project_settings/photoshop.json index 31cd815dd8..b679d9c880 100644 --- a/openpype/settings/defaults/project_settings/photoshop.json +++ b/openpype/settings/defaults/project_settings/photoshop.json @@ -33,6 +33,7 @@ ] }, "ExtractReview": { + "make_image_sequence": false, "jpg_options": { "tags": [] }, @@ -48,4 +49,4 @@ "create_first_version": false, "custom_templates": [] } -} \ No newline at end of file +} diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json index 51ea5b3fe7..644e53cc95 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json @@ -154,6 +154,11 @@ "key": "ExtractReview", "label": "Extract Review", "children": [ + { + "type": "boolean", + "key": "make_image_sequence", + "label": "Makes an image sequence instead of a flatten image" + }, { "type": "dict", "collapsible": false, From 797a1615a3a7084b6f91ee188b8ce3b54c8789fa Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Fri, 21 Jan 2022 11:00:15 +0100 Subject: [PATCH 090/483] remove storing data in object --- .../plugins/publish/extract_review.py | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 57ad573aae..031ef5eefa 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -24,40 +24,43 @@ class ExtractReview(openpype.api.Extractor): make_image_sequence = None def process(self, instance): - self.staging_dir = self.staging_dir(instance) - self.log.info("Outputting image to {}".format(self.staging_dir)) + staging_dir = self.staging_dir(instance) + self.log.info("Outputting image to {}".format(staging_dir)) - self.stub = photoshop.stub() + stub = photoshop.stub() self.output_seq_filename = os.path.splitext( - self.stub.get_active_document_name())[0] + ".%04d.jpg" + stub.get_active_document_name())[0] + ".%04d.jpg" new_img_list = src_img_list = [] if self.make_image_sequence: src_img_list = self._get_image_path_from_instances(instance) if self.make_image_sequence and src_img_list: - new_img_list = self._copy_image_to_staging_dir(src_img_list) + new_img_list = self._copy_image_to_staging_dir( + staging_dir, + src_img_list + ) else: layers = self._get_layers_from_instance(instance) - new_img_list = self._saves_flattened_layers(layers) + new_img_list = self._saves_flattened_layers(staging_dir, layers) instance.data["representations"].append({ "name": "jpg", "ext": "jpg", "files": new_img_list, - "stagingDir": self.staging_dir, + "stagingDir": staging_dir, "tags": self.jpg_options['tags'] }) ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") - instance.data["stagingDir"] = self.staging_dir + instance.data["stagingDir"] = staging_dir # Generate thumbnail. - thumbnail_path = os.path.join(self.staging_dir, "thumbnail.jpg") + thumbnail_path = os.path.join(staging_dir, "thumbnail.jpg") self.log.info(f"Generate thumbnail {thumbnail_path}") args = [ ffmpeg_path, "-y", - "-i", os.path.join(self.staging_dir, self.output_seq_filename), + "-i", os.path.join(staging_dir, self.output_seq_filename), "-vf", "scale=300:-1", "-vframes", "1", thumbnail_path @@ -68,18 +71,18 @@ class ExtractReview(openpype.api.Extractor): "name": "thumbnail", "ext": "jpg", "files": os.path.basename(thumbnail_path), - "stagingDir": self.staging_dir, + "stagingDir": staging_dir, "tags": ["thumbnail"] }) # Generate mov. - mov_path = os.path.join(self.staging_dir, "review.mov") + mov_path = os.path.join(staging_dir, "review.mov") self.log.info(f"Generate mov review: {mov_path}") img_number = len(new_img_list) args = [ ffmpeg_path, "-y", - "-i", os.path.join(self.staging_dir, self.output_seq_filename), + "-i", os.path.join(staging_dir, self.output_seq_filename), "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-vframes", str(img_number), mov_path @@ -90,7 +93,7 @@ class ExtractReview(openpype.api.Extractor): "name": "mov", "ext": "mov", "files": os.path.basename(mov_path), - "stagingDir":self.staging_dir, + "stagingDir": staging_dir, "frameStart": 1, "frameEnd": img_number, "fps": 25, @@ -103,7 +106,7 @@ class ExtractReview(openpype.api.Extractor): instance.data["frameEnd"] = img_number instance.data["fps"] = 25 - self.log.info(f"Extracted {instance} to {self.staging_dir}") + self.log.info(f"Extracted {instance} to {staging_dir}") def _get_image_path_from_instances(self, instance): img_list = [] @@ -121,11 +124,11 @@ class ExtractReview(openpype.api.Extractor): return img_list - def _copy_image_to_staging_dir(self, img_list): + def _copy_image_to_staging_dir(self, staging_dir, img_list): copy_files = [] for i, img_src in enumerate(img_list): - img_filename = self.output_seq_filename %i - img_dst = os.path.join(self.staging_dir, img_filename) + img_filename = self.output_seq_filename % i + img_dst = os.path.join(staging_dir, img_filename) self.log.debug( "Copying file .. {} -> {}".format(img_src, img_dst) @@ -144,21 +147,22 @@ class ExtractReview(openpype.api.Extractor): return layers - def _saves_flattened_layers(self, layers): - img_filename = self.output_seq_filename %0 - output_image_path = os.path.join(self.staging_dir, img_filename) + def _saves_flattened_layers(self, staging_dir, layers): + img_filename = self.output_seq_filename % 0 + output_image_path = os.path.join(staging_dir, img_filename) + stub = photoshop.stub() with photoshop.maintained_visibility(): if layers: # Hide all other layers. - extract_ids = set([ll.id for ll in self.stub. + extract_ids = set([ll.id for ll in stub. get_layers_in_layers(layers)]) self.log.debug("extract_ids {}".format(extract_ids)) - for layer in self.stub.get_layers(): + for layer in stub.get_layers(): # limit unnecessary calls to client if layer.visible and layer.id not in extract_ids: - self.stub.set_visible(layer.id, False) + stub.set_visible(layer.id, False) - self.stub.saveAs(output_image_path, 'jpg', True) + stub.saveAs(output_image_path, 'jpg', True) return img_filename From 856a8e2b02ecb3937c354ac5eb67826dcf7d633a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 8 Feb 2022 14:22:50 +0100 Subject: [PATCH 091/483] fix imports after merge --- openpype/tools/launcher/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index ab22809f7c..eea32e1aee 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -8,8 +8,8 @@ import time import appdirs from Qt import QtCore, QtGui from avalon.vendor import qtawesome -from avalon import style, api -from openpype.lib import ApplicationManager +from avalon import api +from openpype.lib import ApplicationManager, JSONSettingRegistry from openpype.tools.utils.lib import DynamicQThread from openpype.tools.utils.assets_widget import ( AssetModel, From b31f7b2f2067b5c9dfd1e435ebf8f15e9ead0ddc Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Wed, 9 Feb 2022 16:36:48 +0100 Subject: [PATCH 092/483] sort instance by name --- openpype/hosts/photoshop/plugins/publish/extract_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 031ef5eefa..5bda02d51c 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -111,7 +111,7 @@ class ExtractReview(openpype.api.Extractor): def _get_image_path_from_instances(self, instance): img_list = [] - for instance in instance.context: + for instance in sorted(instance.context): if instance.data["family"] != "image": continue From fba83d883775e1952f6358d04133db5d845ed829 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Thu, 10 Feb 2022 20:42:57 +0100 Subject: [PATCH 093/483] extract jpg in review --- .../plugins/publish/extract_image.py | 9 +-- .../plugins/publish/extract_review.py | 74 +++++++++++++------ 2 files changed, 51 insertions(+), 32 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index 2ba81e0bac..88b9a6c1bd 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -26,14 +26,7 @@ class ExtractImage(openpype.api.Extractor): with photoshop.maintained_selection(): self.log.info("Extracting %s" % str(list(instance))) with photoshop.maintained_visibility(): - # Hide all other layers. - extract_ids = set([ll.id for ll in stub. - get_layers_in_layers([instance[0]])]) - - for layer in stub.get_layers(): - # limit unnecessary calls to client - if layer.visible and layer.id not in extract_ids: - stub.set_visible(layer.id, False) + stub.hide_all_others_layers([instance[0]]) file_basename = os.path.splitext( stub.get_active_document_name() diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 5bda02d51c..f8e6cae040 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -27,25 +27,39 @@ class ExtractReview(openpype.api.Extractor): staging_dir = self.staging_dir(instance) self.log.info("Outputting image to {}".format(staging_dir)) + fps = instance.data.get("fps", 25) stub = photoshop.stub() self.output_seq_filename = os.path.splitext( stub.get_active_document_name())[0] + ".%04d.jpg" - new_img_list = src_img_list = [] - if self.make_image_sequence: - src_img_list = self._get_image_path_from_instances(instance) - if self.make_image_sequence and src_img_list: - new_img_list = self._copy_image_to_staging_dir( - staging_dir, - src_img_list - ) - else: - layers = self._get_layers_from_instance(instance) - new_img_list = self._saves_flattened_layers(staging_dir, layers) + + layers = self._get_layers_from_image_instances(instance) + self.log.info("Layers image instance found: {}".format(layers)) + + img_list = [] + if self.make_image_sequence and layers: + self.log.info("Extract layers to image sequence.") + img_list = self._saves_sequences_layers(staging_dir, layers) + instance.data["representations"].append({ "name": "jpg", "ext": "jpg", - "files": new_img_list, + "files": img_list, + "frameStart": 0, + "frameEnd": len(img_list), + "fps": fps, + "stagingDir": staging_dir, + "tags": self.jpg_options['tags'], #"review" + }) + + else: + self.log.info("Extract layers to flatten image.") + img_list = self._saves_flattened_layers(staging_dir, layers) + + instance.data["representations"].append({ + "name": "jpg", + "ext": "jpg", + "files": img_list, "stagingDir": staging_dir, "tags": self.jpg_options['tags'] }) @@ -78,7 +92,7 @@ class ExtractReview(openpype.api.Extractor): # Generate mov. mov_path = os.path.join(staging_dir, "review.mov") self.log.info(f"Generate mov review: {mov_path}") - img_number = len(new_img_list) + img_number = len(img_list) args = [ ffmpeg_path, "-y", @@ -96,7 +110,7 @@ class ExtractReview(openpype.api.Extractor): "stagingDir": staging_dir, "frameStart": 1, "frameEnd": img_number, - "fps": 25, + "fps": fps, "preview": True, "tags": self.mov_options['tags'] }) @@ -138,14 +152,14 @@ class ExtractReview(openpype.api.Extractor): return copy_files - def _get_layers_from_instance(self, instance): + def _get_layers_from_image_instances(self, instance): layers = [] for image_instance in instance.context: if image_instance.data["family"] != "image": continue layers.append(image_instance[0]) - return layers + return sorted(layers) def _saves_flattened_layers(self, staging_dir, layers): img_filename = self.output_seq_filename % 0 @@ -153,16 +167,28 @@ class ExtractReview(openpype.api.Extractor): stub = photoshop.stub() with photoshop.maintained_visibility(): + self.log.info("Extracting {}".format(layers)) if layers: - # Hide all other layers. - extract_ids = set([ll.id for ll in stub. - get_layers_in_layers(layers)]) - self.log.debug("extract_ids {}".format(extract_ids)) - for layer in stub.get_layers(): - # limit unnecessary calls to client - if layer.visible and layer.id not in extract_ids: - stub.set_visible(layer.id, False) + stub.hide_all_others_layers(layers) stub.saveAs(output_image_path, 'jpg', True) return img_filename + + def _saves_sequences_layers(self, staging_dir, layers): + stub = photoshop.stub() + + list_img_filename = [] + with photoshop.maintained_visibility(): + for i, layer in enumerate(layers): + self.log.info("Extracting {}".format(layer)) + + img_filename = self.output_seq_filename % i + output_image_path = os.path.join(staging_dir, img_filename) + list_img_filename.append(img_filename) + + with photoshop.maintained_visibility(): + stub.hide_all_others_layers([layer]) + stub.saveAs(output_image_path, 'jpg', True) + + return list_img_filename From b405a092e08d11f78662c7d0b146fd41c7abf3b5 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Thu, 10 Feb 2022 20:42:33 +0100 Subject: [PATCH 094/483] create method hide_all_others_layers --- openpype/hosts/photoshop/api/ws_stub.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py index b8f66332c6..9db8f38a32 100644 --- a/openpype/hosts/photoshop/api/ws_stub.py +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -327,6 +327,19 @@ class PhotoshopServerStub: ) ) + def hide_all_others_layers(self, layers): + """hides all layers that are not part of the list or that are not + children of this list + + Args: + layers (list): list of PSItem + """ + extract_ids = set([ll.id for ll in self.get_layers_in_layers(layers)]) + + for layer in self.get_layers(): + if layer.visible and layer.id not in extract_ids: + self.set_visible(layer.id, False) + def get_layers_metadata(self): """Reads layers metadata from Headline from active document in PS. (Headline accessible by File > File Info) From c2421af820e7f5bc98ba29e0f50e6b17316092ba Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Thu, 10 Feb 2022 20:48:59 +0100 Subject: [PATCH 095/483] fix formatting --- openpype/hosts/photoshop/api/ws_stub.py | 2 +- openpype/hosts/photoshop/plugins/publish/extract_review.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py index 9db8f38a32..fc114f759e 100644 --- a/openpype/hosts/photoshop/api/ws_stub.py +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -328,7 +328,7 @@ class PhotoshopServerStub: ) def hide_all_others_layers(self, layers): - """hides all layers that are not part of the list or that are not + """hides all layers that are not part of the list or that are not children of this list Args: diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index f8e6cae040..b9750922f8 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -32,7 +32,6 @@ class ExtractReview(openpype.api.Extractor): self.output_seq_filename = os.path.splitext( stub.get_active_document_name())[0] + ".%04d.jpg" - layers = self._get_layers_from_image_instances(instance) self.log.info("Layers image instance found: {}".format(layers)) @@ -49,7 +48,7 @@ class ExtractReview(openpype.api.Extractor): "frameEnd": len(img_list), "fps": fps, "stagingDir": staging_dir, - "tags": self.jpg_options['tags'], #"review" + "tags": self.jpg_options['tags'], }) else: From 4af3b1b1f997dd24e5d48391cbf2337602be403d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 11 Feb 2022 17:25:57 +0100 Subject: [PATCH 096/483] Draft implementation for search in settings --- openpype/tools/settings/settings/search.py | 137 +++++++++++++++++++++ openpype/tools/settings/settings/window.py | 25 ++++ 2 files changed, 162 insertions(+) create mode 100644 openpype/tools/settings/settings/search.py diff --git a/openpype/tools/settings/settings/search.py b/openpype/tools/settings/settings/search.py new file mode 100644 index 0000000000..3d61dd33fe --- /dev/null +++ b/openpype/tools/settings/settings/search.py @@ -0,0 +1,137 @@ +from functools import partial +import re + +from Qt import QtCore, QtWidgets +from openpype.tools.utils.models import TreeModel, Item +from openpype.tools.utils.lib import schedule + + +def get_entity_children(entity): + + if hasattr(entity, "values"): + return entity.values() + return [] + + +class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): + """Filters recursively to regex in all columns""" + + def __init__(self): + super(RecursiveSortFilterProxyModel, self).__init__() + + # Note: Recursive filtering was introduced in Qt 5.10. + self.setRecursiveFilteringEnabled(True) + + def filterAcceptsRow(self, row, parent): + + if not parent.isValid(): + return False + + regex = self.filterRegExp() + if not regex.isEmpty() and regex.isValid(): + pattern = regex.pattern() + source_model = self.sourceModel() + + # Check current index itself in all columns + for column in range(source_model.columnCount(parent)): + source_index = source_model.index(row, column, parent) + if not source_index.isValid(): + continue + + key = source_model.data(source_index, self.filterRole()) + if not key: + continue + + if re.search(pattern, key, re.IGNORECASE): + return True + + return False + + return super(RecursiveSortFilterProxyModel, + self).filterAcceptsRow(row, parent) + + +class SearchEntitiesDialog(QtWidgets.QDialog): + + path_clicked = QtCore.Signal(str) + + def __init__(self, entity, parent=None): + super(SearchEntitiesDialog, self).__init__(parent=parent) + + layout = QtWidgets.QVBoxLayout(self) + + filter_edit = QtWidgets.QLineEdit() + filter_edit.setPlaceholderText("Search..") + + model = EntityTreeModel() + proxy = RecursiveSortFilterProxyModel() + proxy.setSourceModel(model) + proxy.setDynamicSortFilter(True) + view = QtWidgets.QTreeView() + view.setModel(proxy) + + layout.addWidget(filter_edit) + layout.addWidget(view) + + filter_edit.textChanged.connect(self._on_filter_changed) + view.selectionModel().selectionChanged.connect(self.on_select) + + view.setAllColumnsShowFocus(True) + view.setSortingEnabled(True) + view.sortByColumn(1, QtCore.Qt.AscendingOrder) + + self._model = model + self._proxy = proxy + self._view = view + + # Refresh to the passed entity + model.set_root(entity) + + view.resizeColumnToContents(0) + + def _on_filter_changed(self, txt): + # Provide slight delay to filtering so user can type quickly + schedule(partial(self.on_filter_changed, txt), 250, channel="search") + + def on_filter_changed(self, txt): + self._proxy.setFilterRegExp(txt) + + # WARNING This expanding and resizing is relatively slow. + self._view.expandAll() + self._view.resizeColumnToContents(0) + + def on_select(self): + current = self._view.currentIndex() + item = current.data(EntityTreeModel.ItemRole) + self.path_clicked.emit(item["path"]) + + +class EntityTreeModel(TreeModel): + + Columns = ["trail", "label", "key", "path"] + + def add_entity(self, entity, parent=None): + + item = Item() + + # Label and key can sometimes be emtpy so we use the trail from path + # in the most left column since it's never empty + item["trail"] = entity.path.rsplit("/", 1)[-1] + item["label"] = entity.label + item["key"] = entity.key + item["path"] = entity.path + + parent.add_child(item) + + for child in get_entity_children(entity): + self.add_entity(child, parent=item) + + def set_root(self, root_entity): + self.clear() + self.beginResetModel() + + # We don't want to see the root entity so we directly add its children + for child in get_entity_children(root_entity): + self.add_entity(child, parent=self._root_item) + self.endResetModel() + diff --git a/openpype/tools/settings/settings/window.py b/openpype/tools/settings/settings/window.py index 411e7b5e7f..bbfba088ba 100644 --- a/openpype/tools/settings/settings/window.py +++ b/openpype/tools/settings/settings/window.py @@ -164,3 +164,28 @@ class MainWidget(QtWidgets.QWidget): result = dialog.exec_() if result == 1: self.trigger_restart.emit() + + def keyPressEvent(self, event): + + # todo: This might not be the cleanest place but works for prototype + if event.matches(QtGui.QKeySequence.Find): + print("Search!") + + # todo: search in all widgets (or in active)? + widget = self._header_tab_widget.currentWidget() + root_entity = widget.entity + + from .search import SearchEntitiesDialog + search = SearchEntitiesDialog(root_entity, parent=self) + search.resize(700, 500) + search.setWindowTitle("Search") + search.show() + + def on_path(path): + widget.breadcrumbs_widget.change_path(path) + + search.path_clicked.connect(on_path) + event.accept() + return + + return super(MainWidget, self).keyPressEvent(event) From 41d4358500ffe41c043b3ceef76fcde647837051 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 11 Feb 2022 17:34:12 +0100 Subject: [PATCH 097/483] context set by env vars, configurable review for collected jobs --- .../publish/collect_sequences_from_job.py | 3 +- .../perjob/m50__openpype_publish_render.py | 16 ++++++++- .../project_settings/royalrender.json | 7 ++++ .../schemas/projects_schema/schema_main.json | 4 +++ .../schema_project_royalrender.json | 34 +++++++++++++++++++ 5 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 openpype/settings/defaults/project_settings/royalrender.json create mode 100644 openpype/settings/entities/schemas/projects_schema/schema_project_royalrender.json diff --git a/openpype/modules/default_modules/royal_render/plugins/publish/collect_sequences_from_job.py b/openpype/modules/default_modules/royal_render/plugins/publish/collect_sequences_from_job.py index b389b022cf..3f435990e2 100644 --- a/openpype/modules/default_modules/royal_render/plugins/publish/collect_sequences_from_job.py +++ b/openpype/modules/default_modules/royal_render/plugins/publish/collect_sequences_from_job.py @@ -77,6 +77,7 @@ class CollectSequencesFromJob(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder targets = ["rr_control"] label = "Collect Rendered Frames" + review = True def process(self, context): if os.environ.get("OPENPYPE_PUBLISH_DATA"): @@ -150,7 +151,7 @@ class CollectSequencesFromJob(pyblish.api.ContextPlugin): families.append("render") if "ftrack" not in families: families.append("ftrack") - if "review" not in families: + if "review" not in families and self.review: families.append("review") for collection in collections: diff --git a/openpype/modules/default_modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py b/openpype/modules/default_modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py index 7fedb51410..eafb6ffb84 100644 --- a/openpype/modules/default_modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py +++ b/openpype/modules/default_modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py @@ -136,7 +136,7 @@ class OpenPypeContextSelector: def run_publish(self): """Run publish process.""" - env = {'AVALON_PROJECT': str(self.context.get("project")), + env = {"AVALON_PROJECT": str(self.context.get("project")), "AVALON_ASSET": str(self.context.get("asset")), "AVALON_TASK": str(self.context.get("task")), "AVALON_APP_NAME": str(self.context.get("app_name"))} @@ -179,4 +179,18 @@ class OpenPypeContextSelector: print("running selector") selector = OpenPypeContextSelector() + +# try to set context from environment +selector.context["project"] = os.getenv("AVALON_PROJECT") +selector.context["asset"] = os.getenv("AVALON_ASSET") +selector.context["task"] = os.getenv("AVALON_TASK") +selector.context["app_name"] = os.getenv("AVALON_APP_NAME") + +# if anything inside is None, scratch the whole thing and +# ask user for context. +for _, v in selector.context.items(): + if not v: + selector.context = {} + break + selector.process_job() diff --git a/openpype/settings/defaults/project_settings/royalrender.json b/openpype/settings/defaults/project_settings/royalrender.json new file mode 100644 index 0000000000..be267b11d8 --- /dev/null +++ b/openpype/settings/defaults/project_settings/royalrender.json @@ -0,0 +1,7 @@ +{ + "publish": { + "CollectSequencesFromJob": { + "review": true + } + } +} \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index 8a2ad451ee..8e4eba86ef 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -66,6 +66,10 @@ "type": "schema", "name": "schema_project_deadline" }, + { + "type": "schema", + "name": "schema_project_royalrender" + }, { "type": "schema", "name": "schema_project_slack" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_royalrender.json b/openpype/settings/entities/schemas/projects_schema/schema_project_royalrender.json new file mode 100644 index 0000000000..cabb4747d5 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_royalrender.json @@ -0,0 +1,34 @@ +{ + "type": "dict", + "key": "royalrender", + "label": "Royal Render", + "collapsible": true, + "is_file": true, + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "publish", + "label": "Publish plugins", + "children": [ + { + "type": "label", + "label": "Collectors" + }, + { + "type": "dict", + "collapsible": true, + "key": "CollectSequencesFromJob", + "label": "Collect Sequences from the Job", + "children": [ + { + "type": "boolean", + "key": "review", + "label": "Generate reviews from sequences" + } + ] + } + ] + } + ] +} \ No newline at end of file From 12e7f7534750b2b086a6b6428ff7bb7fe111ca39 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Sat, 12 Feb 2022 13:30:03 +0100 Subject: [PATCH 098/483] renamed search.py to search_dialog.py --- .../tools/settings/settings/{search.py => search_dialog.py} | 0 openpype/tools/settings/settings/window.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename openpype/tools/settings/settings/{search.py => search_dialog.py} (100%) diff --git a/openpype/tools/settings/settings/search.py b/openpype/tools/settings/settings/search_dialog.py similarity index 100% rename from openpype/tools/settings/settings/search.py rename to openpype/tools/settings/settings/search_dialog.py diff --git a/openpype/tools/settings/settings/window.py b/openpype/tools/settings/settings/window.py index bbfba088ba..55930b5d88 100644 --- a/openpype/tools/settings/settings/window.py +++ b/openpype/tools/settings/settings/window.py @@ -5,6 +5,7 @@ from .categories import ( ProjectWidget ) from .widgets import ShadowWidget, RestartDialog +from .search_dialog import SearchEntitiesDialog from openpype import style from openpype.lib import is_admin_password_required @@ -175,7 +176,6 @@ class MainWidget(QtWidgets.QWidget): widget = self._header_tab_widget.currentWidget() root_entity = widget.entity - from .search import SearchEntitiesDialog search = SearchEntitiesDialog(root_entity, parent=self) search.resize(700, 500) search.setWindowTitle("Search") From 61b07c356dc404437a1f4b0ebd4956af6abfaa67 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Sat, 12 Feb 2022 15:08:48 +0100 Subject: [PATCH 099/483] extended search dialog and handle reset and recreation of dialog model --- .../tools/settings/settings/categories.py | 9 + .../tools/settings/settings/search_dialog.py | 173 +++++++++++------- openpype/tools/settings/settings/window.py | 52 ++++-- 3 files changed, 157 insertions(+), 77 deletions(-) diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index adbde00bf1..28e38ea51d 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -86,6 +86,8 @@ class SettingsCategoryWidget(QtWidgets.QWidget): state_changed = QtCore.Signal() saved = QtCore.Signal(QtWidgets.QWidget) restart_required_trigger = QtCore.Signal() + restart_started = QtCore.Signal() + restart_finished = QtCore.Signal() full_path_requested = QtCore.Signal(str, str) def __init__(self, user_role, parent=None): @@ -307,7 +309,12 @@ class SettingsCategoryWidget(QtWidgets.QWidget): """Change path of widget based on category full path.""" pass + def change_path(self, path): + """Change path and go to widget.""" + self.breadcrumbs_widget.change_path(path) + def set_path(self, path): + """Called from clicked widget.""" self.breadcrumbs_widget.set_path(path) def _add_developer_ui(self, footer_layout): @@ -429,6 +436,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget): self.require_restart_label.setText(value) def reset(self): + self.restart_started.emit() self.set_state(CategoryState.Working) self._on_reset_start() @@ -509,6 +517,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget): self._on_reset_crash() else: self._on_reset_success() + self.restart_finished.emit() def _on_reset_crash(self): self.save_btn.setEnabled(False) diff --git a/openpype/tools/settings/settings/search_dialog.py b/openpype/tools/settings/settings/search_dialog.py index 3d61dd33fe..3f987c0010 100644 --- a/openpype/tools/settings/settings/search_dialog.py +++ b/openpype/tools/settings/settings/search_dialog.py @@ -1,13 +1,14 @@ -from functools import partial import re +import collections -from Qt import QtCore, QtWidgets -from openpype.tools.utils.models import TreeModel, Item -from openpype.tools.utils.lib import schedule +from Qt import QtCore, QtWidgets, QtGui + +ENTITY_LABEL_ROLE = QtCore.Qt.UserRole + 1 +ENTITY_PATH_ROLE = QtCore.Qt.UserRole + 2 def get_entity_children(entity): - + # TODO find better way how to go through all children if hasattr(entity, "values"): return entity.values() return [] @@ -23,115 +24,163 @@ class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): self.setRecursiveFilteringEnabled(True) def filterAcceptsRow(self, row, parent): - if not parent.isValid(): return False regex = self.filterRegExp() if not regex.isEmpty() and regex.isValid(): pattern = regex.pattern() + compiled_regex = re.compile(pattern) source_model = self.sourceModel() # Check current index itself in all columns - for column in range(source_model.columnCount(parent)): - source_index = source_model.index(row, column, parent) - if not source_index.isValid(): - continue - - key = source_model.data(source_index, self.filterRole()) - if not key: - continue - - if re.search(pattern, key, re.IGNORECASE): - return True - + source_index = source_model.index(row, 0, parent) + if source_index.isValid(): + for role in (ENTITY_PATH_ROLE, ENTITY_LABEL_ROLE): + value = source_model.data(source_index, role) + if value and compiled_regex.search(value): + return True return False - return super(RecursiveSortFilterProxyModel, - self).filterAcceptsRow(row, parent) + return super( + RecursiveSortFilterProxyModel, self + ).filterAcceptsRow(row, parent) class SearchEntitiesDialog(QtWidgets.QDialog): - path_clicked = QtCore.Signal(str) - def __init__(self, entity, parent=None): + def __init__(self, parent): super(SearchEntitiesDialog, self).__init__(parent=parent) - layout = QtWidgets.QVBoxLayout(self) + self.setWindowTitle("Search Settings") - filter_edit = QtWidgets.QLineEdit() - filter_edit.setPlaceholderText("Search..") + filter_edit = QtWidgets.QLineEdit(self) + filter_edit.setPlaceholderText("Search...") model = EntityTreeModel() proxy = RecursiveSortFilterProxyModel() proxy.setSourceModel(model) proxy.setDynamicSortFilter(True) - view = QtWidgets.QTreeView() - view.setModel(proxy) + view = QtWidgets.QTreeView(self) + view.setAllColumnsShowFocus(True) + view.setSortingEnabled(True) + view.setModel(proxy) + model.setColumnCount(3) + + layout = QtWidgets.QVBoxLayout(self) layout.addWidget(filter_edit) layout.addWidget(view) + filter_changed_timer = QtCore.QTimer() + filter_changed_timer.setInterval(200) + + view.selectionModel().selectionChanged.connect( + self._on_selection_change + ) + filter_changed_timer.timeout.connect(self._on_filter_timer) filter_edit.textChanged.connect(self._on_filter_changed) - view.selectionModel().selectionChanged.connect(self.on_select) - - view.setAllColumnsShowFocus(True) - view.setSortingEnabled(True) - view.sortByColumn(1, QtCore.Qt.AscendingOrder) + self._filter_edit = filter_edit self._model = model self._proxy = proxy self._view = view + self._filter_changed_timer = filter_changed_timer - # Refresh to the passed entity - model.set_root(entity) + self._first_show = True - view.resizeColumnToContents(0) + def set_root_entity(self, entity): + self._model.set_root_entity(entity) + self._view.resizeColumnToContents(0) + + def showEvent(self, event): + super(SearchEntitiesDialog, self).showEvent(event) + if self._first_show: + self._first_show = False + self.resize(700, 500) def _on_filter_changed(self, txt): - # Provide slight delay to filtering so user can type quickly - schedule(partial(self.on_filter_changed, txt), 250, channel="search") + self._filter_changed_timer.start() - def on_filter_changed(self, txt): - self._proxy.setFilterRegExp(txt) + def _on_filter_timer(self): + text = self._filter_edit.text() + self._proxy.setFilterRegExp(text) # WARNING This expanding and resizing is relatively slow. self._view.expandAll() self._view.resizeColumnToContents(0) - def on_select(self): + def _on_selection_change(self): current = self._view.currentIndex() - item = current.data(EntityTreeModel.ItemRole) - self.path_clicked.emit(item["path"]) + path = current.data(ENTITY_PATH_ROLE) + self.path_clicked.emit(path) -class EntityTreeModel(TreeModel): +class EntityTreeModel(QtGui.QStandardItemModel): + def __init__(self, *args, **kwargs): + super(EntityTreeModel, self).__init__(*args, **kwargs) + self.setColumnCount(3) - Columns = ["trail", "label", "key", "path"] + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole - def add_entity(self, entity, parent=None): + col = index.column() + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + if col == 0: + pass + elif col == 1: + role = ENTITY_LABEL_ROLE + elif col == 2: + role = ENTITY_PATH_ROLE - item = Item() + if col > 0: + index = self.index(index.row(), 0, index.parent()) + return super(EntityTreeModel, self).data(index, role) - # Label and key can sometimes be emtpy so we use the trail from path - # in the most left column since it's never empty - item["trail"] = entity.path.rsplit("/", 1)[-1] - item["label"] = entity.label - item["key"] = entity.key - item["path"] = entity.path + def flags(self, index): + if index.column() > 0: + index = self.index(index.row(), 0, index.parent()) + return super(EntityTreeModel, self).flags(index) - parent.add_child(item) + def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): + if role == QtCore.Qt.DisplayRole: + if section == 0: + return "Key" + elif section == 1: + return "Label" + elif section == 2: + return "Path" + return "" + return super(EntityTreeModel, self).headerData( + section, orientation, role + ) - for child in get_entity_children(entity): - self.add_entity(child, parent=item) - - def set_root(self, root_entity): - self.clear() - self.beginResetModel() + def set_root_entity(self, root_entity): + parent = self.invisibleRootItem() + parent.removeRows(0, parent.rowCount()) + if not root_entity: + return # We don't want to see the root entity so we directly add its children - for child in get_entity_children(root_entity): - self.add_entity(child, parent=self._root_item) - self.endResetModel() + fill_queue = collections.deque() + fill_queue.append((root_entity, parent)) + cols = self.columnCount() + while fill_queue: + parent_entity, parent_item = fill_queue.popleft() + child_items = [] + for child in get_entity_children(parent_entity): + label = child.label + path = child.path + key = path.split("/")[-1] + item = QtGui.QStandardItem(key) + item.setEditable(False) + item.setData(label, ENTITY_LABEL_ROLE) + item.setData(path, ENTITY_PATH_ROLE) + item.setColumnCount(cols) + child_items.append(item) + fill_queue.append((child, item)) + if child_items: + parent_item.appendRows(child_items) diff --git a/openpype/tools/settings/settings/window.py b/openpype/tools/settings/settings/window.py index 55930b5d88..f6fa9a83a5 100644 --- a/openpype/tools/settings/settings/window.py +++ b/openpype/tools/settings/settings/window.py @@ -55,19 +55,27 @@ class MainWidget(QtWidgets.QWidget): self.setLayout(layout) + search_dialog = SearchEntitiesDialog(self) + self._shadow_widget = ShadowWidget("Working...", self) self._shadow_widget.setVisible(False) + header_tab_widget.currentChanged.connect(self._on_tab_changed) + search_dialog.path_clicked.connect(self._on_search_path_clicked) + for tab_widget in tab_widgets: tab_widget.saved.connect(self._on_tab_save) tab_widget.state_changed.connect(self._on_state_change) tab_widget.restart_required_trigger.connect( self._on_restart_required ) + tab_widget.restart_started.connect(self._on_restart_started) + tab_widget.restart_finished.connect(self._on_restart_finished) tab_widget.full_path_requested.connect(self._on_full_path_request) self._header_tab_widget = header_tab_widget self.tab_widgets = tab_widgets + self._search_dialog = search_dialog def _on_tab_save(self, source_widget): for tab_widget in self.tab_widgets: @@ -151,6 +159,21 @@ class MainWidget(QtWidgets.QWidget): for tab_widget in self.tab_widgets: tab_widget.reset() + def _update_search_dialog(self, clear=False): + if self._search_dialog.isVisible(): + entity = None + if not clear: + widget = self._header_tab_widget.currentWidget() + entity = widget.entity + self._search_dialog.set_root_entity(entity) + + def _on_tab_changed(self): + self._update_search_dialog() + + def _on_search_path_clicked(self, path): + widget = self._header_tab_widget.currentWidget() + widget.change_path(path) + def _on_restart_required(self): # Don't show dialog if there are not registered slots for # `trigger_restart` signal. @@ -166,25 +189,24 @@ class MainWidget(QtWidgets.QWidget): if result == 1: self.trigger_restart.emit() + def _on_restart_started(self): + widget = self.sender() + current_widget = self._header_tab_widget.currentWidget() + if current_widget is widget: + self._update_search_dialog(True) + + def _on_restart_finished(self): + widget = self.sender() + current_widget = self._header_tab_widget.currentWidget() + if current_widget is widget: + self._update_search_dialog() + def keyPressEvent(self, event): - - # todo: This might not be the cleanest place but works for prototype if event.matches(QtGui.QKeySequence.Find): - print("Search!") - # todo: search in all widgets (or in active)? widget = self._header_tab_widget.currentWidget() - root_entity = widget.entity - - search = SearchEntitiesDialog(root_entity, parent=self) - search.resize(700, 500) - search.setWindowTitle("Search") - search.show() - - def on_path(path): - widget.breadcrumbs_widget.change_path(path) - - search.path_clicked.connect(on_path) + self._search_dialog.show() + self._search_dialog.set_root_entity(widget.entity) event.accept() return From 806c417026b8e5ac4edb00fc7c4f1b851097ce51 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 12 Feb 2022 15:15:49 +0100 Subject: [PATCH 100/483] show publish states instead of processed plugin --- openpype/tools/pyblish_pype/control.py | 33 +++- openpype/tools/pyblish_pype/model.py | 6 +- openpype/tools/pyblish_pype/util.py | 256 ++++--------------------- openpype/tools/pyblish_pype/window.py | 39 ++-- 4 files changed, 81 insertions(+), 253 deletions(-) diff --git a/openpype/tools/pyblish_pype/control.py b/openpype/tools/pyblish_pype/control.py index 9774c8020b..0d33aa10f8 100644 --- a/openpype/tools/pyblish_pype/control.py +++ b/openpype/tools/pyblish_pype/control.py @@ -152,6 +152,8 @@ class Controller(QtCore.QObject): self.instance_toggled.connect(self._on_instance_toggled) self._main_thread_processor = MainThreadProcess() + self._current_state = "" + def reset_variables(self): self.log.debug("Resetting pyblish context variables") @@ -159,6 +161,7 @@ class Controller(QtCore.QObject): self.is_running = False self.stopped = False self.errored = False + self._current_state = "" # Active producer of pairs self.pair_generator = None @@ -167,7 +170,6 @@ class Controller(QtCore.QObject): # Orders which changes GUI # - passing collectors order disables plugin/instance toggle - self.collectors_order = None self.collect_state = 0 # - passing validators order disables validate button and gives ability @@ -176,11 +178,8 @@ class Controller(QtCore.QObject): self.validated = False # Get collectors and validators order - self.order_groups.reset() - plugin_groups = self.order_groups.groups() - plugin_groups_keys = list(plugin_groups.keys()) - self.collectors_order = plugin_groups_keys[0] - self.validators_order = self.order_groups.validation_order() + plugin_groups_keys = list(self.order_groups.groups.keys()) + self.validators_order = self.order_groups.validation_order next_group_order = None if len(plugin_groups_keys) > 1: next_group_order = plugin_groups_keys[1] @@ -191,13 +190,18 @@ class Controller(QtCore.QObject): "stop_on_validation": False, # Used? "last_plugin_order": None, - "current_group_order": self.collectors_order, + "current_group_order": plugin_groups_keys[0], "next_group_order": next_group_order, "nextOrder": None, "ordersWithError": set() } + self._set_state_by_order() self.log.debug("Reset of pyblish context variables done") + @property + def current_state(self): + return self._current_state + def presets_by_hosts(self): # Get global filters as base presets = get_project_settings(os.environ['AVALON_PROJECT']) or {} @@ -293,6 +297,7 @@ class Controller(QtCore.QObject): def on_published(self): if self.is_running: self.is_running = False + self._current_state = "Pulished" self.was_finished.emit() self._main_thread_processor.stop() @@ -355,7 +360,7 @@ class Controller(QtCore.QObject): new_current_group_order = self.processing["next_group_order"] if new_current_group_order is not None: current_next_order_found = False - for order in self.order_groups.groups().keys(): + for order in self.order_groups.groups.keys(): if current_next_order_found: new_next_group_order = order break @@ -370,6 +375,7 @@ class Controller(QtCore.QObject): if self.collect_state == 0: self.collect_state = 1 + self._current_state = "Collected" self.switch_toggleability.emit(True) self.passed_group.emit(current_group_order) yield IterationBreak("Collected") @@ -386,17 +392,20 @@ class Controller(QtCore.QObject): if not self.validated and plugin.order > self.validators_order: self.validated = True if self.processing["stop_on_validation"]: + self._current_state = "Validated" yield IterationBreak("Validated") # Stop if was stopped if self.stopped: self.stopped = False + self._current_state = "Paused" yield IterationBreak("Stopped") # check test if will stop self.processing["nextOrder"] = plugin.order message = self.test(**self.processing) if message: + self._current_state = "Paused" yield IterationBreak("Stopped due to \"{}\"".format(message)) self.processing["last_plugin_order"] = plugin.order @@ -426,6 +435,7 @@ class Controller(QtCore.QObject): # Stop if was stopped if self.stopped: self.stopped = False + self._current_state = "Paused" yield IterationBreak("Stopped") yield (plugin, instance) @@ -536,20 +546,27 @@ class Controller(QtCore.QObject): MainThreadItem(on_next) ) + def _set_state_by_order(self): + order = self.processing["current_group_order"] + self._current_state = self.order_groups.groups[order]["state"] + def collect(self): """ Iterate and process Collect plugins - load_plugins method is launched again when finished """ + self._set_state_by_order() self._main_thread_processor.process(self._start_collect) self._main_thread_processor.start() def validate(self): """ Process plugins to validations_order value.""" + self._set_state_by_order() self._main_thread_processor.process(self._start_validate) self._main_thread_processor.start() def publish(self): """ Iterate and process all remaining plugins.""" + self._set_state_by_order() self._main_thread_processor.process(self._start_publish) self._main_thread_processor.start() diff --git a/openpype/tools/pyblish_pype/model.py b/openpype/tools/pyblish_pype/model.py index bb1aff2a9a..0faadb5940 100644 --- a/openpype/tools/pyblish_pype/model.py +++ b/openpype/tools/pyblish_pype/model.py @@ -428,12 +428,12 @@ class PluginModel(QtGui.QStandardItemModel): self.clear() def append(self, plugin): - plugin_groups = self.controller.order_groups.groups() + plugin_groups = self.controller.order_groups.groups label = None order = None - for _order, _label in reversed(plugin_groups.items()): + for _order, item in reversed(plugin_groups.items()): if _order is None or plugin.order < _order: - label = _label + label = item["label"] order = _order else: break diff --git a/openpype/tools/pyblish_pype/util.py b/openpype/tools/pyblish_pype/util.py index d3d76b187c..9f3697be16 100644 --- a/openpype/tools/pyblish_pype/util.py +++ b/openpype/tools/pyblish_pype/util.py @@ -95,224 +95,44 @@ def collect_families_from_instances(instances, only_active=False): class OrderGroups: - # Validator order can be set with environment "PYBLISH_VALIDATION_ORDER" - # - this variable sets when validation button will hide and proecssing - # of validation will end with ability to continue in process - default_validation_order = pyblish.api.ValidatorOrder + 0.5 - - # Group range can be set with environment "PYBLISH_GROUP_RANGE" - default_group_range = 1 - - # Group string can be set with environment "PYBLISH_GROUP_SETTING" - default_groups = { - pyblish.api.CollectorOrder + 0.5: "Collect", - pyblish.api.ValidatorOrder + 0.5: "Validate", - pyblish.api.ExtractorOrder + 0.5: "Extract", - pyblish.api.IntegratorOrder + 0.5: "Integrate", - None: "Other" - } - - # *** This example should have same result as is `default_groups` if - # `group_range` is set to "1" - __groups_str_example__ = ( - # half of `group_range` is added to 0 because number means it is Order - "0=Collect" - # if `<` is before than it means group range is not used - # but is expected that number is already max - ",<1.5=Validate" - # "Extractor" will be used in range `<1.5; 2.5)` - ",<2.5=Extract" - ",<3.5=Integrate" - # "Other" if number is not set than all remaining plugins are in - # - in this case Other's range is <3.5; infinity) - ",Other" - ) - - _groups = None - _validation_order = None - _group_range = None - - def __init__( - self, group_str=None, group_range=None, validation_order=None - ): - super(OrderGroups, self).__init__() - # Override class methods with object methods - self.groups = self._object_groups - self.validation_order = self._object_validation_order - self.group_range = self._object_group_range - self.reset = self._object_reset - - # set - if group_range is not None: - self._group_range = self.parse_group_range( - group_range - ) - - if group_str is not None: - self._groups = self.parse_group_str( - group_str - ) - - if validation_order is not None: - self._validation_order = self.parse_validation_order( - validation_order - ) - - @staticmethod - def _groups_method(obj): - if obj._groups is None: - obj._groups = obj.parse_group_str( - group_range=obj.group_range() - ) - return obj._groups - - @staticmethod - def _reset_method(obj): - obj._groups = None - obj._validation_order = None - obj._group_range = None - - @classmethod - def reset(cls): - return cls._reset_method(cls) - - def _object_reset(self): - return self._reset_method(self) - - @classmethod - def groups(cls): - return cls._groups_method(cls) - - def _object_groups(self): - return self._groups_method(self) - - @staticmethod - def _validation_order_method(obj): - if obj._validation_order is None: - obj._validation_order = obj.parse_validation_order( - group_range=obj.group_range() - ) - return obj._validation_order - - @classmethod - def validation_order(cls): - return cls._validation_order_method(cls) - - def _object_validation_order(self): - return self._validation_order_method(self) - - @staticmethod - def _group_range_method(obj): - if obj._group_range is None: - obj._group_range = obj.parse_group_range() - return obj._group_range - - @classmethod - def group_range(cls): - return cls._group_range_method(cls) - - def _object_group_range(self): - return self._group_range_method(self) - - @staticmethod - def sort_groups(_groups_dict): - sorted_dict = collections.OrderedDict() - - # make sure won't affect any dictionary as pointer - groups_dict = copy.deepcopy(_groups_dict) - last_order = None - if None in groups_dict: - last_order = groups_dict.pop(None) - - for key in sorted(groups_dict): - sorted_dict[key] = groups_dict[key] - - if last_order is not None: - sorted_dict[None] = last_order - - return sorted_dict - - @staticmethod - def parse_group_str(groups_str=None, group_range=None): - if groups_str is None: - groups_str = os.environ.get("PYBLISH_GROUP_SETTING") - - if groups_str is None: - return OrderGroups.sort_groups(OrderGroups.default_groups) - - items = groups_str.split(",") - groups = {} - for item in items: - if "=" not in item: - order = None - label = item - else: - order, label = item.split("=") - order = order.strip() - if not order: - order = None - elif order.startswith("<"): - order = float(order.replace("<", "")) - else: - if group_range is None: - group_range = OrderGroups.default_group_range - print( - "Using default Plugin group range \"{}\".".format( - OrderGroups.default_group_range - ) - ) - order = float(order) + float(group_range) / 2 - - if order in groups: - print(( - "Order \"{}\" is registered more than once." - " Using first found." - ).format(str(order))) - continue - - groups[order] = label - - return OrderGroups.sort_groups(groups) - - @staticmethod - def parse_validation_order(validation_order_value=None, group_range=None): - if validation_order_value is None: - validation_order_value = os.environ.get("PYBLISH_VALIDATION_ORDER") - - if validation_order_value is None: - return OrderGroups.default_validation_order - - if group_range is None: - group_range = OrderGroups.default_group_range - - group_range_half = float(group_range) / 2 - - if isinstance(validation_order_value, numbers.Integral): - return validation_order_value + group_range_half - - if validation_order_value.startswith("<"): - validation_order_value = float( - validation_order_value.replace("<", "") - ) - else: - validation_order_value = ( - float(validation_order_value) - + group_range_half - ) - return validation_order_value - - @staticmethod - def parse_group_range(group_range=None): - if group_range is None: - group_range = os.environ.get("PYBLISH_GROUP_RANGE") - - if group_range is None: - return OrderGroups.default_group_range - - if isinstance(group_range, numbers.Integral): - return group_range - - return float(group_range) + validation_order = pyblish.api.ValidatorOrder + 0.5 + groups = collections.OrderedDict(( + ( + pyblish.api.CollectorOrder + 0.5, + { + "label": "Collect", + "state": "Collecting.." + } + ), + ( + pyblish.api.ValidatorOrder + 0.5, + { + "label": "Validate", + "state": "Validating.." + } + ), + ( + pyblish.api.ExtractorOrder + 0.5, + { + "label": "Extract", + "state": "Extracting.." + } + ), + ( + pyblish.api.IntegratorOrder + 0.5, + { + "label": "Integrate", + "state": "Integrating.." + } + ), + ( + None, + { + "label": "Other", + "state": "Finishing.." + } + ) + )) def env_variable_to_bool(env_key, default=False): diff --git a/openpype/tools/pyblish_pype/window.py b/openpype/tools/pyblish_pype/window.py index 7bb11745d6..576ff8a93d 100644 --- a/openpype/tools/pyblish_pype/window.py +++ b/openpype/tools/pyblish_pype/window.py @@ -300,21 +300,6 @@ class Window(QtWidgets.QDialog): info_effect = QtWidgets.QGraphicsOpacityEffect(footer_info) footer_info.setGraphicsEffect(info_effect) - on = QtCore.QPropertyAnimation(info_effect, b"opacity") - on.setDuration(0) - on.setStartValue(0) - on.setEndValue(1) - - fade = QtCore.QPropertyAnimation(info_effect, b"opacity") - fade.setDuration(500) - fade.setStartValue(1.0) - fade.setEndValue(0.0) - - animation_info_msg = QtCore.QSequentialAnimationGroup() - animation_info_msg.addAnimation(on) - animation_info_msg.addPause(2000) - animation_info_msg.addAnimation(fade) - """Setup Widgets are referred to in CSS via their object-name. We @@ -448,6 +433,8 @@ class Window(QtWidgets.QDialog): self.footer_button_validate = footer_button_validate self.footer_button_play = footer_button_play + self.footer_info = footer_info + self.overview_instance_view = overview_instance_view self.overview_plugin_view = overview_plugin_view self.plugin_model = plugin_model @@ -457,8 +444,6 @@ class Window(QtWidgets.QDialog): self.presets_button = presets_button - self.animation_info_msg = animation_info_msg - self.terminal_model = terminal_model self.terminal_proxy = terminal_proxy self.terminal_view = terminal_view @@ -984,6 +969,8 @@ class Window(QtWidgets.QDialog): self.footer_button_stop.setEnabled(True) self.footer_button_play.setEnabled(False) + self._update_state() + def on_passed_group(self, order): for group_item in self.instance_model.group_items.values(): group_index = self.instance_sort_proxy.mapFromSource( @@ -1015,6 +1002,8 @@ class Window(QtWidgets.QDialog): self.overview_plugin_view.setAnimated(False) self.overview_plugin_view.collapse(group_index) + self._update_state() + def on_was_stopped(self): self.overview_plugin_view.setAnimated(settings.Animated) errored = self.controller.errored @@ -1039,6 +1028,7 @@ class Window(QtWidgets.QDialog): and not self.controller.stopped ) self.button_suspend_logs.setEnabled(suspend_log_bool) + self._update_state() def on_was_skipped(self, plugin): plugin_item = self.plugin_model.plugin_items[plugin.id] @@ -1082,6 +1072,7 @@ class Window(QtWidgets.QDialog): ) self.update_compatibility() + self._update_state() def on_was_processed(self, result): existing_ids = set(self.instance_model.instance_items.keys()) @@ -1160,6 +1151,8 @@ class Window(QtWidgets.QDialog): self.controller.validate() + self._update_state() + def publish(self): self.info(self.tr("Preparing publish..")) self.footer_button_stop.setEnabled(True) @@ -1171,6 +1164,8 @@ class Window(QtWidgets.QDialog): self.controller.publish() + self._update_state() + def act(self, plugin_item, action): self.info("%s %s.." % (self.tr("Preparing"), action)) @@ -1285,6 +1280,9 @@ class Window(QtWidgets.QDialog): # # ------------------------------------------------------------------------- + def _update_state(self): + self.footer_info.setText(self.controller.current_state) + def info(self, message): """Print user-facing information @@ -1292,19 +1290,12 @@ class Window(QtWidgets.QDialog): message (str): Text message for the user """ - - info = self.findChild(QtWidgets.QLabel, "FooterInfo") - info.setText(message) - # Include message in terminal self.terminal_model.append([{ "label": message, "type": "info" }]) - self.animation_info_msg.stop() - self.animation_info_msg.start() - if settings.PrintInfo: # Print message to console util.u_print(message) From f574a817670cce860cf5ecf9bf93a91cde42d36f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 12 Feb 2022 15:21:21 +0100 Subject: [PATCH 101/483] remove effect from label --- openpype/tools/pyblish_pype/window.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openpype/tools/pyblish_pype/window.py b/openpype/tools/pyblish_pype/window.py index 576ff8a93d..48333e79f4 100644 --- a/openpype/tools/pyblish_pype/window.py +++ b/openpype/tools/pyblish_pype/window.py @@ -296,10 +296,6 @@ class Window(QtWidgets.QDialog): self.main_layout.setSpacing(0) self.main_layout.addWidget(main_widget) - # Display info - info_effect = QtWidgets.QGraphicsOpacityEffect(footer_info) - footer_info.setGraphicsEffect(info_effect) - """Setup Widgets are referred to in CSS via their object-name. We From a40dca7f50877fa1aa415cefa6b6ce63341a4e9f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 12 Feb 2022 23:51:48 +0100 Subject: [PATCH 102/483] Remove menu.json files - these are now loaded through settings --- openpype/hosts/maya/api/menu.json | 924 ------------- openpype/hosts/maya/api/menu_backup.json | 1567 ---------------------- 2 files changed, 2491 deletions(-) delete mode 100644 openpype/hosts/maya/api/menu.json delete mode 100644 openpype/hosts/maya/api/menu_backup.json diff --git a/openpype/hosts/maya/api/menu.json b/openpype/hosts/maya/api/menu.json deleted file mode 100644 index a2efd5233c..0000000000 --- a/openpype/hosts/maya/api/menu.json +++ /dev/null @@ -1,924 +0,0 @@ -[ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\save_scene_incremental.py", - "sourcetype": "file", - "title": "# Version Up", - "tooltip": "Incremental save with a specific format" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\open_current_folder.py", - "sourcetype": "file", - "title": "Open working folder..", - "tooltip": "Show current scene in Explorer" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\avalon\\launch_manager.py", - "sourcetype": "file", - "title": "# Project Manager", - "tooltip": "Add assets to the project" - }, - { - "type": "action", - "command": "from openpype.tools.assetcreator import app as assetcreator; assetcreator.show(context='maya')", - "sourcetype": "python", - "title": "Asset Creator", - "tooltip": "Open the Asset Creator" - }, - { - "type": "separator" - }, - { - "type": "menu", - "title": "Modeling", - "items": [ - { - "type": "action", - "command": "import easyTreezSource; reload(easyTreezSource); easyTreezSource.easyTreez()", - "sourcetype": "python", - "tags": ["modeling", "trees", "generate", "create", "plants"], - "title": "EasyTreez", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\modeling\\separateMeshPerShader.py", - "sourcetype": "file", - "tags": ["modeling", "separateMeshPerShader"], - "title": "# Separate Mesh Per Shader", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\modeling\\polyDetachSeparate.py", - "sourcetype": "file", - "tags": ["modeling", "poly", "detach", "separate"], - "title": "# Polygon Detach and Separate", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\modeling\\polySelectEveryNthEdgeUI.py", - "sourcetype": "file", - "tags": ["modeling", "select", "nth", "edge", "ui"], - "title": "# Select Every Nth Edge" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\modeling\\djPFXUVs.py", - "sourcetype": "file", - "tags": ["modeling", "djPFX", "UVs"], - "title": "# dj PFX UVs", - "tooltip": "" - } - ] - }, - { - "type": "menu", - "title": "Rigging", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\advancedSkeleton.py", - "sourcetype": "file", - "tags": [ - "rigging", - "autorigger", - "advanced", - "skeleton", - "advancedskeleton", - "file" - ], - "title": "Advanced Skeleton" - } - ] - }, - { - "type": "menu", - "title": "Shading", - "items": [ - { - "type": "menu", - "title": "# VRay", - "items": [ - { - "type": "action", - "title": "# Import Proxies", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\vrayImportProxies.py", - "sourcetype": "file", - "tags": ["shading", "vray", "import", "proxies"], - "tooltip": "" - }, - { - "type": "separator" - }, - { - "type": "action", - "title": "# Select All GES", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\selectAllGES.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "select All GES"] - }, - { - "type": "action", - "title": "# Select All GES Under Selection", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\selectAllGESUnderSelection.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "select", "all", "GES"] - }, - { - "type": "separator" - }, - { - "type": "action", - "title": "# Selection To VRay Mesh", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\selectionToVrayMesh.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "selection", "vraymesh"] - }, - { - "type": "action", - "title": "# Add VRay Round Edges Attribute", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\addVrayRoundEdgesAttribute.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "round edges", "attribute"] - }, - { - "type": "action", - "title": "# Add Gamma", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\vrayAddGamma.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "add gamma"] - }, - { - "type": "separator" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\select_vraymesh_materials_with_unconnected_shader_slots.py", - "sourcetype": "file", - "title": "# Select Unconnected Shader Materials", - "tags": [ - "shading", - "vray", - "select", - "vraymesh", - "materials", - "unconnected shader slots" - ], - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\vrayMergeSimilarVRayMeshMaterials.py", - "sourcetype": "file", - "title": "# Merge Similar VRay Mesh Materials", - "tags": [ - "shading", - "vray", - "Merge", - "VRayMesh", - "Materials" - ], - "tooltip": "" - }, - { - "type": "action", - "title": "# Create Two Sided Material", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\vrayCreate2SidedMtlForSelectedMtlRenamed.py", - "sourcetype": "file", - "tooltip": "Creates two sided material for selected material and renames it", - "tags": ["shading", "vray", "two sided", "material"] - }, - { - "type": "action", - "title": "# Create Two Sided Material For Selected", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\vrayCreate2SidedMtlForSelectedMtl.py", - "sourcetype": "file", - "tooltip": "Select material to create a two sided version from it", - "tags": [ - "shading", - "vray", - "Create2SidedMtlForSelectedMtl.py" - ] - }, - { - "type": "separator" - }, - { - "type": "action", - "title": "# Add OpenSubdiv Attribute", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\addVrayOpenSubdivAttribute.py", - "sourcetype": "file", - "tooltip": "", - "tags": [ - "shading", - "vray", - "add", - "open subdiv", - "attribute" - ] - }, - { - "type": "action", - "title": "# Remove OpenSubdiv Attribute", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\removeVrayOpenSubdivAttribute.py", - "sourcetype": "file", - "tooltip": "", - "tags": [ - "shading", - "vray", - "remove", - "opensubdiv", - "attributee" - ] - }, - { - "type": "separator" - }, - { - "type": "action", - "title": "# Add Subdivision Attribute", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\addVraySubdivisionAttribute.py", - "sourcetype": "file", - "tooltip": "", - "tags": [ - "shading", - "vray", - "addVraySubdivisionAttribute" - ] - }, - { - "type": "action", - "title": "# Remove Subdivision Attribute.py", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\removeVraySubdivisionAttribute.py", - "sourcetype": "file", - "tooltip": "", - "tags": [ - "shading", - "vray", - "remove", - "subdivision", - "attribute" - ] - }, - { - "type": "separator" - }, - { - "type": "action", - "title": "# Add Vray Object Ids", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\addVrayObjectIds.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "add", "object id"] - }, - { - "type": "action", - "title": "# Add Vray Material Ids", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\addVrayMaterialIds.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "addVrayMaterialIds.py"] - }, - { - "type": "separator" - }, - { - "type": "action", - "title": "# Set Physical DOF Depth", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\vrayPhysicalDOFSetDepth.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "physical", "DOF ", "Depth"] - }, - { - "type": "action", - "title": "# Magic Vray Proxy UI", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\magicVrayProxyUI.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "magicVrayProxyUI"] - } - ] - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\pyblish\\lighting\\set_filename_prefix.py", - "sourcetype": "file", - "tags": [ - "shading", - "lookdev", - "assign", - "shaders", - "prefix", - "filename", - "render" - ], - "title": "# Set filename prefix", - "tooltip": "Set the render file name prefix." - }, - { - "type": "action", - "command": "import mayalookassigner; mayalookassigner.show()", - "sourcetype": "python", - "tags": ["shading", "look", "assign", "shaders", "auto"], - "title": "Look Manager", - "tooltip": "Open the Look Manager UI for look assignment" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\LightLinkUi.py", - "sourcetype": "file", - "tags": ["shading", "light", "link", "ui"], - "title": "# Light Link UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\vdviewer_ui.py", - "sourcetype": "file", - "tags": [ - "shading", - "look", - "vray", - "displacement", - "shaders", - "auto" - ], - "title": "# VRay Displ Viewer", - "tooltip": "Open the VRay Displacement Viewer, select and control the content of the set" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\setTexturePreviewToCLRImage.py", - "sourcetype": "file", - "tags": ["shading", "CLRImage", "textures", "preview"], - "title": "# Set Texture Preview To CLRImage", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\fixDefaultShaderSetBehavior.py", - "sourcetype": "file", - "tags": ["shading", "fix", "DefaultShaderSet", "Behavior"], - "title": "# Fix Default Shader Set Behavior", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\fixSelectedShapesReferenceAssignments.py", - "sourcetype": "file", - "tags": [ - "shading", - "fix", - "Selected", - "Shapes", - "Reference", - "Assignments" - ], - "title": "# Fix Shapes Reference Assignments", - "tooltip": "Select shapes to fix the reference assignments" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\selectLambert1Members.py", - "sourcetype": "file", - "tags": ["shading", "selectLambert1Members"], - "title": "# Select Lambert1 Members", - "tooltip": "Selects all objects which have the Lambert1 shader assigned" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\selectShapesWithoutShader.py", - "sourcetype": "file", - "tags": ["shading", "selectShapesWithoutShader"], - "title": "# Select Shapes Without Shader", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\fixRenderLayerOutAdjustmentErrors.py", - "sourcetype": "file", - "tags": ["shading", "fixRenderLayerOutAdjustmentErrors"], - "title": "# Fix RenderLayer Out Adjustment Errors", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\fix_renderlayer_missing_node_override.py", - "sourcetype": "file", - "tags": [ - "shading", - "renderlayer", - "missing", - "reference", - "switch", - "layer" - ], - "title": "# Fix RenderLayer Missing Referenced Nodes Overrides", - "tooltip": "" - }, - { - "type": "action", - "title": "# Image 2 Tiled EXR", - "command": "$OPENPYPE_SCRIPTS\\shading\\open_img2exr.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "exr"] - } - ] - }, - { - "type": "menu", - "title": "# Rendering", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\pyblish\\open_deadline_submission_settings.py", - "sourcetype": "file", - "tags": ["settings", "deadline", "globals", "render"], - "title": "# DL Submission Settings UI", - "tooltip": "Open the Deadline Submission Settings UI" - } - ] - }, - { - "type": "menu", - "title": "Animation", - "items": [ - { - "type": "menu", - "title": "# Attributes", - "tooltip": "", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\attributes\\copyValues.py", - "sourcetype": "file", - "tags": ["animation", "copy", "attributes"], - "title": "# Copy Values", - "tooltip": "Copy attribute values" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\attributes\\copyInConnections.py", - "sourcetype": "file", - "tags": [ - "animation", - "copy", - "attributes", - "connections", - "incoming" - ], - "title": "# Copy In Connections", - "tooltip": "Copy incoming connections" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\attributes\\copyOutConnections.py", - "sourcetype": "file", - "tags": [ - "animation", - "copy", - "attributes", - "connections", - "out" - ], - "title": "# Copy Out Connections", - "tooltip": "Copy outcoming connections" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\attributes\\copyTransformLocal.py", - "sourcetype": "file", - "tags": [ - "animation", - "copy", - "attributes", - "transforms", - "local" - ], - "title": "# Copy Local Transforms", - "tooltip": "Copy local transforms" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\attributes\\copyTransformMatrix.py", - "sourcetype": "file", - "tags": [ - "animation", - "copy", - "attributes", - "transforms", - "matrix" - ], - "title": "# Copy Matrix Transforms", - "tooltip": "Copy Matrix transforms" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\attributes\\copyTransformUI.py", - "sourcetype": "file", - "tags": [ - "animation", - "copy", - "attributes", - "transforms", - "UI" - ], - "title": "# Copy Transforms UI", - "tooltip": "Open the Copy Transforms UI" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\attributes\\simpleCopyUI.py", - "sourcetype": "file", - "tags": [ - "animation", - "copy", - "attributes", - "transforms", - "UI", - "simple" - ], - "title": "# Simple Copy UI", - "tooltip": "Open the simple Copy Transforms UI" - } - ] - }, - { - "type": "menu", - "title": "# Optimize", - "tooltip": "Optimization scripts", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\optimize\\toggleFreezeHierarchy.py", - "sourcetype": "file", - "tags": ["animation", "hierarchy", "toggle", "freeze"], - "title": "# Toggle Freeze Hierarchy", - "tooltip": "Freeze and unfreeze hierarchy" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\optimize\\toggleParallelNucleus.py", - "sourcetype": "file", - "tags": ["animation", "nucleus", "toggle", "parallel"], - "title": "# Toggle Parallel Nucleus", - "tooltip": "Toggle parallel nucleus" - } - ] - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\bakeSelectedToWorldSpace.py", - "tags": ["animation", "bake", "selection", "worldspace.py"], - "title": "# Bake Selected To Worldspace", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\timeStepper.py", - "tags": ["animation", "time", "stepper"], - "title": "# Time Stepper", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\capture_ui.py", - "tags": [ - "animation", - "capture", - "ui", - "screen", - "movie", - "image" - ], - "title": "# Capture UI", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\simplePlayblastUI.py", - "tags": ["animation", "simple", "playblast", "ui"], - "title": "# Simple Playblast UI", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\tweenMachineUI.py", - "tags": ["animation", "tween", "machine"], - "title": "# Tween Machine UI", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\selectAllAnimationCurves.py", - "tags": ["animation", "select", "curves"], - "title": "# Select All Animation Curves", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\pathAnimation.py", - "tags": ["animation", "path", "along"], - "title": "# Path Animation", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\offsetSelectedObjectsUI.py", - "tags": ["animation", "offsetSelectedObjectsUI.py"], - "title": "# Offset Selected Objects UI", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\key_amplifier_ui.py", - "tags": ["animation", "key", "amplifier"], - "title": "# Key Amplifier UI", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\anim_scene_optimizer.py", - "tags": ["animation", "anim_scene_optimizer.py"], - "title": "# Anim_Scene_Optimizer", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\zvParentMaster.py", - "tags": ["animation", "zvParentMaster.py"], - "title": "# ZV Parent Master", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\animLibrary.py", - "tags": ["animation", "studiolibrary.py"], - "title": "Anim Library", - "type": "action" - } - ] - }, - { - "type": "menu", - "title": "# Layout", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\alignDistributeUI.py", - "sourcetype": "file", - "tags": ["layout", "align", "Distribute", "UI"], - "title": "# Align Distribute UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\alignSimpleUI.py", - "sourcetype": "file", - "tags": ["layout", "align", "UI", "Simple"], - "title": "# Align Simple UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\center_locator.py", - "sourcetype": "file", - "tags": ["layout", "center", "locator"], - "title": "# Center Locator", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\average_locator.py", - "sourcetype": "file", - "tags": ["layout", "average", "locator"], - "title": "# Average Locator", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\selectWithinProximityUI.py", - "sourcetype": "file", - "tags": ["layout", "select", "proximity", "ui"], - "title": "# Select Within Proximity UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\dupCurveUI.py", - "sourcetype": "file", - "tags": ["layout", "Duplicate", "Curve", "UI"], - "title": "# Duplicate Curve UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\randomDeselectUI.py", - "sourcetype": "file", - "tags": ["layout", "random", "Deselect", "UI"], - "title": "# Random Deselect UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\multiReferencerUI.py", - "sourcetype": "file", - "tags": ["layout", "multi", "reference"], - "title": "# Multi Referencer UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\duplicateOffsetUI.py", - "sourcetype": "file", - "tags": ["layout", "duplicate", "offset", "UI"], - "title": "# Duplicate Offset UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\spPaint3d.py", - "sourcetype": "file", - "tags": ["layout", "spPaint3d", "paint", "tool"], - "title": "# SP Paint 3d", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\randomizeUI.py", - "sourcetype": "file", - "tags": ["layout", "randomize", "UI"], - "title": "# Randomize UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\distributeWithinObjectUI.py", - "sourcetype": "file", - "tags": ["layout", "distribute", "ObjectUI", "within"], - "title": "# Distribute Within Object UI", - "tooltip": "" - } - ] - }, - { - "type": "menu", - "title": "# Particles", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\instancerToObjects.py", - "sourcetype": "file", - "tags": ["particles", "instancerToObjects"], - "title": "# Instancer To Objects", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\instancerToObjectsInstances.py", - "sourcetype": "file", - "tags": ["particles", "instancerToObjectsInstances"], - "title": "# Instancer To Objects Instances", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\instancerToObjectsInstancesWithAnimation.py", - "sourcetype": "file", - "tags": [ - "particles", - "instancerToObjectsInstancesWithAnimation" - ], - "title": "# Instancer To Objects Instances With Animation", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\instancerToObjectsWithAnimation.py", - "sourcetype": "file", - "tags": ["particles", "instancerToObjectsWithAnimation"], - "title": "# Instancer To Objects With Animation", - "tooltip": "" - } - ] - }, - { - "type": "menu", - "title": "Cleanup", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\repair_faulty_containers.py", - "sourcetype": "file", - "tags": ["cleanup", "repair", "containers"], - "title": "# Find and Repair Containers", - "tooltip": "" - }, - { - "type": "separator" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\removeNamespaces.py", - "sourcetype": "file", - "tags": ["cleanup", "remove", "namespaces"], - "title": "# Remove Namespaces", - "tooltip": "Remove all namespaces" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\remove_user_defined_attributes.py", - "sourcetype": "file", - "tags": ["cleanup", "remove_user_defined_attributes"], - "title": "# Remove User Defined Attributes", - "tooltip": "Remove all user-defined attributes from all nodes" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\removeUnknownNodes.py", - "sourcetype": "file", - "tags": ["cleanup", "removeUnknownNodes"], - "title": "# Remove Unknown Nodes", - "tooltip": "Remove all unknown nodes" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\removeUnloadedReferences.py", - "sourcetype": "file", - "tags": ["cleanup", "removeUnloadedReferences"], - "title": "# Remove Unloaded References", - "tooltip": "Remove all unloaded references" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\removeReferencesFailedEdits.py", - "sourcetype": "file", - "tags": ["cleanup", "removeReferencesFailedEdits"], - "title": "# Remove References Failed Edits", - "tooltip": "Remove failed edits for all references" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\remove_unused_looks.py", - "sourcetype": "file", - "tags": ["cleanup", "removeUnusedLooks"], - "title": "# Remove Unused Looks", - "tooltip": "Remove all loaded yet unused Avalon look containers" - }, - { - "type": "separator" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\uniqifyNodeNames.py", - "sourcetype": "file", - "tags": ["cleanup", "uniqifyNodeNames"], - "title": "# Uniqify Node Names", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\autoRenameFileNodes.py", - "sourcetype": "file", - "tags": ["cleanup", "auto", "rename", "filenodes"], - "title": "# Auto Rename File Nodes", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\update_asset_id.py", - "sourcetype": "file", - "tags": ["cleanup", "update", "database", "asset", "id"], - "title": "# Update Asset ID", - "tooltip": "Will replace the Colorbleed ID with a new one (asset ID : Unique number)" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\ccRenameReplace.py", - "sourcetype": "file", - "tags": ["cleanup", "rename", "ui"], - "title": "Renamer", - "tooltip": "Rename UI" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\renameShapesToTransform.py", - "sourcetype": "file", - "tags": ["cleanup", "renameShapesToTransform"], - "title": "# Rename Shapes To Transform", - "tooltip": "" - } - ] - } -] diff --git a/openpype/hosts/maya/api/menu_backup.json b/openpype/hosts/maya/api/menu_backup.json deleted file mode 100644 index e2a558aedc..0000000000 --- a/openpype/hosts/maya/api/menu_backup.json +++ /dev/null @@ -1,1567 +0,0 @@ -[ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\save_scene_incremental.py", - "sourcetype": "file", - "title": "Version Up", - "tooltip": "Incremental save with a specific format" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\show_current_scene_in_explorer.py", - "sourcetype": "file", - "title": "Explore current scene..", - "tooltip": "Show current scene in Explorer" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\avalon\\launch_manager.py", - "sourcetype": "file", - "title": "Project Manager", - "tooltip": "Add assets to the project" - }, - { - "type": "separator" - }, - { - "type": "menu", - "title": "Modeling", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\modeling\\duplicate_normalized.py", - "sourcetype": "file", - "tags": ["modeling", "duplicate", "normalized"], - "title": "Duplicate Normalized", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\modeling\\transferUVs.py", - "sourcetype": "file", - "tags": ["modeling", "transfer", "uv"], - "title": "Transfer UVs", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\modeling\\mirrorSymmetry.py", - "sourcetype": "file", - "tags": ["modeling", "mirror", "symmetry"], - "title": "Mirror Symmetry", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\modeling\\selectOutlineUI.py", - "sourcetype": "file", - "tags": ["modeling", "select", "outline", "ui"], - "title": "Select Outline UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\modeling\\polyDeleteOtherUVSets.py", - "sourcetype": "file", - "tags": ["modeling", "polygon", "uvset", "delete"], - "title": "Polygon Delete Other UV Sets", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\modeling\\polyCombineQuick.py", - "sourcetype": "file", - "tags": ["modeling", "combine", "polygon", "quick"], - "title": "Polygon Combine Quick", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\modeling\\separateMeshPerShader.py", - "sourcetype": "file", - "tags": ["modeling", "separateMeshPerShader"], - "title": "Separate Mesh Per Shader", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\modeling\\polyDetachSeparate.py", - "sourcetype": "file", - "tags": ["modeling", "poly", "detach", "separate"], - "title": "Polygon Detach and Separate", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\modeling\\polyRelaxVerts.py", - "sourcetype": "file", - "tags": ["modeling", "relax", "verts"], - "title": "Polygon Relax Vertices", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\modeling\\polySelectEveryNthEdgeUI.py", - "sourcetype": "file", - "tags": ["modeling", "select", "nth", "edge", "ui"], - "title": "Select Every Nth Edge" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\modeling\\djPFXUVs.py", - "sourcetype": "file", - "tags": ["modeling", "djPFX", "UVs"], - "title": "dj PFX UVs", - "tooltip": "" - } - ] - }, - { - "type": "menu", - "title": "Rigging", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\addCurveBetween.py", - "sourcetype": "file", - "tags": ["rigging", "addCurveBetween", "file"], - "title": "Add Curve Between" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\averageSkinWeights.py", - "sourcetype": "file", - "tags": ["rigging", "average", "skin weights", "file"], - "title": "Average Skin Weights" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\cbSmoothSkinWeightUI.py", - "sourcetype": "file", - "tags": ["rigging", "cbSmoothSkinWeightUI", "file"], - "title": "CB Smooth Skin Weight UI" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\channelBoxManagerUI.py", - "sourcetype": "file", - "tags": ["rigging", "channelBoxManagerUI", "file"], - "title": "Channel Box Manager UI" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\characterAutorigger.py", - "sourcetype": "file", - "tags": ["rigging", "characterAutorigger", "file"], - "title": "Character Auto Rigger" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\connectUI.py", - "sourcetype": "file", - "tags": ["rigging", "connectUI", "file"], - "title": "Connect UI" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\copySkinWeightsLocal.py", - "sourcetype": "file", - "tags": ["rigging", "copySkinWeightsLocal", "file"], - "title": "Copy Skin Weights Local" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\createCenterLocator.py", - "sourcetype": "file", - "tags": ["rigging", "createCenterLocator", "file"], - "title": "Create Center Locator" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\freezeTransformToGroup.py", - "sourcetype": "file", - "tags": ["rigging", "freezeTransformToGroup", "file"], - "title": "Freeze Transform To Group" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\groupSelected.py", - "sourcetype": "file", - "tags": ["rigging", "groupSelected", "file"], - "title": "Group Selected" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\ikHandlePoleVectorLocator.py", - "sourcetype": "file", - "tags": ["rigging", "ikHandlePoleVectorLocator", "file"], - "title": "IK Handle Pole Vector Locator" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\jointOrientUI.py", - "sourcetype": "file", - "tags": ["rigging", "jointOrientUI", "file"], - "title": "Joint Orient UI" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\jointsOnCurve.py", - "sourcetype": "file", - "tags": ["rigging", "jointsOnCurve", "file"], - "title": "Joints On Curve" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\resetBindSelectedSkinJoints.py", - "sourcetype": "file", - "tags": ["rigging", "resetBindSelectedSkinJoints", "file"], - "title": "Reset Bind Selected Skin Joints" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\selectSkinclusterJointsFromSelectedComponents.py", - "sourcetype": "file", - "tags": [ - "rigging", - "selectSkinclusterJointsFromSelectedComponents", - "file" - ], - "title": "Select Skincluster Joints From Selected Components" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\selectSkinclusterJointsFromSelectedMesh.py", - "sourcetype": "file", - "tags": [ - "rigging", - "selectSkinclusterJointsFromSelectedMesh", - "file" - ], - "title": "Select Skincluster Joints From Selected Mesh" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\setJointLabels.py", - "sourcetype": "file", - "tags": ["rigging", "setJointLabels", "file"], - "title": "Set Joint Labels" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\setJointOrientationFromCurrentRotation.py", - "sourcetype": "file", - "tags": [ - "rigging", - "setJointOrientationFromCurrentRotation", - "file" - ], - "title": "Set Joint Orientation From Current Rotation" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\setSelectedJointsOrientationZero.py", - "sourcetype": "file", - "tags": ["rigging", "setSelectedJointsOrientationZero", "file"], - "title": "Set Selected Joints Orientation Zero" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\mirrorCurveShape.py", - "sourcetype": "file", - "tags": ["rigging", "mirrorCurveShape", "file"], - "title": "Mirror Curve Shape" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\setRotationOrderUI.py", - "sourcetype": "file", - "tags": ["rigging", "setRotationOrderUI", "file"], - "title": "Set Rotation Order UI" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\paintItNowUI.py", - "sourcetype": "file", - "tags": ["rigging", "paintItNowUI", "file"], - "title": "Paint It Now UI" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\parentScaleConstraint.py", - "sourcetype": "file", - "tags": ["rigging", "parentScaleConstraint", "file"], - "title": "Parent Scale Constraint" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\quickSetWeightsUI.py", - "sourcetype": "file", - "tags": ["rigging", "quickSetWeightsUI", "file"], - "title": "Quick Set Weights UI" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\rapidRig.py", - "sourcetype": "file", - "tags": ["rigging", "rapidRig", "file"], - "title": "Rapid Rig" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\regenerate_blendshape_targets.py", - "sourcetype": "file", - "tags": ["rigging", "regenerate_blendshape_targets", "file"], - "title": "Regenerate Blendshape Targets" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\removeRotationAxis.py", - "sourcetype": "file", - "tags": ["rigging", "removeRotationAxis", "file"], - "title": "Remove Rotation Axis" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\resetBindSelectedMeshes.py", - "sourcetype": "file", - "tags": ["rigging", "resetBindSelectedMeshes", "file"], - "title": "Reset Bind Selected Meshes" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\simpleControllerOnSelection.py", - "sourcetype": "file", - "tags": ["rigging", "simpleControllerOnSelection", "file"], - "title": "Simple Controller On Selection" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\simpleControllerOnSelectionHierarchy.py", - "sourcetype": "file", - "tags": [ - "rigging", - "simpleControllerOnSelectionHierarchy", - "file" - ], - "title": "Simple Controller On Selection Hierarchy" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\superRelativeCluster.py", - "sourcetype": "file", - "tags": ["rigging", "superRelativeCluster", "file"], - "title": "Super Relative Cluster" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\tfSmoothSkinWeight.py", - "sourcetype": "file", - "tags": ["rigging", "tfSmoothSkinWeight", "file"], - "title": "TF Smooth Skin Weight" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\toggleIntermediates.py", - "sourcetype": "file", - "tags": ["rigging", "toggleIntermediates", "file"], - "title": "Toggle Intermediates" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\toggleSegmentScaleCompensate.py", - "sourcetype": "file", - "tags": ["rigging", "toggleSegmentScaleCompensate", "file"], - "title": "Toggle Segment Scale Compensate" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\rigging\\toggleSkinclusterDeformNormals.py", - "sourcetype": "file", - "tags": ["rigging", "toggleSkinclusterDeformNormals", "file"], - "title": "Toggle Skincluster Deform Normals" - } - ] - }, - { - "type": "menu", - "title": "Shading", - "items": [ - { - "type": "menu", - "title": "VRay", - "items": [ - { - "type": "action", - "title": "Import Proxies", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\vrayImportProxies.py", - "sourcetype": "file", - "tags": ["shading", "vray", "import", "proxies"], - "tooltip": "" - }, - { - "type": "separator" - }, - { - "type": "action", - "title": "Select All GES", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\selectAllGES.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "select All GES"] - }, - { - "type": "action", - "title": "Select All GES Under Selection", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\selectAllGESUnderSelection.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "select", "all", "GES"] - }, - { - "type": "separator" - }, - { - "type": "action", - "title": "Selection To VRay Mesh", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\selectionToVrayMesh.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "selection", "vraymesh"] - }, - { - "type": "action", - "title": "Add VRay Round Edges Attribute", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\addVrayRoundEdgesAttribute.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "round edges", "attribute"] - }, - { - "type": "action", - "title": "Add Gamma", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\vrayAddGamma.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "add gamma"] - }, - { - "type": "separator" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\select_vraymesh_materials_with_unconnected_shader_slots.py", - "sourcetype": "file", - "title": "Select Unconnected Shader Materials", - "tags": [ - "shading", - "vray", - "select", - "vraymesh", - "materials", - "unconnected shader slots" - ], - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\vrayMergeSimilarVRayMeshMaterials.py", - "sourcetype": "file", - "title": "Merge Similar VRay Mesh Materials", - "tags": [ - "shading", - "vray", - "Merge", - "VRayMesh", - "Materials" - ], - "tooltip": "" - }, - { - "type": "action", - "title": "Create Two Sided Material", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\vrayCreate2SidedMtlForSelectedMtlRenamed.py", - "sourcetype": "file", - "tooltip": "Creates two sided material for selected material and renames it", - "tags": ["shading", "vray", "two sided", "material"] - }, - { - "type": "action", - "title": "Create Two Sided Material For Selected", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\vrayCreate2SidedMtlForSelectedMtl.py", - "sourcetype": "file", - "tooltip": "Select material to create a two sided version from it", - "tags": [ - "shading", - "vray", - "Create2SidedMtlForSelectedMtl.py" - ] - }, - { - "type": "separator" - }, - { - "type": "action", - "title": "Add OpenSubdiv Attribute", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\addVrayOpenSubdivAttribute.py", - "sourcetype": "file", - "tooltip": "", - "tags": [ - "shading", - "vray", - "add", - "open subdiv", - "attribute" - ] - }, - { - "type": "action", - "title": "Remove OpenSubdiv Attribute", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\removeVrayOpenSubdivAttribute.py", - "sourcetype": "file", - "tooltip": "", - "tags": [ - "shading", - "vray", - "remove", - "opensubdiv", - "attributee" - ] - }, - { - "type": "separator" - }, - { - "type": "action", - "title": "Add Subdivision Attribute", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\addVraySubdivisionAttribute.py", - "sourcetype": "file", - "tooltip": "", - "tags": [ - "shading", - "vray", - "addVraySubdivisionAttribute" - ] - }, - { - "type": "action", - "title": "Remove Subdivision Attribute.py", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\removeVraySubdivisionAttribute.py", - "sourcetype": "file", - "tooltip": "", - "tags": [ - "shading", - "vray", - "remove", - "subdivision", - "attribute" - ] - }, - { - "type": "separator" - }, - { - "type": "action", - "title": "Add Vray Object Ids", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\addVrayObjectIds.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "add", "object id"] - }, - { - "type": "action", - "title": "Add Vray Material Ids", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\addVrayMaterialIds.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "addVrayMaterialIds.py"] - }, - { - "type": "separator" - }, - { - "type": "action", - "title": "Set Physical DOF Depth", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\vrayPhysicalDOFSetDepth.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "physical", "DOF ", "Depth"] - }, - { - "type": "action", - "title": "Magic Vray Proxy UI", - "command": "$OPENPYPE_SCRIPTS\\shading\\vray\\magicVrayProxyUI.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "magicVrayProxyUI"] - } - ] - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\pyblish\\lighting\\set_filename_prefix.py", - "sourcetype": "file", - "tags": [ - "shading", - "lookdev", - "assign", - "shaders", - "prefix", - "filename", - "render" - ], - "title": "Set filename prefix", - "tooltip": "Set the render file name prefix." - }, - { - "type": "action", - "command": "import mayalookassigner; mayalookassigner.show()", - "sourcetype": "python", - "tags": ["shading", "look", "assign", "shaders", "auto"], - "title": "Look Manager", - "tooltip": "Open the Look Manager UI for look assignment" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\LightLinkUi.py", - "sourcetype": "file", - "tags": ["shading", "light", "link", "ui"], - "title": "Light Link UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\vdviewer_ui.py", - "sourcetype": "file", - "tags": [ - "shading", - "look", - "vray", - "displacement", - "shaders", - "auto" - ], - "title": "VRay Displ Viewer", - "tooltip": "Open the VRay Displacement Viewer, select and control the content of the set" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\setTexturePreviewToCLRImage.py", - "sourcetype": "file", - "tags": ["shading", "CLRImage", "textures", "preview"], - "title": "Set Texture Preview To CLRImage", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\fixDefaultShaderSetBehavior.py", - "sourcetype": "file", - "tags": ["shading", "fix", "DefaultShaderSet", "Behavior"], - "title": "Fix Default Shader Set Behavior", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\fixSelectedShapesReferenceAssignments.py", - "sourcetype": "file", - "tags": [ - "shading", - "fix", - "Selected", - "Shapes", - "Reference", - "Assignments" - ], - "title": "Fix Shapes Reference Assignments", - "tooltip": "Select shapes to fix the reference assignments" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\selectLambert1Members.py", - "sourcetype": "file", - "tags": ["shading", "selectLambert1Members"], - "title": "Select Lambert1 Members", - "tooltip": "Selects all objects which have the Lambert1 shader assigned" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\selectShapesWithoutShader.py", - "sourcetype": "file", - "tags": ["shading", "selectShapesWithoutShader"], - "title": "Select Shapes Without Shader", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\fixRenderLayerOutAdjustmentErrors.py", - "sourcetype": "file", - "tags": ["shading", "fixRenderLayerOutAdjustmentErrors"], - "title": "Fix RenderLayer Out Adjustment Errors", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\shading\\fix_renderlayer_missing_node_override.py", - "sourcetype": "file", - "tags": [ - "shading", - "renderlayer", - "missing", - "reference", - "switch", - "layer" - ], - "title": "Fix RenderLayer Missing Referenced Nodes Overrides", - "tooltip": "" - }, - { - "type": "action", - "title": "Image 2 Tiled EXR", - "command": "$OPENPYPE_SCRIPTS\\shading\\open_img2exr.py", - "sourcetype": "file", - "tooltip": "", - "tags": ["shading", "vray", "exr"] - } - ] - }, - { - "type": "menu", - "title": "Rendering", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\pyblish\\open_deadline_submission_settings.py", - "sourcetype": "file", - "tags": ["settings", "deadline", "globals", "render"], - "title": "DL Submission Settings UI", - "tooltip": "Open the Deadline Submission Settings UI" - } - ] - }, - { - "type": "menu", - "title": "Animation", - "items": [ - { - "type": "menu", - "title": "Attributes", - "tooltip": "", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\attributes\\copyValues.py", - "sourcetype": "file", - "tags": ["animation", "copy", "attributes"], - "title": "Copy Values", - "tooltip": "Copy attribute values" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\attributes\\copyInConnections.py", - "sourcetype": "file", - "tags": [ - "animation", - "copy", - "attributes", - "connections", - "incoming" - ], - "title": "Copy In Connections", - "tooltip": "Copy incoming connections" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\attributes\\copyOutConnections.py", - "sourcetype": "file", - "tags": [ - "animation", - "copy", - "attributes", - "connections", - "out" - ], - "title": "Copy Out Connections", - "tooltip": "Copy outcoming connections" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\attributes\\copyTransformLocal.py", - "sourcetype": "file", - "tags": [ - "animation", - "copy", - "attributes", - "transforms", - "local" - ], - "title": "Copy Local Transforms", - "tooltip": "Copy local transforms" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\attributes\\copyTransformMatrix.py", - "sourcetype": "file", - "tags": [ - "animation", - "copy", - "attributes", - "transforms", - "matrix" - ], - "title": "Copy Matrix Transforms", - "tooltip": "Copy Matrix transforms" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\attributes\\copyTransformUI.py", - "sourcetype": "file", - "tags": [ - "animation", - "copy", - "attributes", - "transforms", - "UI" - ], - "title": "Copy Transforms UI", - "tooltip": "Open the Copy Transforms UI" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\attributes\\simpleCopyUI.py", - "sourcetype": "file", - "tags": [ - "animation", - "copy", - "attributes", - "transforms", - "UI", - "simple" - ], - "title": "Simple Copy UI", - "tooltip": "Open the simple Copy Transforms UI" - } - ] - }, - { - "type": "menu", - "title": "Optimize", - "tooltip": "Optimization scripts", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\optimize\\toggleFreezeHierarchy.py", - "sourcetype": "file", - "tags": ["animation", "hierarchy", "toggle", "freeze"], - "title": "Toggle Freeze Hierarchy", - "tooltip": "Freeze and unfreeze hierarchy" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\animation\\optimize\\toggleParallelNucleus.py", - "sourcetype": "file", - "tags": ["animation", "nucleus", "toggle", "parallel"], - "title": "Toggle Parallel Nucleus", - "tooltip": "Toggle parallel nucleus" - } - ] - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\bakeSelectedToWorldSpace.py", - "tags": ["animation", "bake", "selection", "worldspace.py"], - "title": "Bake Selected To Worldspace", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\timeStepper.py", - "tags": ["animation", "time", "stepper"], - "title": "Time Stepper", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\capture_ui.py", - "tags": [ - "animation", - "capture", - "ui", - "screen", - "movie", - "image" - ], - "title": "Capture UI", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\simplePlayblastUI.py", - "tags": ["animation", "simple", "playblast", "ui"], - "title": "Simple Playblast UI", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\tweenMachineUI.py", - "tags": ["animation", "tween", "machine"], - "title": "Tween Machine UI", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\selectAllAnimationCurves.py", - "tags": ["animation", "select", "curves"], - "title": "Select All Animation Curves", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\pathAnimation.py", - "tags": ["animation", "path", "along"], - "title": "Path Animation", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\offsetSelectedObjectsUI.py", - "tags": ["animation", "offsetSelectedObjectsUI.py"], - "title": "Offset Selected Objects UI", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\key_amplifier_ui.py", - "tags": ["animation", "key", "amplifier"], - "title": "Key Amplifier UI", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\anim_scene_optimizer.py", - "tags": ["animation", "anim_scene_optimizer.py"], - "title": "Anim_Scene_Optimizer", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\zvParentMaster.py", - "tags": ["animation", "zvParentMaster.py"], - "title": "ZV Parent Master", - "type": "action" - }, - { - "sourcetype": "file", - "command": "$OPENPYPE_SCRIPTS\\animation\\poseLibrary.py", - "tags": ["animation", "poseLibrary.py"], - "title": "Pose Library", - "type": "action" - } - ] - }, - { - "type": "menu", - "title": "Layout", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\alignDistributeUI.py", - "sourcetype": "file", - "tags": ["layout", "align", "Distribute", "UI"], - "title": "Align Distribute UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\alignSimpleUI.py", - "sourcetype": "file", - "tags": ["layout", "align", "UI", "Simple"], - "title": "Align Simple UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\center_locator.py", - "sourcetype": "file", - "tags": ["layout", "center", "locator"], - "title": "Center Locator", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\average_locator.py", - "sourcetype": "file", - "tags": ["layout", "average", "locator"], - "title": "Average Locator", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\selectWithinProximityUI.py", - "sourcetype": "file", - "tags": ["layout", "select", "proximity", "ui"], - "title": "Select Within Proximity UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\dupCurveUI.py", - "sourcetype": "file", - "tags": ["layout", "Duplicate", "Curve", "UI"], - "title": "Duplicate Curve UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\randomDeselectUI.py", - "sourcetype": "file", - "tags": ["layout", "random", "Deselect", "UI"], - "title": "Random Deselect UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\multiReferencerUI.py", - "sourcetype": "file", - "tags": ["layout", "multi", "reference"], - "title": "Multi Referencer UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\duplicateOffsetUI.py", - "sourcetype": "file", - "tags": ["layout", "duplicate", "offset", "UI"], - "title": "Duplicate Offset UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\spPaint3d.py", - "sourcetype": "file", - "tags": ["layout", "spPaint3d", "paint", "tool"], - "title": "SP Paint 3d", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\randomizeUI.py", - "sourcetype": "file", - "tags": ["layout", "randomize", "UI"], - "title": "Randomize UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\layout\\distributeWithinObjectUI.py", - "sourcetype": "file", - "tags": ["layout", "distribute", "ObjectUI", "within"], - "title": "Distribute Within Object UI", - "tooltip": "" - } - ] - }, - { - "type": "menu", - "title": "Particles", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\instancerToObjects.py", - "sourcetype": "file", - "tags": ["particles", "instancerToObjects"], - "title": "Instancer To Objects", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\instancerToObjectsInstances.py", - "sourcetype": "file", - "tags": ["particles", "instancerToObjectsInstances"], - "title": "Instancer To Objects Instances", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\objectsToParticlesAndInstancerCleanSource.py", - "sourcetype": "file", - "tags": [ - "particles", - "objects", - "Particles", - "Instancer", - "Clean", - "Source" - ], - "title": "Objects To Particles & Instancer - Clean Source", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\particleComponentsToLocators.py", - "sourcetype": "file", - "tags": ["particles", "components", "locators"], - "title": "Particle Components To Locators", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\objectsToParticlesAndInstancer.py", - "sourcetype": "file", - "tags": ["particles", "objects", "particles", "instancer"], - "title": "Objects To Particles And Instancer", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\spawnParticlesOnMesh.py", - "sourcetype": "file", - "tags": ["particles", "spawn", "on", "mesh"], - "title": "Spawn Particles On Mesh", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\instancerToObjectsInstancesWithAnimation.py", - "sourcetype": "file", - "tags": [ - "particles", - "instancerToObjectsInstancesWithAnimation" - ], - "title": "Instancer To Objects Instances With Animation", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\objectsToParticles.py", - "sourcetype": "file", - "tags": ["particles", "objectsToParticles"], - "title": "Objects To Particles", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\add_particle_cacheFile_attrs.py", - "sourcetype": "file", - "tags": ["particles", "add_particle_cacheFile_attrs"], - "title": "Add Particle CacheFile Attributes", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\mergeParticleSystems.py", - "sourcetype": "file", - "tags": ["particles", "mergeParticleSystems"], - "title": "Merge Particle Systems", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\particlesToLocators.py", - "sourcetype": "file", - "tags": ["particles", "particlesToLocators"], - "title": "Particles To Locators", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\instancerToObjectsWithAnimation.py", - "sourcetype": "file", - "tags": ["particles", "instancerToObjectsWithAnimation"], - "title": "Instancer To Objects With Animation", - "tooltip": "" - }, - { - "type": "separator" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\mayaReplicateHoudiniTool.py", - "sourcetype": "file", - "tags": [ - "particles", - "houdini", - "houdiniTool", - "houdiniEngine" - ], - "title": "Replicate Houdini Tool", - "tooltip": "" - }, - { - "type": "separator" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\clearInitialState.py", - "sourcetype": "file", - "tags": ["particles", "clearInitialState"], - "title": "Clear Initial State", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\particles\\killSelectedParticles.py", - "sourcetype": "file", - "tags": ["particles", "killSelectedParticles"], - "title": "Kill Selected Particles", - "tooltip": "" - } - ] - }, - { - "type": "menu", - "title": "Yeti", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\yeti\\yeti_rig_manager.py", - "sourcetype": "file", - "tags": ["yeti", "rig", "fur", "manager"], - "title": "Open Yeti Rig Manager", - "tooltip": "" - } - ] - }, - { - "type": "menu", - "title": "Cleanup", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\repair_faulty_containers.py", - "sourcetype": "file", - "tags": ["cleanup", "repair", "containers"], - "title": "Find and Repair Containers", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\selectByType.py", - "sourcetype": "file", - "tags": ["cleanup", "selectByType"], - "title": "Select By Type", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\selectIntermediateObjects.py", - "sourcetype": "file", - "tags": ["cleanup", "selectIntermediateObjects"], - "title": "Select Intermediate Objects", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\selectNonUniqueNames.py", - "sourcetype": "file", - "tags": ["cleanup", "select", "non unique", "names"], - "title": "Select Non Unique Names", - "tooltip": "" - }, - { - "type": "separator" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\removeNamespaces.py", - "sourcetype": "file", - "tags": ["cleanup", "remove", "namespaces"], - "title": "Remove Namespaces", - "tooltip": "Remove all namespaces" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\remove_user_defined_attributes.py", - "sourcetype": "file", - "tags": ["cleanup", "remove_user_defined_attributes"], - "title": "Remove User Defined Attributes", - "tooltip": "Remove all user-defined attributes from all nodes" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\removeUnknownNodes.py", - "sourcetype": "file", - "tags": ["cleanup", "removeUnknownNodes"], - "title": "Remove Unknown Nodes", - "tooltip": "Remove all unknown nodes" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\removeUnloadedReferences.py", - "sourcetype": "file", - "tags": ["cleanup", "removeUnloadedReferences"], - "title": "Remove Unloaded References", - "tooltip": "Remove all unloaded references" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\removeReferencesFailedEdits.py", - "sourcetype": "file", - "tags": ["cleanup", "removeReferencesFailedEdits"], - "title": "Remove References Failed Edits", - "tooltip": "Remove failed edits for all references" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\remove_unused_looks.py", - "sourcetype": "file", - "tags": ["cleanup", "removeUnusedLooks"], - "title": "Remove Unused Looks", - "tooltip": "Remove all loaded yet unused Avalon look containers" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\deleteGhostIntermediateObjects.py", - "sourcetype": "file", - "tags": ["cleanup", "deleteGhostIntermediateObjects"], - "title": "Delete Ghost Intermediate Objects", - "tooltip": "" - }, - { - "type": "separator" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\resetViewportCache.py", - "sourcetype": "file", - "tags": ["cleanup", "reset", "viewport", "cache"], - "title": "Reset Viewport Cache", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\uniqifyNodeNames.py", - "sourcetype": "file", - "tags": ["cleanup", "uniqifyNodeNames"], - "title": "Uniqify Node Names", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\autoRenameFileNodes.py", - "sourcetype": "file", - "tags": ["cleanup", "auto", "rename", "filenodes"], - "title": "Auto Rename File Nodes", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\update_asset_id.py", - "sourcetype": "file", - "tags": ["cleanup", "update", "database", "asset", "id"], - "title": "Update Asset ID", - "tooltip": "Will replace the Colorbleed ID with a new one (asset ID : Unique number)" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\colorbleedRename.py", - "sourcetype": "file", - "tags": ["cleanup", "rename", "ui"], - "title": "Colorbleed Renamer", - "tooltip": "Colorbleed Rename UI" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\renameShapesToTransform.py", - "sourcetype": "file", - "tags": ["cleanup", "renameShapesToTransform"], - "title": "Rename Shapes To Transform", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\reorderUI.py", - "sourcetype": "file", - "tags": ["cleanup", "reorderUI"], - "title": "Reorder UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\cleanup\\pastedCleaner.py", - "sourcetype": "file", - "tags": ["cleanup", "pastedCleaner"], - "title": "Pasted Cleaner", - "tooltip": "" - } - ] - }, - { - "type": "menu", - "title": "Others", - "items": [ - { - "type": "menu", - "sourcetype": "file", - "title": "Yeti", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\yeti\\cache_selected_yeti_nodes.py", - "sourcetype": "file", - "tags": ["others", "yeti", "cache", "selected"], - "title": "Cache Selected Yeti Nodes", - "tooltip": "" - } - ] - }, - { - "type": "menu", - "title": "Hair", - "tooltip": "", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\hair\\recolorHairCurrentCurve", - "sourcetype": "file", - "tags": ["others", "selectSoftSelection"], - "title": "Select Soft Selection", - "tooltip": "" - } - ] - }, - { - "type": "menu", - "command": "$OPENPYPE_SCRIPTS\\others\\display", - "sourcetype": "file", - "tags": ["others", "display"], - "title": "Display", - "items": [ - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\display\\wireframeSelectedObjects.py", - "sourcetype": "file", - "tags": ["others", "wireframe", "selected", "objects"], - "title": "Wireframe Selected Objects", - "tooltip": "" - } - ] - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\archiveSceneUI.py", - "sourcetype": "file", - "tags": ["others", "archiveSceneUI"], - "title": "Archive Scene UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\getSimilarMeshes.py", - "sourcetype": "file", - "tags": ["others", "getSimilarMeshes"], - "title": "Get Similar Meshes", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\createBoundingBoxEachSelected.py", - "sourcetype": "file", - "tags": ["others", "createBoundingBoxEachSelected"], - "title": "Create BoundingBox Each Selected", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\curveFromPositionEveryFrame.py", - "sourcetype": "file", - "tags": ["others", "curveFromPositionEveryFrame"], - "title": "Curve From Position", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\instanceLeafSmartTransform.py", - "sourcetype": "file", - "tags": ["others", "instance", "leaf", "smart", "transform"], - "title": "Instance Leaf Smart Transform", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\instanceSmartTransform.py", - "sourcetype": "file", - "tags": ["others", "instance", "smart", "transform"], - "title": "Instance Smart Transform", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\randomizeUVShellsSelectedObjects.py", - "sourcetype": "file", - "tags": ["others", "randomizeUVShellsSelectedObjects"], - "title": "Randomize UV Shells", - "tooltip": "Select objects before running action" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\centerPivotGroup.py", - "sourcetype": "file", - "tags": ["others", "centerPivotGroup"], - "title": "Center Pivot Group", - "tooltip": "" - }, - { - "type": "separator" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\locatorsOnSelectedFaces.py", - "sourcetype": "file", - "tags": ["others", "locatorsOnSelectedFaces"], - "title": "Locators On Selected Faces", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\locatorsOnEdgeSelectionPrompt.py", - "sourcetype": "file", - "tags": ["others", "locatorsOnEdgeSelectionPrompt"], - "title": "Locators On Edge Selection Prompt", - "tooltip": "" - }, - { - "type": "separator" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\copyDeformers.py", - "sourcetype": "file", - "tags": ["others", "copyDeformers"], - "title": "Copy Deformers", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\selectInReferenceEditor.py", - "sourcetype": "file", - "tags": ["others", "selectInReferenceEditor"], - "title": "Select In Reference Editor", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\selectConstrainingObject.py", - "sourcetype": "file", - "tags": ["others", "selectConstrainingObject"], - "title": "Select Constraining Object", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\deformerSetRelationsUI.py", - "sourcetype": "file", - "tags": ["others", "deformerSetRelationsUI"], - "title": "Deformer Set Relations UI", - "tooltip": "" - }, - { - "type": "action", - "command": "$OPENPYPE_SCRIPTS\\others\\recreateBaseNodesForAllLatticeNodes.py", - "sourcetype": "file", - "tags": ["others", "recreate", "base", "nodes", "lattice"], - "title": "Recreate Base Nodes For Lattice Nodes", - "tooltip": "" - } - ] - } -] From cae76ef920e581884dbff487c1c8219826006df2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 12 Feb 2022 23:56:13 +0100 Subject: [PATCH 103/483] Remove some unused functions --- openpype/hosts/maya/api/lib.py | 147 --------------------------------- 1 file changed, 147 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 1f6c8c1deb..1eab0bdfb7 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -286,153 +286,6 @@ def pairwise(iterable): return itertools.izip(a, a) -def unique(name): - assert isinstance(name, string_types), "`name` must be string" - - while cmds.objExists(name): - matches = re.findall(r"\d+$", name) - - if matches: - match = matches[-1] - name = name.rstrip(match) - number = int(match) + 1 - else: - number = 1 - - name = name + str(number) - - return name - - -def uv_from_element(element): - """Return the UV coordinate of given 'element' - - Supports components, meshes, nurbs. - - """ - - supported = ["mesh", "nurbsSurface"] - - uv = [0.5, 0.5] - - if "." not in element: - type = cmds.nodeType(element) - if type == "transform": - geometry_shape = cmds.listRelatives(element, shapes=True) - - if len(geometry_shape) >= 1: - geometry_shape = geometry_shape[0] - else: - return - - elif type in supported: - geometry_shape = element - - else: - cmds.error("Could not do what you wanted..") - return - else: - # If it is indeed a component - get the current Mesh - try: - parent = element.split(".", 1)[0] - - # Maya is funny in that when the transform of the shape - # of the component element has children, the name returned - # by that elementection is the shape. Otherwise, it is - # the transform. So lets see what type we're dealing with here. - if cmds.nodeType(parent) in supported: - geometry_shape = parent - else: - geometry_shape = cmds.listRelatives(parent, shapes=1)[0] - - if not geometry_shape: - cmds.error("Skipping %s: Could not find shape." % element) - return - - if len(cmds.ls(geometry_shape)) > 1: - cmds.warning("Multiple shapes with identical " - "names found. This might not work") - - except TypeError as e: - cmds.warning("Skipping %s: Didn't find a shape " - "for component elementection. %s" % (element, e)) - return - - try: - type = cmds.nodeType(geometry_shape) - - if type == "nurbsSurface": - # If a surfacePoint is elementected on a nurbs surface - root, u, v = element.rsplit("[", 2) - uv = [float(u[:-1]), float(v[:-1])] - - if type == "mesh": - # ----------- - # Average the U and V values - # =========== - uvs = cmds.polyListComponentConversion(element, toUV=1) - if not uvs: - cmds.warning("Couldn't derive any UV's from " - "component, reverting to default U and V") - raise TypeError - - # Flatten list of Uv's as sometimes it returns - # neighbors like this [2:3] instead of [2], [3] - flattened = [] - - for uv in uvs: - flattened.extend(cmds.ls(uv, flatten=True)) - - uvs = flattened - - sumU = 0 - sumV = 0 - for uv in uvs: - try: - u, v = cmds.polyEditUV(uv, query=True) - except Exception: - cmds.warning("Couldn't find any UV coordinated, " - "reverting to default U and V") - raise TypeError - - sumU += u - sumV += v - - averagedU = sumU / len(uvs) - averagedV = sumV / len(uvs) - - uv = [averagedU, averagedV] - except TypeError: - pass - - return uv - - -def shape_from_element(element): - """Return shape of given 'element' - - Supports components, meshes, and surfaces - - """ - - try: - # Get either shape or transform, based on element-type - node = cmds.ls(element, objectsOnly=True)[0] - except Exception: - cmds.warning("Could not find node in %s" % element) - return None - - if cmds.nodeType(node) == 'transform': - try: - return cmds.listRelatives(node, shapes=True)[0] - except Exception: - cmds.warning("Could not find shape in %s" % element) - return None - - else: - return node - - def export_alembic(nodes, file, frame_range=None, From 651ac02b3be00afa4199847cd003b51fbf955908 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 12 Feb 2022 23:57:47 +0100 Subject: [PATCH 104/483] Remove old unused function --- openpype/hosts/maya/api/lib.py | 109 --------------------------------- 1 file changed, 109 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 1eab0bdfb7..3efa625e4a 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -430,115 +430,6 @@ def imprint(node, data): cmds.setAttr(node + "." + key, value, **set_type) -def serialise_shaders(nodes): - """Generate a shader set dictionary - - Arguments: - nodes (list): Absolute paths to nodes - - Returns: - dictionary of (shader: id) pairs - - Schema: - { - "shader1": ["id1", "id2"], - "shader2": ["id3", "id1"] - } - - Example: - { - "Bazooka_Brothers01_:blinn4SG": [ - "f9520572-ac1d-11e6-b39e-3085a99791c9.f[4922:5001]", - "f9520572-ac1d-11e6-b39e-3085a99791c9.f[4587:4634]", - "f9520572-ac1d-11e6-b39e-3085a99791c9.f[1120:1567]", - "f9520572-ac1d-11e6-b39e-3085a99791c9.f[4251:4362]" - ], - "lambert2SG": [ - "f9520571-ac1d-11e6-9dbb-3085a99791c9" - ] - } - - """ - - valid_nodes = cmds.ls( - nodes, - long=True, - recursive=True, - showType=True, - objectsOnly=True, - type="transform" - ) - - meshes_by_id = {} - for mesh in valid_nodes: - shapes = cmds.listRelatives(valid_nodes[0], - shapes=True, - fullPath=True) or list() - - if shapes: - shape = shapes[0] - if not cmds.nodeType(shape): - continue - - try: - id_ = cmds.getAttr(mesh + ".mbID") - - if id_ not in meshes_by_id: - meshes_by_id[id_] = list() - - meshes_by_id[id_].append(mesh) - - except ValueError: - continue - - meshes_by_shader = dict() - for mesh in meshes_by_id.values(): - shape = cmds.listRelatives(mesh, - shapes=True, - fullPath=True) or list() - - for shader in cmds.listConnections(shape, - type="shadingEngine") or list(): - - # Objects in this group are those that haven't got - # any shaders. These are expected to be managed - # elsewhere, such as by the default model loader. - if shader == "initialShadingGroup": - continue - - if shader not in meshes_by_shader: - meshes_by_shader[shader] = list() - - shaded = cmds.sets(shader, query=True) or list() - meshes_by_shader[shader].extend(shaded) - - shader_by_id = {} - for shader, shaded in meshes_by_shader.items(): - - if shader not in shader_by_id: - shader_by_id[shader] = list() - - for mesh in shaded: - - # Enable shader assignment to faces. - name = mesh.split(".f[")[0] - - transform = name - if cmds.objectType(transform) == "mesh": - transform = cmds.listRelatives(name, parent=True)[0] - - try: - id_ = cmds.getAttr(transform + ".mbID") - shader_by_id[shader].append(mesh.replace(name, id_)) - except KeyError: - continue - - # Remove duplicates - shader_by_id[shader] = list(set(shader_by_id[shader])) - - return shader_by_id - - def lsattr(attr, value=None): """Return nodes matching `key` and `value` From 33e1687120eb47f9f9239ed8f923e7a4d5238c0e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 13 Feb 2022 00:01:06 +0100 Subject: [PATCH 105/483] Remove maya_temp_folder() function - not used in code base --- openpype/hosts/maya/api/lib.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 3efa625e4a..6396324167 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -1283,15 +1283,6 @@ def extract_alembic(file, return file -def maya_temp_folder(): - scene_dir = os.path.dirname(cmds.file(query=True, sceneName=True)) - tmp_dir = os.path.abspath(os.path.join(scene_dir, "..", "tmp")) - if not os.path.isdir(tmp_dir): - os.makedirs(tmp_dir) - - return tmp_dir - - # region ID def get_id_required_nodes(referenced_nodes=False, nodes=None): """Filter out any node which are locked (reference) or readOnly From 58ac71decb4945e8855de0f90b130ce55938916d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 13 Feb 2022 00:08:13 +0100 Subject: [PATCH 106/483] Fix docstring --- openpype/hosts/maya/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 6396324167..c99c3fefdb 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -2582,7 +2582,7 @@ def get_attr_in_layer(attr, layer): def fix_incompatible_containers(): - """Return whether the current scene has any outdated content""" + """Backwards compatibility: old containers to use new ReferenceLoader""" host = api.registered_host() for container in host.ls(): From c74f4e6f04361b46f97d10929514f9a56a4225d1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 13 Feb 2022 00:08:28 +0100 Subject: [PATCH 107/483] More cleanup --- openpype/hosts/maya/api/lib.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index c99c3fefdb..1e4e65578a 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -2124,6 +2124,7 @@ def reset_scene_resolution(): set_scene_resolution(width, height, pixelAspect) + def set_context_settings(): """Apply the project settings from the project definition @@ -2600,10 +2601,6 @@ def fix_incompatible_containers(): "ReferenceLoader", type="string") -def _null(*args): - pass - - class shelf(): '''A simple class to build shelves in maya. Since the build method is empty, it should be extended by the derived class to build the necessary shelf From b09629397ddc4186b13602fd4b81993529f72cc5 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 13 Feb 2022 00:12:35 +0100 Subject: [PATCH 108/483] Cosmetics --- openpype/hosts/maya/api/lib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 1e4e65578a..8e24d5ea13 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -2818,7 +2818,7 @@ class RenderSetupListObserver: cmds.delete(render_layer_set_name) -class RenderSetupItemObserver(): +class RenderSetupItemObserver: """Handle changes in render setup items.""" def __init__(self, item): @@ -3054,7 +3054,7 @@ def set_colorspace(): @contextlib.contextmanager def root_parent(nodes): # type: (list) -> list - """Context manager to un-parent provided nodes and return then back.""" + """Context manager to un-parent provided nodes and return them back.""" import pymel.core as pm # noqa node_parents = [] From d9ccb5e713f7ea8548eaad0cd2cd0c40abdd7a39 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 13 Feb 2022 11:37:42 +0100 Subject: [PATCH 109/483] Add back `_null(*args)`` because it was used by `shelf` --- openpype/hosts/maya/api/lib.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 8e24d5ea13..cf59a85c9b 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -2601,6 +2601,10 @@ def fix_incompatible_containers(): "ReferenceLoader", type="string") +def _null(*args): + pass + + class shelf(): '''A simple class to build shelves in maya. Since the build method is empty, it should be extended by the derived class to build the necessary shelf From df0d8fcb8719221b4f80f4793e62fe37803fcff1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 13 Feb 2022 13:25:34 +0100 Subject: [PATCH 110/483] Remove Maya 'lock' logic since it's unused in OP code base --- openpype/hosts/maya/api/__init__.py | 11 ----- openpype/hosts/maya/api/pipeline.py | 70 ----------------------------- 2 files changed, 81 deletions(-) diff --git a/openpype/hosts/maya/api/__init__.py b/openpype/hosts/maya/api/__init__.py index 9ea798e927..0eea8c4a53 100644 --- a/openpype/hosts/maya/api/__init__.py +++ b/openpype/hosts/maya/api/__init__.py @@ -10,12 +10,6 @@ from .pipeline import ( ls, containerise, - - lock, - unlock, - is_locked, - lock_ignored, - ) from .plugin import ( Creator, @@ -54,11 +48,6 @@ __all__ = [ "ls", "containerise", - "lock", - "unlock", - "is_locked", - "lock_ignored", - "Creator", "Loader", diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 476ceb840b..2c6335b87b 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -187,76 +187,6 @@ def uninstall(): menu.uninstall() -def lock(): - """Lock scene - - Add an invisible node to your Maya scene with the name of the - current file, indicating that this file is "locked" and cannot - be modified any further. - - """ - - if not cmds.objExists("lock"): - with lib.maintained_selection(): - cmds.createNode("objectSet", name="lock") - cmds.addAttr("lock", ln="basename", dataType="string") - - # Permanently hide from outliner - cmds.setAttr("lock.verticesOnlySet", True) - - fname = cmds.file(query=True, sceneName=True) - basename = os.path.basename(fname) - cmds.setAttr("lock.basename", basename, type="string") - - -def unlock(): - """Permanently unlock a locked scene - - Doesn't throw an error if scene is already unlocked. - - """ - - try: - cmds.delete("lock") - except ValueError: - pass - - -def is_locked(): - """Query whether current scene is locked""" - fname = cmds.file(query=True, sceneName=True) - basename = os.path.basename(fname) - - if self._ignore_lock: - return False - - try: - return cmds.getAttr("lock.basename") == basename - except ValueError: - return False - - -@contextlib.contextmanager -def lock_ignored(): - """Context manager for temporarily ignoring the lock of a scene - - The purpose of this function is to enable locking a scene and - saving it with the lock still in place. - - Example: - >>> with lock_ignored(): - ... pass # Do things without lock - - """ - - self._ignore_lock = True - - try: - yield - finally: - self._ignore_lock = False - - def parse_container(container): """Return the container node's full container data. From 1141a54c5e3332fd41eb90cea84565a10730b7da Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 14 Feb 2022 14:20:32 +0100 Subject: [PATCH 111/483] Merges of extractors with current develop Develop contains additional feature and changed storing of layers. --- openpype/hosts/photoshop/api/ws_stub.py | 11 ++++++++++- .../hosts/photoshop/plugins/publish/extract_image.py | 9 ++++++++- .../hosts/photoshop/plugins/publish/extract_review.py | 3 +-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py index 406c68c7ce..d4406d17b9 100644 --- a/openpype/hosts/photoshop/api/ws_stub.py +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -349,10 +349,19 @@ class PhotoshopServerStub: children of this list Args: - layers (list): list of PSItem + layers (list): list of PSItem - highest hierarchy """ extract_ids = set([ll.id for ll in self.get_layers_in_layers(layers)]) + self.hide_all_others_layers_ids(extract_ids) + + def hide_all_others_layers_ids(self, extract_ids): + """hides all layers that are not part of the list or that are not + children of this list + + Args: + extract_ids (list): list of integer that should be visible + """ for layer in self.get_layers(): if layer.visible and layer.id not in extract_ids: self.set_visible(layer.id, False) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index 88b9a6c1bd..04ce77ee34 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -26,7 +26,14 @@ class ExtractImage(openpype.api.Extractor): with photoshop.maintained_selection(): self.log.info("Extracting %s" % str(list(instance))) with photoshop.maintained_visibility(): - stub.hide_all_others_layers([instance[0]]) + layer = instance.data.get("layer") + ids = set([layer.id]) + add_ids = instance.data.pop("ids", None) + if add_ids: + ids.update(set(add_ids)) + extract_ids = set([ll.id for ll in stub. + get_layers_in_layers_ids(ids)]) + stub.hide_all_others_layers_ids(extract_ids) file_basename = os.path.splitext( stub.get_active_document_name() diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index b9750922f8..455c7b43a3 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -35,7 +35,6 @@ class ExtractReview(openpype.api.Extractor): layers = self._get_layers_from_image_instances(instance) self.log.info("Layers image instance found: {}".format(layers)) - img_list = [] if self.make_image_sequence and layers: self.log.info("Extract layers to image sequence.") img_list = self._saves_sequences_layers(staging_dir, layers) @@ -156,7 +155,7 @@ class ExtractReview(openpype.api.Extractor): for image_instance in instance.context: if image_instance.data["family"] != "image": continue - layers.append(image_instance[0]) + layers.append(image_instance.data.get("layer")) return sorted(layers) From 80bf891183514424586ae35d5392d45caf068d44 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 14 Feb 2022 18:37:14 +0100 Subject: [PATCH 112/483] Fix - do not trigger during automatic testing Skip if automatic testing and no batch file. Eventually we might want to automatically test webpublisher functionality too. --- .../plugins/publish/collect_color_coded_instances.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py index c1ae88fbbb..7d44d55a80 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py @@ -38,10 +38,15 @@ class CollectColorCodedInstances(pyblish.api.ContextPlugin): def process(self, context): self.log.info("CollectColorCodedInstances") - self.log.debug("mapping:: {}".format(self.color_code_mapping)) + batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA") + if (os.environ.get("IS_TEST") and + (not batch_dir or not os.path.exists(batch_dir))): + self.log.debug("Automatic testing, no batch data, skipping") + return existing_subset_names = self._get_existing_subset_names(context) - asset_name, task_name, variant = self._parse_batch() + + asset_name, task_name, variant = self._parse_batch(batch_dir) stub = photoshop.stub() layers = stub.get_layers() @@ -125,9 +130,8 @@ class CollectColorCodedInstances(pyblish.api.ContextPlugin): return existing_subset_names - def _parse_batch(self): + def _parse_batch(self, batch_dir): """Parses asset_name, task_name, variant from batch manifest.""" - batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA") task_data = None if batch_dir and os.path.exists(batch_dir): task_data = parse_json(os.path.join(batch_dir, From 7042330924185be973ccb9e071ec4e7c3ba15d52 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 14 Feb 2022 20:02:38 +0100 Subject: [PATCH 113/483] Updated db_asserts for Photoshop publish test --- .../photoshop/test_publish_in_photoshop.py | 68 +++++++++++++------ 1 file changed, 48 insertions(+), 20 deletions(-) diff --git a/tests/integration/hosts/photoshop/test_publish_in_photoshop.py b/tests/integration/hosts/photoshop/test_publish_in_photoshop.py index 32053cd9d4..5387bbe51e 100644 --- a/tests/integration/hosts/photoshop/test_publish_in_photoshop.py +++ b/tests/integration/hosts/photoshop/test_publish_in_photoshop.py @@ -1,5 +1,10 @@ +import logging + +from tests.lib.assert_classes import DBAssert from tests.integration.hosts.photoshop.lib import PhotoshopTestClass +log = logging.getLogger("test_publish_in_photoshop") + class TestPublishInPhotoshop(PhotoshopTestClass): """Basic test case for publishing in Photoshop @@ -30,7 +35,7 @@ class TestPublishInPhotoshop(PhotoshopTestClass): {OPENPYPE_ROOT}/.venv/Scripts/python.exe {OPENPYPE_ROOT}/start.py runtests ../tests/integration/hosts/photoshop # noqa: E501 """ - PERSIST = False + PERSIST = True TEST_FILES = [ ("1zD2v5cBgkyOm_xIgKz3WKn8aFB_j8qC-", "test_photoshop_publish.zip", "") @@ -44,33 +49,56 @@ class TestPublishInPhotoshop(PhotoshopTestClass): TIMEOUT = 120 # publish timeout - def test_db_asserts(self, dbcon, publish_finished): """Host and input data dependent expected results in DB.""" print("test_db_asserts") - assert 3 == dbcon.count_documents({"type": "version"}), \ - "Not expected no of versions" + failures = [] - assert 0 == dbcon.count_documents({"type": "version", - "name": {"$ne": 1}}), \ - "Only versions with 1 expected" + failures.append(DBAssert.count_of_types(dbcon, "version", 4)) - assert 1 == dbcon.count_documents({"type": "subset", - "name": "imageMainBackgroundcopy"} - ), \ - "modelMain subset must be present" + failures.append( + DBAssert.count_of_types(dbcon, "version", 0, name={"$ne": 1})) - assert 1 == dbcon.count_documents({"type": "subset", - "name": "workfileTesttask"}), \ - "workfileTest_task subset must be present" + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="imageMainForeground")) - assert 6 == dbcon.count_documents({"type": "representation"}), \ - "Not expected no of representations" + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="imageMainBackground")) - assert 1 == dbcon.count_documents({"type": "representation", - "context.subset": "imageMainBackgroundcopy", # noqa: E501 - "context.ext": "png"}), \ - "Not expected no of representations with ext 'png'" + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="workfileTest_task")) + + failures.append( + DBAssert.count_of_types(dbcon, "representation", 8)) + + additional_args = {"context.subset": "imageMainForeground", + "context.ext": "png"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + additional_args = {"context.subset": "imageMainForeground", + "context.ext": "jpg"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + additional_args = {"context.subset": "imageMainBackground", + "context.ext": "png"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + additional_args = {"context.subset": "imageMainBackground", + "context.ext": "jpg"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + assert not any(failures) if __name__ == "__main__": From 037b514409d5e0f514025eecb7931f2b71ce1bcf Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 14 Feb 2022 20:04:07 +0100 Subject: [PATCH 114/483] Added print of more detailed message --- tests/lib/assert_classes.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/lib/assert_classes.py b/tests/lib/assert_classes.py index 7298853b67..98f758767d 100644 --- a/tests/lib/assert_classes.py +++ b/tests/lib/assert_classes.py @@ -1,5 +1,6 @@ """Classed and methods for comparing expected and published items in DBs""" + class DBAssert: @classmethod @@ -41,5 +42,7 @@ class DBAssert: print("Comparing count of {}{} {}".format(queried_type, detail_str, status)) + if msg: + print(msg) return msg From 8b8f9524371161f8f79e93a01e20c15f53f66c9d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 14 Feb 2022 20:06:45 +0100 Subject: [PATCH 115/483] Updated db asserts in After Effects --- tests/integration/hosts/aftereffects/lib.py | 34 ++++++++ .../test_publish_in_aftereffects.py | 85 +++++++------------ 2 files changed, 64 insertions(+), 55 deletions(-) create mode 100644 tests/integration/hosts/aftereffects/lib.py diff --git a/tests/integration/hosts/aftereffects/lib.py b/tests/integration/hosts/aftereffects/lib.py new file mode 100644 index 0000000000..9fffc6073d --- /dev/null +++ b/tests/integration/hosts/aftereffects/lib.py @@ -0,0 +1,34 @@ +import os +import pytest +import shutil + +from tests.lib.testing_classes import HostFixtures + + +class AfterEffectsTestClass(HostFixtures): + @pytest.fixture(scope="module") + def last_workfile_path(self, download_test_data, output_folder_url): + """Get last_workfile_path from source data. + + Maya expects workfile in proper folder, so copy is done first. + """ + src_path = os.path.join(download_test_data, + "input", + "workfile", + "test_project_test_asset_TestTask_v001.aep") + dest_folder = os.path.join(download_test_data, + self.PROJECT, + self.ASSET, + "work", + self.TASK) + os.makedirs(dest_folder) + dest_path = os.path.join(dest_folder, + "test_project_test_asset_TestTask_v001.aep") + shutil.copy(src_path, dest_path) + + yield dest_path + + @pytest.fixture(scope="module") + def startup_scripts(self, monkeypatch_session, download_test_data): + """Points Maya to userSetup file from input data""" + pass diff --git a/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py b/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py index 407c4f8a3a..4925cbd2d7 100644 --- a/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py +++ b/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py @@ -1,11 +1,12 @@ -import pytest -import os -import shutil +import logging -from tests.lib.testing_classes import PublishTest +from tests.lib.assert_classes import DBAssert +from tests.integration.hosts.aftereffects.lib import AfterEffectsTestClass + +log = logging.getLogger("test_publish_in_aftereffects") -class TestPublishInAfterEffects(PublishTest): +class TestPublishInAfterEffects(AfterEffectsTestClass): """Basic test case for publishing in AfterEffects Uses generic TestCase to prepare fixtures for test data, testing DBs, @@ -23,7 +24,7 @@ class TestPublishInAfterEffects(PublishTest): Checks tmp folder if all expected files were published. """ - PERSIST = True + PERSIST = False TEST_FILES = [ ("1c8261CmHwyMgS-g7S4xL5epAp0jCBmhf", @@ -32,70 +33,44 @@ class TestPublishInAfterEffects(PublishTest): ] APP = "aftereffects" - APP_VARIANT = "2022" + APP_VARIANT = "" APP_NAME = "{}/{}".format(APP, APP_VARIANT) TIMEOUT = 120 # publish timeout - @pytest.fixture(scope="module") - def last_workfile_path(self, download_test_data): - """Get last_workfile_path from source data. - - Maya expects workfile in proper folder, so copy is done first. - """ - src_path = os.path.join(download_test_data, - "input", - "workfile", - "test_project_test_asset_TestTask_v001.aep") - dest_folder = os.path.join(download_test_data, - self.PROJECT, - self.ASSET, - "work", - self.TASK) - os.makedirs(dest_folder) - dest_path = os.path.join(dest_folder, - "test_project_test_asset_TestTask_v001.aep") - shutil.copy(src_path, dest_path) - - yield dest_path - - @pytest.fixture(scope="module") - def startup_scripts(self, monkeypatch_session, download_test_data): - """Points AfterEffects to userSetup file from input data""" - pass - def test_db_asserts(self, dbcon, publish_finished): """Host and input data dependent expected results in DB.""" print("test_db_asserts") + failures = [] - assert 2 == dbcon.count_documents({"type": "version"}), \ - "Not expected no of versions" + failures.append(DBAssert.count_of_types(dbcon, "version", 2)) - assert 0 == dbcon.count_documents({"type": "version", - "name": {"$ne": 1}}), \ - "Only versions with 1 expected" + failures.append( + DBAssert.count_of_types(dbcon, "version", 0, name={"$ne": 1})) - assert 1 == dbcon.count_documents({"type": "subset", - "name": "imageMainBackgroundcopy" - }), \ - "modelMain subset must be present" + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="imageMainBackgroundcopy")) - assert 1 == dbcon.count_documents({"type": "subset", - "name": "workfileTest_task"}), \ - "workfileTesttask subset must be present" + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="workfileTest_task")) - assert 1 == dbcon.count_documents({"type": "subset", - "name": "reviewTesttask"}), \ - "reviewTesttask subset must be present" + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="reviewTesttask")) - assert 4 == dbcon.count_documents({"type": "representation"}), \ - "Not expected no of representations" + failures.append( + DBAssert.count_of_types(dbcon, "representation", 4)) - assert 1 == dbcon.count_documents({"type": "representation", - "context.subset": "renderTestTaskDefault", # noqa E501 - "context.ext": "png"}), \ - "Not expected no of representations with ext 'png'" + additional_args = {"context.subset": "renderTestTaskDefault", + "context.ext": "png"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + assert not any(failures) if __name__ == "__main__": From 7cfbfeefa8496de9285f5e68ab9503a6805c8e21 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 16 Feb 2022 13:47:52 +0100 Subject: [PATCH 116/483] changed prefix 'restart' to 'reset' --- openpype/tools/settings/settings/categories.py | 8 ++++---- openpype/tools/settings/settings/window.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index 28e38ea51d..08d3980e24 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -86,8 +86,8 @@ class SettingsCategoryWidget(QtWidgets.QWidget): state_changed = QtCore.Signal() saved = QtCore.Signal(QtWidgets.QWidget) restart_required_trigger = QtCore.Signal() - restart_started = QtCore.Signal() - restart_finished = QtCore.Signal() + reset_started = QtCore.Signal() + reset_finished = QtCore.Signal() full_path_requested = QtCore.Signal(str, str) def __init__(self, user_role, parent=None): @@ -436,7 +436,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget): self.require_restart_label.setText(value) def reset(self): - self.restart_started.emit() + self.reset_started.emit() self.set_state(CategoryState.Working) self._on_reset_start() @@ -517,7 +517,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget): self._on_reset_crash() else: self._on_reset_success() - self.restart_finished.emit() + self.reset_finished.emit() def _on_reset_crash(self): self.save_btn.setEnabled(False) diff --git a/openpype/tools/settings/settings/window.py b/openpype/tools/settings/settings/window.py index f6fa9a83a5..7c0c926fdd 100644 --- a/openpype/tools/settings/settings/window.py +++ b/openpype/tools/settings/settings/window.py @@ -69,8 +69,8 @@ class MainWidget(QtWidgets.QWidget): tab_widget.restart_required_trigger.connect( self._on_restart_required ) - tab_widget.restart_started.connect(self._on_restart_started) - tab_widget.restart_finished.connect(self._on_restart_finished) + tab_widget.reset_started.connect(self._on_reset_started) + tab_widget.reset_started.connect(self._on_reset_finished) tab_widget.full_path_requested.connect(self._on_full_path_request) self._header_tab_widget = header_tab_widget @@ -189,13 +189,13 @@ class MainWidget(QtWidgets.QWidget): if result == 1: self.trigger_restart.emit() - def _on_restart_started(self): + def _on_reset_started(self): widget = self.sender() current_widget = self._header_tab_widget.currentWidget() if current_widget is widget: self._update_search_dialog(True) - def _on_restart_finished(self): + def _on_reset_finished(self): widget = self.sender() current_widget = self._header_tab_widget.currentWidget() if current_widget is widget: From ea54edb9cdc5fb25d71bb6446aa41ce5f6f428bf Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Feb 2022 15:49:37 +0100 Subject: [PATCH 117/483] Fix wrong label --- openpype/hosts/photoshop/api/launch_logic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/photoshop/api/launch_logic.py b/openpype/hosts/photoshop/api/launch_logic.py index 16a1d23244..112cd8fe3f 100644 --- a/openpype/hosts/photoshop/api/launch_logic.py +++ b/openpype/hosts/photoshop/api/launch_logic.py @@ -175,7 +175,7 @@ class ProcessLauncher(QtCore.QObject): def start(self): if self._started: return - self.log.info("Started launch logic of AfterEffects") + self.log.info("Started launch logic of Photoshop") self._started = True self._start_process_timer.start() From cc9fa259dd55b97373855c84af56c885c75c4654 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Feb 2022 16:11:32 +0100 Subject: [PATCH 118/483] Moved tests for RoyalRender to unit tests folder --- .../openpype}/default_modules/royal_render/test_rr_job.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{openpype/modules => unit/openpype}/default_modules/royal_render/test_rr_job.py (100%) diff --git a/tests/openpype/modules/default_modules/royal_render/test_rr_job.py b/tests/unit/openpype/default_modules/royal_render/test_rr_job.py similarity index 100% rename from tests/openpype/modules/default_modules/royal_render/test_rr_job.py rename to tests/unit/openpype/default_modules/royal_render/test_rr_job.py From c6805d5b5af8eca0e531f4e632c1976dc31b7100 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Feb 2022 18:08:15 +0100 Subject: [PATCH 119/483] Added timeout to command line arguments --- openpype/cli.py | 9 +++++++-- openpype/pype_commands.py | 5 ++++- tests/integration/conftest.py | 10 ++++++++++ tests/lib/testing_classes.py | 7 +++++-- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/openpype/cli.py b/openpype/cli.py index 0597c387d0..6851541060 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -371,10 +371,15 @@ def run(script): "--app_variant", help="Provide specific app variant for test, empty for latest", default=None) -def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant): +@click.option("-t", + "--timeout", + help="Provide specific timeout value for test case", + default=None) +def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant, + timeout): """Run all automatic tests after proper initialization via start.py""" PypeCommands().run_tests(folder, mark, pyargs, test_data_folder, - persist, app_variant) + persist, app_variant, timeout) @main.command() diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 47f5e7fcc0..9704b9198f 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -363,7 +363,7 @@ class PypeCommands: pass def run_tests(self, folder, mark, pyargs, - test_data_folder, persist, app_variant): + test_data_folder, persist, app_variant, timeout): """ Runs tests from 'folder' @@ -401,6 +401,9 @@ class PypeCommands: if app_variant: args.extend(["--app_variant", app_variant]) + if timeout: + args.extend(["--timeout", timeout]) + print("run_tests args: {}".format(args)) import pytest pytest.main(args) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 400c0dcc2a..aa850be1a6 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -19,6 +19,11 @@ def pytest_addoption(parser): help="Keep empty to locate latest installed variant or explicit" ) + parser.addoption( + "--timeout", action="store", default=None, + help="Overwrite default timeout" + ) + @pytest.fixture(scope="module") def test_data_folder(request): @@ -33,3 +38,8 @@ def persist(request): @pytest.fixture(scope="module") def app_variant(request): return request.config.getoption("--app_variant") + + +@pytest.fixture(scope="module") +def timeout(request): + return request.config.getoption("--timeout") diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py index fa467acf9c..0a9da1aca8 100644 --- a/tests/lib/testing_classes.py +++ b/tests/lib/testing_classes.py @@ -293,13 +293,16 @@ class PublishTest(ModuleUnitTest): yield app_process @pytest.fixture(scope="module") - def publish_finished(self, dbcon, launched_app, download_test_data): + def publish_finished(self, dbcon, launched_app, download_test_data, + timeout): """Dummy fixture waiting for publish to finish""" import time time_start = time.time() + timeout = timeout or self.TIMEOUT + timeout = float(timeout) while launched_app.poll() is None: time.sleep(0.5) - if time.time() - time_start > self.TIMEOUT: + if time.time() - time_start > timeout: launched_app.terminate() raise ValueError("Timeout reached") From f618c00ad780207314d70601491cfc0941aca4ca Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Feb 2022 18:08:54 +0100 Subject: [PATCH 120/483] Fixed readme for start of tests --- tests/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/README.md b/tests/README.md index d0578f8059..854d56718c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -15,11 +15,11 @@ How to run: - single test class could be run by PyCharm and its pytest runner directly - OR - use Openpype command 'runtests' from command line (`.venv` in ${OPENPYPE_ROOT} must be activated to use configured Python!) --- `${OPENPYPE_ROOT}/python start.py runtests` +-- `python ${OPENPYPE_ROOT}/start.py runtests` By default, this command will run all tests in ${OPENPYPE_ROOT}/tests. Specific location could be provided to this command as an argument, either as absolute path, or relative path to ${OPENPYPE_ROOT}. -(eg. `${OPENPYPE_ROOT}/python start.py runtests ../tests/integration`) will trigger only tests in `integration` folder. +(eg. `python ${OPENPYPE_ROOT}/start.py start.py runtests ../tests/integration`) will trigger only tests in `integration` folder. See `${OPENPYPE_ROOT}/cli.py:runtests` for other arguments. From f902d0dd78158e2aa8e8d2916edd026b0c1b0ad6 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Feb 2022 18:13:53 +0100 Subject: [PATCH 121/483] First iteration of developer documentation for testing --- website/docs/dev_testing.md | 126 ++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 website/docs/dev_testing.md diff --git a/website/docs/dev_testing.md b/website/docs/dev_testing.md new file mode 100644 index 0000000000..7f082951f7 --- /dev/null +++ b/website/docs/dev_testing.md @@ -0,0 +1,126 @@ +--- +id: dev_testing +title: Testing in OpenPype +sidebar_label: Testing +--- + +## Introduction +As OpenPype is growing there also grows need for automatic testing. There are already bunch of tests present in root folder of OpenPype directory. +But many tests should be yet created! + +### How to run tests + +If you would like to experiment with provided tests, and have particular DCC installed on your machine, you could run test for this DCC by: + +- From source: +``` +- use Openpype command 'runtests' from command line (`.venv` in ${OPENPYPE_ROOT} must be activated to use configured Python!) +- `python ${OPENPYPE_ROOT}/start.py runtests ../tests/integration/hosts/nuke` +``` +- From build: +``` +- ${OPENPYPE_BUILD}/openpype_console run {ABSOLUT_PATH_OPENPYPE_ROOT}/tests/integration/hosts/nuke` +``` + + +### Content of tests folder + +Main tests folder contains hierarchy of folders with tests and supporting lib files. It is intended that tests should be run separately in each folder in the hierarchy. + +Main folders in the structure: +- integration - end to end tests in applications, mimicking regular publishing process +- lib - helper classes +- resources - test data skeletons etc. +- unit - unit test covering methods and functions in OP + + +### lib folder + +This location should contain library of helpers and miscellaneous classes used for integration or unit tests. + +Content: +- `assert_classes.py` - helpers for easier use of assert expressions +- `db_handler.py` - class for creation of DB dumps/restore/purge +- `file_hanlder.py` - class for preparation/cleanup of test data +- `testing_classes.py` - base classes for testing of publish in various DCCs + +### integration folder + +Contains end to end testing in DCC. Currently it is setup to start DCC application with prepared worfkile, run publish process and compare results in DB and file system automatically. +This approach is implemented as it should work in any DCC application and should cover most common use cases. + +There will be also possibility to build workfile and publish it programmatically, this would work only in DCCs that support it (Maya, Nuke). + +It is expected that each test class should work with single worfkile with supporting resources (as a dump of project DB, all necessary environment variables, expected published files etc.) + +There are currently implemented basic publish tests for `Maya`, `Nuke`, `AfterEffects` and `Photoshop`. Additional hosts will be added. + +Each `test_` class should contain single test class based on `tests.lib.testing_classes.PublishTest`. This base class handles all necessary +functionality for testing in a host application. + +#### Steps of publish test + +Each publish test is consisted of areas: +- preparation +- launch of host application +- publish +- comparison of results in DB and file system +- cleanup + +##### Preparation + +For each test publish case is expected zip file with this structure: +- expected - published files after workfile is published (in same structure as in regular manual publish) +- input + - dumps - database dumps (check `tests.lib.db_handler` for implemented functionality) + - openpype - settings + - test_db - skeleton of test project (contains project document, asset document etc.) + - env_vars - `env_var.json` file with a dictionary of all required environment variables + - json - json files with human readable content of databases + - startup - any required initialization scripts (for example Nuke requires one `init.py` file) + - workfile - contains single workfile + +These folders needs to be zipped (in zip's root must be this structure directly!), currently zip files for all prepared tests are stored in OpenPype GDrive folder. + +##### Launch of application and publish + +Integration tests are using same approach as OpenPype process regarding launching of host applications (eg. `ApplicationManager().launch`). +Each host application is in charge of triggering of publish process and closing itself. Different hosts handle this differently, Adobe products are handling this via injected "HEADLESS_PUBLISH" environment variable, +Maya and Nuke must contain this in their's startup files. + +Base `PublishTest` class contains configurable timeout in case of publish process is not working, or taking too long. + +##### Comparison of results + +Each test class requires re-iplemented `PublishTest.test_db_asserts` fixture. This method is triggered after publish is finished and should +compare current results in DB (each test has its own database which gets filled with dump data first, cleaned up after test finishing) with expected results. + +`tests.lib.assert_classes.py` contains prepared method `count_of_types` which makes easier to write assert expression. + +Basic use case: +```DBAssert.count_of_types(dbcon, "version", 2)``` >> It is expected that DB contains only 2 documents of `type==version` + +If zip file contains file structure in `expected` folder, `PublishTest.test_folder_structure_same` implements comparison of expected and published file structure, +eg. if test case published all expected files. + +##### Cleanup + +By default, each test case pulls data from GDrive, unzips them in temporary folder, runs publish, compares results and then +purges created temporary test database and temporary folder. This could be changed by setting of `PublishTest.PERSIST`. + +In case you want to modify test data, use `PublishTest.TEST_DATA_FOLDER` to point test to specific location. + +Both options are mostly useful for debugging during implementation of new test cases. + +#### Test configuration + +Each test case could be configured from command line with: +- test_data_folder - use specific folder with extracted test zip file +- persist - keep content of temporary folder and database after test finishes +- app_variant - run test for specific version of host app, matches app variants in Settings, eg. `2021` for Photoshop, `12-2` for Nuke +- timeout - override default time (in seconds) + +### unit folder + +Here should be located unit tests for classes, methods etc. As most classes expect to be triggered in OpenPype context, best option is to +start these test in similar fashion as `integration` tests (eg. via `runtests`). \ No newline at end of file From b7d2ea23b277b6bb8a34f0a18369cb40526143bb Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 16 Feb 2022 21:54:33 +0100 Subject: [PATCH 122/483] Move Houdini Save Current File to beginning of ExtractorOrder --- openpype/hosts/houdini/plugins/publish/save_scene.py | 2 +- openpype/hosts/houdini/plugins/publish/save_scene_deadline.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/save_scene.py b/openpype/hosts/houdini/plugins/publish/save_scene.py index 1b12efa603..0f9e547dee 100644 --- a/openpype/hosts/houdini/plugins/publish/save_scene.py +++ b/openpype/hosts/houdini/plugins/publish/save_scene.py @@ -6,7 +6,7 @@ class SaveCurrentScene(pyblish.api.InstancePlugin): """Save current scene""" label = "Save current file" - order = pyblish.api.IntegratorOrder - 0.49 + order = pyblish.api.ExtractorOrder - 0.49 hosts = ["houdini"] families = ["usdrender", "redshift_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/save_scene_deadline.py b/openpype/hosts/houdini/plugins/publish/save_scene_deadline.py index a0efd0610c..a04f6887ff 100644 --- a/openpype/hosts/houdini/plugins/publish/save_scene_deadline.py +++ b/openpype/hosts/houdini/plugins/publish/save_scene_deadline.py @@ -5,7 +5,7 @@ class SaveCurrentSceneDeadline(pyblish.api.ContextPlugin): """Save current scene""" label = "Save current file" - order = pyblish.api.IntegratorOrder - 0.49 + order = pyblish.api.ExtractorOrder - 0.49 hosts = ["houdini"] targets = ["deadline"] From 351a42fac9a03a8f5f9cbb8cda47760fa88bfa28 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 17 Feb 2022 11:26:35 +0100 Subject: [PATCH 123/483] OP-2551 - fix broken link to settings png --- website/docs/admin_settings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/admin_settings.md b/website/docs/admin_settings.md index ba4bb1a9be..9b00e6c612 100644 --- a/website/docs/admin_settings.md +++ b/website/docs/admin_settings.md @@ -22,7 +22,7 @@ We use simple colour coding to show you any changes to the settings: - **Orange**: [Project Override](#project-overrides) - **Blue**: Changed and unsaved value -![Colour coding](assets\settings\settings_colour_coding.png) +![Colour coding](assets/settings/settings_colour_coding.png) You'll find that settings are split into categories: From 39920905999d07bf89f90a4a2cfb6bbfd43e5884 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 17 Feb 2022 11:38:54 +0100 Subject: [PATCH 124/483] OP-2551 - added new section for Dev docs Added basic dev introduction page Updates to dev_testing.md --- website/docs/dev_introduction.md | 10 ++++ website/docs/dev_testing.md | 83 +++++++++++++++++++------------- website/docusaurus.config.js | 8 ++- website/sidebars.js | 14 +++++- 4 files changed, 80 insertions(+), 35 deletions(-) create mode 100644 website/docs/dev_introduction.md diff --git a/website/docs/dev_introduction.md b/website/docs/dev_introduction.md new file mode 100644 index 0000000000..22a23fc523 --- /dev/null +++ b/website/docs/dev_introduction.md @@ -0,0 +1,10 @@ +--- +id: dev_introduction +title: Introduction +sidebar_label: Introduction +--- + + +Here you should find additional information targetted on developers who would like to contribute or dive deeper into OpenPype platform + +Currently there are details about automatic testing, in the future this should be location for API definition and documentation \ No newline at end of file diff --git a/website/docs/dev_testing.md b/website/docs/dev_testing.md index 7f082951f7..08864988cf 100644 --- a/website/docs/dev_testing.md +++ b/website/docs/dev_testing.md @@ -6,11 +6,15 @@ sidebar_label: Testing ## Introduction As OpenPype is growing there also grows need for automatic testing. There are already bunch of tests present in root folder of OpenPype directory. -But many tests should be yet created! +But many tests should yet be created! -### How to run tests +### How to run (integration) tests -If you would like to experiment with provided tests, and have particular DCC installed on your machine, you could run test for this DCC by: +#### Requirements +- installed DCC you want to test +- `mongorestore` on a PATH + +If you would like just to experiment with provided integration tests, and have particular DCC installed on your machine, you could run test for this host by: - From source: ``` @@ -21,17 +25,17 @@ If you would like to experiment with provided tests, and have particular DCC ins ``` - ${OPENPYPE_BUILD}/openpype_console run {ABSOLUT_PATH_OPENPYPE_ROOT}/tests/integration/hosts/nuke` ``` - +Modify tests path argument to limit which tests should be run (`../tests/integration` will run all implemented integration tests). ### Content of tests folder -Main tests folder contains hierarchy of folders with tests and supporting lib files. It is intended that tests should be run separately in each folder in the hierarchy. +Main tests folder contains hierarchy of folders with tests and supporting lib files. It is intended that tests in each folder of the hierarchy could be run separately. Main folders in the structure: -- integration - end to end tests in applications, mimicking regular publishing process -- lib - helper classes -- resources - test data skeletons etc. -- unit - unit test covering methods and functions in OP +- `integration` - end to end tests in host applications, mimicking regular publishing process +- `lib` - helper classes +- `resources` - test data skeletons etc. +- `unit` - unit test covering methods and functions in OP ### lib folder @@ -46,10 +50,11 @@ Content: ### integration folder -Contains end to end testing in DCC. Currently it is setup to start DCC application with prepared worfkile, run publish process and compare results in DB and file system automatically. -This approach is implemented as it should work in any DCC application and should cover most common use cases. +Contains end to end testing in a DCC. Currently it is setup to start DCC application with prepared worfkile, run publish process and compare results in DB and file system automatically. +This approach is implemented as it should work in any DCC application and should cover most common use cases. Not all hosts allow "real headless" publishing, but all hosts should allow to trigger +publish process programatically when UI of host is actually running. -There will be also possibility to build workfile and publish it programmatically, this would work only in DCCs that support it (Maya, Nuke). +There will be eventually also possibility to build workfile and publish it programmatically, this would work only in DCCs that support it (Maya, Nuke). It is expected that each test class should work with single worfkile with supporting resources (as a dump of project DB, all necessary environment variables, expected published files etc.) @@ -60,7 +65,7 @@ functionality for testing in a host application. #### Steps of publish test -Each publish test is consisted of areas: +Each publish test consists of areas: - preparation - launch of host application - publish @@ -69,24 +74,35 @@ Each publish test is consisted of areas: ##### Preparation -For each test publish case is expected zip file with this structure: -- expected - published files after workfile is published (in same structure as in regular manual publish) -- input - - dumps - database dumps (check `tests.lib.db_handler` for implemented functionality) - - openpype - settings - - test_db - skeleton of test project (contains project document, asset document etc.) - - env_vars - `env_var.json` file with a dictionary of all required environment variables - - json - json files with human readable content of databases - - startup - any required initialization scripts (for example Nuke requires one `init.py` file) - - workfile - contains single workfile +Each test publish case expects zip file with this structure: +- `expected` - published files after workfile is published (in same structure as in regular manual publish) +- `input` + - `dumps` - database dumps (check `tests.lib.db_handler` for implemented functionality) + - `openpype` - settings + - `test_db` - skeleton of test project (contains project document, asset document etc.) + - `env_vars` - `env_var.json` file with a dictionary of all required environment variables + - `json` - json files with human readable content of databases + - `startup` - any required initialization scripts (for example Nuke requires one `init.py` file) + - `workfile` - contains single workfile These folders needs to be zipped (in zip's root must be this structure directly!), currently zip files for all prepared tests are stored in OpenPype GDrive folder. +Each test then goes in steps (by default): +- download test data zip +- create temporary folder and unzip there data zip file +- purge test DB if exists, import dump files from unzipped folder +- sets environment variables from `env_vars` folder +- launches host application and trigger publish process +- waits until publish process finishes, application closes (or timeouts) +- compares results in DB with expected values +- compares published files structure with expected values +- cleans up temporary test DB and folder + ##### Launch of application and publish Integration tests are using same approach as OpenPype process regarding launching of host applications (eg. `ApplicationManager().launch`). Each host application is in charge of triggering of publish process and closing itself. Different hosts handle this differently, Adobe products are handling this via injected "HEADLESS_PUBLISH" environment variable, -Maya and Nuke must contain this in their's startup files. +Maya and Nuke must contain this in theirs startup files. Base `PublishTest` class contains configurable timeout in case of publish process is not working, or taking too long. @@ -95,7 +111,7 @@ Base `PublishTest` class contains configurable timeout in case of publish proces Each test class requires re-iplemented `PublishTest.test_db_asserts` fixture. This method is triggered after publish is finished and should compare current results in DB (each test has its own database which gets filled with dump data first, cleaned up after test finishing) with expected results. -`tests.lib.assert_classes.py` contains prepared method `count_of_types` which makes easier to write assert expression. +`tests.lib.assert_classes.py` contains prepared method `count_of_types` which makes easier to write assert expression. This method also produces formatted error message. Basic use case: ```DBAssert.count_of_types(dbcon, "version", 2)``` >> It is expected that DB contains only 2 documents of `type==version` @@ -106,21 +122,22 @@ eg. if test case published all expected files. ##### Cleanup By default, each test case pulls data from GDrive, unzips them in temporary folder, runs publish, compares results and then -purges created temporary test database and temporary folder. This could be changed by setting of `PublishTest.PERSIST`. +purges created temporary test database and temporary folder. This could be changed by setting of `PublishTest.PERSIST`. If set to True, DB and published folder are kept intact +until next run of any test. -In case you want to modify test data, use `PublishTest.TEST_DATA_FOLDER` to point test to specific location. +In case you want to modify test data, use `PublishTest.TEST_DATA_FOLDER` to point test to specific location where test folder is already unzipped. Both options are mostly useful for debugging during implementation of new test cases. #### Test configuration Each test case could be configured from command line with: -- test_data_folder - use specific folder with extracted test zip file -- persist - keep content of temporary folder and database after test finishes -- app_variant - run test for specific version of host app, matches app variants in Settings, eg. `2021` for Photoshop, `12-2` for Nuke -- timeout - override default time (in seconds) +- `test_data_folder` - use specific folder with extracted test zip file +- `persist` - keep content of temporary folder and database after test finishes +- `app_variant` - run test for specific version of host app, matches app variants in Settings, eg. `2021` for Photoshop, `12-2` for Nuke +- `timeout` - override default time (in seconds) ### unit folder -Here should be located unit tests for classes, methods etc. As most classes expect to be triggered in OpenPype context, best option is to -start these test in similar fashion as `integration` tests (eg. via `runtests`). \ No newline at end of file +Here should be located unit tests for classes, methods of OpenPype etc. As most classes expect to be triggered in OpenPype context, best option is to +start these tests in similar fashion as `integration` tests (eg. via `runtests`). \ No newline at end of file diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 026917b58f..b9ada026e1 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -58,10 +58,16 @@ module.exports = { to: 'docs/artist_getting_started', label: 'User Docs', position: 'left' - }, { + }, + { to: 'docs/system_introduction', label: 'Admin Docs', position: 'left' + }, + { + to: 'docs/dev_introduction', + label: 'Dev Docs', + position: 'left' }, { to: 'https://pype.club', diff --git a/website/sidebars.js b/website/sidebars.js index 38e4206b84..d819796991 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -50,7 +50,6 @@ module.exports = { "dev_build", "admin_distribute", "admin_use", - "dev_contribute", "admin_openpype_commands", ], }, @@ -133,4 +132,17 @@ module.exports = { ], }, ], + Dev: [ + "dev_introduction", + { + type: "category", + label: "Dev documentation", + items: [ + "dev_requirements", + "dev_build", + "dev_testing", + "dev_contribute", + ], + } + ] }; From 67e90668fd98114aeb715f3ff355b4c4f423a2c7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 17 Feb 2022 15:06:02 +0100 Subject: [PATCH 125/483] flame: adding `useShotName` attribute to creator ui --- .../flame/plugins/create/create_shot_clip.py | 17 ++++++++++++----- .../defaults/project_settings/flame.json | 3 ++- .../projects_schema/schema_project_flame.json | 5 +++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index f055c77a89..11c00dab42 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -87,41 +87,48 @@ class CreateShotClip(opfapi.Creator): "target": "tag", "toolTip": "Parents folder for shot root folder, Template filled with `Hierarchy Data` section", # noqa "order": 0}, + "useShotName": { + "value": True, + "type": "QCheckBox", + "label": "Use Shot Name", + "target": "ui", + "toolTip": "Use name form Shot name clip attribute", # noqa + "order": 1}, "clipRename": { "value": False, "type": "QCheckBox", "label": "Rename clips", "target": "ui", "toolTip": "Renaming selected clips on fly", # noqa - "order": 1}, + "order": 2}, "clipName": { "value": "{sequence}{shot}", "type": "QLineEdit", "label": "Clip Name Template", "target": "ui", "toolTip": "template for creating shot namespaused for renaming (use rename: on)", # noqa - "order": 2}, + "order": 3}, "segmentIndex": { "value": True, "type": "QCheckBox", "label": "Segment index", "target": "ui", "toolTip": "Take number from segment index", # noqa - "order": 3}, + "order": 4}, "countFrom": { "value": 10, "type": "QSpinBox", "label": "Count sequence from", "target": "ui", "toolTip": "Set when the sequence number stafrom", # noqa - "order": 4}, + "order": 5}, "countSteps": { "value": 10, "type": "QSpinBox", "label": "Stepping number", "target": "ui", "toolTip": "What number is adding every new step", # noqa - "order": 5}, + "order": 6}, } }, "hierarchyData": { diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index b601f9bcba..6fb6f55528 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -2,7 +2,8 @@ "create": { "CreateShotClip": { "hierarchy": "{folder}/{sequence}", - "clipRename": true, + "useShotName": true, + "clipRename": false, "clipName": "{sequence}{shot}", "segmentIndex": true, "countFrom": 10, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json index 9ef05fa832..dc88d11f61 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -28,6 +28,11 @@ "key": "hierarchy", "label": "Shot parent hierarchy" }, + { + "type": "boolean", + "key": "useShotName", + "label": "Use Shot Name" + }, { "type": "boolean", "key": "clipRename", From 8bead3c1f2460f9ca621b7e49ec3d3e5bc759564 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 17 Feb 2022 15:31:57 +0100 Subject: [PATCH 126/483] flame: shot_name used for publishing asset --- openpype/hosts/flame/api/lib.py | 1 + openpype/hosts/flame/api/plugin.py | 13 ++++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index bbb7c38119..74d9e7607a 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -527,6 +527,7 @@ def get_segment_attributes(segment): # Add timeline segment to tree clip_data = { + "shot_name": segment.shot_name.get_value(), "segment_name": segment.name.get_value(), "segment_comment": segment.comment.get_value(), "tape_name": segment.tape_name, diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index db1793cba8..ec49db1601 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -361,6 +361,7 @@ class PublishableClip: vertical_sync_default = False driving_layer_default = "" index_from_segment_default = False + use_shot_name_default = False def __init__(self, segment, **kwargs): self.rename_index = kwargs["rename_index"] @@ -376,6 +377,7 @@ class PublishableClip: # segment (clip) main attributes self.cs_name = self.clip_data["segment_name"] self.cs_index = int(self.clip_data["segment"]) + self.shot_name = self.clip_data["shot_name"] # get track name and index self.track_index = int(self.clip_data["track"]) @@ -419,18 +421,21 @@ class PublishableClip: # deal with clip name new_name = self.marker_data.pop("newClipName") - if self.rename: + if self.rename and not self.use_shot_name: # rename segment self.current_segment.name = str(new_name) self.marker_data["asset"] = str(new_name) + elif self.use_shot_name: + self.marker_data["asset"] = self.shot_name + self.marker_data["hierarchyData"]["shot"] = self.shot_name else: self.marker_data["asset"] = self.cs_name self.marker_data["hierarchyData"]["shot"] = self.cs_name if self.marker_data["heroTrack"] and self.review_layer: - self.marker_data.update({"reviewTrack": self.review_layer}) + self.marker_data["reviewTrack"] = self.review_layer else: - self.marker_data.update({"reviewTrack": None}) + self.marker_data["reviewTrack"] = None # create pype tag on track_item and add data fpipeline.imprint(self.current_segment, self.marker_data) @@ -463,6 +468,8 @@ class PublishableClip: # ui_inputs data or default values if gui was not used self.rename = self.ui_inputs.get( "clipRename", {}).get("value") or self.rename_default + self.use_shot_name = self.ui_inputs.get( + "useShotName", {}).get("value") or self.use_shot_name_default self.clip_name = self.ui_inputs.get( "clipName", {}).get("value") or self.clip_name_default self.hierarchy = self.ui_inputs.get( From b1578dcdd7103a70f9dfdaa3dc612140cc8e4b0a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 18 Feb 2022 12:22:05 +0100 Subject: [PATCH 127/483] Maya: Fix `unique_namespace` when in an namespace that is empty --- openpype/hosts/maya/api/lib.py | 50 +++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 1f6c8c1deb..20c00de110 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -206,21 +206,51 @@ def unique_namespace(namespace, format="%02d", prefix="", suffix=""): format (str, optional): Formatting of the given iteration number suffix (str, optional): Only consider namespaces with this suffix. + >>> unique_namespace("bar") + # bar01 + >>> unique_namespace(":hello") + # :hello01 + >>> unique_namespace("bar:", suffix="_NS") + # bar01_NS: + """ + def current_namespace(): + current = cmds.namespaceInfo(currentNamespace=True, + absoluteName=True) + # When inside a namespace Maya adds no trailing : + if not current.endswith(":"): + current += ":" + return current + + # Always check against the absolute namespace root + # There's no clash with :x if we're defining namespace :a:x + ROOT = ":" if namespace.startswith(":") else current_namespace() + + # Strip trailing `:` tokens since we might want to add a suffix + start = ":" if namespace.startswith(":") else "" + end = ":" if namespace.endswith(":") else "" + namespace = namespace.strip(":") + if ":" in namespace: + # Split off any nesting that we don't uniqify anyway. + parents, namespace = namespace.rsplit(":", 1) + start += parents + ":" + ROOT += start + + def exists(n): + # Check for clash with nodes and namespaces + fullpath = ROOT + n + return cmds.objExists(fullpath) or cmds.namespace(exists=fullpath) + iteration = 1 - unique = prefix + (namespace + format % iteration) + suffix + while True: + nr_namespace = namespace + format % iteration + unique = prefix + nr_namespace + suffix + + if not exists(unique): + return start + unique + end - # The `existing` set does not just contain the namespaces but *all* nodes - # within "current namespace". We need all because the namespace could - # also clash with a node name. To be truly unique and valid one needs to - # check against all. - existing = set(cmds.namespaceInfo(listNamespace=True)) - while unique in existing: iteration += 1 - unique = prefix + (namespace + format % iteration) + suffix - - return unique def read(node): From 5852e2c9978d75ae948197ac14e9db1fb93f279f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 18 Feb 2022 17:07:37 +0100 Subject: [PATCH 128/483] Update website/docs/dev_testing.md Co-authored-by: Milan Kolar --- website/docs/dev_testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/dev_testing.md b/website/docs/dev_testing.md index 08864988cf..cab298ae37 100644 --- a/website/docs/dev_testing.md +++ b/website/docs/dev_testing.md @@ -23,7 +23,7 @@ If you would like just to experiment with provided integration tests, and have p ``` - From build: ``` -- ${OPENPYPE_BUILD}/openpype_console run {ABSOLUT_PATH_OPENPYPE_ROOT}/tests/integration/hosts/nuke` +- ${OPENPYPE_BUILD}/openpype_console run {ABSOLUTE_PATH_OPENPYPE_ROOT}/tests/integration/hosts/nuke` ``` Modify tests path argument to limit which tests should be run (`../tests/integration` will run all implemented integration tests). From dc911a85baf40c84f5f35425a105abc99841cd72 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Fri, 18 Feb 2022 18:57:05 +0100 Subject: [PATCH 129/483] Bugfix make_image_sequence with one layer --- openpype/hosts/photoshop/plugins/publish/extract_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 455c7b43a3..b8f4470c7b 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -35,7 +35,7 @@ class ExtractReview(openpype.api.Extractor): layers = self._get_layers_from_image_instances(instance) self.log.info("Layers image instance found: {}".format(layers)) - if self.make_image_sequence and layers: + if self.make_image_sequence and len(layers) > 1: self.log.info("Extract layers to image sequence.") img_list = self._saves_sequences_layers(staging_dir, layers) From 72a3a2bdba9a49d11a6baf2b3bd4bbf2a6b30c72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Marinov?= Date: Fri, 18 Feb 2022 20:44:35 +0100 Subject: [PATCH 130/483] Avoid renaming udim indexes --- openpype/plugins/publish/integrate_new.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 3d48fb92ee..486718d8c4 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -478,6 +478,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): if index_frame_start is not None: dst_padding_exp = "%0{}d".format(frame_start_padding) dst_padding = dst_padding_exp % (index_frame_start + frame_number) # noqa: E501 + elif repre.get("udim"): + dst_padding = int(i) dst = "{0}{1}{2}".format( dst_head, From a9506a14806fe92b1d86bb68a3cd84a5749b4141 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 19 Feb 2022 20:31:31 +0100 Subject: [PATCH 131/483] extracted template formatting logic from anatomy --- openpype/lib/path_templates.py | 745 +++++++++++++++++++++++++++++++++ 1 file changed, 745 insertions(+) create mode 100644 openpype/lib/path_templates.py diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py new file mode 100644 index 0000000000..6f68cc4ce9 --- /dev/null +++ b/openpype/lib/path_templates.py @@ -0,0 +1,745 @@ +import os +import re +import copy +import numbers +import collections + +import six + +from .log import PypeLogger + +log = PypeLogger.get_logger(__name__) + + +KEY_PATTERN = re.compile(r"(\{.*?[^{0]*\})") +KEY_PADDING_PATTERN = re.compile(r"([^:]+)\S+[><]\S+") +SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)") +OPTIONAL_PATTERN = re.compile(r"(<.*?[^{0]*>)[^0-9]*?") + + +def merge_dict(main_dict, enhance_dict): + """Merges dictionaries by keys. + + Function call itself if value on key is again dictionary. + + Args: + main_dict (dict): First dict to merge second one into. + enhance_dict (dict): Second dict to be merged. + + Returns: + dict: Merged result. + + .. note:: does not overrides whole value on first found key + but only values differences from enhance_dict + + """ + for key, value in enhance_dict.items(): + if key not in main_dict: + main_dict[key] = value + elif isinstance(value, dict) and isinstance(main_dict[key], dict): + main_dict[key] = merge_dict(main_dict[key], value) + else: + main_dict[key] = value + return main_dict + + +class TemplateMissingKey(Exception): + """Exception for cases when key does not exist in template.""" + + msg = "Template key does not exist: `{}`." + + def __init__(self, parents): + parent_join = "".join(["[\"{0}\"]".format(key) for key in parents]) + super(TemplateMissingKey, self).__init__( + self.msg.format(parent_join) + ) + + +class TemplateUnsolved(Exception): + """Exception for unsolved template when strict is set to True.""" + + msg = "Template \"{0}\" is unsolved.{1}{2}" + invalid_types_msg = " Keys with invalid DataType: `{0}`." + missing_keys_msg = " Missing keys: \"{0}\"." + + def __init__(self, template, missing_keys, invalid_types): + invalid_type_items = [] + for _key, _type in invalid_types.items(): + invalid_type_items.append( + "\"{0}\" {1}".format(_key, str(_type)) + ) + + invalid_types_msg = "" + if invalid_type_items: + invalid_types_msg = self.invalid_types_msg.format( + ", ".join(invalid_type_items) + ) + + missing_keys_msg = "" + if missing_keys: + missing_keys_msg = self.missing_keys_msg.format( + ", ".join(missing_keys) + ) + super(TemplateUnsolved, self).__init__( + self.msg.format(template, missing_keys_msg, invalid_types_msg) + ) + + +class StringTemplate(object): + """String that can be formatted.""" + def __init__(self, template): + if not isinstance(template, six.string_types): + raise TypeError("<{}> argument must be a string, not {}.".format( + self.__class__.__name__, str(type(template)) + )) + + self._template = template + parts = [] + last_end_idx = 0 + for item in KEY_PATTERN.finditer(template): + start, end = item.span() + if start > last_end_idx: + parts.append(template[last_end_idx:start]) + parts.append(FormattingPart(template[start:end])) + last_end_idx = end + + if last_end_idx < len(template): + parts.append(template[last_end_idx:len(template)]) + + new_parts = [] + for part in parts: + if not isinstance(part, six.string_types): + new_parts.append(part) + continue + + substr = "" + for char in part: + if char not in ("<", ">"): + substr += char + else: + if substr: + new_parts.append(substr) + new_parts.append(char) + substr = "" + if substr: + new_parts.append(substr) + + self._parts = self.find_optional_parts(new_parts) + + @property + def template(self): + return self._template + + def format(self, data): + """ Figure out with whole formatting. + + Separate advanced keys (*Like '{project[name]}') from string which must + be formatted separatelly in case of missing or incomplete keys in data. + + Args: + data (dict): Containing keys to be filled into template. + + Returns: + TemplateResult: Filled or partially filled template containing all + data needed or missing for filling template. + """ + result = TemplatePartResult() + for part in self._parts: + if isinstance(part, six.string_types): + result.add_output(part) + else: + part.format(data, result) + + invalid_types = result.invalid_types + invalid_types.update(result.invalid_optional_types) + invalid_types = result.split_keys_to_subdicts(invalid_types) + + missing_keys = result.missing_keys + missing_keys |= result.missing_optional_keys + + solved = result.solved + used_values = result.split_keys_to_subdicts(result.used_values) + + return TemplateResult( + result.output, + self.template, + solved, + used_values, + missing_keys, + invalid_types + ) + + def format_strict(self, *args, **kwargs): + result = self.format(*args, **kwargs) + result.validate() + return result + + @staticmethod + def find_optional_parts(parts): + new_parts = [] + tmp_parts = {} + counted_symb = -1 + for part in parts: + if part == "<": + counted_symb += 1 + tmp_parts[counted_symb] = [] + + elif part == ">": + if counted_symb > -1: + parts = tmp_parts.pop(counted_symb) + counted_symb -= 1 + if parts: + # Remove optional start char + parts.pop(0) + if counted_symb < 0: + out_parts = new_parts + else: + out_parts = tmp_parts[counted_symb] + # Store temp parts + out_parts.append(OptionalPart(parts)) + continue + + if counted_symb < 0: + new_parts.append(part) + else: + tmp_parts[counted_symb].append(part) + + if tmp_parts: + for idx in sorted(tmp_parts.keys()): + new_parts.extend(tmp_parts[idx]) + return new_parts + + +class TemplatesDict(object): + def __init__(self, templates=None): + self._raw_templates = None + self._templates = None + self.set_templates(templates) + + def set_templates(self, templates): + if templates is None: + self._raw_templates = None + self._templates = None + elif isinstance(templates, dict): + self._raw_templates = copy.deepcopy(templates) + self._templates = self.create_ojected_templates(templates) + else: + raise TypeError("<{}> argument must be a dict, not {}.".format( + self.__class__.__name__, str(type(templates)) + )) + + def __getitem__(self, key): + return self.templates[key] + + def get(self, key, *args, **kwargs): + return self.templates.get(key, *args, **kwargs) + + @property + def raw_templates(self): + return self._raw_templates + + @property + def templates(self): + return self._templates + + @classmethod + def create_ojected_templates(cls, templates): + if not isinstance(templates, dict): + raise TypeError("Expected dict object, got {}".format( + str(type(templates)) + )) + + objected_templates = copy.deepcopy(templates) + inner_queue = collections.deque() + inner_queue.append(objected_templates) + while inner_queue: + item = inner_queue.popleft() + if not isinstance(item, dict): + continue + for key in tuple(item.keys()): + value = item[key] + if isinstance(value, six.string_types): + item[key] = StringTemplate(value) + elif isinstance(value, dict): + inner_queue.append(value) + return objected_templates + + def _format_value(self, value, data): + if isinstance(value, StringTemplate): + return value.format(data) + + if isinstance(value, dict): + return self._solve_dict(value, data) + return value + + def _solve_dict(self, templates, data): + """ Solves templates with entered data. + + Args: + templates (dict): All templates which will be formatted. + data (dict): Containing keys to be filled into template. + + Returns: + dict: With `TemplateResult` in values containing filled or + partially filled templates. + """ + output = collections.defaultdict(dict) + for key, value in templates.items(): + output[key] = self._format_value(value, data) + + return output + + def format(self, in_data, only_keys=True, strict=True): + """ Solves templates based on entered data. + + Args: + data (dict): Containing keys to be filled into template. + only_keys (bool, optional): Decides if environ will be used to + fill templates or only keys in data. + + Returns: + TemplatesResultDict: Output `TemplateResult` have `strict` + attribute set to True so accessing unfilled keys in templates + will raise exceptions with explaned error. + """ + # Create a copy of inserted data + data = copy.deepcopy(in_data) + + # Add environment variable to data + if only_keys is False: + for key, val in os.environ.items(): + env_key = "$" + key + if env_key not in data: + data[env_key] = val + + solved = self._solve_dict(self.templates, data) + + output = TemplatesResultDict(solved) + output.strict = strict + return output + + +class TemplateResult(str): + """Result of template format with most of information in. + + Args: + used_values (dict): Dictionary of template filling data with + only used keys. + solved (bool): For check if all required keys were filled. + template (str): Original template. + missing_keys (list): Missing keys that were not in the data. Include + missing optional keys. + invalid_types (dict): When key was found in data, but value had not + allowed DataType. Allowed data types are `numbers`, + `str`(`basestring`) and `dict`. Dictionary may cause invalid type + when value of key in data is dictionary but template expect string + of number. + """ + used_values = None + solved = None + template = None + missing_keys = None + invalid_types = None + + def __new__( + cls, filled_template, template, solved, + used_values, missing_keys, invalid_types + ): + new_obj = super(TemplateResult, cls).__new__(cls, filled_template) + new_obj.used_values = used_values + new_obj.solved = solved + new_obj.template = template + new_obj.missing_keys = list(set(missing_keys)) + new_obj.invalid_types = invalid_types + return new_obj + + def validate(self): + if not self.solved: + raise TemplateUnsolved( + self.template, + self.missing_keys, + self.invalid_types + ) + + +class TemplatesResultDict(dict): + """Holds and wrap TemplateResults for easy bug report.""" + + def __init__(self, in_data, key=None, parent=None, strict=None): + super(TemplatesResultDict, self).__init__() + for _key, _value in in_data.items(): + if isinstance(_value, dict): + _value = self.__class__(_value, _key, self) + self[_key] = _value + + self.key = key + self.parent = parent + self.strict = strict + if self.parent is None and strict is None: + self.strict = True + + def __getitem__(self, key): + if key not in self.keys(): + hier = self.hierarchy() + hier.append(key) + raise TemplateMissingKey(hier) + + value = super(TemplatesResultDict, self).__getitem__(key) + if isinstance(value, self.__class__): + return value + + # Raise exception when expected solved templates and it is not. + if self.raise_on_unsolved and hasattr(value, "validate"): + value.validate() + return value + + @property + def raise_on_unsolved(self): + """To affect this change `strict` attribute.""" + if self.strict is not None: + return self.strict + return self.parent.raise_on_unsolved + + def hierarchy(self): + """Return dictionary keys one by one to root parent.""" + if self.parent is None: + return [] + + hier_keys = [] + par_hier = self.parent.hierarchy() + if par_hier: + hier_keys.extend(par_hier) + hier_keys.append(self.key) + + return hier_keys + + @property + def missing_keys(self): + """Return missing keys of all children templates.""" + missing_keys = set() + for value in self.values(): + missing_keys |= value.missing_keys + return missing_keys + + @property + def invalid_types(self): + """Return invalid types of all children templates.""" + invalid_types = {} + for value in self.values(): + invalid_types = merge_dict(invalid_types, value.invalid_types) + return invalid_types + + @property + def used_values(self): + """Return used values for all children templates.""" + used_values = {} + for value in self.values(): + used_values = merge_dict(used_values, value.used_values) + return used_values + + def get_solved(self): + """Get only solved key from templates.""" + result = {} + for key, value in self.items(): + if isinstance(value, self.__class__): + value = value.get_solved() + if not value: + continue + result[key] = value + + elif ( + not hasattr(value, "solved") or + value.solved + ): + result[key] = value + return self.__class__(result, key=self.key, parent=self.parent) + + +class TemplatePartResult: + """Result to store result of template parts.""" + def __init__(self, optional=False): + # Missing keys or invalid value types of required keys + self._missing_keys = set() + self._invalid_types = {} + # Missing keys or invalid value types of optional keys + self._missing_optional_keys = set() + self._invalid_optional_types = {} + + # Used values stored by key + # - key without any padding or key modifiers + # - value from filling data + # Example: {"version": 1} + self._used_values = {} + # Used values stored by key with all modifirs + # - value is already formatted string + # Example: {"version:0>3": "001"} + self._realy_used_values = {} + # Concatenated string output after formatting + self._output = "" + # Is this result from optional part + self._optional = True + + def add_output(self, other): + if isinstance(other, six.string_types): + self._output += other + + elif isinstance(other, TemplatePartResult): + self._output += other.output + + self._missing_keys |= other.missing_keys + self._missing_optional_keys |= other.missing_optional_keys + + self._invalid_types.update(other.invalid_types) + self._invalid_optional_types.update(other.invalid_optional_types) + + if other.optional and not other.solved: + return + self._used_values.update(other.used_values) + self._realy_used_values.update(other.realy_used_values) + + else: + raise TypeError("Cannot add data from \"{}\" to \"{}\"".format( + str(type(other)), self.__class__.__name__) + ) + + @property + def solved(self): + if self.optional: + if ( + len(self.missing_optional_keys) > 0 + or len(self.invalid_optional_types) > 0 + ): + return False + return ( + len(self.missing_keys) == 0 + and len(self.invalid_types) == 0 + ) + + @property + def optional(self): + return self._optional + + @property + def output(self): + return self._output + + @property + def missing_keys(self): + return self._missing_keys + + @property + def missing_optional_keys(self): + return self._missing_optional_keys + + @property + def invalid_types(self): + return self._invalid_types + + @property + def invalid_optional_types(self): + return self._invalid_optional_types + + @property + def realy_used_values(self): + return self._realy_used_values + + @property + def used_values(self): + return self._used_values + + @staticmethod + def split_keys_to_subdicts(values): + output = {} + for key, value in values.items(): + key_padding = list(KEY_PADDING_PATTERN.findall(key)) + if key_padding: + key = key_padding[0] + key_subdict = list(SUB_DICT_PATTERN.findall(key)) + data = output + last_key = key_subdict.pop(-1) + for subkey in key_subdict: + if subkey not in data: + data[subkey] = {} + data = data[subkey] + data[last_key] = value + return output + + def add_realy_used_value(self, key, value): + self._realy_used_values[key] = value + + def add_used_value(self, key, value): + self._used_values[key] = value + + def add_missing_key(self, key): + if self._optional: + self._missing_optional_keys.add(key) + else: + self._missing_keys.add(key) + + def add_invalid_type(self, key, value): + if self._optional: + self._invalid_optional_types[key] = type(value) + else: + self._invalid_types[key] = type(value) + + +class FormatObject(object): + def __init__(self): + self.value = "" + + def __format__(self, *args, **kwargs): + return self.value.__format__(*args, **kwargs) + + def __str__(self): + return str(self.value) + + def __repr__(self): + return self.__str__() + + +class FormattingPart: + """String with formatting template. + + Containt only single key to format e.g. "{project[name]}". + + Args: + template(str): String containing the formatting key. + """ + def __init__(self, template): + self._template = template + + @property + def template(self): + return self._template + + def __repr__(self): + return "".format(self._template) + + def __str__(self): + return self._template + + @staticmethod + def validate_value_type(value): + """Check if value can be used for formatting of single key.""" + if isinstance(value, (numbers.Number, FormatObject)): + return True + + for inh_class in type(value).mro(): + if inh_class in six.string_types: + return True + return False + + def format(self, data, result): + """Format the formattings string. + + Args: + data(dict): Data that should be used for formatting. + result(TemplatePartResult): Object where result is stored. + """ + key = self.template[1:-1] + if key in result.realy_used_values: + result.add_output(result.realy_used_values[key]) + return result + + # check if key expects subdictionary keys (e.g. project[name]) + existence_check = key + key_padding = list(KEY_PADDING_PATTERN.findall(existence_check)) + if key_padding: + existence_check = key_padding[0] + key_subdict = list(SUB_DICT_PATTERN.findall(existence_check)) + + value = data + missing_key = False + invalid_type = False + used_keys = [] + for sub_key in key_subdict: + if ( + value is None + or (hasattr(value, "items") and sub_key not in value) + ): + missing_key = True + used_keys.append(sub_key) + break + + if not hasattr(value, "items"): + invalid_type = True + break + + used_keys.append(sub_key) + value = value.get(sub_key) + + if missing_key or invalid_type: + if len(used_keys) == 0: + invalid_key = key_subdict[0] + else: + invalid_key = used_keys[0] + for idx, sub_key in enumerate(used_keys): + if idx == 0: + continue + invalid_key += "[{0}]".format(sub_key) + + if missing_key: + result.add_missing_key(invalid_key) + + elif invalid_type: + result.add_invalid_type(invalid_key, value) + + result.add_output(self.template) + return result + + if self.validate_value_type(value): + fill_data = {} + first_value = True + for used_key in reversed(used_keys): + if first_value: + first_value = False + fill_data[used_key] = value + else: + _fill_data = {used_key: fill_data} + fill_data = _fill_data + + formatted_value = self.template.format(**fill_data) + result.add_realy_used_value(key, formatted_value) + result.add_used_value(existence_check, value) + result.add_output(formatted_value) + return result + + result.add_invalid_type(key, value) + result.add_output(self.template) + + return result + + +class OptionalPart: + """Template part which contains optional formatting strings. + + If this part can't be filled the result is empty string. + + Args: + parts(list): Parts of template. Can contain 'str', 'OptionalPart' or + 'FormattingPart'. + """ + def __init__(self, parts): + self._parts = parts + + @property + def parts(self): + return self._parts + + def __str__(self): + return "<{}>".format("".join([str(p) for p in self._parts])) + + def __repr__(self): + return "".format("".join([str(p) for p in self._parts])) + + def format(self, data, result): + new_result = TemplatePartResult(True) + for part in self._parts: + if isinstance(part, six.string_types): + new_result.add_output(part) + else: + part.format(data, new_result) + + if new_result.solved: + result.add_output(new_result) + return result From a80b15d7912cb92b419699a16e0442d6dc8909f4 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 19 Feb 2022 20:33:51 +0100 Subject: [PATCH 132/483] use new templates logic in anatomy templates --- openpype/lib/__init__.py | 18 +- openpype/lib/anatomy.py | 635 +++++---------------------------- openpype/lib/path_templates.py | 4 + 3 files changed, 117 insertions(+), 540 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index ebe7648ad7..4d956d9876 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -16,6 +16,15 @@ sys.path.insert(0, python_version_dir) site.addsitedir(python_version_dir) +from .path_templates import ( + merge_dict, + TemplateMissingKey, + TemplateUnsolved, + StringTemplate, + TemplatesDict, + FormatObject, +) + from .env_tools import ( env_value_to_bool, get_paths_from_environ, @@ -41,7 +50,6 @@ from .mongo import ( OpenPypeMongoConnection ) from .anatomy import ( - merge_dict, Anatomy ) @@ -183,6 +191,13 @@ from .openpype_version import ( terminal = Terminal __all__ = [ + "merge_dict", + "TemplateMissingKey", + "TemplateUnsolved", + "StringTemplate", + "TemplatesDict", + "FormatObject", + "get_openpype_execute_args", "get_pype_execute_args", "get_linux_launcher_args", @@ -285,7 +300,6 @@ __all__ = [ "terminal", - "merge_dict", "Anatomy", "get_datetime_data", diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index fa81a18ff7..f817646cd7 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -9,6 +9,14 @@ from openpype.settings.lib import ( get_default_anatomy_settings, get_anatomy_settings ) +from .path_templates import ( + merge_dict, + TemplateUnsolved, + TemplateResult, + TemplatesResultDict, + TemplatesDict, + FormatObject, +) from .log import PypeLogger log = PypeLogger().get_logger(__name__) @@ -19,32 +27,6 @@ except NameError: StringType = str -def merge_dict(main_dict, enhance_dict): - """Merges dictionaries by keys. - - Function call itself if value on key is again dictionary. - - Args: - main_dict (dict): First dict to merge second one into. - enhance_dict (dict): Second dict to be merged. - - Returns: - dict: Merged result. - - .. note:: does not overrides whole value on first found key - but only values differences from enhance_dict - - """ - for key, value in enhance_dict.items(): - if key not in main_dict: - main_dict[key] = value - elif isinstance(value, dict) and isinstance(main_dict[key], dict): - main_dict[key] = merge_dict(main_dict[key], value) - else: - main_dict[key] = value - return main_dict - - class ProjectNotSet(Exception): """Exception raised when is created Anatomy without project name.""" @@ -59,7 +41,7 @@ class RootCombinationError(Exception): # TODO better error message msg = ( "Combination of root with and" - " without root name in Templates. {}" + " without root name in AnatomyTemplates. {}" ).format(joined_roots) super(RootCombinationError, self).__init__(msg) @@ -68,7 +50,7 @@ class RootCombinationError(Exception): class Anatomy: """Anatomy module helps to keep project settings. - Wraps key project specifications, Templates and Roots. + Wraps key project specifications, AnatomyTemplates and Roots. Args: project_name (str): Project name to look on overrides. @@ -93,7 +75,7 @@ class Anatomy: get_anatomy_settings(project_name, site_name) ) self._site_name = site_name - self._templates_obj = Templates(self) + self._templates_obj = AnatomyTemplates(self) self._roots_obj = Roots(self) # Anatomy used as dictionary @@ -158,12 +140,12 @@ class Anatomy: @property def templates(self): - """Wrap property `templates` of Anatomy's Templates instance.""" + """Wrap property `templates` of Anatomy's AnatomyTemplates instance.""" return self._templates_obj.templates @property def templates_obj(self): - """Return `Templates` object of current Anatomy instance.""" + """Return `AnatomyTemplates` object of current Anatomy instance.""" return self._templates_obj def format(self, *args, **kwargs): @@ -375,203 +357,45 @@ class Anatomy: return rootless_path.format(**data) -class TemplateMissingKey(Exception): - """Exception for cases when key does not exist in Anatomy.""" - - msg = "Anatomy key does not exist: `anatomy{0}`." - - def __init__(self, parents): - parent_join = "".join(["[\"{0}\"]".format(key) for key in parents]) - super(TemplateMissingKey, self).__init__( - self.msg.format(parent_join) - ) - - -class TemplateUnsolved(Exception): +class AnatomyTemplateUnsolved(TemplateUnsolved): """Exception for unsolved template when strict is set to True.""" msg = "Anatomy template \"{0}\" is unsolved.{1}{2}" - invalid_types_msg = " Keys with invalid DataType: `{0}`." - missing_keys_msg = " Missing keys: \"{0}\"." - def __init__(self, template, missing_keys, invalid_types): - invalid_type_items = [] - for _key, _type in invalid_types.items(): - invalid_type_items.append( - "\"{0}\" {1}".format(_key, str(_type)) - ) - invalid_types_msg = "" - if invalid_type_items: - invalid_types_msg = self.invalid_types_msg.format( - ", ".join(invalid_type_items) - ) +class AnatomyTemplateResult(TemplateResult): + rootless = None - missing_keys_msg = "" - if missing_keys: - missing_keys_msg = self.missing_keys_msg.format( - ", ".join(missing_keys) - ) - super(TemplateUnsolved, self).__init__( - self.msg.format(template, missing_keys_msg, invalid_types_msg) + def __new__(cls, result, rootless_path): + new_obj = super(AnatomyTemplateResult, cls).__new__( + cls, + str(result), + result.template, + result.solved, + result.used_values, + result.missing_keys, + result.invalid_types ) - - -class TemplateResult(str): - """Result (formatted template) of anatomy with most of information in. - - Args: - used_values (dict): Dictionary of template filling data with - only used keys. - solved (bool): For check if all required keys were filled. - template (str): Original template. - missing_keys (list): Missing keys that were not in the data. Include - missing optional keys. - invalid_types (dict): When key was found in data, but value had not - allowed DataType. Allowed data types are `numbers`, - `str`(`basestring`) and `dict`. Dictionary may cause invalid type - when value of key in data is dictionary but template expect string - of number. - """ - - def __new__( - cls, filled_template, template, solved, rootless_path, - used_values, missing_keys, invalid_types - ): - new_obj = super(TemplateResult, cls).__new__(cls, filled_template) - new_obj.used_values = used_values - new_obj.solved = solved - new_obj.template = template new_obj.rootless = rootless_path - new_obj.missing_keys = list(set(missing_keys)) - _invalid_types = {} - for invalid_type in invalid_types: - for key, val in invalid_type.items(): - if key in _invalid_types: - continue - _invalid_types[key] = val - new_obj.invalid_types = _invalid_types return new_obj - -class TemplatesDict(dict): - """Holds and wrap TemplateResults for easy bug report.""" - - def __init__(self, in_data, key=None, parent=None, strict=None): - super(TemplatesDict, self).__init__() - for _key, _value in in_data.items(): - if isinstance(_value, dict): - _value = self.__class__(_value, _key, self) - self[_key] = _value - - self.key = key - self.parent = parent - self.strict = strict - if self.parent is None and strict is None: - self.strict = True - - def __getitem__(self, key): - # Raise error about missing key in anatomy.yaml - if key not in self.keys(): - hier = self.hierarchy() - hier.append(key) - raise TemplateMissingKey(hier) - - value = super(TemplatesDict, self).__getitem__(key) - if isinstance(value, self.__class__): - return value - - # Raise exception when expected solved templates and it is not. - if ( - self.raise_on_unsolved - and (hasattr(value, "solved") and not value.solved) - ): - raise TemplateUnsolved( - value.template, value.missing_keys, value.invalid_types + def validate(self): + if not self.solved: + raise AnatomyTemplateUnsolved( + self.template, + self.missing_keys, + self.invalid_types ) - return value - - @property - def raise_on_unsolved(self): - """To affect this change `strict` attribute.""" - if self.strict is not None: - return self.strict - return self.parent.raise_on_unsolved - - def hierarchy(self): - """Return dictionary keys one by one to root parent.""" - if self.parent is None: - return [] - - hier_keys = [] - par_hier = self.parent.hierarchy() - if par_hier: - hier_keys.extend(par_hier) - hier_keys.append(self.key) - - return hier_keys - - @property - def missing_keys(self): - """Return missing keys of all children templates.""" - missing_keys = [] - for value in self.values(): - missing_keys.extend(value.missing_keys) - return list(set(missing_keys)) - - @property - def invalid_types(self): - """Return invalid types of all children templates.""" - invalid_types = {} - for value in self.values(): - for invalid_type in value.invalid_types: - _invalid_types = {} - for key, val in invalid_type.items(): - if key in invalid_types: - continue - _invalid_types[key] = val - invalid_types = merge_dict(invalid_types, _invalid_types) - return invalid_types - - @property - def used_values(self): - """Return used values for all children templates.""" - used_values = {} - for value in self.values(): - used_values = merge_dict(used_values, value.used_values) - return used_values - - def get_solved(self): - """Get only solved key from templates.""" - result = {} - for key, value in self.items(): - if isinstance(value, self.__class__): - value = value.get_solved() - if not value: - continue - result[key] = value - - elif ( - not hasattr(value, "solved") or - value.solved - ): - result[key] = value - return self.__class__(result, key=self.key, parent=self.parent) -class Templates: - key_pattern = re.compile(r"(\{.*?[^{0]*\})") - key_padding_pattern = re.compile(r"([^:]+)\S+[><]\S+") - sub_dict_pattern = re.compile(r"([^\[\]]+)") - optional_pattern = re.compile(r"(<.*?[^{0]*>)[^0-9]*?") - +class AnatomyTemplates(TemplatesDict): inner_key_pattern = re.compile(r"(\{@.*?[^{}0]*\})") inner_key_name_pattern = re.compile(r"\{@(.*?[^{}0]*)\}") def __init__(self, anatomy): + super(AnatomyTemplates, self).__init__() self.anatomy = anatomy self.loaded_project = None - self._templates = None def __getitem__(self, key): return self.templates[key] @@ -596,13 +420,51 @@ class Templates: self._templates = None if self._templates is None: - self._templates = self._discover() + self._discover() self.loaded_project = self.project_name return self._templates + def _format_value(self, value, data): + if isinstance(value, RootItem): + return self._solve_dict(value, data) + + result = super(AnatomyTemplates, self)._format_value(value, data) + if isinstance(result, TemplateResult): + rootless_path = self._rootless_path(result, data) + result = AnatomyTemplateResult(result, rootless_path) + return result + + def set_templates(self, templates): + if not templates: + self._raw_templates = None + self._templates = None + else: + self._raw_templates = copy.deepcopy(templates) + templates = copy.deepcopy(templates) + v_queue = collections.deque() + v_queue.append(templates) + while v_queue: + item = v_queue.popleft() + if not isinstance(item, dict): + continue + + for key in tuple(item.keys()): + value = item[key] + if isinstance(value, dict): + v_queue.append(value) + + elif ( + isinstance(value, StringType) + and "{task}" in value + ): + item[key] = value.replace("{task}", "{task[name]}") + + solved_templates = self.solve_template_inner_links(templates) + self._templates = self.create_ojected_templates(solved_templates) + def default_templates(self): """Return default templates data with solved inner keys.""" - return Templates.solve_template_inner_links( + return self.solve_template_inner_links( self.anatomy["templates"] ) @@ -613,7 +475,7 @@ class Templates: TODO: create templates if not exist. Returns: - TemplatesDict: Contain templates data for current project of + TemplatesResultDict: Contain templates data for current project of default templates. """ @@ -624,7 +486,7 @@ class Templates: " Trying to use default." ).format(self.project_name)) - return Templates.solve_template_inner_links(self.anatomy["templates"]) + self.set_templates(self.anatomy["templates"]) @classmethod def replace_inner_keys(cls, matches, value, key_values, key): @@ -791,149 +653,6 @@ class Templates: return keys_by_subkey - def _filter_optional(self, template, data): - """Filter invalid optional keys. - - Invalid keys may be missing keys of with invalid value DataType. - - Args: - template (str): Anatomy template which will be formatted. - data (dict): Containing keys to be filled into template. - - Result: - tuple: Contain origin template without missing optional keys and - without optional keys identificator ("<" and ">"), information - about missing optional keys and invalid types of optional keys. - - """ - - # Remove optional missing keys - missing_keys = [] - invalid_types = [] - for optional_group in self.optional_pattern.findall(template): - _missing_keys = [] - _invalid_types = [] - for optional_key in self.key_pattern.findall(optional_group): - key = str(optional_key[1:-1]) - key_padding = list( - self.key_padding_pattern.findall(key) - ) - if key_padding: - key = key_padding[0] - - validation_result = self._validate_data_key( - key, data - ) - missing_key = validation_result["missing_key"] - invalid_type = validation_result["invalid_type"] - - valid = True - if missing_key is not None: - _missing_keys.append(missing_key) - valid = False - - if invalid_type is not None: - _invalid_types.append(invalid_type) - valid = False - - if valid: - try: - optional_key.format(**data) - except KeyError: - _missing_keys.append(key) - valid = False - - valid = len(_invalid_types) == 0 and len(_missing_keys) == 0 - missing_keys.extend(_missing_keys) - invalid_types.extend(_invalid_types) - replacement = "" - if valid: - replacement = optional_group[1:-1] - - template = template.replace(optional_group, replacement) - return (template, missing_keys, invalid_types) - - def _validate_data_key(self, key, data): - """Check and prepare missing keys and invalid types of template.""" - result = { - "missing_key": None, - "invalid_type": None - } - - # check if key expects subdictionary keys (e.g. project[name]) - key_subdict = list(self.sub_dict_pattern.findall(key)) - used_keys = [] - if len(key_subdict) <= 1: - if key not in data: - result["missing_key"] = key - return result - - used_keys.append(key) - value = data[key] - - else: - value = data - missing_key = False - invalid_type = False - for sub_key in key_subdict: - if ( - value is None - or (hasattr(value, "items") and sub_key not in value) - ): - missing_key = True - used_keys.append(sub_key) - break - - elif not hasattr(value, "items"): - invalid_type = True - break - - used_keys.append(sub_key) - value = value.get(sub_key) - - if missing_key or invalid_type: - if len(used_keys) == 0: - invalid_key = key_subdict[0] - else: - invalid_key = used_keys[0] - for idx, sub_key in enumerate(used_keys): - if idx == 0: - continue - invalid_key += "[{0}]".format(sub_key) - - if missing_key: - result["missing_key"] = invalid_key - - elif invalid_type: - result["invalid_type"] = {invalid_key: type(value)} - - return result - - if isinstance(value, (numbers.Number, Roots, RootItem)): - return result - - for inh_class in type(value).mro(): - if inh_class == StringType: - return result - - result["missing_key"] = key - result["invalid_type"] = {key: type(value)} - return result - - def _merge_used_values(self, current_used, keys, value): - key = keys[0] - _keys = keys[1:] - if len(_keys) == 0: - current_used[key] = value - else: - next_dict = {} - if key in current_used: - next_dict = current_used[key] - current_used[key] = self._merge_used_values( - next_dict, _keys, value - ) - return current_used - def _dict_to_subkeys_list(self, subdict, pre_keys=None): if pre_keys is None: pre_keys = [] @@ -956,9 +675,11 @@ class Templates: return {key_list[0]: value} return {key_list[0]: self._keys_to_dicts(key_list[1:], value)} - def _rootless_path( - self, template, used_values, final_data, missing_keys, invalid_types - ): + def _rootless_path(self, result, final_data): + used_values = result.used_values + missing_keys = result.missing_keys + template = result.template + invalid_types = result.invalid_types if ( "root" not in used_values or "root" in missing_keys @@ -974,210 +695,48 @@ class Templates: if not root_keys: return - roots_dict = {} + output = str(result) for used_root_keys in root_keys: if not used_root_keys: continue + used_value = used_values root_key = None for key in used_root_keys: + used_value = used_value[key] if root_key is None: root_key = key else: root_key += "[{}]".format(key) root_key = "{" + root_key + "}" - - roots_dict = merge_dict( - roots_dict, - self._keys_to_dicts(used_root_keys, root_key) - ) - - final_data["root"] = roots_dict["root"] - return template.format(**final_data) - - def _format(self, orig_template, data): - """ Figure out with whole formatting. - - Separate advanced keys (*Like '{project[name]}') from string which must - be formatted separatelly in case of missing or incomplete keys in data. - - Args: - template (str): Anatomy template which will be formatted. - data (dict): Containing keys to be filled into template. - - Returns: - TemplateResult: Filled or partially filled template containing all - data needed or missing for filling template. - """ - task_data = data.get("task") - if ( - isinstance(task_data, StringType) - and "{task[name]}" in orig_template - ): - # Change task to dictionary if template expect dictionary - data["task"] = {"name": task_data} - - template, missing_optional, invalid_optional = ( - self._filter_optional(orig_template, data) - ) - # Remove optional missing keys - used_values = {} - invalid_required = [] - missing_required = [] - replace_keys = [] - - for group in self.key_pattern.findall(template): - orig_key = group[1:-1] - key = str(orig_key) - key_padding = list(self.key_padding_pattern.findall(key)) - if key_padding: - key = key_padding[0] - - validation_result = self._validate_data_key(key, data) - missing_key = validation_result["missing_key"] - invalid_type = validation_result["invalid_type"] - - if invalid_type is not None: - invalid_required.append(invalid_type) - replace_keys.append(key) - continue - - if missing_key is not None: - missing_required.append(missing_key) - replace_keys.append(key) - continue - - try: - value = group.format(**data) - key_subdict = list(self.sub_dict_pattern.findall(key)) - if len(key_subdict) <= 1: - used_values[key] = value - - else: - used_values = self._merge_used_values( - used_values, key_subdict, value - ) - - except (TypeError, KeyError): - missing_required.append(key) - replace_keys.append(key) - - final_data = copy.deepcopy(data) - for key in replace_keys: - key_subdict = list(self.sub_dict_pattern.findall(key)) - if len(key_subdict) <= 1: - final_data[key] = "{" + key + "}" - continue - - replace_key_dst = "---".join(key_subdict) - replace_key_dst_curly = "{" + replace_key_dst + "}" - replace_key_src_curly = "{" + key + "}" - template = template.replace( - replace_key_src_curly, replace_key_dst_curly - ) - final_data[replace_key_dst] = replace_key_src_curly - - solved = len(missing_required) == 0 and len(invalid_required) == 0 - - missing_keys = missing_required + missing_optional - invalid_types = invalid_required + invalid_optional - - filled_template = template.format(**final_data) - # WARNING `_rootless_path` change values in `final_data` please keep - # in midn when changing order - rootless_path = self._rootless_path( - template, used_values, final_data, missing_keys, invalid_types - ) - if rootless_path is None: - rootless_path = filled_template - - result = TemplateResult( - filled_template, orig_template, solved, rootless_path, - used_values, missing_keys, invalid_types - ) - return result - - def solve_dict(self, templates, data): - """ Solves templates with entered data. - - Args: - templates (dict): All Anatomy templates which will be formatted. - data (dict): Containing keys to be filled into template. - - Returns: - dict: With `TemplateResult` in values containing filled or - partially filled templates. - """ - output = collections.defaultdict(dict) - for key, orig_value in templates.items(): - if isinstance(orig_value, StringType): - # Replace {task} by '{task[name]}' for backward compatibility - if '{task}' in orig_value: - orig_value = orig_value.replace('{task}', '{task[name]}') - - output[key] = self._format(orig_value, data) - continue - - # Check if orig_value has items attribute (any dict inheritance) - if not hasattr(orig_value, "items"): - # TODO we should handle this case - output[key] = orig_value - continue - - for s_key, s_value in self.solve_dict(orig_value, data).items(): - output[key][s_key] = s_value + output = output.replace(str(used_value), root_key) return output + def format(self, data, strict=True): + roots = self.roots + if roots: + data["root"] = roots + result = super(AnatomyTemplates, self).format(data) + result.strict = strict + return result + def format_all(self, in_data, only_keys=True): """ Solves templates based on entered data. Args: data (dict): Containing keys to be filled into template. - only_keys (bool, optional): Decides if environ will be used to - fill templates or only keys in data. Returns: - TemplatesDict: Output `TemplateResult` have `strict` attribute - set to False so accessing unfilled keys in templates won't - raise any exceptions. + TemplatesResultDict: Output `TemplateResult` have `strict` + attribute set to False so accessing unfilled keys in templates + won't raise any exceptions. """ - output = self.format(in_data, only_keys) - output.strict = False - return output - - def format(self, in_data, only_keys=True): - """ Solves templates based on entered data. - - Args: - data (dict): Containing keys to be filled into template. - only_keys (bool, optional): Decides if environ will be used to - fill templates or only keys in data. - - Returns: - TemplatesDict: Output `TemplateResult` have `strict` attribute - set to True so accessing unfilled keys in templates will - raise exceptions with explaned error. - """ - # Create a copy of inserted data - data = copy.deepcopy(in_data) - - # Add environment variable to data - if only_keys is False: - for key, val in os.environ.items(): - data["$" + key] = val - - # override root value - roots = self.roots - if roots: - data["root"] = roots - solved = self.solve_dict(self.templates, data) - - return TemplatesDict(solved) + return self.format(in_data, strict=False) -class RootItem: +class RootItem(FormatObject): """Represents one item or roots. Holds raw data of root item specification. Raw data contain value diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py index 6f68cc4ce9..b51951851f 100644 --- a/openpype/lib/path_templates.py +++ b/openpype/lib/path_templates.py @@ -584,6 +584,10 @@ class TemplatePartResult: class FormatObject(object): + """Object that can be used for formatting. + + This is base that is valid for to be used in 'StringTemplate' value. + """ def __init__(self): self.value = "" From 1fa2e86d1840a8a8801fdb8455930442550f792d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 19 Feb 2022 20:36:13 +0100 Subject: [PATCH 133/483] reorganized init --- openpype/lib/__init__.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 4d956d9876..6ec10a2209 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -16,15 +16,6 @@ sys.path.insert(0, python_version_dir) site.addsitedir(python_version_dir) -from .path_templates import ( - merge_dict, - TemplateMissingKey, - TemplateUnsolved, - StringTemplate, - TemplatesDict, - FormatObject, -) - from .env_tools import ( env_value_to_bool, get_paths_from_environ, @@ -44,6 +35,16 @@ from .execute import ( CREATE_NO_WINDOW ) from .log import PypeLogger, timeit + +from .path_templates import ( + merge_dict, + TemplateMissingKey, + TemplateUnsolved, + StringTemplate, + TemplatesDict, + FormatObject, +) + from .mongo import ( get_default_components, validate_mongo_connection, @@ -191,13 +192,6 @@ from .openpype_version import ( terminal = Terminal __all__ = [ - "merge_dict", - "TemplateMissingKey", - "TemplateUnsolved", - "StringTemplate", - "TemplatesDict", - "FormatObject", - "get_openpype_execute_args", "get_pype_execute_args", "get_linux_launcher_args", @@ -298,6 +292,13 @@ __all__ = [ "get_version_from_path", "get_last_version_from_path", + "merge_dict", + "TemplateMissingKey", + "TemplateUnsolved", + "StringTemplate", + "TemplatesDict", + "FormatObject", + "terminal", "Anatomy", From 26549b34650a45037084107356f94a12b3146441 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 19 Feb 2022 20:51:28 +0100 Subject: [PATCH 134/483] hound fixes --- openpype/lib/anatomy.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index f817646cd7..8f2f09a803 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -10,10 +10,8 @@ from openpype.settings.lib import ( get_anatomy_settings ) from .path_templates import ( - merge_dict, TemplateUnsolved, TemplateResult, - TemplatesResultDict, TemplatesDict, FormatObject, ) @@ -69,7 +67,10 @@ class Anatomy: " to load data for specific project." )) + from .avalon_context import get_project_code + self.project_name = project_name + self.project_code = get_project_code(project_name) self._data = self._prepare_anatomy_data( get_anatomy_settings(project_name, site_name) From b8d6e2181fff35882d4496beb5ec791cd9ad0993 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 20 Feb 2022 20:17:56 +0100 Subject: [PATCH 135/483] Fix #2714 houdini open last workfile --- openpype/hooks/pre_add_last_workfile_arg.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hooks/pre_add_last_workfile_arg.py b/openpype/hooks/pre_add_last_workfile_arg.py index 653f97b3dd..922dde49bb 100644 --- a/openpype/hooks/pre_add_last_workfile_arg.py +++ b/openpype/hooks/pre_add_last_workfile_arg.py @@ -17,6 +17,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "nuke", "nukex", "hiero", + "houdini", "nukestudio", "blender", "photoshop", From ebde6ced091febcf964e4a85b13064da853b26c6 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 20 Feb 2022 20:20:23 +0100 Subject: [PATCH 136/483] Fix typo in name `afftereffects` -> `aftereffects` --- openpype/hooks/pre_add_last_workfile_arg.py | 2 +- openpype/hooks/pre_copy_template_workfile.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hooks/pre_add_last_workfile_arg.py b/openpype/hooks/pre_add_last_workfile_arg.py index 653f97b3dd..89f627b37f 100644 --- a/openpype/hooks/pre_add_last_workfile_arg.py +++ b/openpype/hooks/pre_add_last_workfile_arg.py @@ -21,7 +21,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "blender", "photoshop", "tvpaint", - "afftereffects" + "aftereffects" ] def execute(self): diff --git a/openpype/hooks/pre_copy_template_workfile.py b/openpype/hooks/pre_copy_template_workfile.py index 5c56d721e8..d975c5e12a 100644 --- a/openpype/hooks/pre_copy_template_workfile.py +++ b/openpype/hooks/pre_copy_template_workfile.py @@ -19,7 +19,7 @@ class CopyTemplateWorkfile(PreLaunchHook): # Before `AddLastWorkfileToLaunchArgs` order = 0 - app_groups = ["blender", "photoshop", "tvpaint", "afftereffects"] + app_groups = ["blender", "photoshop", "tvpaint", "aftereffects"] def execute(self): """Check if can copy template for context and do it if possible. From 586f632f36a169147b379d90da3b294bd578ab70 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 20 Feb 2022 21:28:45 +0100 Subject: [PATCH 137/483] Fix typos --- openpype/hooks/pre_copy_template_workfile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hooks/pre_copy_template_workfile.py b/openpype/hooks/pre_copy_template_workfile.py index d975c5e12a..dffac22ee2 100644 --- a/openpype/hooks/pre_copy_template_workfile.py +++ b/openpype/hooks/pre_copy_template_workfile.py @@ -44,7 +44,7 @@ class CopyTemplateWorkfile(PreLaunchHook): return if os.path.exists(last_workfile): - self.log.debug("Last workfile exits. Skipping {} process.".format( + self.log.debug("Last workfile exists. Skipping {} process.".format( self.__class__.__name__ )) return @@ -120,7 +120,7 @@ class CopyTemplateWorkfile(PreLaunchHook): f"Creating workfile from template: \"{template_path}\"" ) - # Copy template workfile to new destinantion + # Copy template workfile to new destination shutil.copy2( os.path.normpath(template_path), os.path.normpath(last_workfile) From c1a4204fb96d33fede2341096247a23dbce3cecd Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 20 Feb 2022 22:56:07 +0100 Subject: [PATCH 138/483] Implement Reset Frame Range for Houdini --- openpype/hosts/houdini/api/lib.py | 34 +++++++++++++++++++ .../hosts/houdini/startup/MainMenuCommon.xml | 8 +++++ 2 files changed, 42 insertions(+) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 72f1c8e71f..5a087ea276 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -542,3 +542,37 @@ def maintained_selection(): if previous_selection: for node in previous_selection: node.setSelected(on=True) + + +def reset_framerange(): + """Set frame range to current asset""" + + asset_name = api.Session["AVALON_ASSET"] + asset = io.find_one({"name": asset_name, "type": "asset"}) + + frame_start = asset["data"].get("frameStart") + frame_end = asset["data"].get("frameEnd") + # Backwards compatibility + if frame_start is None or frame_end is None: + frame_start = asset["data"].get("edit_in") + frame_end = asset["data"].get("edit_out") + + if frame_start is None or frame_end is None: + log.warning("No edit information found for %s" % asset_name) + return + + handles = asset["data"].get("handles") or 0 + handle_start = asset["data"].get("handleStart") + if handle_start is None: + handle_start = handles + + handle_end = asset["data"].get("handleEnd") + if handle_end is None: + handle_end = handles + + frame_start -= int(handle_start) + frame_end += int(handle_end) + + hou.playbar.setFrameRange(frame_start, frame_end) + hou.playbar.setPlaybackRange(frame_start, frame_end) + hou.setFrame(frame_start) diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml index b8c7f93d76..abfa3f136e 100644 --- a/openpype/hosts/houdini/startup/MainMenuCommon.xml +++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml @@ -66,6 +66,14 @@ host_tools.show_workfiles(parent) ]]> + + + + + From e945775161cd86934464c62dd734ea876ea2448a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 20 Feb 2022 23:05:10 +0100 Subject: [PATCH 139/483] Set asset frame range on Houdini launch and for each new scene --- openpype/hosts/houdini/api/pipeline.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index c3dbdc5ef5..69fb236432 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -66,9 +66,9 @@ def install(): sys.path.append(hou_pythonpath) - # Set asset FPS for the empty scene directly after launch of Houdini - # so it initializes into the correct scene FPS - _set_asset_fps() + # Set asset settings for the empty scene directly after launch of Houdini + # so it initializes into the correct scene FPS, Frame Range, etc. + _set_context_settings() def uninstall(): @@ -280,17 +280,31 @@ def on_open(*args): def on_new(_): """Set project resolution and fps when create a new file""" log.info("Running callback on new..") - _set_asset_fps() + _set_context_settings() -def _set_asset_fps(): - """Set Houdini scene FPS to the default required for current asset""" +def _set_context_settings(): + """Apply the project settings from the project definition + + Settings can be overwritten by an asset if the asset.data contains + any information regarding those settings. + + Examples of settings: + fps + resolution + renderer + + Returns: + None + """ # Set new scene fps fps = get_asset_fps() print("Setting scene FPS to %i" % fps) lib.set_scene_fps(fps) + lib.reset_framerange() + def on_pyblish_instance_toggled(instance, new_value, old_value): """Toggle saver tool passthrough states on instance toggles.""" From 46d3ee4ba4fa392c4ea90f0f9db2ba238b6e9312 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 20 Feb 2022 23:41:18 +0100 Subject: [PATCH 140/483] Add todo --- openpype/hosts/houdini/api/pipeline.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 69fb236432..5280a6b60a 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -68,6 +68,7 @@ def install(): # Set asset settings for the empty scene directly after launch of Houdini # so it initializes into the correct scene FPS, Frame Range, etc. + # todo: make sure this doesn't trigger when opening with last workfile _set_context_settings() From 41d54f80c4ac8da3af8012d8fe9668dcc0889edd Mon Sep 17 00:00:00 2001 From: murphy Date: Mon, 21 Feb 2022 10:27:42 +0100 Subject: [PATCH 141/483] wrong link fix Installation of python and pyside link broken --- website/docs/artist_hosts_resolve.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/artist_hosts_resolve.md b/website/docs/artist_hosts_resolve.md index be069eea79..80e3b0e139 100644 --- a/website/docs/artist_hosts_resolve.md +++ b/website/docs/artist_hosts_resolve.md @@ -9,7 +9,7 @@ import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; :::warning -Before you are able to start with OpenPype tools in DaVinci Resolve, installation of its own Python 3.6 interpreter and PySide 2 has to be done. Go to [Installation of python and pyside](#installation-of-python-and-pyside) link for more information +Before you are able to start with OpenPype tools in DaVinci Resolve, installation of its own Python 3.6 interpreter and PySide 2 has to be done. Go to [Installation of python and pyside](admin_hosts_resolve#installation-of-python-and-pyside) link for more information ::: From e6177c5e7763a9bba45f83e253603b1c62e0d7fa Mon Sep 17 00:00:00 2001 From: murphy Date: Mon, 21 Feb 2022 11:30:42 +0100 Subject: [PATCH 142/483] missing .md missing .md to prevent problems with "/" on the web server --- website/docs/artist_hosts_resolve.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/artist_hosts_resolve.md b/website/docs/artist_hosts_resolve.md index 80e3b0e139..01e50c12c9 100644 --- a/website/docs/artist_hosts_resolve.md +++ b/website/docs/artist_hosts_resolve.md @@ -9,7 +9,7 @@ import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; :::warning -Before you are able to start with OpenPype tools in DaVinci Resolve, installation of its own Python 3.6 interpreter and PySide 2 has to be done. Go to [Installation of python and pyside](admin_hosts_resolve#installation-of-python-and-pyside) link for more information +Before you are able to start with OpenPype tools in DaVinci Resolve, installation of its own Python 3.6 interpreter and PySide 2 has to be done. Go to [Installation of python and pyside](admin_hosts_resolve.md#installation-of-python-and-pyside) link for more information ::: From dbcb8f63e36f8813029a7da25650bb1dfa361875 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 21 Feb 2022 13:55:24 +0100 Subject: [PATCH 143/483] OP-2642 - added subset filter to limit Slack notifications --- .../standalonepublisher/plugins/publish/collect_texture.py | 3 +++ .../modules/slack/plugins/publish/collect_slack_family.py | 7 ++++++- openpype/settings/defaults/project_settings/slack.json | 1 + .../schemas/projects_schema/schema_project_slack.json | 6 ++++++ 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py index 596a8ccfd2..d318070fbc 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py @@ -411,9 +411,11 @@ class CollectTextures(pyblish.api.ContextPlugin): Raises: ValueError - if broken 'input_naming_groups' """ + self.log.info("{} {} {}".format(name, input_naming_patterns, input_naming_groups)) for input_pattern in input_naming_patterns: for cs in color_spaces: pattern = input_pattern.replace('{color_space}', cs) + self.log.info("{} {}".format(pattern, name)) regex_result = re.findall(pattern, name) if regex_result: idx = list(input_naming_groups).index(key) @@ -424,6 +426,7 @@ class CollectTextures(pyblish.api.ContextPlugin): try: parsed_value = regex_result[0][idx] + self.log.info("par{}".format(parsed_value)) return parsed_value except IndexError: self.log.warning("Wrong index, probably " diff --git a/openpype/modules/slack/plugins/publish/collect_slack_family.py b/openpype/modules/slack/plugins/publish/collect_slack_family.py index 2110c0703b..6c965b04cd 100644 --- a/openpype/modules/slack/plugins/publish/collect_slack_family.py +++ b/openpype/modules/slack/plugins/publish/collect_slack_family.py @@ -20,18 +20,23 @@ class CollectSlackFamilies(pyblish.api.InstancePlugin): def process(self, instance): task_name = io.Session.get("AVALON_TASK") family = self.main_family_from_instance(instance) - key_values = { "families": family, "tasks": task_name, "hosts": instance.data["anatomyData"]["app"], + "subsets": instance.data["subset"] } profile = filter_profiles(self.profiles, key_values, logger=self.log) + if not profile: + self.log.info("No profile found, notification won't be send") + return + # make slack publishable if profile: + self.log.info("Found profile: {}".format(profile)) if instance.data.get('families'): instance.data['families'].append('slack') else: diff --git a/openpype/settings/defaults/project_settings/slack.json b/openpype/settings/defaults/project_settings/slack.json index 2d10bd173d..d77b8c2208 100644 --- a/openpype/settings/defaults/project_settings/slack.json +++ b/openpype/settings/defaults/project_settings/slack.json @@ -10,6 +10,7 @@ "hosts": [], "task_types": [], "tasks": [], + "subsets": [], "channel_messages": [] } ] diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json index 4e82c991e7..14814d8b01 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json @@ -69,6 +69,12 @@ "type": "list", "object_type": "text" }, + { + "key": "subsets", + "label": "Subset names", + "type": "list", + "object_type": "text" + }, { "type": "separator" }, From 43010a490ebcbe2bbbe3a882e8a10d57cf415a16 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 21 Feb 2022 15:07:00 +0100 Subject: [PATCH 144/483] Fix OpenPype not initialized on Houdini launch when opening with last workfile --- openpype/hooks/pre_add_last_workfile_arg.py | 40 +++++++++++++++++-- .../startup/scripts/openpype_launch.py | 40 +++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 openpype/hosts/houdini/startup/scripts/openpype_launch.py diff --git a/openpype/hooks/pre_add_last_workfile_arg.py b/openpype/hooks/pre_add_last_workfile_arg.py index 922dde49bb..4797b61580 100644 --- a/openpype/hooks/pre_add_last_workfile_arg.py +++ b/openpype/hooks/pre_add_last_workfile_arg.py @@ -17,7 +17,6 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "nuke", "nukex", "hiero", - "houdini", "nukestudio", "blender", "photoshop", @@ -25,7 +24,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "afftereffects" ] - def execute(self): + def get_last_workfile(self): if not self.data.get("start_last_workfile"): self.log.info("It is set to not start last workfile on start.") return @@ -39,5 +38,38 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): self.log.info("Current context does not have any workfile yet.") return - # Add path to workfile to arguments - self.launch_context.launch_args.append(last_workfile) + return last_workfile + + def execute(self): + + last_workfile = self.get_last_workfile() + if last_workfile: + # Add path to workfile to arguments + self.launch_context.launch_args.append(last_workfile) + + +class AddLastWorkfileToLaunchArgsHoudini(AddLastWorkfileToLaunchArgs): + """Add last workfile path to launch arguments - Houdini specific""" + app_groups = ["houdini"] + + def execute(self): + + last_workfile = self.get_last_workfile() + if last_workfile: + # Whenever a filepath is passed to Houdini then the startup + # scripts 123.py and houdinicore.py won't be triggered. Thus + # OpenPype will not initialize correctly. As such, whenever + # we pass a workfile we first explicitly pass a startup + # script to enforce it to run - which will load the last passed + # argument as workfile directly. + pype_root = os.environ["OPENPYPE_REPOS_ROOT"] + startup_path = os.path.join( + pype_root, "openpype", "hosts", "houdini", "startup" + ) + startup_script = os.path.join(startup_path, + "scripts", + "openpype_launch.py") + self.launch_context.launch_args.append(startup_script) + + # Add path to workfile to arguments + self.launch_context.launch_args.append(last_workfile) diff --git a/openpype/hosts/houdini/startup/scripts/openpype_launch.py b/openpype/hosts/houdini/startup/scripts/openpype_launch.py new file mode 100644 index 0000000000..1a9069dbc6 --- /dev/null +++ b/openpype/hosts/houdini/startup/scripts/openpype_launch.py @@ -0,0 +1,40 @@ +import os +import sys +import avalon.api +from openpype.hosts.houdini import api +import openpype.hosts.houdini.api.workio + +import hou + + +def is_workfile(path): + if not path: + return + + if not os.path.exists(path): + return False + + _, ext = os.path.splitext(path) + if ext in openpype.hosts.houdini.api.workio.file_extensions(): + return True + + +def main(): + print("Installing OpenPype ...") + avalon.api.install(api) + + args = sys.argv + if args and is_workfile(args[-1]): + # If the last argument is a Houdini file open it directly + workfile_path = args[-1].replace("\\", "/") + print("Opening workfile on launch: {}".format(workfile_path)) + + # We don't use `workio.open_file` because we want to explicitly ignore + # load warnings. Otherwise Houdini will fail to start if a scene load + # produces e.g. errors on missing plug-ins + hou.hipFile.load(workfile_path, + suppress_save_prompt=True, + ignore_load_warnings=True) + + +main() From 5b85dff95a323813604e927b4ed3c5428b074097 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 21 Feb 2022 15:17:58 +0100 Subject: [PATCH 145/483] OP-2642 - added subset filter to limit Slack notifications --- website/docs/module_slack.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/module_slack.md b/website/docs/module_slack.md index 67b200259d..3a2842da63 100644 --- a/website/docs/module_slack.md +++ b/website/docs/module_slack.md @@ -47,7 +47,7 @@ It is possible to create multiple tokens and configure different scopes for them ### Profiles Profiles are used to select when to trigger notification. One or multiple profiles -could be configured, `Families`, `Task names` (regex available), `Host names` combination is needed. +could be configured, `Families`, `Task names` (regex available), `Host names`, `Subset names` (regex available) combination is needed. Eg. If I want to be notified when render is published from Maya, setting is: From 1cfdbcb2a14d36cef5bb920b5ae20f74cc476623 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 21 Feb 2022 15:55:32 +0100 Subject: [PATCH 146/483] Use pythonrc.py file for Houdini start instead of 123.py/houdinicore.py --- .../123.py => python2.7libs/pythonrc.py} | 0 .../pythonrc.py} | 0 .../startup/scripts/openpype_launch.py | 40 ------------------- 3 files changed, 40 deletions(-) rename openpype/hosts/houdini/startup/{scripts/123.py => python2.7libs/pythonrc.py} (100%) rename openpype/hosts/houdini/startup/{scripts/houdinicore.py => python3.7libs/pythonrc.py} (100%) delete mode 100644 openpype/hosts/houdini/startup/scripts/openpype_launch.py diff --git a/openpype/hosts/houdini/startup/scripts/123.py b/openpype/hosts/houdini/startup/python2.7libs/pythonrc.py similarity index 100% rename from openpype/hosts/houdini/startup/scripts/123.py rename to openpype/hosts/houdini/startup/python2.7libs/pythonrc.py diff --git a/openpype/hosts/houdini/startup/scripts/houdinicore.py b/openpype/hosts/houdini/startup/python3.7libs/pythonrc.py similarity index 100% rename from openpype/hosts/houdini/startup/scripts/houdinicore.py rename to openpype/hosts/houdini/startup/python3.7libs/pythonrc.py diff --git a/openpype/hosts/houdini/startup/scripts/openpype_launch.py b/openpype/hosts/houdini/startup/scripts/openpype_launch.py deleted file mode 100644 index 1a9069dbc6..0000000000 --- a/openpype/hosts/houdini/startup/scripts/openpype_launch.py +++ /dev/null @@ -1,40 +0,0 @@ -import os -import sys -import avalon.api -from openpype.hosts.houdini import api -import openpype.hosts.houdini.api.workio - -import hou - - -def is_workfile(path): - if not path: - return - - if not os.path.exists(path): - return False - - _, ext = os.path.splitext(path) - if ext in openpype.hosts.houdini.api.workio.file_extensions(): - return True - - -def main(): - print("Installing OpenPype ...") - avalon.api.install(api) - - args = sys.argv - if args and is_workfile(args[-1]): - # If the last argument is a Houdini file open it directly - workfile_path = args[-1].replace("\\", "/") - print("Opening workfile on launch: {}".format(workfile_path)) - - # We don't use `workio.open_file` because we want to explicitly ignore - # load warnings. Otherwise Houdini will fail to start if a scene load - # produces e.g. errors on missing plug-ins - hou.hipFile.load(workfile_path, - suppress_save_prompt=True, - ignore_load_warnings=True) - - -main() From 065c6a092fdcab6920347ed69ea0b30de474afb3 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 21 Feb 2022 15:56:07 +0100 Subject: [PATCH 147/483] Revert separation of Houdini last workfile code --- openpype/hooks/pre_add_last_workfile_arg.py | 35 ++------------------- 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/openpype/hooks/pre_add_last_workfile_arg.py b/openpype/hooks/pre_add_last_workfile_arg.py index 4797b61580..caebd7d034 100644 --- a/openpype/hooks/pre_add_last_workfile_arg.py +++ b/openpype/hooks/pre_add_last_workfile_arg.py @@ -17,6 +17,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "nuke", "nukex", "hiero", + "houdini", "nukestudio", "blender", "photoshop", @@ -24,7 +25,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "afftereffects" ] - def get_last_workfile(self): + def execute(self): if not self.data.get("start_last_workfile"): self.log.info("It is set to not start last workfile on start.") return @@ -38,38 +39,6 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): self.log.info("Current context does not have any workfile yet.") return - return last_workfile - - def execute(self): - - last_workfile = self.get_last_workfile() if last_workfile: # Add path to workfile to arguments self.launch_context.launch_args.append(last_workfile) - - -class AddLastWorkfileToLaunchArgsHoudini(AddLastWorkfileToLaunchArgs): - """Add last workfile path to launch arguments - Houdini specific""" - app_groups = ["houdini"] - - def execute(self): - - last_workfile = self.get_last_workfile() - if last_workfile: - # Whenever a filepath is passed to Houdini then the startup - # scripts 123.py and houdinicore.py won't be triggered. Thus - # OpenPype will not initialize correctly. As such, whenever - # we pass a workfile we first explicitly pass a startup - # script to enforce it to run - which will load the last passed - # argument as workfile directly. - pype_root = os.environ["OPENPYPE_REPOS_ROOT"] - startup_path = os.path.join( - pype_root, "openpype", "hosts", "houdini", "startup" - ) - startup_script = os.path.join(startup_path, - "scripts", - "openpype_launch.py") - self.launch_context.launch_args.append(startup_script) - - # Add path to workfile to arguments - self.launch_context.launch_args.append(last_workfile) From c03f6ec0b1e638e5864e7ca20a725118169c24d0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 21 Feb 2022 15:56:58 +0100 Subject: [PATCH 148/483] Remove redundant if statement that didn't get reverted --- openpype/hooks/pre_add_last_workfile_arg.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/hooks/pre_add_last_workfile_arg.py b/openpype/hooks/pre_add_last_workfile_arg.py index caebd7d034..922dde49bb 100644 --- a/openpype/hooks/pre_add_last_workfile_arg.py +++ b/openpype/hooks/pre_add_last_workfile_arg.py @@ -39,6 +39,5 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): self.log.info("Current context does not have any workfile yet.") return - if last_workfile: - # Add path to workfile to arguments - self.launch_context.launch_args.append(last_workfile) + # Add path to workfile to arguments + self.launch_context.launch_args.append(last_workfile) From 592cf54a976f4eab3a86987adbd23249fa0aa4e7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 21 Feb 2022 16:13:53 +0100 Subject: [PATCH 149/483] OP-2642 - revert of unwanted logs --- .../standalonepublisher/plugins/publish/collect_texture.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py index d318070fbc..3b7343e685 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py @@ -411,7 +411,6 @@ class CollectTextures(pyblish.api.ContextPlugin): Raises: ValueError - if broken 'input_naming_groups' """ - self.log.info("{} {} {}".format(name, input_naming_patterns, input_naming_groups)) for input_pattern in input_naming_patterns: for cs in color_spaces: pattern = input_pattern.replace('{color_space}', cs) @@ -426,7 +425,6 @@ class CollectTextures(pyblish.api.ContextPlugin): try: parsed_value = regex_result[0][idx] - self.log.info("par{}".format(parsed_value)) return parsed_value except IndexError: self.log.warning("Wrong index, probably " From 9dd492ab236bd09f44947bed51336bd5484a4d16 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 21 Feb 2022 16:14:34 +0100 Subject: [PATCH 150/483] OP-2642 - revert of unwanted logs --- .../hosts/standalonepublisher/plugins/publish/collect_texture.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py index 3b7343e685..596a8ccfd2 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py @@ -414,7 +414,6 @@ class CollectTextures(pyblish.api.ContextPlugin): for input_pattern in input_naming_patterns: for cs in color_spaces: pattern = input_pattern.replace('{color_space}', cs) - self.log.info("{} {}".format(pattern, name)) regex_result = re.findall(pattern, name) if regex_result: idx = list(input_naming_groups).index(key) From 88a6aaee70c21f1941704d4280dddc18a663410c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 21 Feb 2022 16:15:10 +0100 Subject: [PATCH 151/483] Fix current frame not set correctly on new scene --- openpype/hosts/houdini/api/pipeline.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 5280a6b60a..4c55de7246 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -4,6 +4,7 @@ import logging import contextlib import hou +import hdefereval import pyblish.api import avalon.api @@ -283,6 +284,15 @@ def on_new(_): log.info("Running callback on new..") _set_context_settings() + # It seems that the current frame always gets reset to frame 1 on + # new scene. So we enforce current frame to be at the start of the playbar + # with execute deferred + def _enforce_start_frame(): + start = hou.playbar.playbackRange()[0] + hou.setFrame(start) + + hdefereval.executeDeferred(_enforce_start_frame) + def _set_context_settings(): """Apply the project settings from the project definition From 823164e6c49732fa96d6960f886b66d2e1d688fb Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 21 Feb 2022 16:29:57 +0100 Subject: [PATCH 152/483] Avoid on_new callback when opening a Houdini file --- openpype/hosts/houdini/api/pipeline.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 4c55de7246..1c08e72d65 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -281,6 +281,14 @@ def on_open(*args): def on_new(_): """Set project resolution and fps when create a new file""" + + if hou.hipFile.isLoadingHipFile(): + # This event also triggers when Houdini opens a file due to the + # new event being registered to 'afterClear'. As such we can skip + # 'new' logic if the user is opening a file anyway + log.debug("Skipping on new callback due to scene being opened.") + return + log.info("Running callback on new..") _set_context_settings() From b7f6e2dd55c24ef5534495e4adc1287d5f49571f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 16:50:25 +0100 Subject: [PATCH 153/483] use proxy model for check of files existence --- openpype/widgets/attribute_defs/files_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index fb48528bdc..5aa76d8754 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -552,7 +552,7 @@ class MultiFilesWidget(QtWidgets.QFrame): self._update_visibility() def _update_visibility(self): - files_exists = self._files_model.rowCount() > 0 + files_exists = self._files_proxy_model.rowCount() > 0 self._files_view.setVisible(files_exists) self._empty_widget.setVisible(not files_exists) From 42f47c868b838adfc1c0762ba64487eb822f6de1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 16:51:30 +0100 Subject: [PATCH 154/483] created clickable label in utils --- openpype/tools/utils/__init__.py | 6 +++++- openpype/tools/utils/widgets.py | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 46af051069..b4b0af106e 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -2,11 +2,12 @@ from .widgets import ( PlaceholderLineEdit, BaseClickableFrame, ClickableFrame, + ClickableLabel, ExpandBtn, PixmapLabel, IconButton, ) - +from .views import DeselectableTreeView from .error_dialog import ErrorMessageBox from .lib import ( WrappedCallbackItem, @@ -24,10 +25,13 @@ __all__ = ( "PlaceholderLineEdit", "BaseClickableFrame", "ClickableFrame", + "ClickableLabel", "ExpandBtn", "PixmapLabel", "IconButton", + "DeselectableTreeView", + "ErrorMessageBox", "WrappedCallbackItem", diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index c62b838231..a4e172ea5c 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -63,6 +63,29 @@ class ClickableFrame(BaseClickableFrame): self.clicked.emit() +class ClickableLabel(QtWidgets.QLabel): + """Label that catch left mouse click and can trigger 'clicked' signal.""" + clicked = QtCore.Signal() + + def __init__(self, parent): + super(ClickableLabel, self).__init__(parent) + + self._mouse_pressed = False + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._mouse_pressed = True + super(ClickableLabel, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + if self._mouse_pressed: + self._mouse_pressed = False + if self.rect().contains(event.pos()): + self.clicked.emit() + + super(ClickableLabel, self).mouseReleaseEvent(event) + + class ExpandBtnLabel(QtWidgets.QLabel): """Label showing expand icon meant for ExpandBtn.""" def __init__(self, parent): From f69091bd8c46ef15459cb9423462ad1fd0e9ef6a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 16:55:24 +0100 Subject: [PATCH 155/483] enhanced report viewer --- .../publish_report_viewer/__init__.py | 5 + .../publisher/publish_report_viewer/model.py | 4 + .../publish_report_viewer/report_items.py | 126 ++++++ .../publish_report_viewer/widgets.py | 394 ++++++++++++------ .../publisher/publish_report_viewer/window.py | 334 ++++++++++++++- 5 files changed, 734 insertions(+), 129 deletions(-) create mode 100644 openpype/tools/publisher/publish_report_viewer/report_items.py diff --git a/openpype/tools/publisher/publish_report_viewer/__init__.py b/openpype/tools/publisher/publish_report_viewer/__init__.py index 3cfaaa5a05..ce1cc3729c 100644 --- a/openpype/tools/publisher/publish_report_viewer/__init__.py +++ b/openpype/tools/publisher/publish_report_viewer/__init__.py @@ -1,3 +1,6 @@ +from .report_items import ( + PublishReport +) from .widgets import ( PublishReportViewerWidget ) @@ -8,6 +11,8 @@ from .window import ( __all__ = ( + "PublishReport", + "PublishReportViewerWidget", "PublishReportViewerWindow", diff --git a/openpype/tools/publisher/publish_report_viewer/model.py b/openpype/tools/publisher/publish_report_viewer/model.py index 460d3e12d1..a88129a358 100644 --- a/openpype/tools/publisher/publish_report_viewer/model.py +++ b/openpype/tools/publisher/publish_report_viewer/model.py @@ -28,6 +28,8 @@ class InstancesModel(QtGui.QStandardItemModel): self.clear() self._items_by_id.clear() self._plugin_items_by_id.clear() + if not report_item: + return root_item = self.invisibleRootItem() @@ -119,6 +121,8 @@ class PluginsModel(QtGui.QStandardItemModel): self.clear() self._items_by_id.clear() self._plugin_items_by_id.clear() + if not report_item: + return root_item = self.invisibleRootItem() diff --git a/openpype/tools/publisher/publish_report_viewer/report_items.py b/openpype/tools/publisher/publish_report_viewer/report_items.py new file mode 100644 index 0000000000..b47d14da25 --- /dev/null +++ b/openpype/tools/publisher/publish_report_viewer/report_items.py @@ -0,0 +1,126 @@ +import uuid +import collections +import copy + + +class PluginItem: + def __init__(self, plugin_data): + self._id = uuid.uuid4() + + self.name = plugin_data["name"] + self.label = plugin_data["label"] + self.order = plugin_data["order"] + self.skipped = plugin_data["skipped"] + self.passed = plugin_data["passed"] + + errored = False + for instance_data in plugin_data["instances_data"]: + for log_item in instance_data["logs"]: + errored = log_item["type"] == "error" + if errored: + break + if errored: + break + + self.errored = errored + + @property + def id(self): + return self._id + + +class InstanceItem: + def __init__(self, instance_id, instance_data, logs_by_instance_id): + self._id = instance_id + self.label = instance_data.get("label") or instance_data.get("name") + self.family = instance_data.get("family") + self.removed = not instance_data.get("exists", True) + + logs = logs_by_instance_id.get(instance_id) or [] + errored = False + for log_item in logs: + if log_item.errored: + errored = True + break + + self.errored = errored + + @property + def id(self): + return self._id + + +class LogItem: + def __init__(self, log_item_data, plugin_id, instance_id): + self._instance_id = instance_id + self._plugin_id = plugin_id + self._errored = log_item_data["type"] == "error" + self.data = log_item_data + + def __getitem__(self, key): + return self.data[key] + + @property + def errored(self): + return self._errored + + @property + def instance_id(self): + return self._instance_id + + @property + def plugin_id(self): + return self._plugin_id + + +class PublishReport: + def __init__(self, report_data): + data = copy.deepcopy(report_data) + + context_data = data["context"] + context_data["name"] = "context" + context_data["label"] = context_data["label"] or "Context" + + logs = [] + plugins_items_by_id = {} + plugins_id_order = [] + for plugin_data in data["plugins_data"]: + item = PluginItem(plugin_data) + plugins_id_order.append(item.id) + plugins_items_by_id[item.id] = item + for instance_data_item in plugin_data["instances_data"]: + instance_id = instance_data_item["id"] + for log_item_data in instance_data_item["logs"]: + log_item = LogItem( + copy.deepcopy(log_item_data), item.id, instance_id + ) + logs.append(log_item) + + logs_by_instance_id = collections.defaultdict(list) + for log_item in logs: + logs_by_instance_id[log_item.instance_id].append(log_item) + + instance_items_by_id = {} + instance_items_by_family = {} + context_item = InstanceItem(None, context_data, logs_by_instance_id) + instance_items_by_id[context_item.id] = context_item + instance_items_by_family[context_item.family] = [context_item] + + for instance_id, instance_data in data["instances"].items(): + item = InstanceItem( + instance_id, instance_data, logs_by_instance_id + ) + instance_items_by_id[item.id] = item + if item.family not in instance_items_by_family: + instance_items_by_family[item.family] = [] + instance_items_by_family[item.family].append(item) + + self.instance_items_by_id = instance_items_by_id + self.instance_items_by_family = instance_items_by_family + + self.plugins_id_order = plugins_id_order + self.plugins_items_by_id = plugins_items_by_id + + self.logs = logs + + self.crashed_plugin_paths = report_data["crashed_file_paths"] diff --git a/openpype/tools/publisher/publish_report_viewer/widgets.py b/openpype/tools/publisher/publish_report_viewer/widgets.py index 24f1d33d0e..0b17efb614 100644 --- a/openpype/tools/publisher/publish_report_viewer/widgets.py +++ b/openpype/tools/publisher/publish_report_viewer/widgets.py @@ -1,10 +1,8 @@ -import copy -import uuid - -from Qt import QtWidgets, QtCore +from Qt import QtWidgets, QtCore, QtGui from openpype.widgets.nice_checkbox import NiceCheckbox +# from openpype.tools.utils import DeselectableTreeView from .constants import ( ITEM_ID_ROLE, ITEM_IS_GROUP_ROLE @@ -16,98 +14,127 @@ from .model import ( PluginsModel, PluginProxyModel ) +from .report_items import PublishReport + +FILEPATH_ROLE = QtCore.Qt.UserRole + 1 +TRACEBACK_ROLE = QtCore.Qt.UserRole + 2 +IS_DETAIL_ITEM_ROLE = QtCore.Qt.UserRole + 3 -class PluginItem: - def __init__(self, plugin_data): - self._id = uuid.uuid4() +class PluginLoadReportModel(QtGui.QStandardItemModel): + def set_report(self, report): + parent = self.invisibleRootItem() + parent.removeRows(0, parent.rowCount()) - self.name = plugin_data["name"] - self.label = plugin_data["label"] - self.order = plugin_data["order"] - self.skipped = plugin_data["skipped"] - self.passed = plugin_data["passed"] + new_items = [] + new_items_by_filepath = {} + for filepath in report.crashed_plugin_paths.keys(): + item = QtGui.QStandardItem(filepath) + new_items.append(item) + new_items_by_filepath[filepath] = item - logs = [] - errored = False - for instance_data in plugin_data["instances_data"]: - for log_item in instance_data["logs"]: - if not errored: - errored = log_item["type"] == "error" - logs.append(copy.deepcopy(log_item)) + if not new_items: + return - self.errored = errored - self.logs = logs - - @property - def id(self): - return self._id + parent.appendRows(new_items) + for filepath, item in new_items_by_filepath.items(): + traceback_txt = report.crashed_plugin_paths[filepath] + detail_item = QtGui.QStandardItem() + detail_item.setData(filepath, FILEPATH_ROLE) + detail_item.setData(traceback_txt, TRACEBACK_ROLE) + detail_item.setData(True, IS_DETAIL_ITEM_ROLE) + item.appendRow(detail_item) -class InstanceItem: - def __init__(self, instance_id, instance_data, report_data): - self._id = instance_id - self.label = instance_data.get("label") or instance_data.get("name") - self.family = instance_data.get("family") - self.removed = not instance_data.get("exists", True) +class DetailWidget(QtWidgets.QTextEdit): + def __init__(self, text, *args, **kwargs): + super(DetailWidget, self).__init__(*args, **kwargs) - logs = [] - for plugin_data in report_data["plugins_data"]: - for instance_data_item in plugin_data["instances_data"]: - if instance_data_item["id"] == self._id: - logs.extend(copy.deepcopy(instance_data_item["logs"])) + self.setReadOnly(True) + self.setHtml(text) + self.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) + self.setWordWrapMode( + QtGui.QTextOption.WrapAtWordBoundaryOrAnywhere + ) - errored = False - for log in logs: - if log["type"] == "error": - errored = True - break - - self.errored = errored - self.logs = logs - - @property - def id(self): - return self._id + def sizeHint(self): + content_margins = ( + self.contentsMargins().top() + + self.contentsMargins().bottom() + ) + size = self.document().documentLayout().documentSize().toSize() + size.setHeight(size.height() + content_margins) + return size -class PublishReport: - def __init__(self, report_data): - data = copy.deepcopy(report_data) +class PluginLoadReportWidget(QtWidgets.QWidget): + def __init__(self, parent): + super(PluginLoadReportWidget, self).__init__(parent) - context_data = data["context"] - context_data["name"] = "context" - context_data["label"] = context_data["label"] or "Context" + view = QtWidgets.QTreeView(self) + view.setEditTriggers(view.NoEditTriggers) + view.setTextElideMode(QtCore.Qt.ElideLeft) + view.setHeaderHidden(True) + view.setAlternatingRowColors(True) + view.setVerticalScrollMode(view.ScrollPerPixel) - instance_items_by_id = {} - instance_items_by_family = {} - context_item = InstanceItem(None, context_data, data) - instance_items_by_id[context_item.id] = context_item - instance_items_by_family[context_item.family] = [context_item] + model = PluginLoadReportModel() + view.setModel(model) - for instance_id, instance_data in data["instances"].items(): - item = InstanceItem(instance_id, instance_data, data) - instance_items_by_id[item.id] = item - if item.family not in instance_items_by_family: - instance_items_by_family[item.family] = [] - instance_items_by_family[item.family].append(item) + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(view, 1) - all_logs = [] - plugins_items_by_id = {} - plugins_id_order = [] - for plugin_data in data["plugins_data"]: - item = PluginItem(plugin_data) - plugins_id_order.append(item.id) - plugins_items_by_id[item.id] = item - all_logs.extend(copy.deepcopy(item.logs)) + view.expanded.connect(self._on_expand) - self.instance_items_by_id = instance_items_by_id - self.instance_items_by_family = instance_items_by_family + self._view = view + self._model = model + self._widgets_by_filepath = {} - self.plugins_id_order = plugins_id_order - self.plugins_items_by_id = plugins_items_by_id + def _on_expand(self, index): + for row in range(self._model.rowCount(index)): + child_index = self._model.index(row, index.column(), index) + self._create_widget(child_index) - self.logs = all_logs + def showEvent(self, event): + super(PluginLoadReportWidget, self).showEvent(event) + self._update_widgets_size_hints() + + def resizeEvent(self, event): + super(PluginLoadReportWidget, self).resizeEvent(event) + self._update_widgets_size_hints() + + def _update_widgets_size_hints(self): + for item in self._widgets_by_filepath.values(): + widget, index = item + if not widget.isVisible(): + continue + self._model.setData( + index, widget.sizeHint(), QtCore.Qt.SizeHintRole + ) + + def _create_widget(self, index): + if not index.data(IS_DETAIL_ITEM_ROLE): + return + + filepath = index.data(FILEPATH_ROLE) + if filepath in self._widgets_by_filepath: + return + + traceback_txt = index.data(TRACEBACK_ROLE) + detail_text = ( + "Filepath:
" + "{}

" + "Traceback:
" + "{}" + ).format(filepath, traceback_txt.replace("\n", "
")) + widget = DetailWidget(detail_text, self) + self._view.setIndexWidget(index, widget) + self._widgets_by_filepath[filepath] = (widget, index) + + def set_report(self, report): + self._widgets_by_filepath = {} + self._model.set_report(report) class DetailsWidget(QtWidgets.QWidget): @@ -123,11 +150,50 @@ class DetailsWidget(QtWidgets.QWidget): layout.addWidget(output_widget) self._output_widget = output_widget + self._report_item = None + self._instance_filter = set() + self._plugin_filter = set() def clear(self): self._output_widget.setPlainText("") - def set_logs(self, logs): + def set_report(self, report): + self._report_item = report + self._plugin_filter = set() + self._instance_filter = set() + self._update_logs() + + def set_plugin_filter(self, plugin_filter): + self._plugin_filter = plugin_filter + self._update_logs() + + def set_instance_filter(self, instance_filter): + self._instance_filter = instance_filter + self._update_logs() + + def _update_logs(self): + if not self._report_item: + self._output_widget.setPlainText("") + return + + filtered_logs = [] + for log in self._report_item.logs: + if ( + self._instance_filter + and log.instance_id not in self._instance_filter + ): + continue + + if ( + self._plugin_filter + and log.plugin_id not in self._plugin_filter + ): + continue + filtered_logs.append(log) + + self._set_logs(filtered_logs) + + def _set_logs(self, logs): lines = [] for log in logs: if log["type"] == "record": @@ -148,6 +214,59 @@ class DetailsWidget(QtWidgets.QWidget): self._output_widget.setPlainText(text) +class DeselectableTreeView(QtWidgets.QTreeView): + """A tree view that deselects on clicking on an empty area in the view""" + + def mousePressEvent(self, event): + index = self.indexAt(event.pos()) + clear_selection = False + if not index.isValid(): + modifiers = QtWidgets.QApplication.keyboardModifiers() + if modifiers == QtCore.Qt.ShiftModifier: + return + elif modifiers == QtCore.Qt.ControlModifier: + return + clear_selection = True + else: + indexes = self.selectedIndexes() + if len(indexes) == 1 and index in indexes: + clear_selection = True + + if clear_selection: + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + event.accept() + return + + QtWidgets.QTreeView.mousePressEvent(self, event) + + +class DetailsPopup(QtWidgets.QDialog): + closed = QtCore.Signal() + + def __init__(self, parent, center_widget): + super(DetailsPopup, self).__init__(parent) + self.setWindowTitle("Report Details") + layout = QtWidgets.QHBoxLayout(self) + + self._center_widget = center_widget + self._first_show = True + + def showEvent(self, event): + layout = self.layout() + layout.insertWidget(0, self._center_widget) + super(DetailsPopup, self).showEvent(event) + if self._first_show: + self._first_show = False + self.resize(700, 400) + + def closeEvent(self, event): + super(DetailsPopup, self).closeEvent(event) + self.closed.emit() + + class PublishReportViewerWidget(QtWidgets.QWidget): def __init__(self, parent=None): super(PublishReportViewerWidget, self).__init__(parent) @@ -171,12 +290,13 @@ class PublishReportViewerWidget(QtWidgets.QWidget): removed_instances_layout.addWidget(removed_instances_check, 0) removed_instances_layout.addWidget(removed_instances_label, 1) - instances_view = QtWidgets.QTreeView(self) + instances_view = DeselectableTreeView(self) instances_view.setObjectName("PublishDetailViews") instances_view.setModel(instances_proxy) instances_view.setIndentation(0) instances_view.setHeaderHidden(True) instances_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers) + instances_view.setSelectionMode(QtWidgets.QTreeView.ExtendedSelection) instances_view.setExpandsOnDoubleClick(False) instances_delegate = GroupItemDelegate(instances_view) @@ -191,29 +311,49 @@ class PublishReportViewerWidget(QtWidgets.QWidget): skipped_plugins_layout.addWidget(skipped_plugins_check, 0) skipped_plugins_layout.addWidget(skipped_plugins_label, 1) - plugins_view = QtWidgets.QTreeView(self) + plugins_view = DeselectableTreeView(self) plugins_view.setObjectName("PublishDetailViews") plugins_view.setModel(plugins_proxy) plugins_view.setIndentation(0) plugins_view.setHeaderHidden(True) + plugins_view.setSelectionMode(QtWidgets.QTreeView.ExtendedSelection) plugins_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers) plugins_view.setExpandsOnDoubleClick(False) plugins_delegate = GroupItemDelegate(plugins_view) plugins_view.setItemDelegate(plugins_delegate) - details_widget = DetailsWidget(self) + details_widget = QtWidgets.QWidget(self) + details_tab_widget = QtWidgets.QTabWidget(details_widget) + details_popup_btn = QtWidgets.QPushButton("PopUp", details_widget) - layout = QtWidgets.QGridLayout(self) + details_layout = QtWidgets.QVBoxLayout(details_widget) + details_layout.setContentsMargins(0, 0, 0, 0) + details_layout.addWidget(details_tab_widget, 1) + details_layout.addWidget(details_popup_btn, 0) + + details_popup = DetailsPopup(self, details_tab_widget) + + logs_text_widget = DetailsWidget(details_tab_widget) + plugin_load_report_widget = PluginLoadReportWidget(details_tab_widget) + + details_tab_widget.addTab(logs_text_widget, "Logs") + details_tab_widget.addTab(plugin_load_report_widget, "Crashed plugins") + + middle_widget = QtWidgets.QWidget(self) + middle_layout = QtWidgets.QGridLayout(middle_widget) + middle_layout.setContentsMargins(0, 0, 0, 0) # Row 1 - layout.addLayout(removed_instances_layout, 0, 0) - layout.addLayout(skipped_plugins_layout, 0, 1) + middle_layout.addLayout(removed_instances_layout, 0, 0) + middle_layout.addLayout(skipped_plugins_layout, 0, 1) # Row 2 - layout.addWidget(instances_view, 1, 0) - layout.addWidget(plugins_view, 1, 1) - layout.addWidget(details_widget, 1, 2) + middle_layout.addWidget(instances_view, 1, 0) + middle_layout.addWidget(plugins_view, 1, 1) - layout.setColumnStretch(2, 1) + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(middle_widget, 0) + layout.addWidget(details_widget, 1) instances_view.selectionModel().selectionChanged.connect( self._on_instance_change @@ -230,10 +370,13 @@ class PublishReportViewerWidget(QtWidgets.QWidget): removed_instances_check.stateChanged.connect( self._on_removed_instances_check ) + details_popup_btn.clicked.connect(self._on_details_popup) + details_popup.closed.connect(self._on_popup_close) self._ignore_selection_changes = False self._report_item = None - self._details_widget = details_widget + self._logs_text_widget = logs_text_widget + self._plugin_load_report_widget = plugin_load_report_widget self._removed_instances_check = removed_instances_check self._instances_view = instances_view @@ -248,6 +391,10 @@ class PublishReportViewerWidget(QtWidgets.QWidget): self._plugins_model = plugins_model self._plugins_proxy = plugins_proxy + self._details_widget = details_widget + self._details_tab_widget = details_tab_widget + self._details_popup = details_popup + def _on_instance_view_clicked(self, index): if not index.isValid() or not index.data(ITEM_IS_GROUP_ROLE): return @@ -266,62 +413,46 @@ class PublishReportViewerWidget(QtWidgets.QWidget): else: self._plugins_view.expand(index) - def set_report(self, report_data): + def set_report_data(self, report_data): + report = PublishReport(report_data) + self.set_report(report) + + def set_report(self, report): self._ignore_selection_changes = True - report_item = PublishReport(report_data) - self._report_item = report_item + self._report_item = report - self._instances_model.set_report(report_item) - self._plugins_model.set_report(report_item) - self._details_widget.set_logs(report_item.logs) + self._instances_model.set_report(report) + self._plugins_model.set_report(report) + self._logs_text_widget.set_report(report) + self._plugin_load_report_widget.set_report(report) self._ignore_selection_changes = False + self._instances_view.expandAll() + self._plugins_view.expandAll() + def _on_instance_change(self, *_args): if self._ignore_selection_changes: return - valid_index = None + instance_ids = set() for index in self._instances_view.selectedIndexes(): if index.isValid(): - valid_index = index - break + instance_ids.add(index.data(ITEM_ID_ROLE)) - if valid_index is None: - return - - if self._plugins_view.selectedIndexes(): - self._ignore_selection_changes = True - self._plugins_view.selectionModel().clearSelection() - self._ignore_selection_changes = False - - plugin_id = valid_index.data(ITEM_ID_ROLE) - instance_item = self._report_item.instance_items_by_id[plugin_id] - self._details_widget.set_logs(instance_item.logs) + self._logs_text_widget.set_instance_filter(instance_ids) def _on_plugin_change(self, *_args): if self._ignore_selection_changes: return - valid_index = None + plugin_ids = set() for index in self._plugins_view.selectedIndexes(): if index.isValid(): - valid_index = index - break + plugin_ids.add(index.data(ITEM_ID_ROLE)) - if valid_index is None: - self._details_widget.set_logs(self._report_item.logs) - return - - if self._instances_view.selectedIndexes(): - self._ignore_selection_changes = True - self._instances_view.selectionModel().clearSelection() - self._ignore_selection_changes = False - - plugin_id = valid_index.data(ITEM_ID_ROLE) - plugin_item = self._report_item.plugins_items_by_id[plugin_id] - self._details_widget.set_logs(plugin_item.logs) + self._logs_text_widget.set_plugin_filter(plugin_ids) def _on_skipped_plugin_check(self): self._plugins_proxy.set_ignore_skipped( @@ -332,3 +463,16 @@ class PublishReportViewerWidget(QtWidgets.QWidget): self._instances_proxy.set_ignore_removed( self._removed_instances_check.isChecked() ) + + def _on_details_popup(self): + self._details_widget.setVisible(False) + self._details_popup.show() + + def _on_popup_close(self): + self._details_widget.setVisible(True) + layout = self._details_widget.layout() + layout.insertWidget(0, self._details_tab_widget) + + def close_details_popup(self): + if self._details_popup.isVisible(): + self._details_popup.close() diff --git a/openpype/tools/publisher/publish_report_viewer/window.py b/openpype/tools/publisher/publish_report_viewer/window.py index 7a0fef7d91..8ca075e4d2 100644 --- a/openpype/tools/publisher/publish_report_viewer/window.py +++ b/openpype/tools/publisher/publish_report_viewer/window.py @@ -1,29 +1,355 @@ -from Qt import QtWidgets +import os +import json +import six +import appdirs +from Qt import QtWidgets, QtCore, QtGui from openpype import style +from openpype.lib import JSONSettingRegistry +from openpype.resources import get_openpype_icon_filepath +from openpype.tools import resources +from openpype.tools.utils import ( + IconButton, + paint_image_with_color +) + +from openpype.tools.utils.delegates import PrettyTimeDelegate + if __package__: from .widgets import PublishReportViewerWidget + from .report_items import PublishReport else: from widgets import PublishReportViewerWidget + from report_items import PublishReport + + +FILEPATH_ROLE = QtCore.Qt.UserRole + 1 +MODIFIED_ROLE = QtCore.Qt.UserRole + 2 + + +class PublisherReportRegistry(JSONSettingRegistry): + """Class handling storing publish report tool. + + Attributes: + vendor (str): Name used for path construction. + product (str): Additional name used for path construction. + + """ + + def __init__(self): + self.vendor = "pypeclub" + self.product = "openpype" + name = "publish_report_viewer" + path = appdirs.user_data_dir(self.product, self.vendor) + super(PublisherReportRegistry, self).__init__(name, path) + + +class LoadedFilesMopdel(QtGui.QStandardItemModel): + def __init__(self, *args, **kwargs): + super(LoadedFilesMopdel, self).__init__(*args, **kwargs) + self.setColumnCount(2) + self._items_by_filepath = {} + self._reports_by_filepath = {} + + self._registry = PublisherReportRegistry() + + self._loading_registry = False + self._load_registry() + + def headerData(self, section, orientation, role): + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + if section == 0: + return "Exports" + if section == 1: + return "Modified" + return "" + super(LoadedFilesMopdel, self).headerData(section, orientation, role) + + def _load_registry(self): + self._loading_registry = True + try: + filepaths = self._registry.get_item("filepaths") + self.add_filepaths(filepaths) + except ValueError: + pass + self._loading_registry = False + + def _store_registry(self): + if self._loading_registry: + return + filepaths = list(self._items_by_filepath.keys()) + self._registry.set_item("filepaths", filepaths) + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + col = index.column() + if col != 0: + index = self.index(index.row(), 0, index.parent()) + + if role == QtCore.Qt.ToolTipRole: + if col == 0: + role = FILEPATH_ROLE + elif col == 1: + return "File modified" + return None + + elif role == QtCore.Qt.DisplayRole: + if col == 1: + role = MODIFIED_ROLE + return super(LoadedFilesMopdel, self).data(index, role) + + def add_filepaths(self, filepaths): + if not filepaths: + return + + if isinstance(filepaths, six.string_types): + filepaths = [filepaths] + + filtered_paths = [] + for filepath in filepaths: + normalized_path = os.path.normpath(filepath) + if normalized_path in self._items_by_filepath: + continue + + if ( + os.path.exists(normalized_path) + and normalized_path not in filtered_paths + ): + filtered_paths.append(normalized_path) + + if not filtered_paths: + return + + new_items = [] + for filepath in filtered_paths: + try: + with open(normalized_path, "r") as stream: + data = json.load(stream) + report = PublishReport(data) + except Exception as exc: + # TODO handle errors + continue + + modified = os.path.getmtime(normalized_path) + item = QtGui.QStandardItem(os.path.basename(normalized_path)) + item.setColumnCount(self.columnCount()) + item.setData(normalized_path, FILEPATH_ROLE) + item.setData(modified, MODIFIED_ROLE) + new_items.append(item) + self._items_by_filepath[normalized_path] = item + self._reports_by_filepath[normalized_path] = report + + if not new_items: + return + + parent = self.invisibleRootItem() + parent.appendRows(new_items) + + self._store_registry() + + def remove_filepaths(self, filepaths): + if not filepaths: + return + + if isinstance(filepaths, six.string_types): + filepaths = [filepaths] + + filtered_paths = [] + for filepath in filepaths: + normalized_path = os.path.normpath(filepath) + if normalized_path in self._items_by_filepath: + filtered_paths.append(normalized_path) + + if not filtered_paths: + return + + parent = self.invisibleRootItem() + for filepath in filtered_paths: + self._reports_by_filepath.pop(normalized_path) + item = self._items_by_filepath.pop(filepath) + parent.removeRow(item.row()) + + self._store_registry() + + def get_report_by_filepath(self, filepath): + return self._reports_by_filepath.get(filepath) + + +class LoadedFilesView(QtWidgets.QTreeView): + selection_changed = QtCore.Signal() + + def __init__(self, *args, **kwargs): + super(LoadedFilesView, self).__init__(*args, **kwargs) + self.setEditTriggers(self.NoEditTriggers) + self.setIndentation(0) + self.setAlternatingRowColors(True) + + model = LoadedFilesMopdel() + self.setModel(model) + + time_delegate = PrettyTimeDelegate() + self.setItemDelegateForColumn(1, time_delegate) + + remove_btn = IconButton(self) + remove_icon_path = resources.get_icon_path("delete") + loaded_remove_image = QtGui.QImage(remove_icon_path) + pix = paint_image_with_color(loaded_remove_image, QtCore.Qt.white) + icon = QtGui.QIcon(pix) + remove_btn.setIcon(icon) + + model.rowsInserted.connect(self._on_rows_inserted) + remove_btn.clicked.connect(self._on_remove_clicked) + self.selectionModel().selectionChanged.connect( + self._on_selection_change + ) + + self._model = model + self._time_delegate = time_delegate + self._remove_btn = remove_btn + + def _update_remove_btn(self): + viewport = self.viewport() + height = viewport.height() + self.header().height() + pos_x = viewport.width() - self._remove_btn.width() - 5 + pos_y = height - self._remove_btn.height() - 5 + self._remove_btn.move(max(0, pos_x), max(0, pos_y)) + + def _on_rows_inserted(self): + header = self.header() + header.resizeSections(header.ResizeToContents) + + def resizeEvent(self, event): + super(LoadedFilesView, self).resizeEvent(event) + self._update_remove_btn() + + def showEvent(self, event): + super(LoadedFilesView, self).showEvent(event) + self._update_remove_btn() + header = self.header() + header.resizeSections(header.ResizeToContents) + + def _on_selection_change(self): + self.selection_changed.emit() + + def add_filepaths(self, filepaths): + self._model.add_filepaths(filepaths) + self._fill_selection() + + def remove_filepaths(self, filepaths): + self._model.remove_filepaths(filepaths) + self._fill_selection() + + def _on_remove_clicked(self): + index = self.currentIndex() + filepath = index.data(FILEPATH_ROLE) + self.remove_filepaths(filepath) + + def _fill_selection(self): + index = self.currentIndex() + if index.isValid(): + return + + index = self._model.index(0, 0) + if index.isValid(): + self.setCurrentIndex(index) + + def get_current_report(self): + index = self.currentIndex() + filepath = index.data(FILEPATH_ROLE) + return self._model.get_report_by_filepath(filepath) + + +class LoadedFilesWidget(QtWidgets.QWidget): + report_changed = QtCore.Signal() + + def __init__(self, parent): + super(LoadedFilesWidget, self).__init__(parent) + + self.setAcceptDrops(True) + + view = LoadedFilesView(self) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(view, 1) + + view.selection_changed.connect(self._on_report_change) + + self._view = view + + def dragEnterEvent(self, event): + mime_data = event.mimeData() + if mime_data.hasUrls(): + event.setDropAction(QtCore.Qt.CopyAction) + event.accept() + + def dragLeaveEvent(self, event): + event.accept() + + def dropEvent(self, event): + mime_data = event.mimeData() + if mime_data.hasUrls(): + filepaths = [] + for url in mime_data.urls(): + filepath = url.toLocalFile() + ext = os.path.splitext(filepath)[-1] + if os.path.exists(filepath) and ext == ".json": + filepaths.append(filepath) + self._add_filepaths(filepaths) + event.accept() + + def _on_report_change(self): + self.report_changed.emit() + + def _add_filepaths(self, filepaths): + self._view.add_filepaths(filepaths) + + def get_current_report(self): + return self._view.get_current_report() class PublishReportViewerWindow(QtWidgets.QWidget): - # TODO add buttons to be able load report file or paste content of report default_width = 1200 default_height = 600 def __init__(self, parent=None): super(PublishReportViewerWindow, self).__init__(parent) + self.setWindowTitle("Publish report viewer") + icon = QtGui.QIcon(get_openpype_icon_filepath()) + self.setWindowIcon(icon) - main_widget = PublishReportViewerWidget(self) + body = QtWidgets.QSplitter(self) + body.setContentsMargins(0, 0, 0, 0) + body.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding + ) + body.setOrientation(QtCore.Qt.Horizontal) + + loaded_files_widget = LoadedFilesWidget(body) + main_widget = PublishReportViewerWidget(body) + + body.addWidget(loaded_files_widget) + body.addWidget(main_widget) + body.setStretchFactor(0, 70) + body.setStretchFactor(1, 65) layout = QtWidgets.QHBoxLayout(self) - layout.addWidget(main_widget) + layout.addWidget(body, 1) + loaded_files_widget.report_changed.connect(self._on_report_change) + + self._loaded_files_widget = loaded_files_widget self._main_widget = main_widget self.resize(self.default_width, self.default_height) self.setStyleSheet(style.load_stylesheet()) + def _on_report_change(self): + report = self._loaded_files_widget.get_current_report() + self.set_report(report) + def set_report(self, report_data): self._main_widget.set_report(report_data) From ae4c7a3ab4357183029bbc6d3bb9894987b6f07a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 16:58:01 +0100 Subject: [PATCH 156/483] fixed few issues in publisher ui --- openpype/tools/publisher/control.py | 15 +- .../tools/publisher/widgets/create_dialog.py | 28 +++- .../tools/publisher/widgets/publish_widget.py | 6 +- .../publisher/widgets/validations_widget.py | 139 ++++++++++++++---- openpype/tools/publisher/widgets/widgets.py | 16 +- openpype/tools/publisher/window.py | 5 +- 6 files changed, 162 insertions(+), 47 deletions(-) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index 2ce0eaad62..ab2dffd489 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -42,18 +42,23 @@ class MainThreadProcess(QtCore.QObject): This approach gives ability to update UI meanwhile plugin is in progress. """ - timer_interval = 3 + count_timeout = 2 def __init__(self): super(MainThreadProcess, self).__init__() self._items_to_process = collections.deque() timer = QtCore.QTimer() - timer.setInterval(self.timer_interval) + timer.setInterval(0) timer.timeout.connect(self._execute) self._timer = timer + self._switch_counter = self.count_timeout + + def process(self, func, *args, **kwargs): + item = MainThreadItem(func, *args, **kwargs) + self.add_item(item) def add_item(self, item): self._items_to_process.append(item) @@ -62,6 +67,12 @@ class MainThreadProcess(QtCore.QObject): if not self._items_to_process: return + if self._switch_counter > 0: + self._switch_counter -= 1 + return + + self._switch_counter = self.count_timeout + item = self._items_to_process.popleft() item.process() diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index f9f8310e09..c5b77eca8b 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -174,6 +174,8 @@ class CreatorDescriptionWidget(QtWidgets.QWidget): class CreateDialog(QtWidgets.QDialog): + default_size = (900, 500) + def __init__( self, controller, asset_name=None, task_name=None, parent=None ): @@ -262,11 +264,16 @@ class CreateDialog(QtWidgets.QDialog): mid_layout.addLayout(form_layout, 0) mid_layout.addWidget(create_btn, 0) + splitter_widget = QtWidgets.QSplitter(self) + splitter_widget.addWidget(context_widget) + splitter_widget.addWidget(mid_widget) + splitter_widget.addWidget(pre_create_widget) + splitter_widget.setStretchFactor(0, 1) + splitter_widget.setStretchFactor(1, 1) + splitter_widget.setStretchFactor(2, 1) + layout = QtWidgets.QHBoxLayout(self) - layout.setSpacing(10) - layout.addWidget(context_widget, 1) - layout.addWidget(mid_widget, 1) - layout.addWidget(pre_create_widget, 1) + layout.addWidget(splitter_widget, 1) prereq_timer = QtCore.QTimer() prereq_timer.setInterval(50) @@ -289,6 +296,8 @@ class CreateDialog(QtWidgets.QDialog): controller.add_plugins_refresh_callback(self._on_plugins_refresh) + self._splitter_widget = splitter_widget + self._pre_create_widget = pre_create_widget self._context_widget = context_widget @@ -308,6 +317,7 @@ class CreateDialog(QtWidgets.QDialog): self.create_btn = create_btn self._prereq_timer = prereq_timer + self._first_show = True def _context_change_is_enabled(self): return self._context_widget.isEnabled() @@ -643,6 +653,16 @@ class CreateDialog(QtWidgets.QDialog): def showEvent(self, event): super(CreateDialog, self).showEvent(event) + if self._first_show: + self._first_show = False + width, height = self.default_size + self.resize(width, height) + + third_size = int(width / 3) + self._splitter_widget.setSizes( + [third_size, third_size, width - (2 * third_size)] + ) + if self._last_pos is not None: self.move(self._last_pos) diff --git a/openpype/tools/publisher/widgets/publish_widget.py b/openpype/tools/publisher/widgets/publish_widget.py index e4f3579978..80d0265dd3 100644 --- a/openpype/tools/publisher/widgets/publish_widget.py +++ b/openpype/tools/publisher/widgets/publish_widget.py @@ -213,7 +213,6 @@ class PublishFrame(QtWidgets.QFrame): close_report_btn.setIcon(close_report_icon) details_layout = QtWidgets.QVBoxLayout(details_widget) - details_layout.setContentsMargins(0, 0, 0, 0) details_layout.addWidget(report_view) details_layout.addWidget(close_report_btn) @@ -495,10 +494,11 @@ class PublishFrame(QtWidgets.QFrame): def _on_show_details(self): self._change_bg_property(2) self._main_layout.setCurrentWidget(self._details_widget) - logs = self.controller.get_publish_report() - self._report_view.set_report(logs) + report_data = self.controller.get_publish_report() + self._report_view.set_report_data(report_data) def _on_close_report_clicked(self): + self._report_view.close_details_popup() if self.controller.get_publish_crash_error(): self._change_bg_property() diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index bb88e1783c..798c1f9d92 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -10,6 +10,9 @@ from openpype.tools.utils import BaseClickableFrame from .widgets import ( IconValuePixmapLabel ) +from ..constants import ( + INSTANCE_ID_ROLE +) class ValidationErrorInstanceList(QtWidgets.QListView): @@ -22,19 +25,20 @@ class ValidationErrorInstanceList(QtWidgets.QListView): self.setObjectName("ValidationErrorInstanceList") + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setSelectionMode(QtWidgets.QListView.ExtendedSelection) def minimumSizeHint(self): - result = super(ValidationErrorInstanceList, self).minimumSizeHint() - result.setHeight(self.sizeHint().height()) - return result + return self.sizeHint() def sizeHint(self): + result = super(ValidationErrorInstanceList, self).sizeHint() row_count = self.model().rowCount() height = 0 if row_count > 0: height = self.sizeHintForRow(0) * row_count - return QtCore.QSize(self.width(), height) + result.setHeight(height) + return result class ValidationErrorTitleWidget(QtWidgets.QWidget): @@ -47,6 +51,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): if there is a list (Valdation error may happen on context). """ selected = QtCore.Signal(int) + instance_changed = QtCore.Signal(int) def __init__(self, index, error_info, parent): super(ValidationErrorTitleWidget, self).__init__(parent) @@ -64,32 +69,38 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) toggle_instance_btn.setMaximumWidth(14) - exception = error_info["exception"] - label_widget = QtWidgets.QLabel(exception.title, title_frame) + label_widget = QtWidgets.QLabel(error_info["title"], title_frame) title_frame_layout = QtWidgets.QHBoxLayout(title_frame) title_frame_layout.addWidget(toggle_instance_btn) title_frame_layout.addWidget(label_widget) instances_model = QtGui.QStandardItemModel() - instances = error_info["instances"] + error_info = error_info["error_info"] + + help_text_by_instance_id = {} context_validation = False if ( - not instances - or (len(instances) == 1 and instances[0] is None) + not error_info + or (len(error_info) == 1 and error_info[0][0] is None) ): context_validation = True toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow) + description = self._prepare_description(error_info[0][1]) + help_text_by_instance_id[None] = description else: items = [] - for instance in instances: + for instance, exception in error_info: label = instance.data.get("label") or instance.data.get("name") item = QtGui.QStandardItem(label) item.setFlags( QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable ) - item.setData(instance.id) + item.setData(label, QtCore.Qt.ToolTipRole) + item.setData(instance.id, INSTANCE_ID_ROLE) items.append(item) + description = self._prepare_description(exception) + help_text_by_instance_id[instance.id] = description instances_model.invisibleRootItem().appendRows(items) @@ -114,17 +125,64 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): if not context_validation: toggle_instance_btn.clicked.connect(self._on_toggle_btn_click) + instances_view.selectionModel().selectionChanged.connect( + self._on_seleciton_change + ) + self._title_frame = title_frame self._toggle_instance_btn = toggle_instance_btn + self._view_layout = view_layout + self._instances_model = instances_model self._instances_view = instances_view + self._context_validation = context_validation + self._help_text_by_instance_id = help_text_by_instance_id + + def sizeHint(self): + result = super().sizeHint() + expected_width = 0 + for idx in range(self._view_layout.count()): + expected_width += self._view_layout.itemAt(idx).sizeHint().width() + + if expected_width < 200: + expected_width = 200 + + if result.width() < expected_width: + result.setWidth(expected_width) + return result + + def minimumSizeHint(self): + return self.sizeHint() + + def _prepare_description(self, exception): + dsc = exception.description + detail = exception.detail + if detail: + dsc += "

{}".format(detail) + + description = dsc + if commonmark: + description = commonmark.commonmark(dsc) + return description + def _mouse_release_callback(self): """Mark this widget as selected on click.""" self.set_selected(True) + def current_desctiption_text(self): + if self._context_validation: + return self._help_text_by_instance_id[None] + index = self._instances_view.currentIndex() + # TODO make sure instance is selected + if not index.isValid(): + index = self._instances_model.index(0, 0) + + indence_id = index.data(INSTANCE_ID_ROLE) + return self._help_text_by_instance_id[indence_id] + @property def is_selected(self): """Is widget marked a selected""" @@ -167,6 +225,9 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): else: self._toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) + def _on_seleciton_change(self): + self.instance_changed.emit(self._index) + class ActionButton(BaseClickableFrame): """Plugin's action callback button. @@ -185,13 +246,15 @@ class ActionButton(BaseClickableFrame): action_label = action.label or action.__name__ action_icon = getattr(action, "icon", None) label_widget = QtWidgets.QLabel(action_label, self) + icon_label = None if action_icon: icon_label = IconValuePixmapLabel(action_icon, self) layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(5, 0, 5, 0) layout.addWidget(label_widget, 1) - layout.addWidget(icon_label, 0) + if icon_label: + layout.addWidget(icon_label, 0) self.setSizePolicy( QtWidgets.QSizePolicy.Minimum, @@ -231,6 +294,7 @@ class ValidateActionsWidget(QtWidgets.QFrame): item = self._content_layout.takeAt(0) widget = item.widget() if widget: + widget.setVisible(False) widget.deleteLater() self._actions_mapping = {} @@ -363,24 +427,23 @@ class ValidationsWidget(QtWidgets.QWidget): errors_scroll.setWidgetResizable(True) errors_widget = QtWidgets.QWidget(errors_scroll) - errors_widget.setFixedWidth(200) errors_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) errors_layout = QtWidgets.QVBoxLayout(errors_widget) errors_layout.setContentsMargins(0, 0, 0, 0) errors_scroll.setWidget(errors_widget) - error_details_widget = QtWidgets.QWidget(self) - error_details_input = QtWidgets.QTextEdit(error_details_widget) + error_details_frame = QtWidgets.QFrame(self) + error_details_input = QtWidgets.QTextEdit(error_details_frame) error_details_input.setObjectName("InfoText") error_details_input.setTextInteractionFlags( QtCore.Qt.TextBrowserInteraction ) actions_widget = ValidateActionsWidget(controller, self) - actions_widget.setFixedWidth(140) + actions_widget.setMinimumWidth(140) - error_details_layout = QtWidgets.QHBoxLayout(error_details_widget) + error_details_layout = QtWidgets.QHBoxLayout(error_details_frame) error_details_layout.addWidget(error_details_input, 1) error_details_layout.addWidget(actions_widget, 0) @@ -389,7 +452,7 @@ class ValidationsWidget(QtWidgets.QWidget): content_layout.setContentsMargins(0, 0, 0, 0) content_layout.addWidget(errors_scroll, 0) - content_layout.addWidget(error_details_widget, 1) + content_layout.addWidget(error_details_frame, 1) top_label = QtWidgets.QLabel("Publish validation report", self) top_label.setObjectName("PublishInfoMainLabel") @@ -403,7 +466,7 @@ class ValidationsWidget(QtWidgets.QWidget): self._top_label = top_label self._errors_widget = errors_widget self._errors_layout = errors_layout - self._error_details_widget = error_details_widget + self._error_details_frame = error_details_frame self._error_details_input = error_details_input self._actions_widget = actions_widget @@ -423,7 +486,7 @@ class ValidationsWidget(QtWidgets.QWidget): widget.deleteLater() self._top_label.setVisible(False) - self._error_details_widget.setVisible(False) + self._error_details_frame.setVisible(False) self._errors_widget.setVisible(False) self._actions_widget.setVisible(False) @@ -434,34 +497,35 @@ class ValidationsWidget(QtWidgets.QWidget): return self._top_label.setVisible(True) - self._error_details_widget.setVisible(True) + self._error_details_frame.setVisible(True) self._errors_widget.setVisible(True) errors_by_title = [] for plugin_info in errors: titles = [] - exception_by_title = {} - instances_by_title = {} + error_info_by_title = {} for error_info in plugin_info["errors"]: exception = error_info["exception"] title = exception.title if title not in titles: titles.append(title) - instances_by_title[title] = [] - exception_by_title[title] = exception - instances_by_title[title].append(error_info["instance"]) + error_info_by_title[title] = [] + error_info_by_title[title].append( + (error_info["instance"], exception) + ) for title in titles: errors_by_title.append({ "plugin": plugin_info["plugin"], - "exception": exception_by_title[title], - "instances": instances_by_title[title] + "error_info": error_info_by_title[title], + "title": title }) for idx, item in enumerate(errors_by_title): widget = ValidationErrorTitleWidget(idx, item, self) widget.selected.connect(self._on_select) + widget.instance_changed.connect(self._on_instance_change) self._errors_layout.addWidget(widget) self._title_widgets[idx] = widget self._error_info[idx] = item @@ -471,6 +535,8 @@ class ValidationsWidget(QtWidgets.QWidget): if self._title_widgets: self._title_widgets[0].set_selected(True) + self.updateGeometry() + def _on_select(self, index): if self._previous_select: if self._previous_select.index == index: @@ -481,10 +547,19 @@ class ValidationsWidget(QtWidgets.QWidget): error_item = self._error_info[index] - dsc = error_item["exception"].description + self._actions_widget.set_plugin(error_item["plugin"]) + + self._update_description() + + def _on_instance_change(self, index): + if self._previous_select and self._previous_select.index != index: + return + self._update_description() + + def _update_description(self): + description = self._previous_select.current_desctiption_text() if commonmark: - html = commonmark.commonmark(dsc) + html = commonmark.commonmark(description) self._error_details_input.setHtml(html) else: - self._error_details_input.setMarkdown(dsc) - self._actions_widget.set_plugin(error_item["plugin"]) + self._error_details_input.setMarkdown(description) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index a63258efb7..fb1f0e54aa 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -535,6 +535,7 @@ class TasksCombobox(QtWidgets.QComboBox): return self._text = text + self.repaint() def paintEvent(self, event): """Paint custom text without using QLineEdit. @@ -548,6 +549,7 @@ class TasksCombobox(QtWidgets.QComboBox): self.initStyleOption(opt) if self._text is not None: opt.currentText = self._text + style = self.style() style.drawComplexControl( QtWidgets.QStyle.CC_ComboBox, opt, painter, self @@ -609,11 +611,15 @@ class TasksCombobox(QtWidgets.QComboBox): if self._selected_items: is_valid = True + valid_task_names = [] for task_name in self._selected_items: - is_valid = self._model.is_task_name_valid(asset_name, task_name) - if not is_valid: - break + _is_valid = self._model.is_task_name_valid(asset_name, task_name) + if _is_valid: + valid_task_names.append(task_name) + else: + is_valid = _is_valid + self._selected_items = valid_task_names if len(self._selected_items) == 0: self.set_selected_item("") @@ -625,6 +631,7 @@ class TasksCombobox(QtWidgets.QComboBox): if multiselection_text is None: multiselection_text = "|".join(self._selected_items) self.set_selected_item(multiselection_text) + self._set_is_valid(is_valid) def set_selected_items(self, asset_task_combinations=None): @@ -708,8 +715,7 @@ class TasksCombobox(QtWidgets.QComboBox): idx = self.findText(item_name) # Set current index (must be set to -1 if is invalid) self.setCurrentIndex(idx) - if idx < 0: - self.set_text(item_name) + self.set_text(item_name) def reset_to_origin(self): """Change to task names set with last `set_selected_items` call.""" diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index 642bd17589..b74e95b227 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -84,7 +84,7 @@ class PublisherWindow(QtWidgets.QDialog): # Content # Subset widget - subset_frame = QtWidgets.QWidget(self) + subset_frame = QtWidgets.QFrame(self) subset_views_widget = BorderedLabelWidget( "Subsets to publish", subset_frame @@ -225,6 +225,9 @@ class PublisherWindow(QtWidgets.QDialog): controller.add_publish_validated_callback(self._on_publish_validated) controller.add_publish_stopped_callback(self._on_publish_stop) + # Store header for TrayPublisher + self._header_layout = header_layout + self.content_stacked_layout = content_stacked_layout self.publish_frame = publish_frame self.subset_frame = subset_frame From 0f3879e41c2218889ecae000249c56772dfc999d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:03:30 +0100 Subject: [PATCH 157/483] base of tray publisher tool --- openpype/style/style.css | 6 + openpype/tools/traypublisher/__init__.py | 6 + openpype/tools/traypublisher/window.py | 148 +++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 openpype/tools/traypublisher/__init__.py create mode 100644 openpype/tools/traypublisher/window.py diff --git a/openpype/style/style.css b/openpype/style/style.css index c96e87aa02..ba40b780ab 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -1261,6 +1261,12 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: {color:restart-btn-bg}; } +/* Tray publisher */ +#ChooseProjectLabel { + font-size: 15pt; + font-weight: 750; +} + /* Globally used names */ #Separator { background: {color:bg-menu-separator}; diff --git a/openpype/tools/traypublisher/__init__.py b/openpype/tools/traypublisher/__init__.py new file mode 100644 index 0000000000..188a234a9e --- /dev/null +++ b/openpype/tools/traypublisher/__init__.py @@ -0,0 +1,6 @@ +from .window import main + + +__all__ = ( + "main", +) diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py new file mode 100644 index 0000000000..d6a5fe56f8 --- /dev/null +++ b/openpype/tools/traypublisher/window.py @@ -0,0 +1,148 @@ +from Qt import QtWidgets, QtCore + +import avalon.api +from avalon import io +from avalon.api import AvalonMongoDB +from openpype.tools.publisher import PublisherWindow +from openpype.tools.utils.constants import PROJECT_NAME_ROLE +from openpype.tools.utils.models import ( + ProjectModel, + ProjectSortFilterProxy +) + + +class StandaloneOverlayWidget(QtWidgets.QFrame): + project_selected = QtCore.Signal(str) + + def __init__(self, publisher_window): + super(StandaloneOverlayWidget, self).__init__(publisher_window) + self.setObjectName("OverlayFrame") + + # Create db connection for projects model + dbcon = AvalonMongoDB() + dbcon.install() + + header_label = QtWidgets.QLabel("Choose project", self) + header_label.setObjectName("ChooseProjectLabel") + # Create project models and view + projects_model = ProjectModel(dbcon) + projects_proxy = ProjectSortFilterProxy() + projects_proxy.setSourceModel(projects_model) + + projects_view = QtWidgets.QListView(self) + projects_view.setModel(projects_proxy) + projects_view.setEditTriggers( + QtWidgets.QAbstractItemView.NoEditTriggers + ) + + confirm_btn = QtWidgets.QPushButton("Choose", self) + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(confirm_btn, 0) + + layout = QtWidgets.QGridLayout(self) + layout.addWidget(header_label, 0, 1, alignment=QtCore.Qt.AlignCenter) + layout.addWidget(projects_view, 1, 1) + layout.addLayout(btns_layout, 2, 1) + layout.setColumnStretch(0, 1) + layout.setColumnStretch(1, 0) + layout.setColumnStretch(2, 1) + layout.setRowStretch(0, 0) + layout.setRowStretch(1, 1) + layout.setRowStretch(2, 0) + + projects_view.doubleClicked.connect(self._on_double_click) + confirm_btn.clicked.connect(self._on_confirm_click) + + self._projects_view = projects_view + self._projects_model = projects_model + self._confirm_btn = confirm_btn + + self._publisher_window = publisher_window + + def showEvent(self, event): + self._projects_model.refresh() + super(StandaloneOverlayWidget, self).showEvent(event) + + def _on_double_click(self): + self.set_selected_project() + + def _on_confirm_click(self): + self.set_selected_project() + + def set_selected_project(self): + index = self._projects_view.currentIndex() + + project_name = index.data(PROJECT_NAME_ROLE) + if not project_name: + return + + traypublisher.set_project_name(project_name) + self.setVisible(False) + self.project_selected.emit(project_name) + + +class TrayPublishWindow(PublisherWindow): + def __init__(self, *args, **kwargs): + super(TrayPublishWindow, self).__init__(reset_on_show=False) + + overlay_widget = StandaloneOverlayWidget(self) + + btns_widget = QtWidgets.QWidget(self) + + back_to_overlay_btn = QtWidgets.QPushButton( + "Change project", btns_widget + ) + save_btn = QtWidgets.QPushButton("Save", btns_widget) + # TODO implement save mechanism of tray publisher + save_btn.setVisible(False) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + + btns_layout.addWidget(save_btn, 0) + btns_layout.addWidget(back_to_overlay_btn, 0) + + self._header_layout.addWidget(btns_widget, 0) + + overlay_widget.project_selected.connect(self._on_project_select) + back_to_overlay_btn.clicked.connect(self._on_back_to_overlay) + save_btn.clicked.connect(self._on_tray_publish_save) + + self._back_to_overlay_btn = back_to_overlay_btn + self._overlay_widget = overlay_widget + + def _on_back_to_overlay(self): + self._overlay_widget.setVisible(True) + self._resize_overlay() + + def _resize_overlay(self): + self._overlay_widget.resize( + self.width(), + self.height() + ) + + def resizeEvent(self, event): + super(TrayPublishWindow, self).resizeEvent(event) + self._resize_overlay() + + def _on_project_select(self, project_name): + self.controller.save_changes() + self.controller.reset_project_data_cache() + io.Session["AVALON_PROJECT"] = project_name + io.install() + + self.reset() + if not self.controller.instances: + self._on_create_clicked() + + def _on_tray_publish_save(self): + self.controller.save_changes() + print("NOT YET IMPLEMENTED") + + +def main(): + app = QtWidgets.QApplication([]) + window = TrayPublishWindow() + window.show() + app.exec_() From 57e1af7ba8677da789e6c822ea62524a53cb3b66 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:09:28 +0100 Subject: [PATCH 158/483] changed 'uuid' to 'instance_id' as 'uuid' may not work in maya --- openpype/hosts/testhost/api/instances.json | 12 ++++++------ openpype/hosts/testhost/api/pipeline.py | 6 +++--- openpype/pipeline/create/README.md | 4 ++-- openpype/pipeline/create/context.py | 8 ++++---- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/testhost/api/instances.json b/openpype/hosts/testhost/api/instances.json index 84021eff91..d955012514 100644 --- a/openpype/hosts/testhost/api/instances.json +++ b/openpype/hosts/testhost/api/instances.json @@ -8,7 +8,7 @@ "asset": "sq01_sh0010", "task": "Compositing", "variant": "myVariant", - "uuid": "a485f148-9121-46a5-8157-aa64df0fb449", + "instance_id": "a485f148-9121-46a5-8157-aa64df0fb449", "creator_attributes": { "number_key": 10, "ha": 10 @@ -29,8 +29,8 @@ "asset": "sq01_sh0010", "task": "Compositing", "variant": "myVariant2", - "uuid": "a485f148-9121-46a5-8157-aa64df0fb444", "creator_attributes": {}, + "instance_id": "a485f148-9121-46a5-8157-aa64df0fb444", "publish_attributes": { "CollectFtrackApi": { "add_ftrack_family": true @@ -47,8 +47,8 @@ "asset": "sq01_sh0010", "task": "Compositing", "variant": "Main", - "uuid": "3607bc95-75f6-4648-a58d-e699f413d09f", "creator_attributes": {}, + "instance_id": "3607bc95-75f6-4648-a58d-e699f413d09f", "publish_attributes": { "CollectFtrackApi": { "add_ftrack_family": true @@ -65,7 +65,7 @@ "asset": "sq01_sh0020", "task": "Compositing", "variant": "Main2", - "uuid": "4ccf56f6-9982-4837-967c-a49695dbe8eb", + "instance_id": "4ccf56f6-9982-4837-967c-a49695dbe8eb", "creator_attributes": {}, "publish_attributes": { "CollectFtrackApi": { @@ -83,7 +83,7 @@ "asset": "sq01_sh0020", "task": "Compositing", "variant": "Main2", - "uuid": "4ccf56f6-9982-4837-967c-a49695dbe8ec", + "instance_id": "4ccf56f6-9982-4837-967c-a49695dbe8ec", "creator_attributes": {}, "publish_attributes": { "CollectFtrackApi": { @@ -101,7 +101,7 @@ "asset": "Alpaca_01", "task": "modeling", "variant": "Main", - "uuid": "7c9ddfc7-9f9c-4c1c-b233-38c966735fb6", + "instance_id": "7c9ddfc7-9f9c-4c1c-b233-38c966735fb6", "creator_attributes": {}, "publish_attributes": {} } diff --git a/openpype/hosts/testhost/api/pipeline.py b/openpype/hosts/testhost/api/pipeline.py index 49f1d3f33d..1f5d680705 100644 --- a/openpype/hosts/testhost/api/pipeline.py +++ b/openpype/hosts/testhost/api/pipeline.py @@ -114,7 +114,7 @@ def update_instances(update_list): instances = HostContext.get_instances() for instance_data in instances: - instance_id = instance_data["uuid"] + instance_id = instance_data["instance_id"] if instance_id in updated_instances: new_instance_data = updated_instances[instance_id] old_keys = set(instance_data.keys()) @@ -132,10 +132,10 @@ def remove_instances(instances): current_instances = HostContext.get_instances() for instance in instances: - instance_id = instance.data["uuid"] + instance_id = instance.data["instance_id"] found_idx = None for idx, _instance in enumerate(current_instances): - if instance_id == _instance["uuid"]: + if instance_id == _instance["instance_id"]: found_idx = idx break diff --git a/openpype/pipeline/create/README.md b/openpype/pipeline/create/README.md index 9eef7c72a7..02b64e52ea 100644 --- a/openpype/pipeline/create/README.md +++ b/openpype/pipeline/create/README.md @@ -14,7 +14,7 @@ Except creating and removing instances are all changes not automatically propaga ## CreatedInstance -Product of creation is "instance" which holds basic data defying it. Core data are `creator_identifier`, `family` and `subset`. Other data can be keys used to fill subset name or metadata modifying publishing process of the instance (more described later). All instances have `id` which holds constant `pyblish.avalon.instance` and `uuid` which is identifier of the instance. +Product of creation is "instance" which holds basic data defying it. Core data are `creator_identifier`, `family` and `subset`. Other data can be keys used to fill subset name or metadata modifying publishing process of the instance (more described later). All instances have `id` which holds constant `pyblish.avalon.instance` and `instance_id` which is identifier of the instance. Family tells how should be instance processed and subset what name will published item have. - There are cases when subset is not fully filled during creation and may change during publishing. That is in most of cases caused because instance is related to other instance or instance data do not represent final product. @@ -26,7 +26,7 @@ Family tells how should be instance processed and subset what name will publishe ## Identifier that this data represents instance for publishing (automatically assigned) "id": "pyblish.avalon.instance", ## Identifier of this specific instance (automatically assigned) - "uuid": , + "instance_id": , ## Instance family (used from Creator) "family": , diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 4454d31d83..e11d32091f 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -361,7 +361,7 @@ class CreatedInstance: # their individual children but not on their own __immutable_keys = ( "id", - "uuid", + "instance_id", "family", "creator_identifier", "creator_attributes", @@ -434,8 +434,8 @@ class CreatedInstance: if data: self._data.update(data) - if not self._data.get("uuid"): - self._data["uuid"] = str(uuid4()) + if not self._data.get("instance_id"): + self._data["instance_id"] = str(uuid4()) self._asset_is_valid = self.has_set_asset self._task_is_valid = self.has_set_task @@ -551,7 +551,7 @@ class CreatedInstance: @property def id(self): """Instance identifier.""" - return self._data["uuid"] + return self._data["instance_id"] @property def data(self): From 2d88deb3510b181bfa36639bf9d7255355813892 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:10:29 +0100 Subject: [PATCH 159/483] implemented base of tray publisher host --- openpype/hosts/traypublisher/api/__init__.py | 20 ++ openpype/hosts/traypublisher/api/pipeline.py | 181 +++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 openpype/hosts/traypublisher/api/__init__.py create mode 100644 openpype/hosts/traypublisher/api/pipeline.py diff --git a/openpype/hosts/traypublisher/api/__init__.py b/openpype/hosts/traypublisher/api/__init__.py new file mode 100644 index 0000000000..c461c0c526 --- /dev/null +++ b/openpype/hosts/traypublisher/api/__init__.py @@ -0,0 +1,20 @@ +from .pipeline import ( + install, + ls, + + set_project_name, + get_context_title, + get_context_data, + update_context_data, +) + + +__all__ = ( + "install", + "ls", + + "set_project_name", + "get_context_title", + "get_context_data", + "update_context_data", +) diff --git a/openpype/hosts/traypublisher/api/pipeline.py b/openpype/hosts/traypublisher/api/pipeline.py new file mode 100644 index 0000000000..83fe326ca4 --- /dev/null +++ b/openpype/hosts/traypublisher/api/pipeline.py @@ -0,0 +1,181 @@ +import os +import json +import tempfile +import atexit + +import avalon.api +import pyblish.api + +from openpype.pipeline import BaseCreator + +ROOT_DIR = os.path.dirname(os.path.dirname( + os.path.abspath(__file__) +)) +PUBLISH_PATH = os.path.join(ROOT_DIR, "plugins", "publish") +CREATE_PATH = os.path.join(ROOT_DIR, "plugins", "create") + + +class HostContext: + _context_json_path = None + + @staticmethod + def _on_exit(): + if ( + HostContext._context_json_path + and os.path.exists(HostContext._context_json_path) + ): + os.remove(HostContext._context_json_path) + + @classmethod + def get_context_json_path(cls): + if cls._context_json_path is None: + output_file = tempfile.NamedTemporaryFile( + mode="w", prefix="traypub_", suffix=".json" + ) + output_file.close() + cls._context_json_path = output_file.name + atexit.register(HostContext._on_exit) + print(cls._context_json_path) + return cls._context_json_path + + @classmethod + def _get_data(cls, group=None): + json_path = cls.get_context_json_path() + data = {} + if not os.path.exists(json_path): + with open(json_path, "w") as json_stream: + json.dump(data, json_stream) + else: + with open(json_path, "r") as json_stream: + content = json_stream.read() + if content: + data = json.loads(content) + if group is None: + return data + return data.get(group) + + @classmethod + def _save_data(cls, group, new_data): + json_path = cls.get_context_json_path() + data = cls._get_data() + data[group] = new_data + with open(json_path, "w") as json_stream: + json.dump(data, json_stream) + + @classmethod + def add_instance(cls, instance): + instances = cls.get_instances() + instances.append(instance) + cls.save_instances(instances) + + @classmethod + def get_instances(cls): + return cls._get_data("instances") or [] + + @classmethod + def save_instances(cls, instances): + cls._save_data("instances", instances) + + @classmethod + def get_context_data(cls): + return cls._get_data("context") or {} + + @classmethod + def save_context_data(cls, data): + cls._save_data("context", data) + + @classmethod + def get_project_name(cls): + return cls._get_data("project_name") + + @classmethod + def set_project_name(cls, project_name): + cls._save_data("project_name", project_name) + + @classmethod + def get_data_to_store(cls): + return { + "project_name": cls.get_project_name(), + "instances": cls.get_instances(), + "context": cls.get_context_data(), + } + + +def list_instances(): + return HostContext.get_instances() + + +def update_instances(update_list): + updated_instances = {} + for instance, _changes in update_list: + updated_instances[instance.id] = instance.data_to_store() + + instances = HostContext.get_instances() + for instance_data in instances: + instance_id = instance_data["instance_id"] + if instance_id in updated_instances: + new_instance_data = updated_instances[instance_id] + old_keys = set(instance_data.keys()) + new_keys = set(new_instance_data.keys()) + instance_data.update(new_instance_data) + for key in (old_keys - new_keys): + instance_data.pop(key) + + HostContext.save_instances(instances) + + +def remove_instances(instances): + if not isinstance(instances, (tuple, list)): + instances = [instances] + + current_instances = HostContext.get_instances() + for instance in instances: + instance_id = instance.data["instance_id"] + found_idx = None + for idx, _instance in enumerate(current_instances): + if instance_id == _instance["instance_id"]: + found_idx = idx + break + + if found_idx is not None: + current_instances.pop(found_idx) + HostContext.save_instances(current_instances) + + +def get_context_data(): + return HostContext.get_context_data() + + +def update_context_data(data, changes): + HostContext.save_context_data(data) + + +def get_context_title(): + return HostContext.get_project_name() + + +def ls(): + """Probably will never return loaded containers.""" + return [] + + +def install(): + """This is called before a project is known. + + Project is defined with 'set_project_name'. + """ + os.environ["AVALON_APP"] = "traypublisher" + + pyblish.api.register_host("traypublisher") + pyblish.api.register_plugin_path(PUBLISH_PATH) + avalon.api.register_plugin_path(BaseCreator, CREATE_PATH) + + +def set_project_name(project_name): + # Deregister project specific plugins and register new project plugins + old_project_name = HostContext.get_project_name() + if old_project_name is not None and old_project_name != project_name: + pass + os.environ["AVALON_PROJECT"] = project_name + avalon.api.Session["AVALON_PROJECT"] = project_name + HostContext.set_project_name(project_name) From d62a09729a3df1274a816b011ec2e5b840189b9b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:23:06 +0100 Subject: [PATCH 160/483] added creator for workfile family --- .../plugins/create/create_workfile.py | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 openpype/hosts/traypublisher/plugins/create/create_workfile.py diff --git a/openpype/hosts/traypublisher/plugins/create/create_workfile.py b/openpype/hosts/traypublisher/plugins/create/create_workfile.py new file mode 100644 index 0000000000..38b25ea3c6 --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/create/create_workfile.py @@ -0,0 +1,98 @@ +from openpype import resources +from openpype.hosts.traypublisher.api import pipeline +from openpype.pipeline import ( + Creator, + CreatedInstance, + lib +) + + +class WorkfileCreator(Creator): + identifier = "workfile" + label = "Workfile" + family = "workfile" + description = "Publish backup of workfile" + + create_allow_context_change = True + + extensions = [ + # Maya + ".ma", ".mb", + # Nuke + ".nk", + # Hiero + ".hrox", + # Houdini + ".hip", ".hiplc", ".hipnc", + # Blender + ".blend", + # Celaction + ".scn", + # TVPaint + ".tvpp", + # Fusion + ".comp", + # Harmony + ".zip", + # Premiere + ".prproj", + # Resolve + ".drp", + # Photoshop + ".psd", ".psb", + # Aftereffects + ".aep" + ] + + def get_icon(self): + return resources.get_openpype_splash_filepath() + + def collect_instances(self): + for instance_data in pipeline.list_instances(): + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + instance = CreatedInstance.from_existing( + instance_data, self + ) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + pipeline.update_instances(update_list) + + def remove_instances(self, instances): + pipeline.remove_instances(instances) + for instance in instances: + self._remove_instance_from_context(instance) + + def create(self, subset_name, data, pre_create_data): + # Pass precreate data to creator attributes + data["creator_attributes"] = pre_create_data + # Create new instance + new_instance = CreatedInstance(self.family, subset_name, data, self) + # Host implementation of storing metadata about instance + pipeline.HostContext.add_instance(new_instance.data_to_store()) + # Add instance to current context + self._add_instance_to_context(new_instance) + + def get_default_variants(self): + return [ + "Main" + ] + + def get_instance_attr_defs(self): + output = [ + lib.FileDef( + "filepath", + folders=False, + extensions=self.extensions, + label="Filepath" + ) + ] + return output + + def get_pre_create_attr_defs(self): + # Use same attributes as for instance attrobites + return self.get_instance_attr_defs() + + def get_detail_description(self): + return """# Publish workfile backup""" From 026d9688fec9b4f8c259e891760964a75fe26d57 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:23:35 +0100 Subject: [PATCH 161/483] added collector and validator for workfile family --- .../plugins/publish/collect_workfile.py | 31 +++++++++++++++++++ .../plugins/publish/validate_workfile.py | 24 ++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 openpype/hosts/traypublisher/plugins/publish/collect_workfile.py create mode 100644 openpype/hosts/traypublisher/plugins/publish/validate_workfile.py diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py b/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py new file mode 100644 index 0000000000..d48bace047 --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py @@ -0,0 +1,31 @@ +import os +import pyblish.api + + +class CollectWorkfile(pyblish.api.InstancePlugin): + """Collect representation of workfile instances.""" + + label = "Collect Workfile" + order = pyblish.api.CollectorOrder - 0.49 + families = ["workfile"] + hosts = ["traypublisher"] + + def process(self, instance): + if "representations" not in instance.data: + instance.data["representations"] = [] + repres = instance.data["representations"] + + creator_attributes = instance.data["creator_attributes"] + filepath = creator_attributes["filepath"] + instance.data["sourceFilepath"] = filepath + + staging_dir = os.path.dirname(filepath) + filename = os.path.basename(filepath) + ext = os.path.splitext(filename)[-1] + + repres.append({ + "ext": ext, + "name": ext, + "stagingDir": staging_dir, + "files": filename + }) diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py b/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py new file mode 100644 index 0000000000..88339d2aac --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py @@ -0,0 +1,24 @@ +import os +import pyblish.api +from openpype.pipeline import PublishValidationError + + +class ValidateWorkfilePath(pyblish.api.InstancePlugin): + """Validate existence of workfile instance existence.""" + + label = "Collect Workfile" + order = pyblish.api.ValidatorOrder - 0.49 + families = ["workfile"] + hosts = ["traypublisher"] + + def process(self, instance): + filepath = instance.data["sourceFilepath"] + if not filepath: + raise PublishValidationError(( + "Filepath of 'workfile' instance \"{}\" is not set" + ).format(instance.data["name"])) + + if not os.path.exists(filepath): + raise PublishValidationError(( + "Filepath of 'workfile' instance \"{}\" does not exist: {}" + ).format(instance.data["name"], filepath)) From 1d1a07cc761c34ecc799cdf4cd88b1a350a0a59c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:23:41 +0100 Subject: [PATCH 162/483] added collector for source --- .../plugins/publish/collect_source.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 openpype/hosts/traypublisher/plugins/publish/collect_source.py diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_source.py b/openpype/hosts/traypublisher/plugins/publish/collect_source.py new file mode 100644 index 0000000000..6ff22be13a --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/collect_source.py @@ -0,0 +1,24 @@ +import pyblish.api + + +class CollectSource(pyblish.api.ContextPlugin): + """Collecting instances from traypublisher host.""" + + label = "Collect source" + order = pyblish.api.CollectorOrder - 0.49 + hosts = ["traypublisher"] + + def process(self, context): + # get json paths from os and load them + source_name = "traypublisher" + for instance in context: + source = instance.data.get("source") + if not source: + instance.data["source"] = source_name + self.log.info(( + "Source of instance \"{}\" is changed to \"{}\"" + ).format(instance.data["name"], source_name)) + else: + self.log.info(( + "Source of instance \"{}\" was already set to \"{}\"" + ).format(instance.data["name"], source)) From cd7f54a8f51a3e79f6ac01bb4e1333c175dc06e5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:24:05 +0100 Subject: [PATCH 163/483] created function to run detached process --- openpype/lib/__init__.py | 2 ++ openpype/lib/execute.py | 78 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index ebe7648ad7..8c3ebc8a3c 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -29,6 +29,7 @@ from .execute import ( get_linux_launcher_args, execute, run_subprocess, + run_detached_process, run_openpype_process, clean_envs_for_openpype_process, path_to_subprocess_arg, @@ -188,6 +189,7 @@ __all__ = [ "get_linux_launcher_args", "execute", "run_subprocess", + "run_detached_process", "run_openpype_process", "clean_envs_for_openpype_process", "path_to_subprocess_arg", diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index afde844f2d..f2eb97c5f5 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -1,5 +1,9 @@ import os +import sys import subprocess +import platform +import json +import tempfile import distutils.spawn from .log import PypeLogger as Logger @@ -181,6 +185,80 @@ def run_openpype_process(*args, **kwargs): return run_subprocess(args, env=env, **kwargs) +def run_detached_process(args, **kwargs): + """Execute process with passed arguments as separated process. + + Values from 'os.environ' are used for environments if are not passed. + They are cleaned using 'clean_envs_for_openpype_process' function. + + Example: + ``` + run_detached_openpype_process("run", "") + ``` + + Args: + *args (tuple): OpenPype cli arguments. + **kwargs (dict): Keyword arguments for for subprocess.Popen. + + Returns: + subprocess.Popen: Pointer to launched process but it is possible that + launched process is already killed (on linux). + """ + env = kwargs.pop("env", None) + # Keep env untouched if are passed and not empty + if not env: + env = os.environ + + # Create copy of passed env + kwargs["env"] = {k: v for k, v in env.items()} + + low_platform = platform.system().lower() + if low_platform == "darwin": + new_args = ["open", "-na", args.pop(0), "--args"] + new_args.extend(args) + args = new_args + + elif low_platform == "windows": + flags = ( + subprocess.CREATE_NEW_PROCESS_GROUP + | subprocess.DETACHED_PROCESS + ) + kwargs["creationflags"] = flags + + if not sys.stdout: + kwargs["stdout"] = subprocess.DEVNULL + kwargs["stderr"] = subprocess.DEVNULL + + elif low_platform == "linux" and get_linux_launcher_args() is not None: + json_data = { + "args": args, + "env": kwargs.pop("env") + } + json_temp = tempfile.NamedTemporaryFile( + mode="w", prefix="op_app_args", suffix=".json", delete=False + ) + json_temp.close() + json_temp_filpath = json_temp.name + with open(json_temp_filpath, "w") as stream: + json.dump(json_data, stream) + + new_args = get_linux_launcher_args() + new_args.append(json_temp_filpath) + + # Create mid-process which will launch application + process = subprocess.Popen(new_args, **kwargs) + # Wait until the process finishes + # - This is important! The process would stay in "open" state. + process.wait() + # Remove the temp file + os.remove(json_temp_filpath) + # Return process which is already terminated + return process + + process = subprocess.Popen(args, **kwargs) + return process + + def path_to_subprocess_arg(path): """Prepare path for subprocess arguments. From 96981a05d3017c4f805c8a03fc70e613449bf55d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:24:44 +0100 Subject: [PATCH 164/483] install traypublish host in tool --- openpype/tools/traypublisher/window.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py index d6a5fe56f8..34ba042e91 100644 --- a/openpype/tools/traypublisher/window.py +++ b/openpype/tools/traypublisher/window.py @@ -1,8 +1,21 @@ +"""Tray publisher is extending publisher tool. + +Adds ability to select project using overlay widget with list of projects. + +Tray publisher can be considered as host implementeation with creators and +publishing plugins. +""" + +import os from Qt import QtWidgets, QtCore import avalon.api from avalon import io from avalon.api import AvalonMongoDB +from openpype.hosts.traypublisher import ( + api as traypublisher +) +from openpype.hosts.traypublisher.api.pipeline import HostContext from openpype.tools.publisher import PublisherWindow from openpype.tools.utils.constants import PROJECT_NAME_ROLE from openpype.tools.utils.models import ( @@ -127,8 +140,10 @@ class TrayPublishWindow(PublisherWindow): self._resize_overlay() def _on_project_select(self, project_name): + # TODO register project specific plugin paths self.controller.save_changes() self.controller.reset_project_data_cache() + os.environ["AVALON_PROJECT"] = project_name io.Session["AVALON_PROJECT"] = project_name io.install() @@ -142,6 +157,7 @@ class TrayPublishWindow(PublisherWindow): def main(): + avalon.api.install(traypublisher) app = QtWidgets.QApplication([]) window = TrayPublishWindow() window.show() From f27d705577b49f5e17b1a7a070dad02c93386d79 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:25:12 +0100 Subject: [PATCH 165/483] added command line arguments to run tray publisher --- openpype/cli.py | 6 ++++++ openpype/pype_commands.py | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/openpype/cli.py b/openpype/cli.py index 0597c387d0..b9c80ca065 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -42,6 +42,12 @@ def standalonepublisher(): PypeCommands().launch_standalone_publisher() +@main.command() +def traypublisher(): + """Show new OpenPype Standalone publisher UI.""" + PypeCommands().launch_traypublisher() + + @main.command() @click.option("-d", "--debug", is_flag=True, help=("Run pype tray in debug mode")) diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 47f5e7fcc0..9dc3e29337 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -80,6 +80,11 @@ class PypeCommands: from openpype.tools import standalonepublish standalonepublish.main() + @staticmethod + def launch_traypublisher(): + from openpype.tools import traypublisher + traypublisher.main() + @staticmethod def publish(paths, targets=None, gui=False): """Start headless publishing. From 7b9b1ef287cc06afdcf23f69aa4831b9dba510e9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:43:43 +0100 Subject: [PATCH 166/483] added missing method to clear project cache --- openpype/tools/publisher/control.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index ab2dffd489..04158ad05e 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -982,6 +982,9 @@ class PublisherController: self._publish_next_process() + def reset_project_data_cache(self): + self._asset_docs_cache.reset() + def collect_families_from_instances(instances, only_active=False): """Collect all families for passed publish instances. From 34af4ea3e3a0a33df76a47183e0e2cf742be2405 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:44:37 +0100 Subject: [PATCH 167/483] implemented traypublish module to show it in tray --- openpype/modules/base.py | 2 + openpype/modules/traypublish_action.py | 38 +++++++++++++++++++ .../defaults/system_settings/modules.json | 3 ++ .../schemas/system_schema/schema_modules.json | 14 +++++++ 4 files changed, 57 insertions(+) create mode 100644 openpype/modules/traypublish_action.py diff --git a/openpype/modules/base.py b/openpype/modules/base.py index d566692439..c601194a82 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -41,6 +41,7 @@ DEFAULT_OPENPYPE_MODULES = ( "project_manager_action", "settings_action", "standalonepublish_action", + "traypublish_action", "job_queue", "timers_manager", ) @@ -844,6 +845,7 @@ class TrayModulesManager(ModulesManager): "avalon", "clockify", "standalonepublish_tool", + "traypublish_tool", "log_viewer", "local_settings", "settings" diff --git a/openpype/modules/traypublish_action.py b/openpype/modules/traypublish_action.py new file mode 100644 index 0000000000..039ce96206 --- /dev/null +++ b/openpype/modules/traypublish_action.py @@ -0,0 +1,38 @@ +import os +from openpype.lib import get_openpype_execute_args +from openpype.lib.execute import run_detached_process +from openpype.modules import OpenPypeModule +from openpype_interfaces import ITrayAction + + +class TrayPublishAction(OpenPypeModule, ITrayAction): + label = "Tray Publish (beta)" + name = "traypublish_tool" + + def initialize(self, modules_settings): + import openpype + self.enabled = modules_settings[self.name]["enabled"] + self.publish_paths = [ + os.path.join( + openpype.PACKAGE_DIR, + "hosts", + "traypublisher", + "plugins", + "publish" + ) + ] + + def tray_init(self): + return + + def on_action_trigger(self): + self.run_traypublisher() + + def connect_with_modules(self, enabled_modules): + """Collect publish paths from other modules.""" + publish_paths = self.manager.collect_plugin_paths()["publish"] + self.publish_paths.extend(publish_paths) + + def run_traypublisher(self): + args = get_openpype_execute_args("traypublisher") + run_detached_process(args) diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index d74269922f..70dc584360 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -191,6 +191,9 @@ "standalonepublish_tool": { "enabled": true }, + "traypublish_tool": { + "enabled": false + }, "project_manager": { "enabled": true }, diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index 52595914ed..21c8163cea 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -233,6 +233,20 @@ } ] }, + { + "type": "dict", + "key": "traypublish_tool", + "label": "Tray Publish (beta)", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + }, { "type": "dict", "key": "project_manager", From bed0a09e6327fb63e04725fc184935a18b081b9c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:48:21 +0100 Subject: [PATCH 168/483] modified publish plugins to be able work without global context --- .../plugins/publish/collect_ftrack_api.py | 62 ++++++++++--------- .../publish/collect_anatomy_context_data.py | 56 +++++++++-------- .../publish/collect_anatomy_instance_data.py | 14 +++-- .../publish/collect_avalon_entities.py | 6 +- openpype/plugins/publish/integrate_new.py | 5 +- .../plugins/publish/validate_aseset_docs.py | 31 ++++++++++ 6 files changed, 113 insertions(+), 61 deletions(-) create mode 100644 openpype/plugins/publish/validate_aseset_docs.py diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_api.py b/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_api.py index a348617cfc..07af217fb6 100644 --- a/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_api.py +++ b/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_api.py @@ -1,4 +1,3 @@ -import os import logging import pyblish.api import avalon.api @@ -43,37 +42,48 @@ class CollectFtrackApi(pyblish.api.ContextPlugin): ).format(project_name)) project_entity = project_entities[0] + self.log.debug("Project found: {0}".format(project_entity)) - # Find asset entity - entity_query = ( - 'TypedContext where project_id is "{0}"' - ' and name is "{1}"' - ).format(project_entity["id"], asset_name) - self.log.debug("Asset entity query: < {0} >".format(entity_query)) - asset_entities = [] - for entity in session.query(entity_query).all(): - # Skip tasks - if entity.entity_type.lower() != "task": - asset_entities.append(entity) + asset_entity = None + if asset_name: + # Find asset entity + entity_query = ( + 'TypedContext where project_id is "{0}"' + ' and name is "{1}"' + ).format(project_entity["id"], asset_name) + self.log.debug("Asset entity query: < {0} >".format(entity_query)) + asset_entities = [] + for entity in session.query(entity_query).all(): + # Skip tasks + if entity.entity_type.lower() != "task": + asset_entities.append(entity) - if len(asset_entities) == 0: - raise AssertionError(( - "Entity with name \"{0}\" not found" - " in Ftrack project \"{1}\"." - ).format(asset_name, project_name)) + if len(asset_entities) == 0: + raise AssertionError(( + "Entity with name \"{0}\" not found" + " in Ftrack project \"{1}\"." + ).format(asset_name, project_name)) - elif len(asset_entities) > 1: - raise AssertionError(( - "Found more than one entity with name \"{0}\"" - " in Ftrack project \"{1}\"." - ).format(asset_name, project_name)) + elif len(asset_entities) > 1: + raise AssertionError(( + "Found more than one entity with name \"{0}\"" + " in Ftrack project \"{1}\"." + ).format(asset_name, project_name)) + + asset_entity = asset_entities[0] - asset_entity = asset_entities[0] self.log.debug("Asset found: {0}".format(asset_entity)) + task_entity = None # Find task entity if task is set - if task_name: + if not asset_entity: + self.log.warning( + "Asset entity is not set. Skipping query of task entity." + ) + elif not task_name: + self.log.warning("Task name is not set.") + else: task_query = ( 'Task where name is "{0}" and parent_id is "{1}"' ).format(task_name, asset_entity["id"]) @@ -88,10 +98,6 @@ class CollectFtrackApi(pyblish.api.ContextPlugin): else: self.log.debug("Task entity found: {0}".format(task_entity)) - else: - task_entity = None - self.log.warning("Task name is not set.") - context.data["ftrackSession"] = session context.data["ftrackPythonModule"] = ftrack_api context.data["ftrackProject"] = project_entity diff --git a/openpype/plugins/publish/collect_anatomy_context_data.py b/openpype/plugins/publish/collect_anatomy_context_data.py index b0474b93ce..bd8d9e50c4 100644 --- a/openpype/plugins/publish/collect_anatomy_context_data.py +++ b/openpype/plugins/publish/collect_anatomy_context_data.py @@ -44,42 +44,18 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin): label = "Collect Anatomy Context Data" def process(self, context): - - task_name = api.Session["AVALON_TASK"] - project_entity = context.data["projectEntity"] - asset_entity = context.data["assetEntity"] - - asset_tasks = asset_entity["data"]["tasks"] - task_type = asset_tasks.get(task_name, {}).get("type") - - project_task_types = project_entity["config"]["tasks"] - task_code = project_task_types.get(task_type, {}).get("short_name") - - asset_parents = asset_entity["data"]["parents"] - hierarchy = "/".join(asset_parents) - - parent_name = project_entity["name"] - if asset_parents: - parent_name = asset_parents[-1] - context_data = { "project": { "name": project_entity["name"], "code": project_entity["data"].get("code") }, - "asset": asset_entity["name"], - "parent": parent_name, - "hierarchy": hierarchy, - "task": { - "name": task_name, - "type": task_type, - "short": task_code, - }, "username": context.data["user"], "app": context.data["hostName"] } + context.data["anatomyData"] = context_data + # add system general settings anatomy data system_general_data = get_system_general_anatomy_data() context_data.update(system_general_data) @@ -87,7 +63,33 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin): datetime_data = context.data.get("datetimeData") or {} context_data.update(datetime_data) - context.data["anatomyData"] = context_data + asset_entity = context.data.get("assetEntity") + if asset_entity: + task_name = api.Session["AVALON_TASK"] + + asset_tasks = asset_entity["data"]["tasks"] + task_type = asset_tasks.get(task_name, {}).get("type") + + project_task_types = project_entity["config"]["tasks"] + task_code = project_task_types.get(task_type, {}).get("short_name") + + asset_parents = asset_entity["data"]["parents"] + hierarchy = "/".join(asset_parents) + + parent_name = project_entity["name"] + if asset_parents: + parent_name = asset_parents[-1] + + context_data.update({ + "asset": asset_entity["name"], + "parent": parent_name, + "hierarchy": hierarchy, + "task": { + "name": task_name, + "type": task_type, + "short": task_code, + } + }) self.log.info("Global anatomy Data collected") self.log.debug(json.dumps(context_data, indent=4)) diff --git a/openpype/plugins/publish/collect_anatomy_instance_data.py b/openpype/plugins/publish/collect_anatomy_instance_data.py index 74b556e28a..42836e796b 100644 --- a/openpype/plugins/publish/collect_anatomy_instance_data.py +++ b/openpype/plugins/publish/collect_anatomy_instance_data.py @@ -52,7 +52,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): def fill_missing_asset_docs(self, context): self.log.debug("Qeurying asset documents for instances.") - context_asset_doc = context.data["assetEntity"] + context_asset_doc = context.data.get("assetEntity") instances_with_missing_asset_doc = collections.defaultdict(list) for instance in context: @@ -69,7 +69,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): # Check if asset name is the same as what is in context # - they may be different, e.g. in NukeStudio - if context_asset_doc["name"] == _asset_name: + if context_asset_doc and context_asset_doc["name"] == _asset_name: instance.data["assetEntity"] = context_asset_doc else: @@ -212,7 +212,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): self.log.debug("Storing anatomy data to instance data.") project_doc = context.data["projectEntity"] - context_asset_doc = context.data["assetEntity"] + context_asset_doc = context.data.get("assetEntity") project_task_types = project_doc["config"]["tasks"] @@ -240,7 +240,13 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): # Hiearchy asset_doc = instance.data.get("assetEntity") - if asset_doc and asset_doc["_id"] != context_asset_doc["_id"]: + if ( + asset_doc + and ( + not context_asset_doc + or asset_doc["_id"] != context_asset_doc["_id"] + ) + ): parents = asset_doc["data"].get("parents") or list() parent_name = project_doc["name"] if parents: diff --git a/openpype/plugins/publish/collect_avalon_entities.py b/openpype/plugins/publish/collect_avalon_entities.py index a6120d42fe..c099a2cf75 100644 --- a/openpype/plugins/publish/collect_avalon_entities.py +++ b/openpype/plugins/publish/collect_avalon_entities.py @@ -33,6 +33,11 @@ class CollectAvalonEntities(pyblish.api.ContextPlugin): ).format(project_name) self.log.debug("Collected Project \"{}\"".format(project_entity)) + context.data["projectEntity"] = project_entity + + if not asset_name: + self.log.info("Context is not set. Can't collect global data.") + return asset_entity = io.find_one({ "type": "asset", "name": asset_name, @@ -44,7 +49,6 @@ class CollectAvalonEntities(pyblish.api.ContextPlugin): self.log.debug("Collected Asset \"{}\"".format(asset_entity)) - context.data["projectEntity"] = project_entity context.data["assetEntity"] = asset_entity data = asset_entity['data'] diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index bf214d9139..a706ccbab6 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -147,7 +147,10 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): project_entity = instance.data["projectEntity"] - context_asset_name = context.data["assetEntity"]["name"] + context_asset_name = None + context_asset_doc = context.data.get("assetEntity") + if context_asset_doc: + context_asset_name = context_asset_doc["name"] asset_name = instance.data["asset"] asset_entity = instance.data.get("assetEntity") diff --git a/openpype/plugins/publish/validate_aseset_docs.py b/openpype/plugins/publish/validate_aseset_docs.py new file mode 100644 index 0000000000..eed75cdf8a --- /dev/null +++ b/openpype/plugins/publish/validate_aseset_docs.py @@ -0,0 +1,31 @@ +import pyblish.api +from openpype.pipeline import PublishValidationError + + +class ValidateContainers(pyblish.api.InstancePlugin): + """Validate existence of asset asset documents on instances. + + Without asset document it is not possible to publish the instance. + + If context has set asset document the validation is skipped. + + Plugin was added because there are cases when context asset is not defined + e.g. in tray publisher. + """ + + label = "Validate Asset docs" + order = pyblish.api.ValidatorOrder + + def process(self, instance): + context_asset_doc = instance.context.data.get("assetEntity") + if context_asset_doc: + return + + if instance.data.get("assetEntity"): + self.log.info("Instance have set asset document in it's data.") + + else: + raise PublishValidationError(( + "Instance \"{}\" don't have set asset" + " document which is needed for publishing." + ).format(instance.data["name"])) From aa9df7edd5fe2d0e693495a79dd7498b2fadc08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 21 Feb 2022 18:42:48 +0100 Subject: [PATCH 169/483] wip on integrating avalon functionality --- openpype/hosts/unreal/__init__.py | 7 +- openpype/hosts/unreal/api/helpers.py | 44 ++ openpype/hosts/unreal/api/lib.py | 18 +- openpype/hosts/unreal/api/pipeline.py | 388 ++++++++++++++++++ openpype/hosts/unreal/api/plugin.py | 7 +- openpype/hosts/unreal/integration/.gitignore | 35 ++ .../integration/Content/Python/init_unreal.py | 27 ++ .../hosts/unreal/integration/OpenPype.uplugin | 24 ++ openpype/hosts/unreal/integration/README.md | 11 + .../integration/Resources/openpype128.png | Bin 0 -> 14594 bytes .../integration/Resources/openpype40.png | Bin 0 -> 4884 bytes .../integration/Resources/openpype512.png | Bin 0 -> 85856 bytes .../integration/Source/Avalon/Avalon.Build.cs | 57 +++ .../Source/Avalon/Private/AssetContainer.cpp | 115 ++++++ .../Avalon/Private/AssetContainerFactory.cpp | 20 + .../Source/Avalon/Private/Avalon.cpp | 103 +++++ .../Source/Avalon/Private/AvalonLib.cpp | 48 +++ .../Avalon/Private/AvalonPublishInstance.cpp | 108 +++++ .../Private/AvalonPublishInstanceFactory.cpp | 20 + .../Avalon/Private/AvalonPythonBridge.cpp | 13 + .../Source/Avalon/Private/AvalonStyle.cpp | 69 ++++ .../Source/Avalon/Public/AssetContainer.h | 39 ++ .../Avalon/Public/AssetContainerFactory.h | 21 + .../integration/Source/Avalon/Public/Avalon.h | 21 + .../Source/Avalon/Public/AvalonLib.h | 19 + .../Avalon/Public/AvalonPublishInstance.h | 21 + .../Public/AvalonPublishInstanceFactory.h | 19 + .../Source/Avalon/Public/AvalonPythonBridge.h | 20 + .../Source/Avalon/Public/AvalonStyle.h | 22 + 29 files changed, 1282 insertions(+), 14 deletions(-) create mode 100644 openpype/hosts/unreal/api/helpers.py create mode 100644 openpype/hosts/unreal/api/pipeline.py create mode 100644 openpype/hosts/unreal/integration/.gitignore create mode 100644 openpype/hosts/unreal/integration/Content/Python/init_unreal.py create mode 100644 openpype/hosts/unreal/integration/OpenPype.uplugin create mode 100644 openpype/hosts/unreal/integration/README.md create mode 100644 openpype/hosts/unreal/integration/Resources/openpype128.png create mode 100644 openpype/hosts/unreal/integration/Resources/openpype40.png create mode 100644 openpype/hosts/unreal/integration/Resources/openpype512.png create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py index 1280442916..e6ca1e833d 100644 --- a/openpype/hosts/unreal/__init__.py +++ b/openpype/hosts/unreal/__init__.py @@ -3,11 +3,12 @@ import os def add_implementation_envs(env, _app): """Modify environments to contain all required for implementation.""" - # Set AVALON_UNREAL_PLUGIN required for Unreal implementation + # Set OPENPYPE_UNREAL_PLUGIN required for Unreal implementation unreal_plugin_path = os.path.join( - os.environ["OPENPYPE_REPOS_ROOT"], "repos", "avalon-unreal-integration" + os.environ["OPENPYPE_ROOT"], "openpype", "hosts", + "unreal", "integration" ) - env["AVALON_UNREAL_PLUGIN"] = unreal_plugin_path + env["OPENPYPE_UNREAL_PLUGIN"] = unreal_plugin_path # Set default environments if are not set via settings defaults = { diff --git a/openpype/hosts/unreal/api/helpers.py b/openpype/hosts/unreal/api/helpers.py new file mode 100644 index 0000000000..6fc89cf176 --- /dev/null +++ b/openpype/hosts/unreal/api/helpers.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +import unreal # noqa + + +class OpenPypeUnrealException(Exception): + pass + + +@unreal.uclass() +class OpenPypeHelpers(unreal.OpenPypeLib): + """Class wrapping some useful functions for OpenPype. + + This class is extending native BP class in OpenPype Integration Plugin. + + """ + + @unreal.ufunction(params=[str, unreal.LinearColor, bool]) + def set_folder_color(self, path: str, color: unreal.LinearColor) -> Bool: + """Set color on folder in Content Browser. + + This method sets color on folder in Content Browser. Unfortunately + there is no way to refresh Content Browser so new color isn't applied + immediately. They are saved to config file and appears correctly + only after Editor is restarted. + + Args: + path (str): Path to folder + color (:class:`unreal.LinearColor`): Color of the folder + + Example: + + AvalonHelpers().set_folder_color( + "/Game/Path", unreal.LinearColor(a=1.0, r=1.0, g=0.5, b=0) + ) + + Note: + This will take effect only after Editor is restarted. I couldn't + find a way to refresh it. Also this saves the color definition + into the project config, binding this path with color. So if you + delete this path and later re-create, it will set this color + again. + + """ + self.c_set_folder_color(path, color, False) diff --git a/openpype/hosts/unreal/api/lib.py b/openpype/hosts/unreal/api/lib.py index 61dac46fac..e04606a333 100644 --- a/openpype/hosts/unreal/api/lib.py +++ b/openpype/hosts/unreal/api/lib.py @@ -169,11 +169,11 @@ def create_unreal_project(project_name: str, env: dict = None) -> None: """This will create `.uproject` file at specified location. - As there is no way I know to create project via command line, this is - easiest option. Unreal project file is basically JSON file. If we find - `AVALON_UNREAL_PLUGIN` environment variable we assume this is location - of Avalon Integration Plugin and we copy its content to project folder - and enable this plugin. + As there is no way I know to create a project via command line, this is + easiest option. Unreal project file is basically a JSON file. If we find + the `OPENPYPE_UNREAL_PLUGIN` environment variable we assume this is the + location of the Integration Plugin and we copy its content to the project + folder and enable this plugin. Args: project_name (str): Name of the project. @@ -254,14 +254,14 @@ def create_unreal_project(project_name: str, {"Name": "PythonScriptPlugin", "Enabled": True}, {"Name": "EditorScriptingUtilities", "Enabled": True}, {"Name": "SequencerScripting", "Enabled": True}, - {"Name": "Avalon", "Enabled": True} + {"Name": "OpenPype", "Enabled": True} ] } if dev_mode or preset["dev_mode"]: - # this will add project module and necessary source file to make it - # C++ project and to (hopefully) make Unreal Editor to compile all - # sources at start + # this will add the project module and necessary source file to + # make it a C++ project and to (hopefully) make Unreal Editor to + # compile all # sources at start data["Modules"] = [{ "Name": project_name, diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py new file mode 100644 index 0000000000..c255005f31 --- /dev/null +++ b/openpype/hosts/unreal/api/pipeline.py @@ -0,0 +1,388 @@ +# -*- coding: utf-8 -*- +import sys +import pyblish.api +from avalon.pipeline import AVALON_CONTAINER_ID + +import unreal # noqa +from typing import List + +from openpype.tools.utils import host_tools + +from avalon import api + + +AVALON_CONTAINERS = "OpenPypeContainers" + + +def install(): + + pyblish.api.register_host("unreal") + _register_callbacks() + _register_events() + + +def _register_callbacks(): + """ + TODO: Implement callbacks if supported by UE4 + """ + pass + + +def _register_events(): + """ + TODO: Implement callbacks if supported by UE4 + """ + pass + + +def uninstall(): + pyblish.api.deregister_host("unreal") + + +class Creator(api.Creator): + hosts = ["unreal"] + asset_types = [] + + def process(self): + nodes = list() + + with unreal.ScopedEditorTransaction("Avalon Creating Instance"): + if (self.options or {}).get("useSelection"): + self.log.info("setting ...") + print("settings ...") + nodes = unreal.EditorUtilityLibrary.get_selected_assets() + + asset_paths = [a.get_path_name() for a in nodes] + self.name = move_assets_to_path( + "/Game", self.name, asset_paths + ) + + instance = create_publish_instance("/Game", self.name) + imprint(instance, self.data) + + return instance + + +class Loader(api.Loader): + hosts = ["unreal"] + + +def ls(): + """ + List all containers found in *Content Manager* of Unreal and return + metadata from them. Adding `objectName` to set. + """ + ar = unreal.AssetRegistryHelpers.get_asset_registry() + avalon_containers = ar.get_assets_by_class("AssetContainer", True) + + # get_asset_by_class returns AssetData. To get all metadata we need to + # load asset. get_tag_values() work only on metadata registered in + # Asset Registy Project settings (and there is no way to set it with + # python short of editing ini configuration file). + for asset_data in avalon_containers: + asset = asset_data.get_asset() + data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset) + data["objectName"] = asset_data.asset_name + data = cast_map_to_str_dict(data) + + yield data + + +def parse_container(container): + """ + To get data from container, AssetContainer must be loaded. + + Args: + container(str): path to container + + Returns: + dict: metadata stored on container + """ + asset = unreal.EditorAssetLibrary.load_asset(container) + data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset) + data["objectName"] = asset.get_name() + data = cast_map_to_str_dict(data) + + return data + + +def publish(): + """Shorthand to publish from within host""" + import pyblish.util + + return pyblish.util.publish() + + +def containerise(name, namespace, nodes, context, loader=None, suffix="_CON"): + + """Bundles *nodes* (assets) into a *container* and add metadata to it. + + Unreal doesn't support *groups* of assets that you can add metadata to. + But it does support folders that helps to organize asset. Unfortunately + those folders are just that - you cannot add any additional information + to them. `Avalon Integration Plugin`_ is providing way out - Implementing + `AssetContainer` Blueprint class. This class when added to folder can + handle metadata on it using standard + :func:`unreal.EditorAssetLibrary.set_metadata_tag()` and + :func:`unreal.EditorAssetLibrary.get_metadata_tag_values()`. It also + stores and monitor all changes in assets in path where it resides. List of + those assets is available as `assets` property. + + This is list of strings starting with asset type and ending with its path: + `Material /Game/Avalon/Test/TestMaterial.TestMaterial` + + .. _Avalon Integration Plugin: + https://github.com/pypeclub/avalon-unreal-integration + + """ + # 1 - create directory for container + root = "/Game" + container_name = "{}{}".format(name, suffix) + new_name = move_assets_to_path(root, container_name, nodes) + + # 2 - create Asset Container there + path = "{}/{}".format(root, new_name) + create_container(container=container_name, path=path) + + namespace = path + + data = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": new_name, + "namespace": namespace, + "loader": str(loader), + "representation": context["representation"]["_id"], + } + # 3 - imprint data + imprint("{}/{}".format(path, container_name), data) + return path + + +def instantiate(root, name, data, assets=None, suffix="_INS"): + """ + Bundles *nodes* into *container* marking it with metadata as publishable + instance. If assets are provided, they are moved to new path where + `AvalonPublishInstance` class asset is created and imprinted with metadata. + + This can then be collected for publishing by Pyblish for example. + + Args: + root (str): root path where to create instance container + name (str): name of the container + data (dict): data to imprint on container + assets (list of str): list of asset paths to include in publish + instance + suffix (str): suffix string to append to instance name + """ + container_name = "{}{}".format(name, suffix) + + # if we specify assets, create new folder and move them there. If not, + # just create empty folder + if assets: + new_name = move_assets_to_path(root, container_name, assets) + else: + new_name = create_folder(root, name) + + path = "{}/{}".format(root, new_name) + create_publish_instance(instance=container_name, path=path) + + imprint("{}/{}".format(path, container_name), data) + + +def imprint(node, data): + loaded_asset = unreal.EditorAssetLibrary.load_asset(node) + for key, value in data.items(): + # Support values evaluated at imprint + if callable(value): + value = value() + # Unreal doesn't support NoneType in metadata values + if value is None: + value = "" + unreal.EditorAssetLibrary.set_metadata_tag( + loaded_asset, key, str(value) + ) + + with unreal.ScopedEditorTransaction("Avalon containerising"): + unreal.EditorAssetLibrary.save_asset(node) + + +def show_tools_popup(): + """Show popup with tools. + + Popup will disappear on click or loosing focus. + """ + from openpype.hosts.unreal.api import tools_ui + + tools_ui.show_tools_popup() + + +def show_tools_dialog(): + """Show dialog with tools. + + Dialog will stay visible. + """ + from openpype.hosts.unreal.api import tools_ui + + tools_ui.show_tools_dialog() + + +def show_creator(): + host_tools.show_creator() + + +def show_loader(): + host_tools.show_loader(use_context=True) + + +def show_publisher(): + host_tools.show_publish() + + +def show_manager(): + host_tools.show_scene_inventory() + + +def show_experimental_tools(): + host_tools.show_experimental_tools_dialog() + + +def create_folder(root: str, name: str) -> str: + """Create new folder + + If folder exists, append number at the end and try again, incrementing + if needed. + + Args: + root (str): path root + name (str): folder name + + Returns: + str: folder name + + Example: + >>> create_folder("/Game/Foo") + /Game/Foo + >>> create_folder("/Game/Foo") + /Game/Foo1 + + """ + eal = unreal.EditorAssetLibrary + index = 1 + while True: + if eal.does_directory_exist("{}/{}".format(root, name)): + name = "{}{}".format(name, index) + index += 1 + else: + eal.make_directory("{}/{}".format(root, name)) + break + + return name + + +def move_assets_to_path(root: str, name: str, assets: List[str]) -> str: + """ + Moving (renaming) list of asset paths to new destination. + + Args: + root (str): root of the path (eg. `/Game`) + name (str): name of destination directory (eg. `Foo` ) + assets (list of str): list of asset paths + + Returns: + str: folder name + + Example: + This will get paths of all assets under `/Game/Test` and move them + to `/Game/NewTest`. If `/Game/NewTest` already exists, then resulting + path will be `/Game/NewTest1` + + >>> assets = unreal.EditorAssetLibrary.list_assets("/Game/Test") + >>> move_assets_to_path("/Game", "NewTest", assets) + NewTest + + """ + eal = unreal.EditorAssetLibrary + name = create_folder(root, name) + + unreal.log(assets) + for asset in assets: + loaded = eal.load_asset(asset) + eal.rename_asset( + asset, "{}/{}/{}".format(root, name, loaded.get_name()) + ) + + return name + + +def create_container(container: str, path: str) -> unreal.Object: + """ + Helper function to create Asset Container class on given path. + This Asset Class helps to mark given path as Container + and enable asset version control on it. + + Args: + container (str): Asset Container name + path (str): Path where to create Asset Container. This path should + point into container folder + + Returns: + :class:`unreal.Object`: instance of created asset + + Example: + + create_avalon_container( + "/Game/modelingFooCharacter_CON", + "modelingFooCharacter_CON" + ) + + """ + factory = unreal.AssetContainerFactory() + tools = unreal.AssetToolsHelpers().get_asset_tools() + + asset = tools.create_asset(container, path, None, factory) + return asset + + +def create_publish_instance(instance: str, path: str) -> unreal.Object: + """ + Helper function to create Avalon Publish Instance on given path. + This behaves similary as :func:`create_avalon_container`. + + Args: + path (str): Path where to create Publish Instance. + This path should point into container folder + instance (str): Publish Instance name + + Returns: + :class:`unreal.Object`: instance of created asset + + Example: + + create_publish_instance( + "/Game/modelingFooCharacter_INST", + "modelingFooCharacter_INST" + ) + + """ + factory = unreal.AvalonPublishInstanceFactory() + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset = tools.create_asset(instance, path, None, factory) + return asset + + +def cast_map_to_str_dict(map) -> dict: + """Cast Unreal Map to dict. + + Helper function to cast Unreal Map object to plain old python + dict. This will also cast values and keys to str. Useful for + metadata dicts. + + Args: + map: Unreal Map object + + Returns: + dict + + """ + return {str(key): str(value) for (key, value) in map.items()} diff --git a/openpype/hosts/unreal/api/plugin.py b/openpype/hosts/unreal/api/plugin.py index 5a6b236730..2327fc09c8 100644 --- a/openpype/hosts/unreal/api/plugin.py +++ b/openpype/hosts/unreal/api/plugin.py @@ -1,5 +1,8 @@ -from avalon import api +# -*- coding: utf-8 -*- +from abc import ABC + import openpype.api +import avalon.api class Creator(openpype.api.Creator): @@ -7,6 +10,6 @@ class Creator(openpype.api.Creator): defaults = ['Main'] -class Loader(api.Loader): +class Loader(avalon.api.Loader, ABC): """This serves as skeleton for future OpenPype specific functionality""" pass diff --git a/openpype/hosts/unreal/integration/.gitignore b/openpype/hosts/unreal/integration/.gitignore new file mode 100644 index 0000000000..b32a6f55e5 --- /dev/null +++ b/openpype/hosts/unreal/integration/.gitignore @@ -0,0 +1,35 @@ +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + +/Binaries +/Intermediate diff --git a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py new file mode 100644 index 0000000000..48e931bb04 --- /dev/null +++ b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py @@ -0,0 +1,27 @@ +import unreal + +avalon_detected = True +try: + from avalon import api + from avalon import unreal as avalon_unreal +except ImportError as exc: + avalon_detected = False + unreal.log_error("Avalon: cannot load avalon [ {} ]".format(exc)) + +if avalon_detected: + api.install(avalon_unreal) + + +@unreal.uclass() +class AvalonIntegration(unreal.AvalonPythonBridge): + @unreal.ufunction(override=True) + def RunInPython_Popup(self): + unreal.log_warning("Avalon: showing tools popup") + if avalon_detected: + avalon_unreal.show_tools_popup() + + @unreal.ufunction(override=True) + def RunInPython_Dialog(self): + unreal.log_warning("Avalon: showing tools dialog") + if avalon_detected: + avalon_unreal.show_tools_dialog() diff --git a/openpype/hosts/unreal/integration/OpenPype.uplugin b/openpype/hosts/unreal/integration/OpenPype.uplugin new file mode 100644 index 0000000000..4c7a74403c --- /dev/null +++ b/openpype/hosts/unreal/integration/OpenPype.uplugin @@ -0,0 +1,24 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "1.0", + "FriendlyName": "OpenPype", + "Description": "OpenPype Integration", + "Category": "OpenPype.Integration", + "CreatedBy": "Ondrej Samohel", + "CreatedByURL": "https://openpype.io", + "DocsURL": "https://openpype.io/docs/artist_hosts_unreal", + "MarketplaceURL": "", + "SupportURL": "https://pype.club/", + "CanContainContent": true, + "IsBetaVersion": true, + "IsExperimentalVersion": false, + "Installed": false, + "Modules": [ + { + "Name": "OpenPype", + "Type": "Editor", + "LoadingPhase": "Default" + } + ] +} \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/README.md b/openpype/hosts/unreal/integration/README.md new file mode 100644 index 0000000000..a32d89aab8 --- /dev/null +++ b/openpype/hosts/unreal/integration/README.md @@ -0,0 +1,11 @@ +# OpenPype Unreal Integration plugin + +This is plugin for Unreal Editor, creating menu for [OpenPype](https://github.com/getavalon) tools to run. + +## How does this work + +Plugin is creating basic menu items in **Window/OpenPype** section of Unreal Editor main menu and a button +on the main toolbar with associated menu. Clicking on those menu items is calling callbacks that are +declared in c++ but needs to be implemented during Unreal Editor +startup in `Plugins/OpenPype/Content/Python/init_unreal.py` - this should be executed by Unreal Editor +automatically. diff --git a/openpype/hosts/unreal/integration/Resources/openpype128.png b/openpype/hosts/unreal/integration/Resources/openpype128.png new file mode 100644 index 0000000000000000000000000000000000000000..abe8a807ef40f00b75d7446d020a2437732c7583 GIT binary patch literal 14594 zcmbWe1y~$i7A@MiTY%sJnh>C&k;dKKU4y&3ySo!KXmAhi9xTD#B?Nc(%Rlqayt(hq zmGAY}RbA)QI%}`9_ddJp>#B}WkP}BkCPW4R0BDjDB1&(c{(o(V@NfG*K7&yJ0LsTg zSXjYHNnD6bQdF3YiIa^D454QN0H_mOCc3PYp>PJy$E88Im1>MZ5@BH~c-j`Uu%C&Q z>XB#3W8y_puUPq!?+3hSlyu`D&PpNz?zBTfyc>1-q)HvIG*sP zj*=@|ihngW4wZAm#KS{2Xi-8F?;=canoy*&Qk?2)cg{0be|{kIcbh&gNjmDh)jQTJ zrNZjb&5C+B_ul}%w?ln^32Yk@tz1IxagrI>kxJR%bsQCb3Dt2L@{5m>9`JyKVY0cM zU7*KmIfVU0{ltCTV1AebFuFdOr6leP7MTnToS@wOHJa(7rn^?pS^V?6v@bk%)gF?l z=fm898fycp3{s`!ObDT&i;K;f8 zw(mFRqyhF7zwQY5?fF+|A5yckvvW%Ow|F4gOK3U)04UghZBT%WEPMa}?!rPv!&yUC zhRev#hTg!~&d`M3-R3Ve0KmiVZf{^@W#UX`Xkunz%L_bh>jIKl81n+vS!Eez?S)Ou zEhIc0O_V+5RE#{Wj5v*f{Cs3Q?p$vKHYUynWbQWBwoY8`yug3(a=jh@)y)7T`v=6? ziWeyOmq9WOSp_m-J4X{Tc6uhT5hEib89OJviLn91klB=u48jOuVqkiEvw)c(T+EDI zED*B4U%)qWj>e{3N+M!^8+&W<0?nPB?YS5j+}zyg-I(d^9L*S*I5{~P7$FQ02>1;F zcJi=wHgE^qI#K+KLBzz#$kD>y*}~42>@P+GLpv8|Uf`S5f6l?i{@=8=PJjF9&0`Gi z2KEe0^o)Pa=^sF2qkrSCdy=?%;DZ>+t!owJ>jx!wPQ`roJj zCj)Q3m6iRsjsL2}#^&E9oSa2n-=^`mL;fq;NyWq7gh9!~$m$VAljO(w-(v$5wA zb~G_?wsTamv$OtJq!j)onGC{A&qPM8ZeeR|=jKH79|KH844h4PfqzBqEnZ*n(7s?6izbT#StWgv#0(TbO$MS11b?1oA&Y-*U#-z}evc2sSq2GPQHGF?gG>g^huk z34^_@8IbJXZsZcSv$k`5GyJBG`9J$5-|Ca2ovDTO+ll{Ao%)AdSy?VgTPJ4&TO$)m z5nkY%bLcHBjJcQ$m{|?k3=P0+Y^F?LP7W3pFbAh8E7*j|(1_ibjgy(l5c03_B6dbD zf2F`*v;8K+2x4FYW@TqF1{)eMn!Gic-x{ojoJ@?6zta96 znZzYw;q(?`kG~g^vWdgrN7fc(|41G#1Eaqd1uxL(uWT?e2L9b`@n8J$e`Wda@owfO zZ>0a5EcvH(Cp%MTHv>l#L9;jC{U5WC;eRFG$-wo0Fa7^6l>gN9U#0(N*8cyI{iR~M;<6D_7 zkEi?%05E|iMFdscvyQ)dG=tSuH~g&BzdD_C*hyUIzC%Pp!b&6q#(z;14E2YVERZ2g zDT%3%gxNpQ?EnWZGNroX3a|1i`wJh^%)q30G;t%N+xk^_wAf0G9s{~i>j^q6?l;I z=tbyp`GD$gE6yP-@BuoWDF^T~kJ zOCS4@e|utesBRKbd-+=hs0NewLInhsSpyfiOZ}V#L4wjI?LHc2{jv7yl>V3g;xKrK z7ZReUP;wU{A7OyGApcl@d6+lbNGan{dnw551B5UJDwAGt;n!l$;;7k4)eTpuEpb@aOuyp0K|vfPm}lqWsnlwCM}Jt9t90}U^AY>O z+M}NdZ4~<}sSpox)U9I$jNYPcFerLp=j*lA5iY}PQEO@SBSYA5GAT{XbKgb&f@TIy z2qi=mZ1lXC2xN)u0S2_m;xnCY6@Ml^a#51Lny4ZwYt4%Nd!D`EuBYC*65n+ zka7_&AQP#1S1>=A&p%u}h6Su6wA3F8khowD+<*~rh$sivpiB#Lb&f^pn>vue*6lUW zWs;}Dz>}&wo?g(&*hhaeK($bdx@`MkEf!`6@tqIWy%#SI)vZKO;Fri7ItZ4h7eX?E zSK4)=VS%%b4Y7f_0ZD!wRpvLi6IAGCqBFu|()S-V2PQ+X1?({0Ye9ByFzz_h#Ne~Zh%`mpMxJy>`YH$sUs>g zxBl~q5=GfiS zm@kCX#WT%DHPtZuP~PsQXaRjbSVnMcvLfGAib8+0ET^qV>^fTXezrQ34QiA>wraf9 z#*F<0)sA_mcV7J`x#j{rm{fc*4MZUH4OxRRDgVbW^u^8(+vm1ILNKu~yPNKz1tNe4 z!uZ`^!)IR2-SQ;u9AX2a<1-AaX1`RJ-P;Ci8jz6Y80n@_wg( z^w$rAQW`6pXxuN@i{enRbWqf%u-TtpFc1>6dqcWSly_ij#?qqf@euR9X}c3X`n!?y zG*mfR;d^tO+1bE-O&gDVDqiC=wRd-lz>L2(-6$1s9&eu~cH|8t7{UnErGCt@x86KM z5^zWBts}I!AHM~>E@|CV`QH(Y4KyJWS0TGYF^(_!5!-Tp3DRjFC!MJaIE6idp@T{i zw%fu%j6z%wxrF=HyXU*X7W~|-ribBOH|x`Z(wm3vfA&o6$2f@hsENxr(_jC)C&R)W z@yszeN^32Zto{3O!?j1q&HmiJ3p~B%w`>IOtR8}I^wwzI$GLjYRWQ%6XIRM$`pbvB z*?BzuLw^XMUng{SbI5NR5UcILoGACbP66{So{#Np>{zQU=mwr69%4plZCD=)^W%E= zDCrHYyDIj?O$=WmrTEBFzWJaHGNlbkYPaz%pcX*q)sY&8Y9ZFygYA(%C#=K0-#;Q4 zx3ZFbg4CO)b8wT;AMVCTjTMYL9Z6lwyDtAm=B{CYl1sF#>TjFwnem$5*@|gy@IpV} zSOsLWT)#ivZL+CUXyV&=B{t>v=Jqy{VeSCiX?sQ?YuxEdqGPge|>YnDG%*DO=b zFHk-n<{$AWNjJx`Bx_&5Bu6RuG(HL^wql>Z9e$DSzjJaJmbf%Z0;+Es+7N!B>^)vtS^369%XAdqPJ()ZbofwL{(Km0lA7n zjQN?Mlb*^}b+})VKbBk0&g9vOgiuvNJ^N&MoDwn)Iaudls&}0uqlVY(dv>m$!Q%}< zhfh#WVr`44*G)57V5K{h9h@g~_G;wWy6KlI6g}+?VqDik{$;U`nRhhO)F@Zof=AiQ zlMQJ2F^V`yLn4sh2p>)N;W4{5D1b{8>&;!Fu+irp17KxZ9ZlA)H52<_oA7bO@_N-o zByuwBT|t#)D*37}WenH1==Ah+qqoa1U%P5rj_OC$JfMe^50zVAelShfX^%>p-Zfx% zTQtlgS|q?{WE3F>L1{#FyucqFyZNhrTMrWKDtfURienR$!(22Wv`9E)?q!@vMg(O` zzJ3$ME(KBqp_hH`YqQgtTV}pH?SScEUs<#@T0GyegX@~H%s}Krdg}X`czHV` zr%BfGH5b~D_hQhf%&L0ugNEoA_;99ctg&Srk@kL(M9U!*7QOZU&|Ohh*%wrY zmqn3A{m}-YYJ@qZ&rT$+E@+yX7kB1F0mPgyrvk88ZW*N^V*BYUX|t1r79&er%R5Sx zPT4G`NfFxNtsW0cq?wWq(sp{UUmMN1&-1lL%?Bqc9W#hK_o!VukvRn8*KOdvzLVcs ziFC2lIn%$GA^88{VKz6YnfD?2{8?D-i(nrVmW)+(_jBIbgY4+K4Zd9hw4hS|gZ7AM zVa_5QYj4Rb7&{2(%dFE)r_ucq2?h=N`<${bRCHQS8WO{2-(2RuQINsCFZEY10Vye4 zHjKSq{7mHRqYKPJym<7*SZyQi@L`}sD{AiKR8xkb-`bKmN<{`dgf^?E+F;#k#bSv?wMNJd3KN%Zd{!s=GX{Gw5ST~0StR{O*!!E@pul=|=%Y4IMYuIaUG35TEp~VP>E#;+hbRnBv zX;)7;{xV>CIn+jt%pBot`klIAA~dTlF&~0=en-ivI;J0rg}&G)ioK;)l(VZ5i)GqQ z>L;n2=^7aCV0t$Grkjf=meXRi&Y~Xva(UK(_eWp!1w?T-?VhA|8|u*86R^yJc|+@R z58|lNZ>Z;`-EO+Qm9-Q-5!d6Evy_80lLWhP9`-!rEicZ*o%Cp`l$|I=T!l2~S@TnD z!CWgpz3;@HH`(p^?)F#c{Fn(junDESiW<$a&rzbYY8FK-@WGm#P|S31m9RKYoU@aR!b|5oh(RFdq?IZGTUtTLURBguou%<__ah4ppo>He4=Rwe? z#a}p|jV&_FzO;%@O0l%O8zQa$2KRvo_pJ)KRX4_vn$5*rUYhAd7OpH8YtT;GGIQmK zH#c+2fsYEuh&Cc7Ulk8>vno3{=G}ClNR7Ue4hUC zvk%?UgVk0L8WuQ;1XN>EZTdJ5IYLCg7y5y}K0Z>!$D$vK z`b#w=cn+3`%+B~%nboko51sKrk*>2beh!Og1!e{ z+HrDnZDI89^8D{IpwhxOjk*JFu%vk-o==N%v@FdJlSIbkk4G?hSLX;01P)wGU0N!@ zK;CK@Q!=(c7Ls;Ticeqvyeoy}E{VRmcG%G(Wd@n)LTISfyoNN)Q}xjl?itUvJ(R$h z-}d5)$ReG-_EzM)zWSi(y(_xpst!w9oC1nNIF;Gr$Fd$x&6_1*slz3Q*h4cOY!8K% z8)gKQiI|Cs=%roCB%V~eyX|iuLcW7eYo(nko+t(Ba5CX$r9$X*xWT75JMVB$pjHsC z70y-_KCC6%Rxuwg@*)@KC~-HLUqu`fqF zqcQcQ0pg{k>w@jyrDO|h@~Mnm=rF<0f@Z;F$2gO7<;lc6%lpJ1E3C>Sd(g|oC72F< zG*c@C#o(;V32jYjy(gHj>-^P)e0aM<$Yu;*@5pLK9f^9o7(9h5?ZRL)xDuT-=)c2A z_@t*x1Yv$B?Zy%?v70+TyS{qxkLX8(FGKzsZNLM|v~Kn#Rqx~T8n#xZ=JZZW=V(oK z1M_wHKJwgS72y4mz&LX)4&lFI211F8mZv3@E>CodrbVuA$fl;=ppzsp9$c34xk?uHpCLzBu6Vt5SHnr8UN@vq|I{! zqPcjZROj<}YB`%+jpjw>*EQE5q4rE{N41Ds1TnWrWtZ3L$|1;*-zWY#B2wasEpuDC z83&L*BWe1y{e3Wmn9y?r4fOgD2d>~M_^LnT(t2?rD>kx}01Vsrz(E!AiP3Y9Uk&p< zNT`?{Qit%mD_seo-o$F_=2?~7UjUod0fzVh!K}OU*Yl=7YuAV0yw@6rid>2j#7c9o$Fqb{L>L<$IcgB zOEaPtoQK|jmIpm%u`7H)^QJ5D7nkyd78dbL15Ck8Y+lim@3&Oia>wk#6(Qlx%atCw z{@MbNGwprNq9{fbRCt0Of4WE%@Z=@_0Z!(ne!eWEOp{S_f!vp99`LK}f{`sqO6?zeJ3fl5hQGd6Yu@`tJU7%{&zuU{=OT9 z=AI6o6xQ@e^l)v~;>F==X3YdX5QP=?Jp@0k zF5PisiE}3`1Qa$k=~;JOh?Lnlh3X3a2yJ*gE?(m6v8MmsZU@ZWyUia)YuCeh)mlHx zqt2h|Y)FQ_f7)%Uzs)0K-pZ72&p@6G2daH9oXPXOEjQhD=H zZ1GxA_?$5#q?j~P&y>@rNo1UmZ&ulydbK*`3`B^+g8O?;AHw5ZKf4jQZh{m&nj^E- zf!;)WRT&kDp`prr>v>CyFztZAdGhZXKpYHrrcq0L~|BJEwmEIVLv-1zL&DHw^O_I&?rv5p=5+BhQo zPN|koQDQ1$TE^c#h+j=mu{$&Q$vmX$gS}wh+J^1Gi85*ExwJ!Z@9_QG<>Ppv=*=4b z{LsU4)l*zWT?Lk}$o^K5NzZ-2{<;5p@>3K5a0Pg}l0&ZB1}ATTK%A08-ma;&V4Q^jyR5_}O1yyi(#QbL!bYn5ysb*_cmze;ooe&};Mc>|u@Q=38 zGzY55wsk+}!{6@~j`4Zvw(VmSQznkexeuLW`M;V7bSqy*Da6ACS^~+oE3$lzTfL`k z8R1QMD%dcT!t;IjQ>$>zR~C`xfNG?a%W~c=$sL2cS?dSzVkc-rzXrT4#eiEn?*x|b zq$o>j+0{m|t?hcs#%qwhUe38WWxToN=vAHFI=_qA6d~&fcTJT_^;D}aL*LGGC6q}z zL9Oj&aGePjjZlV;$FC*BqDvOz(_~wWDA7E^Gl70k7abLNV#s(D(WS)U(wxd0B)Z#r z+*Ys^_KQtWOBG05PjCC9Ko1Z3@03}3d{?NjvZ$4w?Tv9Iknt{;17o?u`D9i?%YOo_ zOq7l=cEgRO9S~BNUVr<*h_uz{B0rd%=EfcoPiTY5Fe&4}HA?k_W`Yq3%>-u{U57FA zuCbuyhlYsI6{D3vvXqv%9DSussCN!KVJOQ8YgiBUj;VvsYxmTzD1PpOp?>`)O$}qZ zPxz0YP^Bj7ySOl%2%*YLhAO|7MH3h8yV2$vU$D-khxur8aE+!GOFzRi;BD#nYekib z&Nwuet+Rbv`=D?*hBS1DXt1qgybG(AnTpsJA(WwBwKZ@Zcs@C0$TlLMeAp2pNY?Ea zAw&Go`U~NzJ-Kou*v;i}?a&DtLBVEKiDeae{my<}5inx}dWlB z?$fdo?!1nt=2sk11=*?wbNsRnCyZg6k#A2O+NG(qq4IqCbxg`mVm%i*8cLhFrw^sB z9!RfUmNKl?7{kyeR`vxEPE3*30Z!vCFqMp5Xb=WVlvg}K?aJAI2}D}CUHM5#J7GPa zC>-nOnvN8c#UWr!{qQO>;0*b0X(VU(K$HHn0gfC zav$CpXwZs*rIoGkZ-LhOhQXnizvD4vL<}sFBRS5+t$buzB^eOAr*E^yYnw(+K0zte zUW%wZLg%GXEfMKBwOGwHVGcy#o~MZ%V%cGUWk_7DoJki?!$KHRf%|psAM@xf7|z6} zbxh~C3)R1LU_uc!JkbqUnV{qznK#Kj)&u|5S)@#nkx_}#v z11Z-~N?*_C(2Wa00?!tDn2@yDIUzPnGk_|%B6e?;wb&iT_&8>|h83nZ57J+ad;#jV zy2E#Kv^6u{;ldKF%~=oonAAMG%&Z)q&H72#Q(YRjdTUe0fMOtBaaP* z2YB_)R&=t?C-W1f*zHDFFRnavKrZr<{Bst9wklM7X*KF(b?=?^@M zl^Hr_$lre@y>=>vaEr7fD>(GjP{Y+t0opIfo>>X00^Z z`n`-RMrot0&O$S{H&OqOV{iabkzse|J*bXtcidLv7Hk0Z?lmM$VyqtVAW#Te` z+%M{6IQ)|Y{4Jat`*blIrmq-m4=yCHGjwlUb!vu3u#)?0E}=?V&`x0o@AG;oJoYQe z7F>u*d;o;c;rBS!!jwNhnS_d{9UWH3*zwDGjhW1K*#GqZ(El@T3R<(IBUnXDwy*^u zyR9+>y)3U7X)tVli`iz)L{V3`spc+V8L0Di)zIhUwbfU{sKNygMP@s@Hqtw&!j_t? zxmW`I5#Dn}{=-I|E9q0j(zbx0Z((BD^(E9AiUNw3yK0go&hSAh!E+f&U}eMYSUp-l zoOk5xh4OM|?WI>Vc!F_<+inlmcx#JdHUfd^2D85M?DI($G~DK4VXwNVhOl$yq5N4b zZoG~5zz#VIh+OO?jBVGstpB3aCI=!?|98JQ+N-B9yy^uD9gA)EY^+HO8 z8#c}DpP$el^tNZ@YPDWmUT+GcMB%iTk_~zm(j|NTyxmBV#{OwKh%10JCcw$wDSrQ5 zP(yulMBw>^J+fAr%kFe!#BRUrYg={jv#aIId^(3)YczjIfBYPID5iBd5cve>Od*vD zP<{|Cc#1Vs`&)bne+hMzaK^OG6jMrqHmjHB_EuN-gjA;@1Y)R4R&s^0$Fh4dl z>c|i#iWq+QH7hY>w6Z2{&HmP5^<9a~?-**oh)ALw z@WL$A&jm)!?3xxY5@z}*f@tlkKTx^F#bSqD0$jj9b)Z&IuWOSC4`yIi(Vp$bO~|Yl zlX~#6ItkHUbXV)1Ox7u9GgfSw=&hsr1eJUT*GK$reAGfJm2E!ryb}@Z+GY$z#Fx`W zm{mT-g85wUFOe89sWje{V9Q0Wt%!l(O`5|6A)3$_3hUOs=~b;T{VOF3e14fqX^>UQV2HM#PnkO2 zO=hdO3gzMAVuNKcXddv5veXw2gO-eQh#ElAn5&pO=x_sj^QzaL$ySP0P~$t`V?G?l zg22rav>(f1I0X>IE^rWXYB13R)6fpUq;Y>ksX=G22=GxIZWnU6+{FoNv7hSXa?-qI zh9%shLt^YI;4SZ~$KGa{NIqJwse7DmwK&^m-26%gp{Mqu9b3bRdLK#C6PZVi^35*y z`#y1VMjQnsh z9cs<)C8h!W(iNB2%SDe7DH{+6#~TalO)?=Rfj_GJa8jQ1yPYT$MvS-aBv4 zhih81>PwSQX$wiblZ!yMh4I*hOy$b8(nkgEo@8m#;CI|*8ZEo&{szb8^Lar?s1Mra zH88j?#m;K*%=C=H%C8b%6f178D@#|_d5L`dz_e`YY|CLHB#NrfWrwyoniDX-Jc!PA z-(ElC*Dv7-{%mbYJ-8^n*{8Vhk(p`$za*I3a++!IfJ6k%UVjST`bVx~7H-cS(FHdpGB@T$R;=GXt z11F5;_(VT^B=t#KOpX#03E9`!0J15?_O-{6j4FtM? z>E=%6V64->{?f!MmAkrGm#+d@4MuCo8J6*JqJYrB<3heE!&5uIHeIi5;TP7QtK+mf zx9(;~5dllQe4OpXwFm#TABo(9cAlUUasS(`&Vnls+zy?PYH}Tvb}09~JVOUsHW%N# z_A+{-ogkKv9?N*bYZdvbofC?ikKDglG3gwRePtU~C`Qb`pAfC@sph<=awALG-p^brk&zX{8dwjIKvyJ2KBuXKERLV);nCpFP%w8^JAfYuP_)X5p*HGLt6*@-xi_ zHlm-m1WwH?0jC?U<_7oYdj$-?g`e}ED%de!fCtJ{bMgouW=d;&=l(!{%z+^*YH{9b z?f$)%5dYa-XlVOI5KR=XZtR}Cqmm>VbVLJplT9hAI;BmdV0**VhAM3)3R1ni zzJCf}S1}UaWsEKATid zHqd${Ub@%TQ19H4k2gVb&mZGxnz0CztX{vv%6!nQ8zL=i_5^s^Lp4kf)zo3zmI}!A z-e8%2?dN=M1z}brL+Ho8NqmUO6dQ^j)fj=HgqTk1JZlFO?)$c3#j(j6g(}Cu704?V zjPK72tjs}5F;-#bMV30LgC*_gv@F${dT214sS%zyj9KV96ET4I_FCJKNt9Fg zj0M@3yuDE(n3A}#V{-X;j)p@^?@VdUfa|PX_$kwkLL_4doz;uG8#ZhmaYZ;6&)%{c zn2=jgWm2%gU>hdE1u?wGL=+%eZe%ym130~N0g(vfo`H}qKMFxVi z<>DuXIhpqMROG?)o~H3L8CIXp@<2&>ztETx_-s}A*=CgV=-maAOqZp5I~!t$s|S#7 z{;~9!86ivA-|e^Ox^zHl!HGz`8j-#(LqK)eS&{JyP4_7sHTIPpNeeUDkCmnQkDS3j z%CfBiPHp7d){u|$D>4P?_31td6qans-MmFg18(g%ZuB9`Q`bKi<=U5-6)JA`j!E@H z3zuJu`|S{<+F@6ApCUb(f3qp-a$>+01!>-+AZ8S?UBBCyxu0|1`T++LIMB)ZoM*jR zMLlKJ^HsB7U`|?(Z4u5iPb&ihuClflwGC?+OkBRtIif#TW3@T=#Bi($uNbKqAQKsW zRueyN5KksFI6R$T3HjdNsuGe=eRChETjm&7Cg3YK&-8rx6p>q zF5C3XPp12~<|2PwWVK(_8I<%$)YhJCm<1hAE@{r!qD+Rvq2X#TvdP8}N3U7d~-TH4k5qF9PmPmdANfJKmDQ{8|+*v+Is8%@q!DHl?wz<7dtcX@p&PJSS@&6~=Z5x3e=~x}uGqs>$XiqnQR%fI5sC1g z8N?|-selA=5MRtt>={AhX2RZ@$1lh zd6b|2i+Fh4$A}R(X!Uy2CZPrvgL<3av5Zz>2#DVF;Y`B_6_fX+1qxOsQ|l+44L{W# zqIAq|#&5vfU5@Xho2uxz3T`dBxi4YCGxsNj4~3sk=HF+0iN=lm!lhy)FSy#uBlyI` z5W17;s(^}8Rw%g#=sCWgx;4XkPGXeFyG&2!N#Sf1#a=23+T9a_yBhAMM)7R-o6`qL zdDO_;b)3fXAQ1r%vTi+ab#Xa-LHQ)>70^f zf2OaM6-p%;E?(LQF-uAWbM%1e@CZA}#c^)NAMe}1b)kFg!TUw8cRbiB%UV7w=>fTw zeY&{WX*zTr+-JAHWR&g)lAxb`U>*?Q9eh@B!aMP6?IhM2$71*mwQR5Fi<8p76D5Me ze{5isOftA;=4TTwXr4)TzecZy1J@28sVO^}D~(C*12pc_qDh!aYOm1IHCh}H21>eb zv=i+7yP)q`a|gCDMLf{XV(C5kyCS`KU#S)YNj4a&unk~-BkA^M(+)`n%a3{%48}xm zudHd2mZB!5FpY@}A!q=67&-$O zJ9bUC-uH#sk5-)SRDW?D#oQccdT<6`MIqAv(9?K$OeI*U-TJ1=zgM|*4G6{n@)UVM zv>N@+x4SW1EUd8`O<`D2`t-t6az4U;a%Va#+j|hO&C>q?H=ApC`5O&yuVfKUF+aIm zSzrurN4J|T8R_@i9lGNDjT?X5ZZP$H_yv>-A9k~So(^7GMKs4bO7VBcX;<+I?Vv{( zJadcjMlt!^38ID?^+loe>cS39l&E9PBmF>`Xo~s0-;DwON%PkJnofk92+<+jvec0IK=%%%_n(1@} zr}xtHd#m!KjJ4GtKPo1kBmhoRVw2t^H>3XGER|x_&9rBi+!9%NRlV&AKRdK^)Um3{ zD0bwp#6s;RgCB6Nj1gkGp?9YWlj;;alO1})5oDwM&?hm@Zw)x~0eJgkiKaN!&2{GA z9fWr&nmg9xKsAj{6QZ}gv97*g)(u-q>;t$oiZnn3K?ch%RQ6k`a;}+7O>wo)KTR#p zrMSApRz6`U8tgaPRCrIfc}$x@5pFd3(~9gw*i4F+U_DX<5H*(FUX~vRHESbGikECU z8SdzeTXrEhei|8XGMN|3Q-b&U!qk_zIefU#>0-4J2S=@+tChhqfB46SZoU4iV@KSv zynUFd6oh*A)uvDm5#?i0Bn6q^9?6li?G0l=uVm1asUeO(DL@x@RmG0#egbC`V7E>t z;$G29h2cgWiwad@4UHjsl>~B(y)E*UORlpg$ppH*TBy9OjJJEBnZ;F1i+rx-D(H4p zKeJr7F!@U`bGzIkQZk#Sm)QkS3bhE@c-TfsVFkiD%33+R$0Tin$(l)ov4hfah88wl z;Z-MBuXgC)9)@%je9{&6J&7+u-fluv%?{oRi|dakCV;lD(F=J6_U=UTDd1pwK+5!B zTY}jvachDi4K->metnI0vxTfNnSO`3D%Oq=rK(O_aj?_`Az$0>Pub93-GT!I8hG+d zn;QjhWKd*jozrYHOPaBHgxWf0*Zswz8KAPWq;baA6A-_ntwMbWyX1*QB>s+tpNAEV zNlQ6`AacKk340#&2vpG};R!S*ZvCz#sT0Ax$Y$DZ^BYeTq@}Vj)$x<$YEMrsq{H2J zK*l67WzX#>p0!%%w9b1JIcPam@bf}y^9l&1{Kxp{6eADw+^<=IdR!urB{_vS1YQ5V zq)X0N8tHX}gU^q+`nZ#W<(t?7kx|5-h|!=Co9wV?`QL->cPVuuFQ64WbtnJ~QlbOL zVMM7X8^HXBO&_YafTZCQqRlW^6 zv;DVB{!tECePL`7KT4fmbIgGd)qauFI~YWHD6TB>HW!YJcimuZq3nnYwqnAMjx8GZ zppj~%3w2f`Ow647m*L`S{v=SiPl(hC`f9r@IScWU+V)+vQCujjLN;2ve+_prT2UaE z^2o_+e}S>>{Dxh?WBJ)WBj+}^FKKWSOpZ{?odReK!(?I#&hcUtTslT~ zP=GPo%>d97bf>tC#}M%vS~t@jgg-Otj*3+ih9O3Q2HZjNF@7l4OP|BXNBZK0zOg|0 z!gFlmH|$IFx8{&lH2@p~hi4mQ|_)s;3jS$KmmLHI^j+;V7#m<=7Ka5GIRwdR#oZ^SOgH4vh z4G(MYmyCeIgSxZX9pu6j_SUPllW?cDG&{%^AW@#yn{9TOd%PX zk&21*+cTMVU<5DV?N<_<6gxmuenkP~L;%36kF?{gGKRx}!GXQwOMMH|qW=A??xhh6 zDsPPA-Sjq2KkQ0mCX1KJ$O~yYK&iznd?ZTo!#j4qh2ZA2b+#D+-gVFj2;>E8D^Ac> zlAzt-Z>Zp!oAn#7)>?QG>k!!|f3kW-YaB_S1WU+%S35|kx01SA^iDW=cIlEk0uXCT zwx_T`^3p0H>ZiNWdxmAj@|q}lHo%N3;oV8HfbQvrX2=Vq8M3^Eh}Lm*dVRU#_>dXP zXxJLRwk*JB%u>*hQ3d;g<4)1LghfzF0Xw*avWM)4!6UiZlUuFY2kxYJuwcN_5j7KOx*g?FD&ATLk>>bOQMY5jaWO2lZD9AH` zY#E6v4eOimSj&rg!Os#3Cb>2)brxUd$!&gEEThllU*VLxJGPCQ2hvYB?d1@;#q8U$ z1^m%gB8ctaTxlOfuv!3DlVrDYi|zR1ah_CUGv!Eek2-#4#)&iz!@;7|BFCA8*@!2A z7lJz~9n9Ub0%llogcA^KeBW_1wPtwoSBwXR_%Mnr3{UzY$B~n3cL9x$(6XN21Th6a ziv%(5y5k)`5}yE${rW1MN{F1@$eU^331IUbR?mAKda>K6@)cMy5jw_^mhHk^; zt&KIvP|_!9hM8R!wC%e;U??F}5OC%*}lO>d0lqC^w zwiXqyU6$-Zmc;*P``+*W-}n2z<2(L2=9zi!`#$gMItVs-B)1+uZO77_YCVj?_|DhoWFe02JrlTIpfIv!iyV z#aLN9#~N|^Xk?_N#0lzw4UW?0g~Dxe7h`c(=FmFAOSJ*}%^mpPg@ujuKhRy{b>w%| zHQS-vxxrOm0@@;XW@n!3GHVih<%OJuyI~5M9C3`hRYF3T@W@=$uu>|H-FjTnaFetJx;CgGQHo zv8)$*s}TusmlYJcew96kG}4SdCi#>YHxWwN*@l0(VUGRuPfGAik&&}*!RfIq|g`ogr1{1P0!%@dnAdPEXIsGl6;tF^}4@ zL7+|B*DoH>wd=b;Ac0D%r7g$S)C5Cf&|m~IgGhn-($>)+&_NwvCV}KZ;ed%0S1KI~ znJTY@fT?6G#0G7OFlFjd+^9$WSriNZW0oX;50VxcqH_p*&=&(3piwvkurJM%&c^s+ zA>Zs`fcy1nI0XC+!tuaDbk`k%ZB5O4*m%jLqjsxSu26^_)>(t;yU za1;s(AfRkNI)~s3rL*_`w1A_qNh~UpLuJsx>lO(_hBpTb0jPeDfyVr0md^f>Cm>^R zUjh@3gdx^r`UWJEe&LwjEYEMw$s{<%lR~4=Icxxn{Doz@F*ppi8{=@MKW(7k2pkF)vR*ZyUQiUu5~+#-3WwG>fIwmpi0@ES z2AS&O_m@yL3|jS{pnzt`1PSf29$l$M9sZ0LK73 z)j!YUf&Ro|xKKTTh5ys1zR@)`#o*~|4uMXh;Bi<8kQ^A5O2#0FWHL!pod{JUqBNlh zvKk2r=!^hWC#Y%>C`2_E5?bTuejJ13y)J?E{ojuRnLz?<{DYnvihxijB1uq^3mFY1 zlhja9O${Vq4MilYp%5A*3R>-_wcl7&;6xHU|7>-g6&bLoPIe)yYq_AIBou)HMQf;$ zp+o|L0t{7w0h*|VM4;AX|4m7lqf|CfW4|8<$%5kb(eSn*uuf`t7f03NZNfRHD#c_wr5h30Llj_x32l> zMpfhFk6PWmcG#Z5kCuB@*LrQ2P#$&Py-Nm5r<0sj&J{;MeDTV!c%^b$caHFPY=%xW zY8AkH{AJf{d%B0Oe-tUsB~wy^Di{yuvxlExr(W$|8`5dl*$~Tny7Q0_6YpT+Gx0i3 zr+@ml@rz=W_`uZcKmm~$(yq5bnm!OLndU|#{5)F5X81Brd8&Z^k)1~>k z&n%&{Pfq)dYtts{Iu&O`Q&%@ha_hou1@e?`irqz5KqA<{hD5=tC%ZyVR5ev;C-4UD zu7;{_*-Mgqa1|OsM0IdQb+y`$56K|mv4vdaXAm=CuFGnEyxBH)zc^6sZiK8HT6ab3 z69co7ZOj*OMkMbK4WdD8yS)8|c-Qod=`=lRAz?y5&sFhl!;sxBruHlnK(k=-CF3|j0#0i=90f8-#yj#-3ymHzF1`W$5?yC zTo$~&_>8M#+O?;3bWW|nO968$rm*nVm{^QE4L6e$Vbg~zknC=kll7Ax+N8uIW+tr6 zU&b4B*oi6YyA2_l#IwE9M{_jB<9Kj-$i1~YeD`!Nq(;<4&2CXiy|5@e9sE0w>Kw?# z|6r;`<@8p+Ph>vDB1!kSfVzt!>h@s*wkMgRiEWQwl+@*e+#A{-#$*rk9F*|d%lks( z(LU?qGs&-Qa9IaF#Q)aUwvEQ<+qzkohkvh`x&p&Fh`Cu|sJj3Cyb9i%tgL4DQBEFa6PDl z^^7o4(N7s{d5JtOkf*|#4dW$ap&U6TrStt;f|@xnf5=tluF_XP@i(nzv*|THAoZZbkkRq7Au?_q8P|EAT1W z#Ju5Qn=>^wn5w>kaGjDFtvYk@DE1D)-J$7CW~FfWjGFV?TSR8J;rN9)(`mI3WL@ov z9F<}$IcK;gX%i+AE_d-rYDvnMn9(5TWXaC=qf$zYT_R_X4$Y>E^L!=LAN#>f>ZM|jIXs7tA-mpVX)bT*(X_Gp6v-y*DQl{gZM{b`=xOR2#L+nel zp4^5H)Js=9zSKW>{V`0zo7lL)C5m^~{ZIjH)~&n_TUWvbz`o<&d;wOa;18R4| z^o=#m&d{9naPf?d$|pE~lu+}r3e$0C&%V@Gvj-cd#k-|RX?Il~Z`)x$a}u_JypUoX zVPWR${`x@axl1Zq27Mc`omF#t57&YA^BBE4@Dz`G_bTqaF}AwCL#bgIcYo2X@rBSr z_eSfqYq{Y!TKk&&+TPb+?=ZU@(9~q`!M*e50O<53rkKGS`~B+C?_XRXJ-gF+7wAO$ z6`s3J3G{hOe3uw%V z%x@4V0V9tHkMLKObSiqf3+7jl?@;EqBlgM5SV7ZHhmw?=ZAby)?ZLw>;Qf9h6XL zp<~o`dyhy=R<>FN2n!nT(Y^IMEb?%l~BEQ|6cF>c|c=6c_ zKHxiP$yq}I0e78rl;tSc6;3RK5Ajhp3#Rp49jPK z7l+qIp0+(3{PJ~608wcQ7uZ~zTD0={;sNnH9SKqHH?3~UnYDd+xEhz`7ghB6NxOV_ z2qnkfAxiPw7xmrsMxT2>N=Z+E)B3wVu=KI0Dt(Kxu02+SB>}?4WVpe`Zl{*DF+NnNRkJDic{q zOi9qE@|JW}uT0)UiO0(~9100Z63H&M0dcgq6UCLh#fD<=K}Ryzj-R((*c9HAP;1ef z<6-(1lXt#6yFJD^y%&GUk<{cd%k@(-cJaw^d~YMC_(hLBSI|&)EA^nZ$01C3!m?tK z`=@*8I|0}me$9^Ez5Ra}o`2bTJNMWaseSnL?%1wF1q=A%$|>%ZmbQ;o53$+zPl=V} zKKNwW)_8R#Q>9o|J970|I40BLS*3H3;802&X3u`e{Nv;^It{RpeDczzrV@@e_O9)o z6dUiAq%8XLGU9l$h4pb|WNNgPgGap9ee%puv%n0!)$;x#f6i0sPfm!DdwNdVMqJjB zE-9Gcq3kzYxz%e?Tcp7&S&=%yTZymToX%MtydzhUo*g#cxUwP>p?9EASt3CSf^XYs zmw9TSbnpz__37t<$d8psT4sif!1>c%V$SoJ-sX(r z;+Js);?i?1O(Qjf zOYRyx_IbX*r|dr zzSg!aUF;!2N$99~#^^39*(fQUJ@>-|f(#_NS);*LL3Wwjf8TYqI0567X>+N}WT?p2 zrplh}=BNt%%ou~oHa1U$1vhR^kCE^<{A$&!xV7i1e2+|YN$7)p?PIUhubduRo7f(2 zx_AgSd;FOoa-p&+$Y~C`7Q$C~c2WL=tU{^O9AB@EGwiOUuYSJSh>+qIp`Mue{k?mf eS0M`nAw10wMiL+(J>&JiOYr)~aJfgE!~O>^B0mQJ literal 0 HcmV?d00001 diff --git a/openpype/hosts/unreal/integration/Resources/openpype512.png b/openpype/hosts/unreal/integration/Resources/openpype512.png new file mode 100644 index 0000000000000000000000000000000000000000..97c4d4326bc16ba6dfb45d35c4362d8bc15900ae GIT binary patch literal 85856 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 bCJFu@j}?W0K?Ok;00000NkvXXu0mjf9`4Bp literal 0 HcmV?d00001 diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs b/openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs new file mode 100644 index 0000000000..5068e37d80 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs @@ -0,0 +1,57 @@ +// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved. + +using UnrealBuildTool; + +public class Avalon : ModuleRules +{ + public Avalon(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicIncludePaths.AddRange( + new string[] { + // ... add public include paths required here ... + } + ); + + + PrivateIncludePaths.AddRange( + new string[] { + // ... add other private include paths required here ... + } + ); + + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", + // ... add other public dependencies that you statically link with here ... + } + ); + + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "Projects", + "InputCore", + "UnrealEd", + "LevelEditor", + "CoreUObject", + "Engine", + "Slate", + "SlateCore", + // ... add private dependencies that you statically link with here ... + } + ); + + + DynamicallyLoadedModuleNames.AddRange( + new string[] + { + // ... add any modules that your module loads dynamically here ... + } + ); + } +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp new file mode 100644 index 0000000000..c766f87a8e --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp @@ -0,0 +1,115 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#include "AssetContainer.h" +#include "AssetRegistryModule.h" +#include "Misc/PackageName.h" +#include "Engine.h" +#include "Containers/UnrealString.h" + +UAssetContainer::UAssetContainer(const FObjectInitializer& ObjectInitializer) +: UAssetUserData(ObjectInitializer) +{ + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); + FString path = UAssetContainer::GetPathName(); + UE_LOG(LogTemp, Warning, TEXT("UAssetContainer %s"), *path); + FARFilter Filter; + Filter.PackagePaths.Add(FName(*path)); + + AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAssetContainer::OnAssetAdded); + AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAssetContainer::OnAssetRemoved); + AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAssetContainer::OnAssetRenamed); +} + +void UAssetContainer::OnAssetAdded(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAssetContainer::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + assets.Add(assetPath); + assetsData.Add(AssetData); + UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir); + } + } +} + +void UAssetContainer::OnAssetRemoved(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAssetContainer::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + FString path = UAssetContainer::GetPathName(); + FString lpp = FPackageName::GetLongPackagePath(*path); + + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp); + assets.Remove(assetPath); + assetsData.Remove(AssetData); + } + } +} + +void UAssetContainer::OnAssetRenamed(const FAssetData& AssetData, const FString& str) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAssetContainer::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + + assets.Remove(str); + assets.Add(assetPath); + assetsData.Remove(AssetData); + // UE_LOG(LogTemp, Warning, TEXT("%s: asset renamed %s"), *lpp, *str); + } + } +} + diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp new file mode 100644 index 0000000000..b943150bdd --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp @@ -0,0 +1,20 @@ +#include "AssetContainerFactory.h" +#include "AssetContainer.h" + +UAssetContainerFactory::UAssetContainerFactory(const FObjectInitializer& ObjectInitializer) + : UFactory(ObjectInitializer) +{ + SupportedClass = UAssetContainer::StaticClass(); + bCreateNew = false; + bEditorImport = true; +} + +UObject* UAssetContainerFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) +{ + UAssetContainer* AssetContainer = NewObject(InParent, Class, Name, Flags); + return AssetContainer; +} + +bool UAssetContainerFactory::ShouldShowInNewMenu() const { + return false; +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp new file mode 100644 index 0000000000..ed782f4870 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp @@ -0,0 +1,103 @@ +#include "Avalon.h" +#include "LevelEditor.h" +#include "AvalonPythonBridge.h" +#include "AvalonStyle.h" + + +static const FName AvalonTabName("Avalon"); + +#define LOCTEXT_NAMESPACE "FAvalonModule" + +// This function is triggered when the plugin is staring up +void FAvalonModule::StartupModule() +{ + + FAvalonStyle::Initialize(); + FAvalonStyle::SetIcon("Logo", "openpype40"); + + // Create the Extender that will add content to the menu + FLevelEditorModule& LevelEditorModule = FModuleManager::LoadModuleChecked("LevelEditor"); + + TSharedPtr MenuExtender = MakeShareable(new FExtender()); + TSharedPtr ToolbarExtender = MakeShareable(new FExtender()); + + MenuExtender->AddMenuExtension( + "LevelEditor", + EExtensionHook::After, + NULL, + FMenuExtensionDelegate::CreateRaw(this, &FAvalonModule::AddMenuEntry) + ); + ToolbarExtender->AddToolBarExtension( + "Settings", + EExtensionHook::After, + NULL, + FToolBarExtensionDelegate::CreateRaw(this, &FAvalonModule::AddToobarEntry)); + + + LevelEditorModule.GetMenuExtensibilityManager()->AddExtender(MenuExtender); + LevelEditorModule.GetToolBarExtensibilityManager()->AddExtender(ToolbarExtender); + +} + +void FAvalonModule::ShutdownModule() +{ + FAvalonStyle::Shutdown(); +} + + +void FAvalonModule::AddMenuEntry(FMenuBuilder& MenuBuilder) +{ + // Create Section + MenuBuilder.BeginSection("OpenPype", TAttribute(FText::FromString("OpenPype"))); + { + // Create a Submenu inside of the Section + MenuBuilder.AddMenuEntry( + FText::FromString("Tools..."), + FText::FromString("Pipeline tools"), + FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo"), + FUIAction(FExecuteAction::CreateRaw(this, &FAvalonModule::MenuPopup)) + ); + + MenuBuilder.AddMenuEntry( + FText::FromString("Tools dialog..."), + FText::FromString("Pipeline tools dialog"), + FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo"), + FUIAction(FExecuteAction::CreateRaw(this, &FAvalonModule::MenuDialog)) + ); + + } + MenuBuilder.EndSection(); +} + +void FAvalonModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder) +{ + ToolbarBuilder.BeginSection(TEXT("OpenPype")); + { + ToolbarBuilder.AddToolBarButton( + FUIAction( + FExecuteAction::CreateRaw(this, &FAvalonModule::MenuPopup), + NULL, + FIsActionChecked() + + ), + NAME_None, + LOCTEXT("OpenPype_label", "OpenPype"), + LOCTEXT("OpenPype_tooltip", "OpenPype Tools"), + FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo") + ); + } + ToolbarBuilder.EndSection(); +} + + +void FAvalonModule::MenuPopup() { + UAvalonPythonBridge* bridge = UAvalonPythonBridge::Get(); + bridge->RunInPython_Popup(); +} + +void FAvalonModule::MenuDialog() { + UAvalonPythonBridge* bridge = UAvalonPythonBridge::Get(); + bridge->RunInPython_Dialog(); +} + +IMPLEMENT_MODULE(FAvalonModule, Avalon) diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp new file mode 100644 index 0000000000..312656424c --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp @@ -0,0 +1,48 @@ +#include "AvalonLib.h" +#include "Misc/Paths.h" +#include "Misc/ConfigCacheIni.h" +#include "UObject/UnrealType.h" + +/** + * Sets color on folder icon on given path + * @param InPath - path to folder + * @param InFolderColor - color of the folder + * @warning This color will appear only after Editor restart. Is there a better way? + */ + +void UAvalonLib::CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd) +{ + auto SaveColorInternal = [](FString InPath, FLinearColor InFolderColor) + { + // Saves the color of the folder to the config + if (FPaths::FileExists(GEditorPerProjectIni)) + { + GConfig->SetString(TEXT("PathColor"), *InPath, *InFolderColor.ToString(), GEditorPerProjectIni); + } + + }; + + SaveColorInternal(FolderPath, FolderColor); + +} +/** + * Returns all poperties on given object + * @param cls - class + * @return TArray of properties + */ +TArray UAvalonLib::GetAllProperties(UClass* cls) +{ + TArray Ret; + if (cls != nullptr) + { + for (TFieldIterator It(cls); It; ++It) + { + FProperty* Property = *It; + if (Property->HasAnyPropertyFlags(EPropertyFlags::CPF_Edit)) + { + Ret.Add(Property->GetName()); + } + } + } + return Ret; +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp new file mode 100644 index 0000000000..2bb31a4853 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp @@ -0,0 +1,108 @@ +#pragma once + +#include "AvalonPublishInstance.h" +#include "AssetRegistryModule.h" + + +UAvalonPublishInstance::UAvalonPublishInstance(const FObjectInitializer& ObjectInitializer) + : UObject(ObjectInitializer) +{ + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); + FString path = UAvalonPublishInstance::GetPathName(); + FARFilter Filter; + Filter.PackagePaths.Add(FName(*path)); + + AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAvalonPublishInstance::OnAssetAdded); + AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAvalonPublishInstance::OnAssetRemoved); + AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAvalonPublishInstance::OnAssetRenamed); +} + +void UAvalonPublishInstance::OnAssetAdded(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AvalonPublishInstance") + { + assets.Add(assetPath); + UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir); + } + } +} + +void UAvalonPublishInstance::OnAssetRemoved(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + FString path = UAvalonPublishInstance::GetPathName(); + FString lpp = FPackageName::GetLongPackagePath(*path); + + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AvalonPublishInstance") + { + // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp); + assets.Remove(assetPath); + } + } +} + +void UAvalonPublishInstance::OnAssetRenamed(const FAssetData& AssetData, const FString& str) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + + assets.Remove(str); + assets.Add(assetPath); + // UE_LOG(LogTemp, Warning, TEXT("%s: asset renamed %s"), *lpp, *str); + } + } +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp new file mode 100644 index 0000000000..e14a14f1e5 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp @@ -0,0 +1,20 @@ +#include "AvalonPublishInstanceFactory.h" +#include "AvalonPublishInstance.h" + +UAvalonPublishInstanceFactory::UAvalonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer) + : UFactory(ObjectInitializer) +{ + SupportedClass = UAvalonPublishInstance::StaticClass(); + bCreateNew = false; + bEditorImport = true; +} + +UObject* UAvalonPublishInstanceFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) +{ + UAvalonPublishInstance* AvalonPublishInstance = NewObject(InParent, Class, Name, Flags); + return AvalonPublishInstance; +} + +bool UAvalonPublishInstanceFactory::ShouldShowInNewMenu() const { + return false; +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp new file mode 100644 index 0000000000..8642ab6b63 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp @@ -0,0 +1,13 @@ +#include "AvalonPythonBridge.h" + +UAvalonPythonBridge* UAvalonPythonBridge::Get() +{ + TArray AvalonPythonBridgeClasses; + GetDerivedClasses(UAvalonPythonBridge::StaticClass(), AvalonPythonBridgeClasses); + int32 NumClasses = AvalonPythonBridgeClasses.Num(); + if (NumClasses > 0) + { + return Cast(AvalonPythonBridgeClasses[NumClasses - 1]->GetDefaultObject()); + } + return nullptr; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp new file mode 100644 index 0000000000..5b3d1269b0 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp @@ -0,0 +1,69 @@ +#include "AvalonStyle.h" +#include "Framework/Application/SlateApplication.h" +#include "Styling/SlateStyle.h" +#include "Styling/SlateStyleRegistry.h" + + +TUniquePtr< FSlateStyleSet > FAvalonStyle::AvalonStyleInstance = nullptr; + +void FAvalonStyle::Initialize() +{ + if (!AvalonStyleInstance.IsValid()) + { + AvalonStyleInstance = Create(); + FSlateStyleRegistry::RegisterSlateStyle(*AvalonStyleInstance); + } +} + +void FAvalonStyle::Shutdown() +{ + if (AvalonStyleInstance.IsValid()) + { + FSlateStyleRegistry::UnRegisterSlateStyle(*AvalonStyleInstance); + AvalonStyleInstance.Reset(); + } +} + +FName FAvalonStyle::GetStyleSetName() +{ + static FName StyleSetName(TEXT("AvalonStyle")); + return StyleSetName; +} + +FName FAvalonStyle::GetContextName() +{ + static FName ContextName(TEXT("OpenPype")); + return ContextName; +} + +#define IMAGE_BRUSH(RelativePath, ...) FSlateImageBrush( Style->RootToContentDir( RelativePath, TEXT(".png") ), __VA_ARGS__ ) + +const FVector2D Icon40x40(40.0f, 40.0f); + +TUniquePtr< FSlateStyleSet > FAvalonStyle::Create() +{ + TUniquePtr< FSlateStyleSet > Style = MakeUnique(GetStyleSetName()); + Style->SetContentRoot(FPaths::ProjectPluginsDir() / TEXT("Avalon/Resources")); + + return Style; +} + +void FAvalonStyle::SetIcon(const FString& StyleName, const FString& ResourcePath) +{ + FSlateStyleSet* Style = AvalonStyleInstance.Get(); + + FString Name(GetContextName().ToString()); + Name = Name + "." + StyleName; + Style->Set(*Name, new FSlateImageBrush(Style->RootToContentDir(ResourcePath, TEXT(".png")), Icon40x40)); + + + FSlateApplication::Get().GetRenderer()->ReloadTextureResources(); +} + +#undef IMAGE_BRUSH + +const ISlateStyle& FAvalonStyle::Get() +{ + check(AvalonStyleInstance); + return *AvalonStyleInstance; +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h new file mode 100644 index 0000000000..1195f95cba --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h @@ -0,0 +1,39 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "UObject/NoExportTypes.h" +#include "Engine/AssetUserData.h" +#include "AssetData.h" +#include "AssetContainer.generated.h" + +/** + * + */ +UCLASS(Blueprintable) +class AVALON_API UAssetContainer : public UAssetUserData +{ + GENERATED_BODY() + +public: + + UAssetContainer(const FObjectInitializer& ObjectInitalizer); + // ~UAssetContainer(); + + UPROPERTY(EditAnywhere, BlueprintReadOnly) + TArray assets; + + // There seems to be no reflection option to expose array of FAssetData + /* + UPROPERTY(Transient, BlueprintReadOnly, Category = "Python", meta=(DisplayName="Assets Data")) + TArray assetsData; + */ +private: + TArray assetsData; + void OnAssetAdded(const FAssetData& AssetData); + void OnAssetRemoved(const FAssetData& AssetData); + void OnAssetRenamed(const FAssetData& AssetData, const FString& str); +}; + + diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h new file mode 100644 index 0000000000..62b6e73640 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h @@ -0,0 +1,21 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Factories/Factory.h" +#include "AssetContainerFactory.generated.h" + +/** + * + */ +UCLASS() +class AVALON_API UAssetContainerFactory : public UFactory +{ + GENERATED_BODY() + +public: + UAssetContainerFactory(const FObjectInitializer& ObjectInitializer); + virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; + virtual bool ShouldShowInNewMenu() const override; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h new file mode 100644 index 0000000000..2dd6a825ab --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h @@ -0,0 +1,21 @@ +// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "Engine.h" + + +class FAvalonModule : public IModuleInterface +{ +public: + virtual void StartupModule() override; + virtual void ShutdownModule() override; + +private: + + void AddMenuEntry(FMenuBuilder& MenuBuilder); + void AddToobarEntry(FToolBarBuilder& ToolbarBuilder); + void MenuPopup(); + void MenuDialog(); + +}; diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h new file mode 100644 index 0000000000..da3369970c --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h @@ -0,0 +1,19 @@ +#pragma once + +#include "Engine.h" +#include "AvalonLib.generated.h" + + +UCLASS(Blueprintable) +class AVALON_API UAvalonLib : public UObject +{ + + GENERATED_BODY() + +public: + UFUNCTION(BlueprintCallable, Category = Python) + static void CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd); + + UFUNCTION(BlueprintCallable, Category = Python) + static TArray GetAllProperties(UClass* cls); +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h new file mode 100644 index 0000000000..7678f78924 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h @@ -0,0 +1,21 @@ +#pragma once + +#include "Engine.h" +#include "AvalonPublishInstance.generated.h" + + +UCLASS(Blueprintable) +class AVALON_API UAvalonPublishInstance : public UObject +{ + GENERATED_BODY() + +public: + UAvalonPublishInstance(const FObjectInitializer& ObjectInitalizer); + + UPROPERTY(EditAnywhere, BlueprintReadOnly) + TArray assets; +private: + void OnAssetAdded(const FAssetData& AssetData); + void OnAssetRemoved(const FAssetData& AssetData); + void OnAssetRenamed(const FAssetData& AssetData, const FString& str); +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h new file mode 100644 index 0000000000..79e781c60c --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h @@ -0,0 +1,19 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Factories/Factory.h" +#include "AvalonPublishInstanceFactory.generated.h" + +/** + * + */ +UCLASS() +class AVALON_API UAvalonPublishInstanceFactory : public UFactory +{ + GENERATED_BODY() + +public: + UAvalonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer); + virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; + virtual bool ShouldShowInNewMenu() const override; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h new file mode 100644 index 0000000000..db4b16d53f --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h @@ -0,0 +1,20 @@ +#pragma once +#include "Engine.h" +#include "AvalonPythonBridge.generated.h" + +UCLASS(Blueprintable) +class UAvalonPythonBridge : public UObject +{ + GENERATED_BODY() + +public: + UFUNCTION(BlueprintCallable, Category = Python) + static UAvalonPythonBridge* Get(); + + UFUNCTION(BlueprintImplementableEvent, Category = Python) + void RunInPython_Popup() const; + + UFUNCTION(BlueprintImplementableEvent, Category = Python) + void RunInPython_Dialog() const; + +}; diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h new file mode 100644 index 0000000000..ffb2bc7aa4 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h @@ -0,0 +1,22 @@ +#pragma once +#include "CoreMinimal.h" + +class FSlateStyleSet; +class ISlateStyle; + + +class FAvalonStyle +{ +public: + static void Initialize(); + static void Shutdown(); + static const ISlateStyle& Get(); + static FName GetStyleSetName(); + static FName GetContextName(); + + static void SetIcon(const FString& StyleName, const FString& ResourcePath); + +private: + static TUniquePtr< FSlateStyleSet > Create(); + static TUniquePtr< FSlateStyleSet > AvalonStyleInstance; +}; \ No newline at end of file From 70537ecfd441d30d12df3ecde4b4f2fba0b14337 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 19:00:50 +0100 Subject: [PATCH 170/483] moved io install into host api --- openpype/hosts/traypublisher/api/pipeline.py | 7 +++---- openpype/tools/traypublisher/window.py | 5 ----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/traypublisher/api/pipeline.py b/openpype/hosts/traypublisher/api/pipeline.py index 83fe326ca4..a39e5641ae 100644 --- a/openpype/hosts/traypublisher/api/pipeline.py +++ b/openpype/hosts/traypublisher/api/pipeline.py @@ -3,6 +3,7 @@ import json import tempfile import atexit +from avalon import io import avalon.api import pyblish.api @@ -172,10 +173,8 @@ def install(): def set_project_name(project_name): - # Deregister project specific plugins and register new project plugins - old_project_name = HostContext.get_project_name() - if old_project_name is not None and old_project_name != project_name: - pass + # TODO Deregister project specific plugins and register new project plugins os.environ["AVALON_PROJECT"] = project_name avalon.api.Session["AVALON_PROJECT"] = project_name + io.install() HostContext.set_project_name(project_name) diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py index 34ba042e91..fc9493be0a 100644 --- a/openpype/tools/traypublisher/window.py +++ b/openpype/tools/traypublisher/window.py @@ -6,11 +6,9 @@ Tray publisher can be considered as host implementeation with creators and publishing plugins. """ -import os from Qt import QtWidgets, QtCore import avalon.api -from avalon import io from avalon.api import AvalonMongoDB from openpype.hosts.traypublisher import ( api as traypublisher @@ -143,9 +141,6 @@ class TrayPublishWindow(PublisherWindow): # TODO register project specific plugin paths self.controller.save_changes() self.controller.reset_project_data_cache() - os.environ["AVALON_PROJECT"] = project_name - io.Session["AVALON_PROJECT"] = project_name - io.install() self.reset() if not self.controller.instances: From f69a9055bd393cf3956018d8a9d3382b52763b68 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 19:01:25 +0100 Subject: [PATCH 171/483] changed icon of workfile creator --- openpype/hosts/traypublisher/plugins/create/create_workfile.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/traypublisher/plugins/create/create_workfile.py b/openpype/hosts/traypublisher/plugins/create/create_workfile.py index 38b25ea3c6..2db4770bbc 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_workfile.py +++ b/openpype/hosts/traypublisher/plugins/create/create_workfile.py @@ -1,4 +1,3 @@ -from openpype import resources from openpype.hosts.traypublisher.api import pipeline from openpype.pipeline import ( Creator, @@ -45,7 +44,7 @@ class WorkfileCreator(Creator): ] def get_icon(self): - return resources.get_openpype_splash_filepath() + return "fa.file" def collect_instances(self): for instance_data in pipeline.list_instances(): From 5bbfca8dc5c9dfe178a60336300c5681ab1bc5c9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 19:32:03 +0100 Subject: [PATCH 172/483] hound fixes --- openpype/tools/publisher/publish_report_viewer/widgets.py | 1 + openpype/tools/publisher/publish_report_viewer/window.py | 4 ++-- openpype/tools/traypublisher/window.py | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/tools/publisher/publish_report_viewer/widgets.py b/openpype/tools/publisher/publish_report_viewer/widgets.py index 0b17efb614..fd226ea0e4 100644 --- a/openpype/tools/publisher/publish_report_viewer/widgets.py +++ b/openpype/tools/publisher/publish_report_viewer/widgets.py @@ -253,6 +253,7 @@ class DetailsPopup(QtWidgets.QDialog): self._center_widget = center_widget self._first_show = True + self._layout = layout def showEvent(self, event): layout = self.layout() diff --git a/openpype/tools/publisher/publish_report_viewer/window.py b/openpype/tools/publisher/publish_report_viewer/window.py index 8ca075e4d2..678884677c 100644 --- a/openpype/tools/publisher/publish_report_viewer/window.py +++ b/openpype/tools/publisher/publish_report_viewer/window.py @@ -123,12 +123,12 @@ class LoadedFilesMopdel(QtGui.QStandardItemModel): return new_items = [] - for filepath in filtered_paths: + for normalized_path in filtered_paths: try: with open(normalized_path, "r") as stream: data = json.load(stream) report = PublishReport(data) - except Exception as exc: + except Exception: # TODO handle errors continue diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py index fc9493be0a..53f8ca450a 100644 --- a/openpype/tools/traypublisher/window.py +++ b/openpype/tools/traypublisher/window.py @@ -13,7 +13,6 @@ from avalon.api import AvalonMongoDB from openpype.hosts.traypublisher import ( api as traypublisher ) -from openpype.hosts.traypublisher.api.pipeline import HostContext from openpype.tools.publisher import PublisherWindow from openpype.tools.utils.constants import PROJECT_NAME_ROLE from openpype.tools.utils.models import ( From b52e511a6e3d233242fa5053434953e4b0a52f8c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 21 Feb 2022 21:03:16 +0100 Subject: [PATCH 173/483] Remove more unused code --- openpype/hosts/maya/api/__init__.py | 4 -- openpype/hosts/maya/api/lib.py | 91 ----------------------------- 2 files changed, 95 deletions(-) diff --git a/openpype/hosts/maya/api/__init__.py b/openpype/hosts/maya/api/__init__.py index 0eea8c4a53..5d76bf0f04 100644 --- a/openpype/hosts/maya/api/__init__.py +++ b/openpype/hosts/maya/api/__init__.py @@ -32,11 +32,9 @@ from .lib import ( read, apply_shaders, - without_extension, maintained_selection, suspended_refresh, - unique_name, unique_namespace, ) @@ -65,11 +63,9 @@ __all__ = [ "lsattrs", "read", - "unique_name", "unique_namespace", "apply_shaders", - "without_extension", "maintained_selection", "suspended_refresh", diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index cf59a85c9b..cd5f6ffbd8 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -154,53 +154,9 @@ def maintained_selection(): cmds.select(clear=True) -def unique_name(name, format="%02d", namespace="", prefix="", suffix=""): - """Return unique `name` - - The function takes into consideration an optional `namespace` - and `suffix`. The suffix is included in evaluating whether a - name exists - such as `name` + "_GRP" - but isn't included - in the returned value. - - If a namespace is provided, only names within that namespace - are considered when evaluating whether the name is unique. - - Arguments: - format (str, optional): The `name` is given a number, this determines - how this number is formatted. Defaults to a padding of 2. - E.g. my_name01, my_name02. - namespace (str, optional): Only consider names within this namespace. - suffix (str, optional): Only consider names with this suffix. - - Example: - >>> name = cmds.createNode("transform", name="MyName") - >>> cmds.objExists(name) - True - >>> unique = unique_name(name) - >>> cmds.objExists(unique) - False - - """ - - iteration = 1 - unique = prefix + (name + format % iteration) + suffix - - while cmds.objExists(namespace + ":" + unique): - iteration += 1 - unique = prefix + (name + format % iteration) + suffix - - if suffix: - return unique[:-len(suffix)] - - return unique - - def unique_namespace(namespace, format="%02d", prefix="", suffix=""): """Return unique namespace - Similar to :func:`unique_name` but evaluating namespaces - as opposed to object names. - Arguments: namespace (str): Name of namespace to consider format (str, optional): Formatting of the given iteration number @@ -508,17 +464,6 @@ def lsattrs(attrs): return list(matches) -@contextlib.contextmanager -def without_extension(): - """Use cmds.file with defaultExtensions=False""" - previous_setting = cmds.file(defaultExtensions=True, query=True) - try: - cmds.file(defaultExtensions=False) - yield - finally: - cmds.file(defaultExtensions=previous_setting) - - @contextlib.contextmanager def attribute_values(attr_values): """Remaps node attributes to values during context. @@ -597,26 +542,6 @@ def evaluation(mode="off"): cmds.evaluationManager(mode=original) -@contextlib.contextmanager -def no_refresh(): - """Temporarily disables Maya's UI updates - - Note: - This only disabled the main pane and will sometimes still - trigger updates in torn off panels. - - """ - - pane = _get_mel_global('gMainPane') - state = cmds.paneLayout(pane, query=True, manage=True) - cmds.paneLayout(pane, edit=True, manage=False) - - try: - yield - finally: - cmds.paneLayout(pane, edit=True, manage=state) - - @contextlib.contextmanager def empty_sets(sets, force=False): """Remove all members of the sets during the context""" @@ -1467,22 +1392,6 @@ def set_id(node, unique_id, overwrite=False): cmds.setAttr(attr, unique_id, type="string") -def remove_id(node): - """Remove the id attribute from the input node. - - Args: - node (str): The node name - - Returns: - bool: Whether an id attribute was deleted - - """ - if cmds.attributeQuery("cbId", node=node, exists=True): - cmds.deleteAttr("{}.cbId".format(node)) - return True - return False - - # endregion ID def get_reference_node(path): """ From edbd0326160c42d0831717bb26af758fb66ee6b8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 21 Feb 2022 21:05:13 +0100 Subject: [PATCH 174/483] Remove unused import --- openpype/hosts/maya/api/lib.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index cd5f6ffbd8..1da36ff2d4 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -2,7 +2,6 @@ import os import sys -import re import platform import uuid import math From fe4a15dc4faeb3627bbbc38cc294fc9ef36feb8c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 21 Feb 2022 21:14:59 +0100 Subject: [PATCH 175/483] Remove unused code --- openpype/hosts/houdini/api/__init__.py | 6 +- openpype/hosts/houdini/api/lib.py | 123 ------------------------- 2 files changed, 2 insertions(+), 127 deletions(-) diff --git a/openpype/hosts/houdini/api/__init__.py b/openpype/hosts/houdini/api/__init__.py index e1500aa5f5..fddf7ab98d 100644 --- a/openpype/hosts/houdini/api/__init__.py +++ b/openpype/hosts/houdini/api/__init__.py @@ -24,8 +24,7 @@ from .lib import ( lsattrs, read, - maintained_selection, - unique_name + maintained_selection ) @@ -51,8 +50,7 @@ __all__ = [ "lsattrs", "read", - "maintained_selection", - "unique_name" + "maintained_selection" ] # Backwards API compatibility diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 72f1c8e71f..6212e721b3 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -99,65 +99,6 @@ def get_id_required_nodes(): return list(nodes) -def get_additional_data(container): - """Not implemented yet!""" - return container - - -def set_parameter_callback(node, parameter, language, callback): - """Link a callback to a parameter of a node - - Args: - node(hou.Node): instance of the nodee - parameter(str): name of the parameter - language(str): name of the language, e.g.: python - callback(str): command which needs to be triggered - - Returns: - None - - """ - - template_grp = node.parmTemplateGroup() - template = template_grp.find(parameter) - if not template: - return - - script_language = (hou.scriptLanguage.Python if language == "python" else - hou.scriptLanguage.Hscript) - - template.setScriptCallbackLanguage(script_language) - template.setScriptCallback(callback) - - template.setTags({"script_callback": callback, - "script_callback_language": language.lower()}) - - # Replace the existing template with the adjusted one - template_grp.replace(parameter, template) - - node.setParmTemplateGroup(template_grp) - - -def set_parameter_callbacks(node, parameter_callbacks): - """Set callbacks for multiple parameters of a node - - Args: - node(hou.Node): instance of a hou.Node - parameter_callbacks(dict): collection of parameter and callback data - example: {"active" : - {"language": "python", - "callback": "print('hello world)'"} - } - Returns: - None - """ - for parameter, data in parameter_callbacks.items(): - language = data["language"] - callback = data["callback"] - - set_parameter_callback(node, parameter, language, callback) - - def get_output_parameter(node): """Return the render output parameter name of the given node @@ -189,19 +130,6 @@ def get_output_parameter(node): raise TypeError("Node type '%s' not supported" % node_type) -@contextmanager -def attribute_values(node, data): - - previous_attrs = {key: node.parm(key).eval() for key in data.keys()} - try: - node.setParms(data) - yield - except Exception as exc: - pass - finally: - node.setParms(previous_attrs) - - def set_scene_fps(fps): hou.setFps(fps) @@ -349,10 +277,6 @@ def render_rop(ropnode): raise RuntimeError("Render failed: {0}".format(exc)) -def children_as_string(node): - return [c.name() for c in node.children()] - - def imprint(node, data): """Store attributes with value on a node @@ -473,53 +397,6 @@ def read(node): parameter in node.spareParms()} -def unique_name(name, format="%03d", namespace="", prefix="", suffix="", - separator="_"): - """Return unique `name` - - The function takes into consideration an optional `namespace` - and `suffix`. The suffix is included in evaluating whether a - name exists - such as `name` + "_GRP" - but isn't included - in the returned value. - - If a namespace is provided, only names within that namespace - are considered when evaluating whether the name is unique. - - Arguments: - format (str, optional): The `name` is given a number, this determines - how this number is formatted. Defaults to a padding of 2. - E.g. my_name01, my_name02. - namespace (str, optional): Only consider names within this namespace. - suffix (str, optional): Only consider names with this suffix. - - Example: - >>> name = hou.node("/obj").createNode("geo", name="MyName") - >>> assert hou.node("/obj/MyName") - True - >>> unique = unique_name(name) - >>> assert hou.node("/obj/{}".format(unique)) - False - - """ - - iteration = 1 - - parts = [prefix, name, format % iteration, suffix] - if namespace: - parts.insert(0, namespace) - - unique = separator.join(parts) - children = children_as_string(hou.node("/obj")) - while unique in children: - iteration += 1 - unique = separator.join(parts) - - if suffix: - return unique[:-len(suffix)] - - return unique - - @contextmanager def maintained_selection(): """Maintain selection during context From 7de42ee923dabba0c534774d6367fc44df67ecf6 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 21 Feb 2022 22:07:10 +0100 Subject: [PATCH 176/483] Always perform scene save in Houdini on publish --- .../houdini/plugins/publish/save_scene.py | 16 ++----------- .../plugins/publish/save_scene_deadline.py | 23 ------------------- 2 files changed, 2 insertions(+), 37 deletions(-) delete mode 100644 openpype/hosts/houdini/plugins/publish/save_scene_deadline.py diff --git a/openpype/hosts/houdini/plugins/publish/save_scene.py b/openpype/hosts/houdini/plugins/publish/save_scene.py index 0f9e547dee..fe5962fbd3 100644 --- a/openpype/hosts/houdini/plugins/publish/save_scene.py +++ b/openpype/hosts/houdini/plugins/publish/save_scene.py @@ -2,26 +2,14 @@ import pyblish.api import avalon.api -class SaveCurrentScene(pyblish.api.InstancePlugin): +class SaveCurrentScene(pyblish.api.ContextPlugin): """Save current scene""" label = "Save current file" order = pyblish.api.ExtractorOrder - 0.49 hosts = ["houdini"] - families = ["usdrender", - "redshift_rop"] - targets = ["local"] - def process(self, instance): - - # This should be a ContextPlugin, but this is a workaround - # for a bug in pyblish to run once for a family: issue #250 - context = instance.context - key = "__hasRun{}".format(self.__class__.__name__) - if context.data.get(key, False): - return - else: - context.data[key] = True + def process(self, context): # Filename must not have changed since collecting host = avalon.api.registered_host() diff --git a/openpype/hosts/houdini/plugins/publish/save_scene_deadline.py b/openpype/hosts/houdini/plugins/publish/save_scene_deadline.py deleted file mode 100644 index a04f6887ff..0000000000 --- a/openpype/hosts/houdini/plugins/publish/save_scene_deadline.py +++ /dev/null @@ -1,23 +0,0 @@ -import pyblish.api - - -class SaveCurrentSceneDeadline(pyblish.api.ContextPlugin): - """Save current scene""" - - label = "Save current file" - order = pyblish.api.ExtractorOrder - 0.49 - hosts = ["houdini"] - targets = ["deadline"] - - def process(self, context): - import hou - - assert ( - context.data["currentFile"] == hou.hipFile.path() - ), "Collected filename from current scene name." - - if hou.hipFile.hasUnsavedChanges(): - self.log.info("Saving current file..") - hou.hipFile.save(save_to_recent_files=True) - else: - self.log.debug("No unsaved changes, skipping file save..") From 1382811bc75e6302f8398af3b4d00a73685a283c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 21 Feb 2022 22:09:51 +0100 Subject: [PATCH 177/483] Remove duplicate ValidateOutputNode code --- .../plugins/publish/validate_output_node.py | 77 ------------------- 1 file changed, 77 deletions(-) delete mode 100644 openpype/hosts/houdini/plugins/publish/validate_output_node.py diff --git a/openpype/hosts/houdini/plugins/publish/validate_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_output_node.py deleted file mode 100644 index 0b60ab5c48..0000000000 --- a/openpype/hosts/houdini/plugins/publish/validate_output_node.py +++ /dev/null @@ -1,77 +0,0 @@ -import pyblish.api - - -class ValidateOutputNode(pyblish.api.InstancePlugin): - """Validate the instance SOP Output Node. - - This will ensure: - - The SOP Path is set. - - The SOP Path refers to an existing object. - - The SOP Path node is a SOP node. - - The SOP Path node has at least one input connection (has an input) - - The SOP Path has geometry data. - - """ - - order = pyblish.api.ValidatorOrder - families = ["pointcache", "vdbcache"] - hosts = ["houdini"] - label = "Validate Output Node" - - def process(self, instance): - - invalid = self.get_invalid(instance) - if invalid: - raise RuntimeError( - "Output node(s) `%s` are incorrect. " - "See plug-in log for details." % invalid - ) - - @classmethod - def get_invalid(cls, instance): - - import hou - - output_node = instance.data["output_node"] - - if output_node is None: - node = instance[0] - cls.log.error( - "SOP Output node in '%s' does not exist. " - "Ensure a valid SOP output path is set." % node.path() - ) - - return [node.path()] - - # Output node must be a Sop node. - if not isinstance(output_node, hou.SopNode): - cls.log.error( - "Output node %s is not a SOP node. " - "SOP Path must point to a SOP node, " - "instead found category type: %s" - % (output_node.path(), output_node.type().category().name()) - ) - return [output_node.path()] - - # For the sake of completeness also assert the category type - # is Sop to avoid potential edge case scenarios even though - # the isinstance check above should be stricter than this category - assert output_node.type().category().name() == "Sop", ( - "Output node %s is not of category Sop. This is a bug.." - % output_node.path() - ) - - # Check if output node has incoming connections - if not output_node.inputConnections(): - cls.log.error( - "Output node `%s` has no incoming connections" - % output_node.path() - ) - return [output_node.path()] - - # Ensure the output node has at least Geometry data - if not output_node.geometry(): - cls.log.error( - "Output node `%s` has no geometry data." % output_node.path() - ) - return [output_node.path()] From df11c4af655825df2f398062ae47b88640f09529 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 22 Feb 2022 03:59:02 +0100 Subject: [PATCH 178/483] Improve labels with errors + fix end of validation stage --- openpype/tools/pyblish_pype/control.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/openpype/tools/pyblish_pype/control.py b/openpype/tools/pyblish_pype/control.py index 0d33aa10f8..455a338499 100644 --- a/openpype/tools/pyblish_pype/control.py +++ b/openpype/tools/pyblish_pype/control.py @@ -297,7 +297,9 @@ class Controller(QtCore.QObject): def on_published(self): if self.is_running: self.is_running = False - self._current_state = "Pulished" + self._current_state = ( + "Published" if not self.errored else "Published, with errors" + ) self.was_finished.emit() self._main_thread_processor.stop() @@ -375,7 +377,10 @@ class Controller(QtCore.QObject): if self.collect_state == 0: self.collect_state = 1 - self._current_state = "Collected" + self._current_state = ( + "Ready" if not self.errored else + "Collected, with errors" + ) self.switch_toggleability.emit(True) self.passed_group.emit(current_group_order) yield IterationBreak("Collected") @@ -383,6 +388,11 @@ class Controller(QtCore.QObject): else: self.passed_group.emit(current_group_order) if self.errored: + self._current_state = ( + "Stopped, due to errors" if not + self.processing["stop_on_validation"] else + "Validated, with errors" + ) yield IterationBreak("Last group errored") if self.collect_state == 1: @@ -392,7 +402,10 @@ class Controller(QtCore.QObject): if not self.validated and plugin.order > self.validators_order: self.validated = True if self.processing["stop_on_validation"]: - self._current_state = "Validated" + self._current_state = ( + "Validated" if not self.errored else + "Validated, with errors" + ) yield IterationBreak("Validated") # Stop if was stopped From 9d0951a93d2b638d27ca3de740d70257785216a2 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 22 Feb 2022 10:50:06 +0100 Subject: [PATCH 179/483] OP-2726 - fixed pushing ftrack username as username --- .../default_modules/ftrack/plugins/publish/collect_username.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py b/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py index 303490189b..88a5cb1da8 100644 --- a/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py +++ b/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py @@ -33,7 +33,6 @@ class CollectUsername(pyblish.api.ContextPlugin): def process(self, context): self.log.info("CollectUsername") - os.environ["FTRACK_API_USER"] = os.environ["FTRACK_BOT_API_USER"] os.environ["FTRACK_API_KEY"] = os.environ["FTRACK_BOT_API_KEY"] @@ -61,3 +60,4 @@ class CollectUsername(pyblish.api.ContextPlugin): username = user[0].get("username") self.log.debug("Resolved ftrack username:: {}".format(username)) os.environ["FTRACK_API_USER"] = username + os.environ["OPENPYPE_USERNAME"] = username # for burnins From 449bbe022c733b2e202d765e31767feae3dcfb1a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Feb 2022 11:15:25 +0100 Subject: [PATCH 180/483] fix flame version string in default settings --- openpype/settings/defaults/system_settings/applications.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 912e2d9924..2f99200a88 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -135,7 +135,7 @@ "OPENPYPE_WIRETAP_TOOLS": "/opt/Autodesk/wiretap/tools/2021" } }, - "2021.1": { + "2021_1": { "use_python_2": true, "executables": { "windows": [], @@ -159,7 +159,7 @@ }, "__dynamic_keys_labels__": { "2021": "2021", - "2021.1": "2021.1" + "2021_1": "2021.1" } } }, From f576be7604b725b0e9db695056384cd56106c61d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 22 Feb 2022 11:45:47 +0100 Subject: [PATCH 181/483] Fix parenting of save prompt QMessageBox - Setting the windowFlags without the original messagebox.windowFlags() was the culprit as to why the messagebox previously wouldn't show when parented. Likely because then it's missing the Dialog window flag and thus would try to embed itself into the parent UI, which you then cannot exec() (cherry picked from commit 290e2b601d0e20f4aaba356d4f053bf733de406b) --- openpype/tools/workfiles/app.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index 4b5bf07b47..27ffb768a3 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -544,10 +544,6 @@ class FilesWidget(QtWidgets.QWidget): # file on a refresh of the files model. self.auto_select_latest_modified = True - # Avoid crash in Blender and store the message box - # (setting parent doesn't work as it hides the message box) - self._messagebox = None - files_view = FilesView(self) # Create the Files model @@ -726,9 +722,9 @@ class FilesWidget(QtWidgets.QWidget): self.file_opened.emit() def save_changes_prompt(self): - self._messagebox = messagebox = QtWidgets.QMessageBox() - - messagebox.setWindowFlags(QtCore.Qt.FramelessWindowHint) + messagebox = QtWidgets.QMessageBox(parent=self) + messagebox.setWindowFlags(messagebox.windowFlags() | + QtCore.Qt.FramelessWindowHint) messagebox.setIcon(messagebox.Warning) messagebox.setWindowTitle("Unsaved Changes!") messagebox.setText( @@ -739,10 +735,6 @@ class FilesWidget(QtWidgets.QWidget): messagebox.Yes | messagebox.No | messagebox.Cancel ) - # Parenting the QMessageBox to the Widget seems to crash - # so we skip parenting and explicitly apply the stylesheet. - messagebox.setStyle(self.style()) - result = messagebox.exec_() if result == messagebox.Yes: return True From 24e22c1c598ef5eac849d68a80a8df179e182951 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 22 Feb 2022 11:52:53 +0100 Subject: [PATCH 182/483] Refactor breadcrumbs_widget -> breadcrumbs_bar Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/tools/settings/settings/categories.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index 059a8b9bf8..14e25a54d8 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -383,7 +383,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget): def change_path(self, path): """Change path and go to widget.""" - self.breadcrumbs_widget.change_path(path) + self.breadcrumbs_bar.change_path(path) def set_path(self, path): """Called from clicked widget.""" From 2583b390e6a8fc2aba855955675a0deecd00de11 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 22 Feb 2022 12:23:40 +0100 Subject: [PATCH 183/483] OP-2726 - introduced new env var WEBPUBLISH_OPENPYPE_USERNAME OPENPYPE_USERNAME might be used in different places (as os pats etc), so explicit env var is safer. Changed its value to first and last name of Ftrack user by request. --- .../ftrack/plugins/publish/collect_username.py | 8 +++++--- openpype/plugins/publish/extract_burnin.py | 6 ++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py b/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py index 88a5cb1da8..459e441afe 100644 --- a/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py +++ b/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py @@ -56,8 +56,10 @@ class CollectUsername(pyblish.api.ContextPlugin): if not user: raise ValueError( "Couldn't find user with {} email".format(user_email)) - - username = user[0].get("username") + user = user[0] + username = user.get("username") self.log.debug("Resolved ftrack username:: {}".format(username)) os.environ["FTRACK_API_USER"] = username - os.environ["OPENPYPE_USERNAME"] = username # for burnins + burnin_name = "{} {}".format(user.get("first_name"), + user.get("last_name")) + os.environ["WEBPUBLISH_OPENPYPE_USERNAME"] = burnin_name diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 7ff1b24689..b2ca8850b6 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -478,6 +478,12 @@ class ExtractBurnin(openpype.api.Extractor): "frame_end_handle": frame_end_handle } + # use explicit username for webpublishes as rewriting + # OPENPYPE_USERNAME might have side effects + webpublish_user_name = os.environ.get("WEBPUBLISH_OPENPYPE_USERNAME") + if webpublish_user_name: + burnin_data["username"] = webpublish_user_name + self.log.debug( "Basic burnin_data: {}".format(json.dumps(burnin_data, indent=4)) ) From a931353b793c47ccf2fa9c4e6222c95e9ab48b4b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 22 Feb 2022 12:43:30 +0100 Subject: [PATCH 184/483] Refactor - removed requirements and build from dev section Flattened dev category --- website/docs/dev_introduction.md | 8 ++++++-- website/sidebars.js | 12 ++---------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/website/docs/dev_introduction.md b/website/docs/dev_introduction.md index 22a23fc523..af17f30692 100644 --- a/website/docs/dev_introduction.md +++ b/website/docs/dev_introduction.md @@ -5,6 +5,10 @@ sidebar_label: Introduction --- -Here you should find additional information targetted on developers who would like to contribute or dive deeper into OpenPype platform +Here you should find additional information targeted on developers who would like to contribute or dive deeper into OpenPype platform -Currently there are details about automatic testing, in the future this should be location for API definition and documentation \ No newline at end of file +Currently there are details about automatic testing, in the future this should be location for API definition and documentation + +Check also: +- [Requirements](dev_requirements.md) +- [Build](dev_build.md) \ No newline at end of file diff --git a/website/sidebars.js b/website/sidebars.js index d819796991..f1b77871f3 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -134,15 +134,7 @@ module.exports = { ], Dev: [ "dev_introduction", - { - type: "category", - label: "Dev documentation", - items: [ - "dev_requirements", - "dev_build", - "dev_testing", - "dev_contribute", - ], - } + "dev_testing", + "dev_contribute" ] }; From 0d986ea5c7c7d5c62169991fa622dc6667906726 Mon Sep 17 00:00:00 2001 From: murphy Date: Tue, 22 Feb 2022 14:09:46 +0100 Subject: [PATCH 185/483] Documentation: broken link fix another missing .md extension in link to different md file --- website/docs/artist_getting_started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/artist_getting_started.md b/website/docs/artist_getting_started.md index be1960a38c..2598ca0669 100644 --- a/website/docs/artist_getting_started.md +++ b/website/docs/artist_getting_started.md @@ -81,4 +81,4 @@ If you're connected to your studio, OpenPype will check for, and install updates ## Advanced use -For more advanced use of OpenPype commands please visit [Admin section](admin_openpype_commands). +For more advanced use of OpenPype commands please visit [Admin section](admin_openpype_commands.md). From e82654392394b77f008c0521840afb12d6546edf Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Feb 2022 14:53:06 +0100 Subject: [PATCH 186/483] moved deadline module one hierarchy level higher --- openpype/modules/base.py | 1 + openpype/modules/{default_modules => }/deadline/__init__.py | 0 .../modules/{default_modules => }/deadline/deadline_module.py | 0 .../plugins/publish/collect_deadline_server_from_instance.py | 0 .../deadline/plugins/publish/collect_default_deadline_server.py | 0 .../deadline/plugins/publish/submit_aftereffects_deadline.py | 0 .../deadline/plugins/publish/submit_harmony_deadline.py | 0 .../deadline/plugins/publish/submit_houdini_remote_publish.py | 0 .../deadline/plugins/publish/submit_houdini_render_deadline.py | 0 .../deadline/plugins/publish/submit_maya_deadline.py | 0 .../deadline/plugins/publish/submit_nuke_deadline.py | 0 .../deadline/plugins/publish/submit_publish_job.py | 0 .../deadline/plugins/publish/validate_deadline_connection.py | 0 .../plugins/publish/validate_expected_and_rendered_files.py | 0 14 files changed, 1 insertion(+) rename openpype/modules/{default_modules => }/deadline/__init__.py (100%) rename openpype/modules/{default_modules => }/deadline/deadline_module.py (100%) rename openpype/modules/{default_modules => }/deadline/plugins/publish/collect_deadline_server_from_instance.py (100%) rename openpype/modules/{default_modules => }/deadline/plugins/publish/collect_default_deadline_server.py (100%) rename openpype/modules/{default_modules => }/deadline/plugins/publish/submit_aftereffects_deadline.py (100%) rename openpype/modules/{default_modules => }/deadline/plugins/publish/submit_harmony_deadline.py (100%) rename openpype/modules/{default_modules => }/deadline/plugins/publish/submit_houdini_remote_publish.py (100%) rename openpype/modules/{default_modules => }/deadline/plugins/publish/submit_houdini_render_deadline.py (100%) rename openpype/modules/{default_modules => }/deadline/plugins/publish/submit_maya_deadline.py (100%) rename openpype/modules/{default_modules => }/deadline/plugins/publish/submit_nuke_deadline.py (100%) rename openpype/modules/{default_modules => }/deadline/plugins/publish/submit_publish_job.py (100%) rename openpype/modules/{default_modules => }/deadline/plugins/publish/validate_deadline_connection.py (100%) rename openpype/modules/{default_modules => }/deadline/plugins/publish/validate_expected_and_rendered_files.py (100%) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index d566692439..07589fff18 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -33,6 +33,7 @@ DEFAULT_OPENPYPE_MODULES = ( "avalon_apps", "clockify", "log_viewer", + "deadline", "muster", "python_console_interpreter", "slack", diff --git a/openpype/modules/default_modules/deadline/__init__.py b/openpype/modules/deadline/__init__.py similarity index 100% rename from openpype/modules/default_modules/deadline/__init__.py rename to openpype/modules/deadline/__init__.py diff --git a/openpype/modules/default_modules/deadline/deadline_module.py b/openpype/modules/deadline/deadline_module.py similarity index 100% rename from openpype/modules/default_modules/deadline/deadline_module.py rename to openpype/modules/deadline/deadline_module.py diff --git a/openpype/modules/default_modules/deadline/plugins/publish/collect_deadline_server_from_instance.py b/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py similarity index 100% rename from openpype/modules/default_modules/deadline/plugins/publish/collect_deadline_server_from_instance.py rename to openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py diff --git a/openpype/modules/default_modules/deadline/plugins/publish/collect_default_deadline_server.py b/openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py similarity index 100% rename from openpype/modules/default_modules/deadline/plugins/publish/collect_default_deadline_server.py rename to openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_aftereffects_deadline.py b/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py similarity index 100% rename from openpype/modules/default_modules/deadline/plugins/publish/submit_aftereffects_deadline.py rename to openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_harmony_deadline.py b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py similarity index 100% rename from openpype/modules/default_modules/deadline/plugins/publish/submit_harmony_deadline.py rename to openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_remote_publish.py b/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py similarity index 100% rename from openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_remote_publish.py rename to openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_render_deadline.py b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py similarity index 100% rename from openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_render_deadline.py rename to openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py similarity index 100% rename from openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py rename to openpype/modules/deadline/plugins/publish/submit_maya_deadline.py diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py similarity index 100% rename from openpype/modules/default_modules/deadline/plugins/publish/submit_nuke_deadline.py rename to openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py similarity index 100% rename from openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py rename to openpype/modules/deadline/plugins/publish/submit_publish_job.py diff --git a/openpype/modules/default_modules/deadline/plugins/publish/validate_deadline_connection.py b/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py similarity index 100% rename from openpype/modules/default_modules/deadline/plugins/publish/validate_deadline_connection.py rename to openpype/modules/deadline/plugins/publish/validate_deadline_connection.py diff --git a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py similarity index 100% rename from openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py rename to openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py From e144323361de111bd4aec951721b9d0b26e00577 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Feb 2022 15:02:20 +0100 Subject: [PATCH 187/483] moved royal render module one hierarchy level higher --- openpype/modules/base.py | 3 +-- .../modules/{default_modules => }/royal_render/__init__.py | 0 openpype/modules/{default_modules => }/royal_render/api.py | 0 .../royal_render/plugins/publish/collect_default_rr_path.py | 0 .../plugins/publish/collect_rr_path_from_instance.py | 0 .../royal_render/plugins/publish/collect_sequences_from_job.py | 0 .../{default_modules => }/royal_render/royal_render_module.py | 0 openpype/modules/{default_modules => }/royal_render/rr_job.py | 0 .../{default_modules => }/royal_render/rr_root/README.md | 0 .../plugins/control_job/perjob/m50__openpype_publish_render.py | 0 10 files changed, 1 insertion(+), 2 deletions(-) rename openpype/modules/{default_modules => }/royal_render/__init__.py (100%) rename openpype/modules/{default_modules => }/royal_render/api.py (100%) rename openpype/modules/{default_modules => }/royal_render/plugins/publish/collect_default_rr_path.py (100%) rename openpype/modules/{default_modules => }/royal_render/plugins/publish/collect_rr_path_from_instance.py (100%) rename openpype/modules/{default_modules => }/royal_render/plugins/publish/collect_sequences_from_job.py (100%) rename openpype/modules/{default_modules => }/royal_render/royal_render_module.py (100%) rename openpype/modules/{default_modules => }/royal_render/rr_job.py (100%) rename openpype/modules/{default_modules => }/royal_render/rr_root/README.md (100%) rename openpype/modules/{default_modules => }/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py (100%) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index d566692439..d203fa42c1 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -34,6 +34,7 @@ DEFAULT_OPENPYPE_MODULES = ( "clockify", "log_viewer", "muster", + "royal_render", "python_console_interpreter", "slack", "webserver", @@ -218,8 +219,6 @@ def load_interfaces(force=False): def _load_interfaces(): # Key under which will be modules imported in `sys.modules` - from openpype.lib import import_filepath - modules_key = "openpype_interfaces" sys.modules[modules_key] = openpype_interfaces = ( diff --git a/openpype/modules/default_modules/royal_render/__init__.py b/openpype/modules/royal_render/__init__.py similarity index 100% rename from openpype/modules/default_modules/royal_render/__init__.py rename to openpype/modules/royal_render/__init__.py diff --git a/openpype/modules/default_modules/royal_render/api.py b/openpype/modules/royal_render/api.py similarity index 100% rename from openpype/modules/default_modules/royal_render/api.py rename to openpype/modules/royal_render/api.py diff --git a/openpype/modules/default_modules/royal_render/plugins/publish/collect_default_rr_path.py b/openpype/modules/royal_render/plugins/publish/collect_default_rr_path.py similarity index 100% rename from openpype/modules/default_modules/royal_render/plugins/publish/collect_default_rr_path.py rename to openpype/modules/royal_render/plugins/publish/collect_default_rr_path.py diff --git a/openpype/modules/default_modules/royal_render/plugins/publish/collect_rr_path_from_instance.py b/openpype/modules/royal_render/plugins/publish/collect_rr_path_from_instance.py similarity index 100% rename from openpype/modules/default_modules/royal_render/plugins/publish/collect_rr_path_from_instance.py rename to openpype/modules/royal_render/plugins/publish/collect_rr_path_from_instance.py diff --git a/openpype/modules/default_modules/royal_render/plugins/publish/collect_sequences_from_job.py b/openpype/modules/royal_render/plugins/publish/collect_sequences_from_job.py similarity index 100% rename from openpype/modules/default_modules/royal_render/plugins/publish/collect_sequences_from_job.py rename to openpype/modules/royal_render/plugins/publish/collect_sequences_from_job.py diff --git a/openpype/modules/default_modules/royal_render/royal_render_module.py b/openpype/modules/royal_render/royal_render_module.py similarity index 100% rename from openpype/modules/default_modules/royal_render/royal_render_module.py rename to openpype/modules/royal_render/royal_render_module.py diff --git a/openpype/modules/default_modules/royal_render/rr_job.py b/openpype/modules/royal_render/rr_job.py similarity index 100% rename from openpype/modules/default_modules/royal_render/rr_job.py rename to openpype/modules/royal_render/rr_job.py diff --git a/openpype/modules/default_modules/royal_render/rr_root/README.md b/openpype/modules/royal_render/rr_root/README.md similarity index 100% rename from openpype/modules/default_modules/royal_render/rr_root/README.md rename to openpype/modules/royal_render/rr_root/README.md diff --git a/openpype/modules/default_modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py b/openpype/modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py similarity index 100% rename from openpype/modules/default_modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py rename to openpype/modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py From efee466761875723fd33aaa99d196b322c31312c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Feb 2022 15:05:04 +0100 Subject: [PATCH 188/483] moved sync server module one hierarchy level higher --- openpype/modules/base.py | 1 + .../{default_modules => }/sync_server/README.md | 0 .../{default_modules => }/sync_server/__init__.py | 0 .../sync_server/providers/__init__.py | 0 .../sync_server/providers/abstract_provider.py | 0 .../sync_server/providers/dropbox.py | 0 .../sync_server/providers/gdrive.py | 0 .../sync_server/providers/lib.py | 0 .../sync_server/providers/local_drive.py | 0 .../sync_server/providers/resources/dropbox.png | Bin .../sync_server/providers/resources/folder.png | Bin .../sync_server/providers/resources/gdrive.png | Bin .../sync_server/providers/resources/local_drive.png | Bin .../sync_server/providers/resources/sftp.png | Bin .../sync_server/providers/resources/studio.png | Bin .../sync_server/providers/sftp.py | 0 .../sync_server/resources/paused.png | Bin .../sync_server/resources/refresh.png | Bin .../sync_server/resources/synced.png | Bin .../sync_server/sync_server.py | 0 .../sync_server/sync_server_module.py | 0 .../{default_modules => }/sync_server/tray/app.py | 0 .../sync_server/tray/delegates.py | 0 .../{default_modules => }/sync_server/tray/lib.py | 0 .../sync_server/tray/models.py | 0 .../sync_server/tray/widgets.py | 0 .../{default_modules => }/sync_server/utils.py | 0 27 files changed, 1 insertion(+) rename openpype/modules/{default_modules => }/sync_server/README.md (100%) rename openpype/modules/{default_modules => }/sync_server/__init__.py (100%) rename openpype/modules/{default_modules => }/sync_server/providers/__init__.py (100%) rename openpype/modules/{default_modules => }/sync_server/providers/abstract_provider.py (100%) rename openpype/modules/{default_modules => }/sync_server/providers/dropbox.py (100%) rename openpype/modules/{default_modules => }/sync_server/providers/gdrive.py (100%) rename openpype/modules/{default_modules => }/sync_server/providers/lib.py (100%) rename openpype/modules/{default_modules => }/sync_server/providers/local_drive.py (100%) rename openpype/modules/{default_modules => }/sync_server/providers/resources/dropbox.png (100%) rename openpype/modules/{default_modules => }/sync_server/providers/resources/folder.png (100%) rename openpype/modules/{default_modules => }/sync_server/providers/resources/gdrive.png (100%) rename openpype/modules/{default_modules => }/sync_server/providers/resources/local_drive.png (100%) rename openpype/modules/{default_modules => }/sync_server/providers/resources/sftp.png (100%) rename openpype/modules/{default_modules => }/sync_server/providers/resources/studio.png (100%) rename openpype/modules/{default_modules => }/sync_server/providers/sftp.py (100%) rename openpype/modules/{default_modules => }/sync_server/resources/paused.png (100%) rename openpype/modules/{default_modules => }/sync_server/resources/refresh.png (100%) rename openpype/modules/{default_modules => }/sync_server/resources/synced.png (100%) rename openpype/modules/{default_modules => }/sync_server/sync_server.py (100%) rename openpype/modules/{default_modules => }/sync_server/sync_server_module.py (100%) rename openpype/modules/{default_modules => }/sync_server/tray/app.py (100%) rename openpype/modules/{default_modules => }/sync_server/tray/delegates.py (100%) rename openpype/modules/{default_modules => }/sync_server/tray/lib.py (100%) rename openpype/modules/{default_modules => }/sync_server/tray/models.py (100%) rename openpype/modules/{default_modules => }/sync_server/tray/widgets.py (100%) rename openpype/modules/{default_modules => }/sync_server/utils.py (100%) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index d566692439..437f5efdbc 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -43,6 +43,7 @@ DEFAULT_OPENPYPE_MODULES = ( "standalonepublish_action", "job_queue", "timers_manager", + "sync_server", ) diff --git a/openpype/modules/default_modules/sync_server/README.md b/openpype/modules/sync_server/README.md similarity index 100% rename from openpype/modules/default_modules/sync_server/README.md rename to openpype/modules/sync_server/README.md diff --git a/openpype/modules/default_modules/sync_server/__init__.py b/openpype/modules/sync_server/__init__.py similarity index 100% rename from openpype/modules/default_modules/sync_server/__init__.py rename to openpype/modules/sync_server/__init__.py diff --git a/openpype/modules/default_modules/sync_server/providers/__init__.py b/openpype/modules/sync_server/providers/__init__.py similarity index 100% rename from openpype/modules/default_modules/sync_server/providers/__init__.py rename to openpype/modules/sync_server/providers/__init__.py diff --git a/openpype/modules/default_modules/sync_server/providers/abstract_provider.py b/openpype/modules/sync_server/providers/abstract_provider.py similarity index 100% rename from openpype/modules/default_modules/sync_server/providers/abstract_provider.py rename to openpype/modules/sync_server/providers/abstract_provider.py diff --git a/openpype/modules/default_modules/sync_server/providers/dropbox.py b/openpype/modules/sync_server/providers/dropbox.py similarity index 100% rename from openpype/modules/default_modules/sync_server/providers/dropbox.py rename to openpype/modules/sync_server/providers/dropbox.py diff --git a/openpype/modules/default_modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py similarity index 100% rename from openpype/modules/default_modules/sync_server/providers/gdrive.py rename to openpype/modules/sync_server/providers/gdrive.py diff --git a/openpype/modules/default_modules/sync_server/providers/lib.py b/openpype/modules/sync_server/providers/lib.py similarity index 100% rename from openpype/modules/default_modules/sync_server/providers/lib.py rename to openpype/modules/sync_server/providers/lib.py diff --git a/openpype/modules/default_modules/sync_server/providers/local_drive.py b/openpype/modules/sync_server/providers/local_drive.py similarity index 100% rename from openpype/modules/default_modules/sync_server/providers/local_drive.py rename to openpype/modules/sync_server/providers/local_drive.py diff --git a/openpype/modules/default_modules/sync_server/providers/resources/dropbox.png b/openpype/modules/sync_server/providers/resources/dropbox.png similarity index 100% rename from openpype/modules/default_modules/sync_server/providers/resources/dropbox.png rename to openpype/modules/sync_server/providers/resources/dropbox.png diff --git a/openpype/modules/default_modules/sync_server/providers/resources/folder.png b/openpype/modules/sync_server/providers/resources/folder.png similarity index 100% rename from openpype/modules/default_modules/sync_server/providers/resources/folder.png rename to openpype/modules/sync_server/providers/resources/folder.png diff --git a/openpype/modules/default_modules/sync_server/providers/resources/gdrive.png b/openpype/modules/sync_server/providers/resources/gdrive.png similarity index 100% rename from openpype/modules/default_modules/sync_server/providers/resources/gdrive.png rename to openpype/modules/sync_server/providers/resources/gdrive.png diff --git a/openpype/modules/default_modules/sync_server/providers/resources/local_drive.png b/openpype/modules/sync_server/providers/resources/local_drive.png similarity index 100% rename from openpype/modules/default_modules/sync_server/providers/resources/local_drive.png rename to openpype/modules/sync_server/providers/resources/local_drive.png diff --git a/openpype/modules/default_modules/sync_server/providers/resources/sftp.png b/openpype/modules/sync_server/providers/resources/sftp.png similarity index 100% rename from openpype/modules/default_modules/sync_server/providers/resources/sftp.png rename to openpype/modules/sync_server/providers/resources/sftp.png diff --git a/openpype/modules/default_modules/sync_server/providers/resources/studio.png b/openpype/modules/sync_server/providers/resources/studio.png similarity index 100% rename from openpype/modules/default_modules/sync_server/providers/resources/studio.png rename to openpype/modules/sync_server/providers/resources/studio.png diff --git a/openpype/modules/default_modules/sync_server/providers/sftp.py b/openpype/modules/sync_server/providers/sftp.py similarity index 100% rename from openpype/modules/default_modules/sync_server/providers/sftp.py rename to openpype/modules/sync_server/providers/sftp.py diff --git a/openpype/modules/default_modules/sync_server/resources/paused.png b/openpype/modules/sync_server/resources/paused.png similarity index 100% rename from openpype/modules/default_modules/sync_server/resources/paused.png rename to openpype/modules/sync_server/resources/paused.png diff --git a/openpype/modules/default_modules/sync_server/resources/refresh.png b/openpype/modules/sync_server/resources/refresh.png similarity index 100% rename from openpype/modules/default_modules/sync_server/resources/refresh.png rename to openpype/modules/sync_server/resources/refresh.png diff --git a/openpype/modules/default_modules/sync_server/resources/synced.png b/openpype/modules/sync_server/resources/synced.png similarity index 100% rename from openpype/modules/default_modules/sync_server/resources/synced.png rename to openpype/modules/sync_server/resources/synced.png diff --git a/openpype/modules/default_modules/sync_server/sync_server.py b/openpype/modules/sync_server/sync_server.py similarity index 100% rename from openpype/modules/default_modules/sync_server/sync_server.py rename to openpype/modules/sync_server/sync_server.py diff --git a/openpype/modules/default_modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py similarity index 100% rename from openpype/modules/default_modules/sync_server/sync_server_module.py rename to openpype/modules/sync_server/sync_server_module.py diff --git a/openpype/modules/default_modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py similarity index 100% rename from openpype/modules/default_modules/sync_server/tray/app.py rename to openpype/modules/sync_server/tray/app.py diff --git a/openpype/modules/default_modules/sync_server/tray/delegates.py b/openpype/modules/sync_server/tray/delegates.py similarity index 100% rename from openpype/modules/default_modules/sync_server/tray/delegates.py rename to openpype/modules/sync_server/tray/delegates.py diff --git a/openpype/modules/default_modules/sync_server/tray/lib.py b/openpype/modules/sync_server/tray/lib.py similarity index 100% rename from openpype/modules/default_modules/sync_server/tray/lib.py rename to openpype/modules/sync_server/tray/lib.py diff --git a/openpype/modules/default_modules/sync_server/tray/models.py b/openpype/modules/sync_server/tray/models.py similarity index 100% rename from openpype/modules/default_modules/sync_server/tray/models.py rename to openpype/modules/sync_server/tray/models.py diff --git a/openpype/modules/default_modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py similarity index 100% rename from openpype/modules/default_modules/sync_server/tray/widgets.py rename to openpype/modules/sync_server/tray/widgets.py diff --git a/openpype/modules/default_modules/sync_server/utils.py b/openpype/modules/sync_server/utils.py similarity index 100% rename from openpype/modules/default_modules/sync_server/utils.py rename to openpype/modules/sync_server/utils.py From 4d44a374ae1ced9f03c95006e614e08982d452c8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 22 Feb 2022 15:15:12 +0100 Subject: [PATCH 189/483] Preserve comment on reset --- openpype/tools/pyblish_pype/control.py | 8 ++++++++ openpype/tools/pyblish_pype/window.py | 5 +++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/openpype/tools/pyblish_pype/control.py b/openpype/tools/pyblish_pype/control.py index 455a338499..d479124be1 100644 --- a/openpype/tools/pyblish_pype/control.py +++ b/openpype/tools/pyblish_pype/control.py @@ -228,6 +228,10 @@ class Controller(QtCore.QObject): def reset_context(self): self.log.debug("Resetting pyblish context object") + comment = None + if self.context is not None and self.context.data.get("comment"): + comment = self.context.data["comment"] + self.context = pyblish.api.Context() self.context._publish_states = InstanceStates.ContextType @@ -249,6 +253,10 @@ class Controller(QtCore.QObject): self.context.families = ("__context__",) + if comment: + # Preserve comment on reset if user previously had a comment + self.context.data["comment"] = comment + self.log.debug("Reset of pyblish context object done") def reset(self): diff --git a/openpype/tools/pyblish_pype/window.py b/openpype/tools/pyblish_pype/window.py index 38907c1a52..ef9be8093c 100644 --- a/openpype/tools/pyblish_pype/window.py +++ b/openpype/tools/pyblish_pype/window.py @@ -1138,8 +1138,9 @@ class Window(QtWidgets.QDialog): if self.intent_model.has_items: self.intent_box.setCurrentIndex(self.intent_model.default_index) - self.comment_box.placeholder.setVisible(False) - self.comment_box.placeholder.setVisible(True) + if self.comment_box.text(): + self.comment_box.placeholder.setVisible(False) + self.comment_box.placeholder.setVisible(True) # Launch controller reset self.controller.reset() From 4306a618a021ec8a4a66054e66ad4fca694b139a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Feb 2022 15:18:12 +0100 Subject: [PATCH 190/483] moved ftrack module one hierarchy level higher --- .gitmodules | 6 - openpype/modules/base.py | 1 + .../ftrack/python2_vendor/arrow | 1 - .../ftrack/python2_vendor/ftrack-python-api | 1 - .../{default_modules => }/ftrack/__init__.py | 0 .../action_clone_review_session.py | 0 .../action_multiple_notes.py | 0 .../action_prepare_project.py | 0 .../action_private_project_detection.py | 0 .../action_push_frame_values_to_task.py | 0 .../action_sync_to_avalon.py | 0 .../event_del_avalon_id_from_new.py | 0 .../event_first_version_status.py | 0 .../event_next_task_update.py | 0 .../event_push_frame_values_to_task.py | 0 .../event_radio_buttons.py | 0 .../event_handlers_server/event_sync_links.py | 0 .../event_sync_to_avalon.py | 0 .../event_task_to_parent_status.py | 0 .../event_task_to_version_status.py | 0 .../event_thumbnail_updates.py | 0 .../event_user_assigment.py | 0 .../event_version_to_task_statuses.py | 0 .../action_applications.py | 0 .../action_batch_task_creation.py | 0 .../action_clean_hierarchical_attributes.py | 0 .../action_client_review_sort.py | 0 .../action_component_open.py | 0 .../action_create_cust_attrs.py | 0 .../action_create_folders.py | 0 .../action_create_project_structure.py | 0 .../action_delete_asset.py | 0 .../action_delete_old_versions.py | 0 .../event_handlers_user/action_delivery.py | 0 .../event_handlers_user/action_djvview.py | 0 .../event_handlers_user/action_job_killer.py | 0 .../action_multiple_notes.py | 0 .../action_prepare_project.py | 0 .../ftrack/event_handlers_user/action_rv.py | 0 .../ftrack/event_handlers_user/action_seed.py | 0 .../action_store_thumbnails_to_avalon.py | 0 .../action_sync_to_avalon.py | 0 .../ftrack/event_handlers_user/action_test.py | 0 .../action_thumbnail_to_childern.py | 0 .../action_thumbnail_to_parent.py | 0 .../action_where_run_ask.py | 0 .../ftrack/ftrack_module.py | 0 .../ftrack/ftrack_server/__init__.py | 0 .../ftrack/ftrack_server/event_server_cli.py | 0 .../ftrack/ftrack_server/ftrack_server.py | 0 .../ftrack/ftrack_server/lib.py | 0 .../ftrack/ftrack_server/socket_thread.py | 0 .../launch_hooks/post_ftrack_changes.py | 0 .../ftrack/launch_hooks/pre_python2_vendor.py | 0 .../ftrack/lib/__init__.py | 0 .../ftrack/lib/avalon_sync.py | 0 .../ftrack/lib/constants.py | 0 .../ftrack/lib/credentials.py | 0 .../ftrack/lib/custom_attributes.json | 0 .../ftrack/lib/custom_attributes.py | 0 .../ftrack/lib/ftrack_action_handler.py | 0 .../ftrack/lib/ftrack_base_handler.py | 0 .../ftrack/lib/ftrack_event_handler.py | 0 .../ftrack/lib/settings.py | 0 .../integrate_ftrack_comments.py | 0 .../plugins/publish/collect_ftrack_api.py | 0 .../plugins/publish/collect_ftrack_family.py | 0 .../publish/collect_local_ftrack_creds.py | 0 .../plugins/publish/collect_username.py | 0 .../plugins/publish/integrate_ftrack_api.py | 0 .../integrate_ftrack_component_overwrite.py | 0 .../publish/integrate_ftrack_instances.py | 0 .../plugins/publish/integrate_ftrack_note.py | 0 .../publish/integrate_hierarchy_ftrack.py | 0 .../validate_custom_ftrack_attributes.py | 0 .../ftrack/python2_vendor/arrow/.gitignore | 211 + .../arrow/.pre-commit-config.yaml | 41 + .../ftrack/python2_vendor/arrow/CHANGELOG.rst | 598 +++ .../ftrack/python2_vendor/arrow/LICENSE | 201 + .../ftrack/python2_vendor/arrow/MANIFEST.in | 3 + .../ftrack/python2_vendor/arrow/Makefile | 44 + .../ftrack/python2_vendor/arrow/README.rst | 133 + .../python2_vendor/arrow/arrow/__init__.py | 18 + .../python2_vendor/arrow/arrow/_version.py | 1 + .../ftrack/python2_vendor/arrow/arrow/api.py | 54 + .../python2_vendor/arrow/arrow/arrow.py | 1584 ++++++ .../python2_vendor/arrow/arrow/constants.py | 9 + .../python2_vendor/arrow/arrow/factory.py | 301 ++ .../python2_vendor/arrow/arrow/formatter.py | 139 + .../python2_vendor/arrow/arrow/locales.py | 4267 +++++++++++++++++ .../python2_vendor/arrow/arrow/parser.py | 596 +++ .../ftrack/python2_vendor/arrow/arrow/util.py | 115 + .../ftrack/python2_vendor/arrow/docs/Makefile | 20 + .../ftrack/python2_vendor/arrow/docs/conf.py | 62 + .../python2_vendor/arrow/docs/index.rst | 566 +++ .../ftrack/python2_vendor/arrow/docs/make.bat | 35 + .../python2_vendor/arrow/docs/releases.rst | 3 + .../python2_vendor/arrow/requirements.txt | 14 + .../ftrack/python2_vendor/arrow/setup.cfg | 2 + .../ftrack/python2_vendor/arrow/setup.py | 50 + .../python2_vendor/arrow/tests/__init__.py | 0 .../python2_vendor/arrow/tests/conftest.py | 76 + .../python2_vendor/arrow/tests/test_api.py | 28 + .../python2_vendor/arrow/tests/test_arrow.py | 2150 +++++++++ .../arrow/tests/test_factory.py | 390 ++ .../arrow/tests/test_formatter.py | 282 ++ .../arrow/tests/test_locales.py | 1352 ++++++ .../python2_vendor/arrow/tests/test_parser.py | 1657 +++++++ .../python2_vendor/arrow/tests/test_util.py | 81 + .../python2_vendor/arrow/tests/utils.py | 16 + .../ftrack/python2_vendor/arrow/tox.ini | 53 + .../backports/__init__.py | 0 .../backports/configparser/__init__.py | 0 .../backports/configparser/helpers.py | 0 .../backports/functools_lru_cache.py | 0 .../builtins/builtins/__init__.py | 0 .../ftrack-python-api/.gitignore | 42 + .../ftrack-python-api/LICENSE.python | 254 + .../ftrack-python-api/LICENSE.txt | 176 + .../ftrack-python-api/MANIFEST.in | 4 + .../ftrack-python-api/README.rst | 34 + .../ftrack-python-api/bitbucket-pipelines.yml | 24 + .../ftrack-python-api/doc/_static/ftrack.css | 16 + .../doc/api_reference/accessor/base.rst | 8 + .../doc/api_reference/accessor/disk.rst | 8 + .../doc/api_reference/accessor/index.rst | 14 + .../doc/api_reference/accessor/server.rst | 8 + .../doc/api_reference/attribute.rst | 8 + .../doc/api_reference/cache.rst | 8 + .../doc/api_reference/collection.rst | 8 + .../api_reference/entity/asset_version.rst | 8 + .../doc/api_reference/entity/base.rst | 8 + .../doc/api_reference/entity/component.rst | 8 + .../doc/api_reference/entity/factory.rst | 8 + .../doc/api_reference/entity/index.rst | 14 + .../doc/api_reference/entity/job.rst | 8 + .../doc/api_reference/entity/location.rst | 8 + .../doc/api_reference/entity/note.rst | 8 + .../api_reference/entity/project_schema.rst | 8 + .../doc/api_reference/entity/user.rst | 8 + .../doc/api_reference/event/base.rst | 8 + .../doc/api_reference/event/expression.rst | 8 + .../doc/api_reference/event/hub.rst | 8 + .../doc/api_reference/event/index.rst | 14 + .../doc/api_reference/event/subscriber.rst | 8 + .../doc/api_reference/event/subscription.rst | 8 + .../doc/api_reference/exception.rst | 8 + .../doc/api_reference/formatter.rst | 8 + .../doc/api_reference/index.rst | 20 + .../doc/api_reference/inspection.rst | 8 + .../doc/api_reference/logging.rst | 8 + .../doc/api_reference/operation.rst | 8 + .../doc/api_reference/plugin.rst | 8 + .../doc/api_reference/query.rst | 8 + .../resource_identifier_transformer/base.rst | 10 + .../resource_identifier_transformer/index.rst | 16 + .../doc/api_reference/session.rst | 8 + .../doc/api_reference/structure/base.rst | 8 + .../doc/api_reference/structure/id.rst | 8 + .../doc/api_reference/structure/index.rst | 14 + .../doc/api_reference/structure/origin.rst | 8 + .../doc/api_reference/structure/standard.rst | 8 + .../doc/api_reference/symbol.rst | 8 + .../ftrack-python-api/doc/caching.rst | 175 + .../ftrack-python-api/doc/conf.py | 102 + .../ftrack-python-api/doc/docutils.conf | 2 + .../doc/environment_variables.rst | 56 + .../ftrack-python-api/doc/event_list.rst | 137 + .../example/assignments_and_allocations.rst | 82 + .../doc/example/component.rst | 23 + .../doc/example/custom_attribute.rst | 94 + .../doc/example/encode_media.rst | 53 + .../doc/example/entity_links.rst | 56 + .../ftrack-python-api/doc/example/index.rst | 52 + .../doc/example/invite_user.rst | 31 + .../ftrack-python-api/doc/example/job.rst | 97 + .../doc/example/link_attribute.rst | 55 + .../ftrack-python-api/doc/example/list.rst | 46 + .../manage_custom_attribute_configuration.rst | 320 ++ .../doc/example/metadata.rst | 43 + .../ftrack-python-api/doc/example/note.rst | 169 + .../ftrack-python-api/doc/example/project.rst | 65 + .../doc/example/publishing.rst | 73 + .../doc/example/review_session.rst | 87 + .../ftrack-python-api/doc/example/scope.rst | 27 + .../doc/example/security_roles.rst | 73 + .../doc/example/sync_ldap_users.rst | 30 + .../doc/example/task_template.rst | 56 + .../doc/example/thumbnail.rst | 71 + .../ftrack-python-api/doc/example/timer.rst | 37 + .../doc/example/web_review.rst | 78 + .../ftrack-python-api/doc/glossary.rst | 76 + .../ftrack-python-api/doc/handling_events.rst | 315 ++ .../image/configuring_plugins_directory.png | Bin 0 -> 7313 bytes .../ftrack-python-api/doc/index.rst | 42 + .../ftrack-python-api/doc/installing.rst | 77 + .../ftrack-python-api/doc/introduction.rst | 26 + .../doc/locations/configuring.rst | 87 + .../ftrack-python-api/doc/locations/index.rst | 18 + .../doc/locations/overview.rst | 143 + .../doc/locations/tutorial.rst | 193 + .../ftrack-python-api/doc/querying.rst | 263 + .../ftrack-python-api/doc/release/index.rst | 18 + .../doc/release/migrating_from_old_api.rst | 613 +++ .../doc/release/migration.rst | 98 + .../doc/release/release_notes.rst | 1478 ++++++ .../doc/resource/example_plugin.py | 24 + .../doc/resource/example_plugin_safe.py | 0 .../resource/example_plugin_using_session.py | 37 + .../doc/security_and_authentication.rst | 38 + .../ftrack-python-api/doc/tutorial.rst | 156 + .../doc/understanding_sessions.rst | 281 ++ .../doc/working_with_entities.rst | 434 ++ .../ftrack-python-api/pytest.ini | 7 + .../resource/plugin/configure_locations.py | 39 + .../resource/plugin/construct_entity_type.py | 46 + .../ftrack-python-api/setup.cfg | 6 + .../python2_vendor/ftrack-python-api/setup.py | 81 + .../ftrack-python-api/source/__init__.py | 1 + .../source/ftrack_api/__init__.py | 32 + .../_centralized_storage_scenario.py | 656 +++ .../source/ftrack_api/_python_ntpath.py | 534 +++ .../source/ftrack_api/_version.py | 1 + .../source/ftrack_api/_weakref.py | 66 + .../source/ftrack_api/accessor/__init__.py | 2 + .../source/ftrack_api/accessor/base.py | 124 + .../source/ftrack_api/accessor/disk.py | 250 + .../source/ftrack_api/accessor/server.py | 240 + .../source/ftrack_api/attribute.py | 707 +++ .../source/ftrack_api/cache.py | 579 +++ .../source/ftrack_api/collection.py | 507 ++ .../source/ftrack_api/data.py | 119 + .../source/ftrack_api/entity/__init__.py | 2 + .../source/ftrack_api/entity/asset_version.py | 91 + .../source/ftrack_api/entity/base.py | 402 ++ .../source/ftrack_api/entity/component.py | 74 + .../source/ftrack_api/entity/factory.py | 435 ++ .../source/ftrack_api/entity/job.py | 48 + .../source/ftrack_api/entity/location.py | 733 +++ .../source/ftrack_api/entity/note.py | 105 + .../ftrack_api/entity/project_schema.py | 94 + .../source/ftrack_api/entity/user.py | 123 + .../source/ftrack_api/event/__init__.py | 2 + .../source/ftrack_api/event/base.py | 85 + .../source/ftrack_api/event/expression.py | 282 ++ .../source/ftrack_api/event/hub.py | 1091 +++++ .../source/ftrack_api/event/subscriber.py | 27 + .../source/ftrack_api/event/subscription.py | 23 + .../source/ftrack_api/exception.py | 392 ++ .../source/ftrack_api/formatter.py | 131 + .../source/ftrack_api/inspection.py | 135 + .../source/ftrack_api/logging.py | 43 + .../source/ftrack_api/operation.py | 115 + .../source/ftrack_api/plugin.py | 121 + .../source/ftrack_api/query.py | 202 + .../__init__.py | 2 + .../resource_identifier_transformer/base.py | 50 + .../source/ftrack_api/session.py | 2515 ++++++++++ .../source/ftrack_api/structure/__init__.py | 2 + .../source/ftrack_api/structure/base.py | 38 + .../source/ftrack_api/structure/entity_id.py | 12 + .../source/ftrack_api/structure/id.py | 91 + .../source/ftrack_api/structure/origin.py | 28 + .../source/ftrack_api/structure/standard.py | 217 + .../source/ftrack_api/symbol.py | 77 + .../test/fixture/media/colour_wheel.mov | Bin 0 -> 17627 bytes .../test/fixture/media/image-resized-10.png | Bin 0 -> 115 bytes .../test/fixture/media/image.png | Bin 0 -> 883 bytes .../fixture/plugin/configure_locations.py | 40 + .../fixture/plugin/construct_entity_type.py | 52 + .../fixture/plugin/count_session_event.py | 41 + .../ftrack-python-api/test/unit/__init__.py | 2 + .../test/unit/accessor/__init__.py | 2 + .../test/unit/accessor/test_disk.py | 267 ++ .../test/unit/accessor/test_server.py | 41 + .../ftrack-python-api/test/unit/conftest.py | 539 +++ .../test/unit/entity/__init__.py | 2 + .../test/unit/entity/test_asset_version.py | 54 + .../test/unit/entity/test_base.py | 14 + .../test/unit/entity/test_component.py | 70 + .../test/unit/entity/test_factory.py | 25 + .../test/unit/entity/test_job.py | 42 + .../test/unit/entity/test_location.py | 516 ++ .../test/unit/entity/test_metadata.py | 135 + .../test/unit/entity/test_note.py | 67 + .../test/unit/entity/test_project_schema.py | 64 + .../test/unit/entity/test_scopes.py | 24 + .../test/unit/entity/test_user.py | 49 + .../test/unit/event/__init__.py | 2 + .../unit/event/event_hub_server_heartbeat.py | 92 + .../test/unit/event/test_base.py | 36 + .../test/unit/event/test_expression.py | 174 + .../test/unit/event/test_hub.py | 701 +++ .../test/unit/event/test_subscriber.py | 33 + .../test/unit/event/test_subscription.py | 28 + .../__init__.py | 2 + .../test_base.py | 36 + .../test/unit/structure/__init__.py | 2 + .../test/unit/structure/test_base.py | 31 + .../test/unit/structure/test_entity_id.py | 49 + .../test/unit/structure/test_id.py | 115 + .../test/unit/structure/test_origin.py | 33 + .../test/unit/structure/test_standard.py | 309 ++ .../test/unit/test_attribute.py | 146 + .../ftrack-python-api/test/unit/test_cache.py | 416 ++ .../test/unit/test_collection.py | 574 +++ .../test/unit/test_custom_attribute.py | 251 + .../ftrack-python-api/test/unit/test_data.py | 129 + .../test/unit/test_formatter.py | 70 + .../test/unit/test_inspection.py | 101 + .../test/unit/test_operation.py | 79 + .../test/unit/test_package.py | 48 + .../test/unit/test_plugin.py | 192 + .../ftrack-python-api/test/unit/test_query.py | 164 + .../test/unit/test_session.py | 1519 ++++++ .../ftrack-python-api/test/unit/test_timer.py | 74 + .../ftrack/scripts/sub_event_processor.py | 0 .../ftrack/scripts/sub_event_status.py | 0 .../ftrack/scripts/sub_event_storer.py | 0 .../ftrack/scripts/sub_legacy_server.py | 0 .../ftrack/scripts/sub_user_server.py | 0 .../ftrack/tray/__init__.py | 0 .../ftrack/tray/ftrack_tray.py | 0 .../ftrack/tray/login_dialog.py | 0 .../ftrack/tray/login_tools.py | 0 325 files changed, 41913 insertions(+), 8 deletions(-) delete mode 160000 openpype/modules/default_modules/ftrack/python2_vendor/arrow delete mode 160000 openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api rename openpype/modules/{default_modules => }/ftrack/__init__.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/action_clone_review_session.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/action_multiple_notes.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/action_prepare_project.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/action_private_project_detection.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/action_push_frame_values_to_task.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/action_sync_to_avalon.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/event_del_avalon_id_from_new.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/event_first_version_status.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/event_next_task_update.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/event_push_frame_values_to_task.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/event_radio_buttons.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/event_sync_links.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/event_sync_to_avalon.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/event_task_to_parent_status.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/event_task_to_version_status.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/event_thumbnail_updates.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/event_user_assigment.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_server/event_version_to_task_statuses.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_applications.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_batch_task_creation.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_client_review_sort.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_component_open.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_create_cust_attrs.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_create_folders.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_create_project_structure.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_delete_asset.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_delete_old_versions.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_delivery.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_djvview.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_job_killer.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_multiple_notes.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_prepare_project.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_rv.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_seed.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_store_thumbnails_to_avalon.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_sync_to_avalon.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_test.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_thumbnail_to_childern.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_thumbnail_to_parent.py (100%) rename openpype/modules/{default_modules => }/ftrack/event_handlers_user/action_where_run_ask.py (100%) rename openpype/modules/{default_modules => }/ftrack/ftrack_module.py (100%) rename openpype/modules/{default_modules => }/ftrack/ftrack_server/__init__.py (100%) rename openpype/modules/{default_modules => }/ftrack/ftrack_server/event_server_cli.py (100%) rename openpype/modules/{default_modules => }/ftrack/ftrack_server/ftrack_server.py (100%) rename openpype/modules/{default_modules => }/ftrack/ftrack_server/lib.py (100%) rename openpype/modules/{default_modules => }/ftrack/ftrack_server/socket_thread.py (100%) rename openpype/modules/{default_modules => }/ftrack/launch_hooks/post_ftrack_changes.py (100%) rename openpype/modules/{default_modules => }/ftrack/launch_hooks/pre_python2_vendor.py (100%) rename openpype/modules/{default_modules => }/ftrack/lib/__init__.py (100%) rename openpype/modules/{default_modules => }/ftrack/lib/avalon_sync.py (100%) rename openpype/modules/{default_modules => }/ftrack/lib/constants.py (100%) rename openpype/modules/{default_modules => }/ftrack/lib/credentials.py (100%) rename openpype/modules/{default_modules => }/ftrack/lib/custom_attributes.json (100%) rename openpype/modules/{default_modules => }/ftrack/lib/custom_attributes.py (100%) rename openpype/modules/{default_modules => }/ftrack/lib/ftrack_action_handler.py (100%) rename openpype/modules/{default_modules => }/ftrack/lib/ftrack_base_handler.py (100%) rename openpype/modules/{default_modules => }/ftrack/lib/ftrack_event_handler.py (100%) rename openpype/modules/{default_modules => }/ftrack/lib/settings.py (100%) rename openpype/modules/{default_modules => }/ftrack/plugins/_unused_publish/integrate_ftrack_comments.py (100%) rename openpype/modules/{default_modules => }/ftrack/plugins/publish/collect_ftrack_api.py (100%) rename openpype/modules/{default_modules => }/ftrack/plugins/publish/collect_ftrack_family.py (100%) rename openpype/modules/{default_modules => }/ftrack/plugins/publish/collect_local_ftrack_creds.py (100%) rename openpype/modules/{default_modules => }/ftrack/plugins/publish/collect_username.py (100%) rename openpype/modules/{default_modules => }/ftrack/plugins/publish/integrate_ftrack_api.py (100%) rename openpype/modules/{default_modules => }/ftrack/plugins/publish/integrate_ftrack_component_overwrite.py (100%) rename openpype/modules/{default_modules => }/ftrack/plugins/publish/integrate_ftrack_instances.py (100%) rename openpype/modules/{default_modules => }/ftrack/plugins/publish/integrate_ftrack_note.py (100%) rename openpype/modules/{default_modules => }/ftrack/plugins/publish/integrate_hierarchy_ftrack.py (100%) rename openpype/modules/{default_modules => }/ftrack/plugins/publish/validate_custom_ftrack_attributes.py (100%) create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/.gitignore create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/.pre-commit-config.yaml create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/CHANGELOG.rst create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/LICENSE create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/MANIFEST.in create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/Makefile create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/README.rst create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/arrow/__init__.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/arrow/_version.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/arrow/api.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/arrow/arrow.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/arrow/constants.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/arrow/factory.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/arrow/formatter.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/arrow/locales.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/arrow/parser.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/arrow/util.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/docs/Makefile create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/docs/conf.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/docs/index.rst create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/docs/make.bat create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/docs/releases.rst create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/requirements.txt create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/setup.cfg create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/setup.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/tests/__init__.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/tests/conftest.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/tests/test_api.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/tests/test_arrow.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/tests/test_factory.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/tests/test_formatter.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/tests/test_locales.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/tests/test_parser.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/tests/test_util.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/tests/utils.py create mode 100644 openpype/modules/ftrack/python2_vendor/arrow/tox.ini rename openpype/modules/{default_modules => }/ftrack/python2_vendor/backports.functools_lru_cache/backports/__init__.py (100%) rename openpype/modules/{default_modules => }/ftrack/python2_vendor/backports.functools_lru_cache/backports/configparser/__init__.py (100%) rename openpype/modules/{default_modules => }/ftrack/python2_vendor/backports.functools_lru_cache/backports/configparser/helpers.py (100%) rename openpype/modules/{default_modules => }/ftrack/python2_vendor/backports.functools_lru_cache/backports/functools_lru_cache.py (100%) rename openpype/modules/{default_modules => }/ftrack/python2_vendor/builtins/builtins/__init__.py (100%) create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/.gitignore create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/LICENSE.python create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/LICENSE.txt create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/MANIFEST.in create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/README.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/bitbucket-pipelines.yml create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/_static/ftrack.css create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/accessor/base.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/accessor/disk.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/accessor/index.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/accessor/server.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/attribute.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/cache.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/collection.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/asset_version.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/base.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/component.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/factory.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/index.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/job.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/location.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/note.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/project_schema.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/user.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/base.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/expression.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/hub.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/index.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/subscriber.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/subscription.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/exception.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/formatter.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/index.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/inspection.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/logging.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/operation.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/plugin.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/query.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/resource_identifier_transformer/base.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/resource_identifier_transformer/index.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/session.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/base.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/id.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/index.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/origin.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/standard.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/symbol.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/caching.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/conf.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/docutils.conf create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/environment_variables.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/event_list.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/assignments_and_allocations.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/component.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/custom_attribute.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/encode_media.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/entity_links.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/index.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/invite_user.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/job.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/link_attribute.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/list.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/manage_custom_attribute_configuration.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/metadata.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/note.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/project.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/publishing.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/review_session.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/scope.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/security_roles.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/sync_ldap_users.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/task_template.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/thumbnail.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/timer.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/web_review.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/glossary.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/handling_events.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/image/configuring_plugins_directory.png create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/index.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/installing.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/introduction.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/locations/configuring.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/locations/index.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/locations/overview.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/locations/tutorial.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/querying.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/release/index.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/release/migrating_from_old_api.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/release/migration.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/release/release_notes.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/resource/example_plugin.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/resource/example_plugin_safe.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/resource/example_plugin_using_session.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/security_and_authentication.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/tutorial.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/understanding_sessions.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/working_with_entities.rst create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/pytest.ini create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/resource/plugin/configure_locations.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/resource/plugin/construct_entity_type.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/setup.cfg create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/setup.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/__init__.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/__init__.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/_centralized_storage_scenario.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/_python_ntpath.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/_version.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/_weakref.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/accessor/__init__.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/accessor/base.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/accessor/disk.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/accessor/server.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/attribute.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/cache.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/collection.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/data.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/__init__.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/asset_version.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/base.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/component.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/factory.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/job.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/location.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/note.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/project_schema.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/user.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/__init__.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/base.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/expression.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/hub.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/subscriber.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/subscription.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/exception.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/formatter.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/inspection.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/logging.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/operation.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/plugin.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/query.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/resource_identifier_transformer/__init__.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/resource_identifier_transformer/base.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/session.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/__init__.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/base.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/entity_id.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/id.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/origin.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/standard.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/symbol.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/fixture/media/colour_wheel.mov create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/fixture/media/image-resized-10.png create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/fixture/media/image.png create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/fixture/plugin/configure_locations.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/fixture/plugin/construct_entity_type.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/fixture/plugin/count_session_event.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/__init__.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/accessor/__init__.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/accessor/test_disk.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/accessor/test_server.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/conftest.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/__init__.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_asset_version.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_base.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_component.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_factory.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_job.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_location.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_metadata.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_note.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_project_schema.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_scopes.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_user.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/__init__.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/event_hub_server_heartbeat.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_base.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_expression.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_hub.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_subscriber.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_subscription.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/resource_identifier_transformer/__init__.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/resource_identifier_transformer/test_base.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/__init__.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_base.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_entity_id.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_id.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_origin.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_standard.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_attribute.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_cache.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_collection.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_custom_attribute.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_data.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_formatter.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_inspection.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_operation.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_package.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_plugin.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_query.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_session.py create mode 100644 openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_timer.py rename openpype/modules/{default_modules => }/ftrack/scripts/sub_event_processor.py (100%) rename openpype/modules/{default_modules => }/ftrack/scripts/sub_event_status.py (100%) rename openpype/modules/{default_modules => }/ftrack/scripts/sub_event_storer.py (100%) rename openpype/modules/{default_modules => }/ftrack/scripts/sub_legacy_server.py (100%) rename openpype/modules/{default_modules => }/ftrack/scripts/sub_user_server.py (100%) rename openpype/modules/{default_modules => }/ftrack/tray/__init__.py (100%) rename openpype/modules/{default_modules => }/ftrack/tray/ftrack_tray.py (100%) rename openpype/modules/{default_modules => }/ftrack/tray/login_dialog.py (100%) rename openpype/modules/{default_modules => }/ftrack/tray/login_tools.py (100%) diff --git a/.gitmodules b/.gitmodules index e1b0917e9d..2c4816801c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,9 +4,3 @@ [submodule "repos/avalon-unreal-integration"] path = repos/avalon-unreal-integration url = https://github.com/pypeclub/avalon-unreal-integration.git -[submodule "openpype/modules/default_modules/ftrack/python2_vendor/arrow"] - path = openpype/modules/default_modules/ftrack/python2_vendor/arrow - url = https://github.com/arrow-py/arrow.git -[submodule "openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api"] - path = openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api - url = https://bitbucket.org/ftrack/ftrack-python-api.git \ No newline at end of file diff --git a/openpype/modules/base.py b/openpype/modules/base.py index d566692439..6c83a76319 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -35,6 +35,7 @@ DEFAULT_OPENPYPE_MODULES = ( "log_viewer", "muster", "python_console_interpreter", + "ftrack", "slack", "webserver", "launcher_action", diff --git a/openpype/modules/default_modules/ftrack/python2_vendor/arrow b/openpype/modules/default_modules/ftrack/python2_vendor/arrow deleted file mode 160000 index b746fedf72..0000000000 --- a/openpype/modules/default_modules/ftrack/python2_vendor/arrow +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b746fedf7286c3755a46f07ab72f4c414cd41fc0 diff --git a/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api b/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api deleted file mode 160000 index d277f474ab..0000000000 --- a/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d277f474ab016e7b53479c36af87cb861d0cc53e diff --git a/openpype/modules/default_modules/ftrack/__init__.py b/openpype/modules/ftrack/__init__.py similarity index 100% rename from openpype/modules/default_modules/ftrack/__init__.py rename to openpype/modules/ftrack/__init__.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/action_clone_review_session.py b/openpype/modules/ftrack/event_handlers_server/action_clone_review_session.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/action_clone_review_session.py rename to openpype/modules/ftrack/event_handlers_server/action_clone_review_session.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/action_multiple_notes.py b/openpype/modules/ftrack/event_handlers_server/action_multiple_notes.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/action_multiple_notes.py rename to openpype/modules/ftrack/event_handlers_server/action_multiple_notes.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/action_prepare_project.py b/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/action_prepare_project.py rename to openpype/modules/ftrack/event_handlers_server/action_prepare_project.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/action_private_project_detection.py b/openpype/modules/ftrack/event_handlers_server/action_private_project_detection.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/action_private_project_detection.py rename to openpype/modules/ftrack/event_handlers_server/action_private_project_detection.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py b/openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py rename to openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/action_sync_to_avalon.py b/openpype/modules/ftrack/event_handlers_server/action_sync_to_avalon.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/action_sync_to_avalon.py rename to openpype/modules/ftrack/event_handlers_server/action_sync_to_avalon.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/event_del_avalon_id_from_new.py b/openpype/modules/ftrack/event_handlers_server/event_del_avalon_id_from_new.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/event_del_avalon_id_from_new.py rename to openpype/modules/ftrack/event_handlers_server/event_del_avalon_id_from_new.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/event_first_version_status.py b/openpype/modules/ftrack/event_handlers_server/event_first_version_status.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/event_first_version_status.py rename to openpype/modules/ftrack/event_handlers_server/event_first_version_status.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/event_next_task_update.py b/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/event_next_task_update.py rename to openpype/modules/ftrack/event_handlers_server/event_next_task_update.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py b/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py rename to openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/event_radio_buttons.py b/openpype/modules/ftrack/event_handlers_server/event_radio_buttons.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/event_radio_buttons.py rename to openpype/modules/ftrack/event_handlers_server/event_radio_buttons.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_links.py b/openpype/modules/ftrack/event_handlers_server/event_sync_links.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_links.py rename to openpype/modules/ftrack/event_handlers_server/event_sync_links.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_to_avalon.py rename to openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/event_task_to_parent_status.py b/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/event_task_to_parent_status.py rename to openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/event_task_to_version_status.py b/openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/event_task_to_version_status.py rename to openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/event_thumbnail_updates.py b/openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/event_thumbnail_updates.py rename to openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/event_user_assigment.py b/openpype/modules/ftrack/event_handlers_server/event_user_assigment.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/event_user_assigment.py rename to openpype/modules/ftrack/event_handlers_server/event_user_assigment.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/event_version_to_task_statuses.py b/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_server/event_version_to_task_statuses.py rename to openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_applications.py b/openpype/modules/ftrack/event_handlers_user/action_applications.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_applications.py rename to openpype/modules/ftrack/event_handlers_user/action_applications.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_batch_task_creation.py b/openpype/modules/ftrack/event_handlers_user/action_batch_task_creation.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_batch_task_creation.py rename to openpype/modules/ftrack/event_handlers_user/action_batch_task_creation.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py b/openpype/modules/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py rename to openpype/modules/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_client_review_sort.py b/openpype/modules/ftrack/event_handlers_user/action_client_review_sort.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_client_review_sort.py rename to openpype/modules/ftrack/event_handlers_user/action_client_review_sort.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_component_open.py b/openpype/modules/ftrack/event_handlers_user/action_component_open.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_component_open.py rename to openpype/modules/ftrack/event_handlers_user/action_component_open.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_cust_attrs.py b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_create_cust_attrs.py rename to openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py b/openpype/modules/ftrack/event_handlers_user/action_create_folders.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py rename to openpype/modules/ftrack/event_handlers_user/action_create_folders.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_project_structure.py b/openpype/modules/ftrack/event_handlers_user/action_create_project_structure.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_create_project_structure.py rename to openpype/modules/ftrack/event_handlers_user/action_create_project_structure.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py b/openpype/modules/ftrack/event_handlers_user/action_delete_asset.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py rename to openpype/modules/ftrack/event_handlers_user/action_delete_asset.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_old_versions.py b/openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_old_versions.py rename to openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_delivery.py b/openpype/modules/ftrack/event_handlers_user/action_delivery.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_delivery.py rename to openpype/modules/ftrack/event_handlers_user/action_delivery.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_djvview.py b/openpype/modules/ftrack/event_handlers_user/action_djvview.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_djvview.py rename to openpype/modules/ftrack/event_handlers_user/action_djvview.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_job_killer.py b/openpype/modules/ftrack/event_handlers_user/action_job_killer.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_job_killer.py rename to openpype/modules/ftrack/event_handlers_user/action_job_killer.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_multiple_notes.py b/openpype/modules/ftrack/event_handlers_user/action_multiple_notes.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_multiple_notes.py rename to openpype/modules/ftrack/event_handlers_user/action_multiple_notes.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_prepare_project.py b/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_prepare_project.py rename to openpype/modules/ftrack/event_handlers_user/action_prepare_project.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_rv.py b/openpype/modules/ftrack/event_handlers_user/action_rv.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_rv.py rename to openpype/modules/ftrack/event_handlers_user/action_rv.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_seed.py b/openpype/modules/ftrack/event_handlers_user/action_seed.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_seed.py rename to openpype/modules/ftrack/event_handlers_user/action_seed.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_store_thumbnails_to_avalon.py b/openpype/modules/ftrack/event_handlers_user/action_store_thumbnails_to_avalon.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_store_thumbnails_to_avalon.py rename to openpype/modules/ftrack/event_handlers_user/action_store_thumbnails_to_avalon.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_sync_to_avalon.py b/openpype/modules/ftrack/event_handlers_user/action_sync_to_avalon.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_sync_to_avalon.py rename to openpype/modules/ftrack/event_handlers_user/action_sync_to_avalon.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_test.py b/openpype/modules/ftrack/event_handlers_user/action_test.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_test.py rename to openpype/modules/ftrack/event_handlers_user/action_test.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_thumbnail_to_childern.py b/openpype/modules/ftrack/event_handlers_user/action_thumbnail_to_childern.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_thumbnail_to_childern.py rename to openpype/modules/ftrack/event_handlers_user/action_thumbnail_to_childern.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_thumbnail_to_parent.py b/openpype/modules/ftrack/event_handlers_user/action_thumbnail_to_parent.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_thumbnail_to_parent.py rename to openpype/modules/ftrack/event_handlers_user/action_thumbnail_to_parent.py diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_where_run_ask.py b/openpype/modules/ftrack/event_handlers_user/action_where_run_ask.py similarity index 100% rename from openpype/modules/default_modules/ftrack/event_handlers_user/action_where_run_ask.py rename to openpype/modules/ftrack/event_handlers_user/action_where_run_ask.py diff --git a/openpype/modules/default_modules/ftrack/ftrack_module.py b/openpype/modules/ftrack/ftrack_module.py similarity index 100% rename from openpype/modules/default_modules/ftrack/ftrack_module.py rename to openpype/modules/ftrack/ftrack_module.py diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/__init__.py b/openpype/modules/ftrack/ftrack_server/__init__.py similarity index 100% rename from openpype/modules/default_modules/ftrack/ftrack_server/__init__.py rename to openpype/modules/ftrack/ftrack_server/__init__.py diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py b/openpype/modules/ftrack/ftrack_server/event_server_cli.py similarity index 100% rename from openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py rename to openpype/modules/ftrack/ftrack_server/event_server_cli.py diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/ftrack_server.py b/openpype/modules/ftrack/ftrack_server/ftrack_server.py similarity index 100% rename from openpype/modules/default_modules/ftrack/ftrack_server/ftrack_server.py rename to openpype/modules/ftrack/ftrack_server/ftrack_server.py diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/lib.py b/openpype/modules/ftrack/ftrack_server/lib.py similarity index 100% rename from openpype/modules/default_modules/ftrack/ftrack_server/lib.py rename to openpype/modules/ftrack/ftrack_server/lib.py diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py b/openpype/modules/ftrack/ftrack_server/socket_thread.py similarity index 100% rename from openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py rename to openpype/modules/ftrack/ftrack_server/socket_thread.py diff --git a/openpype/modules/default_modules/ftrack/launch_hooks/post_ftrack_changes.py b/openpype/modules/ftrack/launch_hooks/post_ftrack_changes.py similarity index 100% rename from openpype/modules/default_modules/ftrack/launch_hooks/post_ftrack_changes.py rename to openpype/modules/ftrack/launch_hooks/post_ftrack_changes.py diff --git a/openpype/modules/default_modules/ftrack/launch_hooks/pre_python2_vendor.py b/openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py similarity index 100% rename from openpype/modules/default_modules/ftrack/launch_hooks/pre_python2_vendor.py rename to openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py diff --git a/openpype/modules/default_modules/ftrack/lib/__init__.py b/openpype/modules/ftrack/lib/__init__.py similarity index 100% rename from openpype/modules/default_modules/ftrack/lib/__init__.py rename to openpype/modules/ftrack/lib/__init__.py diff --git a/openpype/modules/default_modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py similarity index 100% rename from openpype/modules/default_modules/ftrack/lib/avalon_sync.py rename to openpype/modules/ftrack/lib/avalon_sync.py diff --git a/openpype/modules/default_modules/ftrack/lib/constants.py b/openpype/modules/ftrack/lib/constants.py similarity index 100% rename from openpype/modules/default_modules/ftrack/lib/constants.py rename to openpype/modules/ftrack/lib/constants.py diff --git a/openpype/modules/default_modules/ftrack/lib/credentials.py b/openpype/modules/ftrack/lib/credentials.py similarity index 100% rename from openpype/modules/default_modules/ftrack/lib/credentials.py rename to openpype/modules/ftrack/lib/credentials.py diff --git a/openpype/modules/default_modules/ftrack/lib/custom_attributes.json b/openpype/modules/ftrack/lib/custom_attributes.json similarity index 100% rename from openpype/modules/default_modules/ftrack/lib/custom_attributes.json rename to openpype/modules/ftrack/lib/custom_attributes.json diff --git a/openpype/modules/default_modules/ftrack/lib/custom_attributes.py b/openpype/modules/ftrack/lib/custom_attributes.py similarity index 100% rename from openpype/modules/default_modules/ftrack/lib/custom_attributes.py rename to openpype/modules/ftrack/lib/custom_attributes.py diff --git a/openpype/modules/default_modules/ftrack/lib/ftrack_action_handler.py b/openpype/modules/ftrack/lib/ftrack_action_handler.py similarity index 100% rename from openpype/modules/default_modules/ftrack/lib/ftrack_action_handler.py rename to openpype/modules/ftrack/lib/ftrack_action_handler.py diff --git a/openpype/modules/default_modules/ftrack/lib/ftrack_base_handler.py b/openpype/modules/ftrack/lib/ftrack_base_handler.py similarity index 100% rename from openpype/modules/default_modules/ftrack/lib/ftrack_base_handler.py rename to openpype/modules/ftrack/lib/ftrack_base_handler.py diff --git a/openpype/modules/default_modules/ftrack/lib/ftrack_event_handler.py b/openpype/modules/ftrack/lib/ftrack_event_handler.py similarity index 100% rename from openpype/modules/default_modules/ftrack/lib/ftrack_event_handler.py rename to openpype/modules/ftrack/lib/ftrack_event_handler.py diff --git a/openpype/modules/default_modules/ftrack/lib/settings.py b/openpype/modules/ftrack/lib/settings.py similarity index 100% rename from openpype/modules/default_modules/ftrack/lib/settings.py rename to openpype/modules/ftrack/lib/settings.py diff --git a/openpype/modules/default_modules/ftrack/plugins/_unused_publish/integrate_ftrack_comments.py b/openpype/modules/ftrack/plugins/_unused_publish/integrate_ftrack_comments.py similarity index 100% rename from openpype/modules/default_modules/ftrack/plugins/_unused_publish/integrate_ftrack_comments.py rename to openpype/modules/ftrack/plugins/_unused_publish/integrate_ftrack_comments.py diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py similarity index 100% rename from openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_api.py rename to openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_family.py b/openpype/modules/ftrack/plugins/publish/collect_ftrack_family.py similarity index 100% rename from openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_family.py rename to openpype/modules/ftrack/plugins/publish/collect_ftrack_family.py diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/collect_local_ftrack_creds.py b/openpype/modules/ftrack/plugins/publish/collect_local_ftrack_creds.py similarity index 100% rename from openpype/modules/default_modules/ftrack/plugins/publish/collect_local_ftrack_creds.py rename to openpype/modules/ftrack/plugins/publish/collect_local_ftrack_creds.py diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py b/openpype/modules/ftrack/plugins/publish/collect_username.py similarity index 100% rename from openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py rename to openpype/modules/ftrack/plugins/publish/collect_username.py diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/integrate_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py similarity index 100% rename from openpype/modules/default_modules/ftrack/plugins/publish/integrate_ftrack_api.py rename to openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/integrate_ftrack_component_overwrite.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_component_overwrite.py similarity index 100% rename from openpype/modules/default_modules/ftrack/plugins/publish/integrate_ftrack_component_overwrite.py rename to openpype/modules/ftrack/plugins/publish/integrate_ftrack_component_overwrite.py diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/integrate_ftrack_instances.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py similarity index 100% rename from openpype/modules/default_modules/ftrack/plugins/publish/integrate_ftrack_instances.py rename to openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/integrate_ftrack_note.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py similarity index 100% rename from openpype/modules/default_modules/ftrack/plugins/publish/integrate_ftrack_note.py rename to openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py b/openpype/modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py similarity index 100% rename from openpype/modules/default_modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py rename to openpype/modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/validate_custom_ftrack_attributes.py b/openpype/modules/ftrack/plugins/publish/validate_custom_ftrack_attributes.py similarity index 100% rename from openpype/modules/default_modules/ftrack/plugins/publish/validate_custom_ftrack_attributes.py rename to openpype/modules/ftrack/plugins/publish/validate_custom_ftrack_attributes.py diff --git a/openpype/modules/ftrack/python2_vendor/arrow/.gitignore b/openpype/modules/ftrack/python2_vendor/arrow/.gitignore new file mode 100644 index 0000000000..0448d0cf0c --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/.gitignore @@ -0,0 +1,211 @@ +README.rst.new + +# Small entry point file for debugging tasks +test.py + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +local/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +.idea/ +.vscode/ + +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk diff --git a/openpype/modules/ftrack/python2_vendor/arrow/.pre-commit-config.yaml b/openpype/modules/ftrack/python2_vendor/arrow/.pre-commit-config.yaml new file mode 100644 index 0000000000..1f5128595b --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/.pre-commit-config.yaml @@ -0,0 +1,41 @@ +default_language_version: + python: python3 +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: fix-encoding-pragma + exclude: ^arrow/_version.py + - id: requirements-txt-fixer + - id: check-ast + - id: check-yaml + - id: check-case-conflict + - id: check-docstring-first + - id: check-merge-conflict + - id: debug-statements + - repo: https://github.com/timothycrosley/isort + rev: 5.4.2 + hooks: + - id: isort + - repo: https://github.com/asottile/pyupgrade + rev: v2.7.2 + hooks: + - id: pyupgrade + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.6.0 + hooks: + - id: python-no-eval + - id: python-check-blanket-noqa + - id: rst-backticks + - repo: https://github.com/psf/black + rev: 20.8b1 + hooks: + - id: black + args: [--safe, --quiet] + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.3 + hooks: + - id: flake8 + additional_dependencies: [flake8-bugbear] diff --git a/openpype/modules/ftrack/python2_vendor/arrow/CHANGELOG.rst b/openpype/modules/ftrack/python2_vendor/arrow/CHANGELOG.rst new file mode 100644 index 0000000000..0b55a4522c --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/CHANGELOG.rst @@ -0,0 +1,598 @@ +Changelog +========= + +0.17.0 (2020-10-2) +------------------- + +- [WARN] Arrow will **drop support** for Python 2.7 and 3.5 in the upcoming 1.0.0 release. This is the last major release to support Python 2.7 and Python 3.5. +- [NEW] Arrow now properly handles imaginary datetimes during DST shifts. For example: + +..code-block:: python + >>> just_before = arrow.get(2013, 3, 31, 1, 55, tzinfo="Europe/Paris") + >>> just_before.shift(minutes=+10) + + +..code-block:: python + >>> before = arrow.get("2018-03-10 23:00:00", "YYYY-MM-DD HH:mm:ss", tzinfo="US/Pacific") + >>> after = arrow.get("2018-03-11 04:00:00", "YYYY-MM-DD HH:mm:ss", tzinfo="US/Pacific") + >>> result=[(t, t.to("utc")) for t in arrow.Arrow.range("hour", before, after)] + >>> for r in result: + ... print(r) + ... + (, ) + (, ) + (, ) + (, ) + (, ) + +- [NEW] Added ``humanize`` week granularity translation for Tagalog. +- [CHANGE] Calls to the ``timestamp`` property now emit a ``DeprecationWarning``. In a future release, ``timestamp`` will be changed to a method to align with Python's datetime module. If you would like to continue using the property, please change your code to use the ``int_timestamp`` or ``float_timestamp`` properties instead. +- [CHANGE] Expanded and improved Catalan locale. +- [FIX] Fixed a bug that caused ``Arrow.range()`` to incorrectly cut off ranges in certain scenarios when using month, quarter, or year endings. +- [FIX] Fixed a bug that caused day of week token parsing to be case sensitive. +- [INTERNAL] A number of functions were reordered in arrow.py for better organization and grouping of related methods. This change will have no impact on usage. +- [INTERNAL] A minimum tox version is now enforced for compatibility reasons. Contributors must use tox >3.18.0 going forward. + +0.16.0 (2020-08-23) +------------------- + +- [WARN] Arrow will **drop support** for Python 2.7 and 3.5 in the upcoming 1.0.0 release. The 0.16.x and 0.17.x releases are the last to support Python 2.7 and 3.5. +- [NEW] Implemented `PEP 495 `_ to handle ambiguous datetimes. This is achieved by the addition of the ``fold`` attribute for Arrow objects. For example: + +.. code-block:: python + + >>> before = Arrow(2017, 10, 29, 2, 0, tzinfo='Europe/Stockholm') + + >>> before.fold + 0 + >>> before.ambiguous + True + >>> after = Arrow(2017, 10, 29, 2, 0, tzinfo='Europe/Stockholm', fold=1) + + >>> after = before.replace(fold=1) + + +- [NEW] Added ``normalize_whitespace`` flag to ``arrow.get``. This is useful for parsing log files and/or any files that may contain inconsistent spacing. For example: + +.. code-block:: python + + >>> arrow.get("Jun 1 2005 1:33PM", "MMM D YYYY H:mmA", normalize_whitespace=True) + + >>> arrow.get("2013-036 \t 04:05:06Z", normalize_whitespace=True) + + +0.15.8 (2020-07-23) +------------------- + +- [WARN] Arrow will **drop support** for Python 2.7 and 3.5 in the upcoming 1.0.0 release. The 0.15.x, 0.16.x, and 0.17.x releases are the last to support Python 2.7 and 3.5. +- [NEW] Added ``humanize`` week granularity translation for Czech. +- [FIX] ``arrow.get`` will now pick sane defaults when weekdays are passed with particular token combinations, see `#446 `_. +- [INTERNAL] Moved arrow to an organization. The repo can now be found `here `_. +- [INTERNAL] Started issuing deprecation warnings for Python 2.7 and 3.5. +- [INTERNAL] Added Python 3.9 to CI pipeline. + +0.15.7 (2020-06-19) +------------------- + +- [NEW] Added a number of built-in format strings. See the `docs `_ for a complete list of supported formats. For example: + +.. code-block:: python + + >>> arw = arrow.utcnow() + >>> arw.format(arrow.FORMAT_COOKIE) + 'Wednesday, 27-May-2020 10:30:35 UTC' + +- [NEW] Arrow is now fully compatible with Python 3.9 and PyPy3. +- [NEW] Added Makefile, tox.ini, and requirements.txt files to the distribution bundle. +- [NEW] Added French Canadian and Swahili locales. +- [NEW] Added ``humanize`` week granularity translation for Hebrew, Greek, Macedonian, Swedish, Slovak. +- [FIX] ms and μs timestamps are now normalized in ``arrow.get()``, ``arrow.fromtimestamp()``, and ``arrow.utcfromtimestamp()``. For example: + +.. code-block:: python + + >>> ts = 1591161115194556 + >>> arw = arrow.get(ts) + + >>> arw.timestamp + 1591161115 + +- [FIX] Refactored and updated Macedonian, Hebrew, Korean, and Portuguese locales. + +0.15.6 (2020-04-29) +------------------- + +- [NEW] Added support for parsing and formatting `ISO 8601 week dates `_ via a new token ``W``, for example: + +.. code-block:: python + + >>> arrow.get("2013-W29-6", "W") + + >>> utc=arrow.utcnow() + >>> utc + + >>> utc.format("W") + '2020-W04-4' + +- [NEW] Formatting with ``x`` token (microseconds) is now possible, for example: + +.. code-block:: python + + >>> dt = arrow.utcnow() + >>> dt.format("x") + '1585669870688329' + >>> dt.format("X") + '1585669870' + +- [NEW] Added ``humanize`` week granularity translation for German, Italian, Polish & Taiwanese locales. +- [FIX] Consolidated and simplified German locales. +- [INTERNAL] Moved testing suite from nosetest/Chai to pytest/pytest-mock. +- [INTERNAL] Converted xunit-style setup and teardown functions in tests to pytest fixtures. +- [INTERNAL] Setup Github Actions for CI alongside Travis. +- [INTERNAL] Help support Arrow's future development by donating to the project on `Open Collective `_. + +0.15.5 (2020-01-03) +------------------- + +- [WARN] Python 2 reached EOL on 2020-01-01. arrow will **drop support** for Python 2 in a future release to be decided (see `#739 `_). +- [NEW] Added bounds parameter to ``span_range``, ``interval`` and ``span`` methods. This allows you to include or exclude the start and end values. +- [NEW] ``arrow.get()`` can now create arrow objects from a timestamp with a timezone, for example: + +.. code-block:: python + + >>> arrow.get(1367900664, tzinfo=tz.gettz('US/Pacific')) + + +- [NEW] ``humanize`` can now combine multiple levels of granularity, for example: + +.. code-block:: python + + >>> later140 = arrow.utcnow().shift(seconds=+8400) + >>> later140.humanize(granularity="minute") + 'in 139 minutes' + >>> later140.humanize(granularity=["hour", "minute"]) + 'in 2 hours and 19 minutes' + +- [NEW] Added Hong Kong locale (``zh_hk``). +- [NEW] Added ``humanize`` week granularity translation for Dutch. +- [NEW] Numbers are now displayed when using the seconds granularity in ``humanize``. +- [CHANGE] ``range`` now supports both the singular and plural forms of the ``frames`` argument (e.g. day and days). +- [FIX] Improved parsing of strings that contain punctuation. +- [FIX] Improved behaviour of ``humanize`` when singular seconds are involved. + +0.15.4 (2019-11-02) +------------------- + +- [FIX] Fixed an issue that caused package installs to fail on Conda Forge. + +0.15.3 (2019-11-02) +------------------- + +- [NEW] ``factory.get()`` can now create arrow objects from a ISO calendar tuple, for example: + +.. code-block:: python + + >>> arrow.get((2013, 18, 7)) + + +- [NEW] Added a new token ``x`` to allow parsing of integer timestamps with milliseconds and microseconds. +- [NEW] Formatting now supports escaping of characters using the same syntax as parsing, for example: + +.. code-block:: python + + >>> arw = arrow.now() + >>> fmt = "YYYY-MM-DD h [h] m" + >>> arw.format(fmt) + '2019-11-02 3 h 32' + +- [NEW] Added ``humanize`` week granularity translations for Chinese, Spanish and Vietnamese. +- [CHANGE] Added ``ParserError`` to module exports. +- [FIX] Added support for midnight at end of day. See `#703 `_ for details. +- [INTERNAL] Created Travis build for macOS. +- [INTERNAL] Test parsing and formatting against full timezone database. + +0.15.2 (2019-09-14) +------------------- + +- [NEW] Added ``humanize`` week granularity translations for Portuguese and Brazilian Portuguese. +- [NEW] Embedded changelog within docs and added release dates to versions. +- [FIX] Fixed a bug that caused test failures on Windows only, see `#668 `_ for details. + +0.15.1 (2019-09-10) +------------------- + +- [NEW] Added ``humanize`` week granularity translations for Japanese. +- [FIX] Fixed a bug that caused Arrow to fail when passed a negative timestamp string. +- [FIX] Fixed a bug that caused Arrow to fail when passed a datetime object with ``tzinfo`` of type ``StaticTzInfo``. + +0.15.0 (2019-09-08) +------------------- + +- [NEW] Added support for DDD and DDDD ordinal date tokens. The following functionality is now possible: ``arrow.get("1998-045")``, ``arrow.get("1998-45", "YYYY-DDD")``, ``arrow.get("1998-045", "YYYY-DDDD")``. +- [NEW] ISO 8601 basic format for dates and times is now supported (e.g. ``YYYYMMDDTHHmmssZ``). +- [NEW] Added ``humanize`` week granularity translations for French, Russian and Swiss German locales. +- [CHANGE] Timestamps of type ``str`` are no longer supported **without a format string** in the ``arrow.get()`` method. This change was made to support the ISO 8601 basic format and to address bugs such as `#447 `_. + +The following will NOT work in v0.15.0: + +.. code-block:: python + + >>> arrow.get("1565358758") + >>> arrow.get("1565358758.123413") + +The following will work in v0.15.0: + +.. code-block:: python + + >>> arrow.get("1565358758", "X") + >>> arrow.get("1565358758.123413", "X") + >>> arrow.get(1565358758) + >>> arrow.get(1565358758.123413) + +- [CHANGE] When a meridian token (a|A) is passed and no meridians are available for the specified locale (e.g. unsupported or untranslated) a ``ParserError`` is raised. +- [CHANGE] The timestamp token (``X``) will now match float timestamps of type ``str``: ``arrow.get(“1565358758.123415”, “X”)``. +- [CHANGE] Strings with leading and/or trailing whitespace will no longer be parsed without a format string. Please see `the docs `_ for ways to handle this. +- [FIX] The timestamp token (``X``) will now only match on strings that **strictly contain integers and floats**, preventing incorrect matches. +- [FIX] Most instances of ``arrow.get()`` returning an incorrect ``Arrow`` object from a partial parsing match have been eliminated. The following issue have been addressed: `#91 `_, `#196 `_, `#396 `_, `#434 `_, `#447 `_, `#456 `_, `#519 `_, `#538 `_, `#560 `_. + +0.14.7 (2019-09-04) +------------------- + +- [CHANGE] ``ArrowParseWarning`` will no longer be printed on every call to ``arrow.get()`` with a datetime string. The purpose of the warning was to start a conversation about the upcoming 0.15.0 changes and we appreciate all the feedback that the community has given us! + +0.14.6 (2019-08-28) +------------------- + +- [NEW] Added support for ``week`` granularity in ``Arrow.humanize()``. For example, ``arrow.utcnow().shift(weeks=-1).humanize(granularity="week")`` outputs "a week ago". This change introduced two new untranslated words, ``week`` and ``weeks``, to all locale dictionaries, so locale contributions are welcome! +- [NEW] Fully translated the Brazilian Portugese locale. +- [CHANGE] Updated the Macedonian locale to inherit from a Slavic base. +- [FIX] Fixed a bug that caused ``arrow.get()`` to ignore tzinfo arguments of type string (e.g. ``arrow.get(tzinfo="Europe/Paris")``). +- [FIX] Fixed a bug that occurred when ``arrow.Arrow()`` was instantiated with a ``pytz`` tzinfo object. +- [FIX] Fixed a bug that caused Arrow to fail when passed a sub-second token, that when rounded, had a value greater than 999999 (e.g. ``arrow.get("2015-01-12T01:13:15.9999995")``). Arrow should now accurately propagate the rounding for large sub-second tokens. + +0.14.5 (2019-08-09) +------------------- + +- [NEW] Added Afrikaans locale. +- [CHANGE] Removed deprecated ``replace`` shift functionality. Users looking to pass plural properties to the ``replace`` function to shift values should use ``shift`` instead. +- [FIX] Fixed bug that occurred when ``factory.get()`` was passed a locale kwarg. + +0.14.4 (2019-07-30) +------------------- + +- [FIX] Fixed a regression in 0.14.3 that prevented a tzinfo argument of type string to be passed to the ``get()`` function. Functionality such as ``arrow.get("2019072807", "YYYYMMDDHH", tzinfo="UTC")`` should work as normal again. +- [CHANGE] Moved ``backports.functools_lru_cache`` dependency from ``extra_requires`` to ``install_requires`` for ``Python 2.7`` installs to fix `#495 `_. + +0.14.3 (2019-07-28) +------------------- + +- [NEW] Added full support for Python 3.8. +- [CHANGE] Added warnings for upcoming factory.get() parsing changes in 0.15.0. Please see `#612 `_ for full details. +- [FIX] Extensive refactor and update of documentation. +- [FIX] factory.get() can now construct from kwargs. +- [FIX] Added meridians to Spanish Locale. + +0.14.2 (2019-06-06) +------------------- + +- [CHANGE] Travis CI builds now use tox to lint and run tests. +- [FIX] Fixed UnicodeDecodeError on certain locales (#600). + +0.14.1 (2019-06-06) +------------------- + +- [FIX] Fixed ``ImportError: No module named 'dateutil'`` (#598). + +0.14.0 (2019-06-06) +------------------- + +- [NEW] Added provisional support for Python 3.8. +- [CHANGE] Removed support for EOL Python 3.4. +- [FIX] Updated setup.py with modern Python standards. +- [FIX] Upgraded dependencies to latest versions. +- [FIX] Enabled flake8 and black on travis builds. +- [FIX] Formatted code using black and isort. + +0.13.2 (2019-05-30) +------------------- + +- [NEW] Add is_between method. +- [FIX] Improved humanize behaviour for near zero durations (#416). +- [FIX] Correct humanize behaviour with future days (#541). +- [FIX] Documentation updates. +- [FIX] Improvements to German Locale. + +0.13.1 (2019-02-17) +------------------- + +- [NEW] Add support for Python 3.7. +- [CHANGE] Remove deprecation decorators for Arrow.range(), Arrow.span_range() and Arrow.interval(), all now return generators, wrap with list() to get old behavior. +- [FIX] Documentation and docstring updates. + +0.13.0 (2019-01-09) +------------------- + +- [NEW] Added support for Python 3.6. +- [CHANGE] Drop support for Python 2.6/3.3. +- [CHANGE] Return generator instead of list for Arrow.range(), Arrow.span_range() and Arrow.interval(). +- [FIX] Make arrow.get() work with str & tzinfo combo. +- [FIX] Make sure special RegEx characters are escaped in format string. +- [NEW] Added support for ZZZ when formatting. +- [FIX] Stop using datetime.utcnow() in internals, use datetime.now(UTC) instead. +- [FIX] Return NotImplemented instead of TypeError in arrow math internals. +- [NEW] Added Estonian Locale. +- [FIX] Small fixes to Greek locale. +- [FIX] TagalogLocale improvements. +- [FIX] Added test requirements to setup. +- [FIX] Improve docs for get, now and utcnow methods. +- [FIX] Correct typo in depreciation warning. + +0.12.1 +------ + +- [FIX] Allow universal wheels to be generated and reliably installed. +- [FIX] Make humanize respect only_distance when granularity argument is also given. + +0.12.0 +------ + +- [FIX] Compatibility fix for Python 2.x + +0.11.0 +------ + +- [FIX] Fix grammar of ArabicLocale +- [NEW] Add Nepali Locale +- [FIX] Fix month name + rename AustriaLocale -> AustrianLocale +- [FIX] Fix typo in Basque Locale +- [FIX] Fix grammar in PortugueseBrazilian locale +- [FIX] Remove pip --user-mirrors flag +- [NEW] Add Indonesian Locale + +0.10.0 +------ + +- [FIX] Fix getattr off by one for quarter +- [FIX] Fix negative offset for UTC +- [FIX] Update arrow.py + +0.9.0 +----- + +- [NEW] Remove duplicate code +- [NEW] Support gnu date iso 8601 +- [NEW] Add support for universal wheels +- [NEW] Slovenian locale +- [NEW] Slovak locale +- [NEW] Romanian locale +- [FIX] respect limit even if end is defined range +- [FIX] Separate replace & shift functions +- [NEW] Added tox +- [FIX] Fix supported Python versions in documentation +- [NEW] Azerbaijani locale added, locale issue fixed in Turkish. +- [FIX] Format ParserError's raise message + +0.8.0 +----- + +- [] + +0.7.1 +----- + +- [NEW] Esperanto locale (batisteo) + +0.7.0 +----- + +- [FIX] Parse localized strings #228 (swistakm) +- [FIX] Modify tzinfo parameter in ``get`` api #221 (bottleimp) +- [FIX] Fix Czech locale (PrehistoricTeam) +- [FIX] Raise TypeError when adding/subtracting non-dates (itsmeolivia) +- [FIX] Fix pytz conversion error (Kudo) +- [FIX] Fix overzealous time truncation in span_range (kdeldycke) +- [NEW] Humanize for time duration #232 (ybrs) +- [NEW] Add Thai locale (sipp11) +- [NEW] Adding Belarusian (be) locale (oire) +- [NEW] Search date in strings (beenje) +- [NEW] Note that arrow's tokens differ from strptime's. (offby1) + +0.6.0 +----- + +- [FIX] Added support for Python 3 +- [FIX] Avoid truncating oversized epoch timestamps. Fixes #216. +- [FIX] Fixed month abbreviations for Ukrainian +- [FIX] Fix typo timezone +- [FIX] A couple of dialect fixes and two new languages +- [FIX] Spanish locale: ``Miercoles`` should have acute accent +- [Fix] Fix Finnish grammar +- [FIX] Fix typo in 'Arrow.floor' docstring +- [FIX] Use read() utility to open README +- [FIX] span_range for week frame +- [NEW] Add minimal support for fractional seconds longer than six digits. +- [NEW] Adding locale support for Marathi (mr) +- [NEW] Add count argument to span method +- [NEW] Improved docs + +0.5.1 - 0.5.4 +------------- + +- [FIX] test the behavior of simplejson instead of calling for_json directly (tonyseek) +- [FIX] Add Hebrew Locale (doodyparizada) +- [FIX] Update documentation location (andrewelkins) +- [FIX] Update setup.py Development Status level (andrewelkins) +- [FIX] Case insensitive month match (cshowe) + +0.5.0 +----- + +- [NEW] struct_time addition. (mhworth) +- [NEW] Version grep (eirnym) +- [NEW] Default to ISO 8601 format (emonty) +- [NEW] Raise TypeError on comparison (sniekamp) +- [NEW] Adding Macedonian(mk) locale (krisfremen) +- [FIX] Fix for ISO seconds and fractional seconds (sdispater) (andrewelkins) +- [FIX] Use correct Dutch wording for "hours" (wbolster) +- [FIX] Complete the list of english locales (indorilftw) +- [FIX] Change README to reStructuredText (nyuszika7h) +- [FIX] Parse lower-cased 'h' (tamentis) +- [FIX] Slight modifications to Dutch locale (nvie) + +0.4.4 +----- + +- [NEW] Include the docs in the released tarball +- [NEW] Czech localization Czech localization for Arrow +- [NEW] Add fa_ir to locales +- [FIX] Fixes parsing of time strings with a final Z +- [FIX] Fixes ISO parsing and formatting for fractional seconds +- [FIX] test_fromtimestamp sp +- [FIX] some typos fixed +- [FIX] removed an unused import statement +- [FIX] docs table fix +- [FIX] Issue with specify 'X' template and no template at all to arrow.get +- [FIX] Fix "import" typo in docs/index.rst +- [FIX] Fix unit tests for zero passed +- [FIX] Update layout.html +- [FIX] In Norwegian and new Norwegian months and weekdays should not be capitalized +- [FIX] Fixed discrepancy between specifying 'X' to arrow.get and specifying no template + +0.4.3 +----- + +- [NEW] Turkish locale (Emre) +- [NEW] Arabic locale (Mosab Ahmad) +- [NEW] Danish locale (Holmars) +- [NEW] Icelandic locale (Holmars) +- [NEW] Hindi locale (Atmb4u) +- [NEW] Malayalam locale (Atmb4u) +- [NEW] Finnish locale (Stormpat) +- [NEW] Portuguese locale (Danielcorreia) +- [NEW] ``h`` and ``hh`` strings are now supported (Averyonghub) +- [FIX] An incorrect inflection in the Polish locale has been fixed (Avalanchy) +- [FIX] ``arrow.get`` now properly handles ``Date`` (Jaapz) +- [FIX] Tests are now declared in ``setup.py`` and the manifest (Pypingou) +- [FIX] ``__version__`` has been added to ``__init__.py`` (Sametmax) +- [FIX] ISO 8601 strings can be parsed without a separator (Ivandiguisto / Root) +- [FIX] Documentation is now more clear regarding some inputs on ``arrow.get`` (Eriktaubeneck) +- [FIX] Some documentation links have been fixed (Vrutsky) +- [FIX] Error messages for parse errors are now more descriptive (Maciej Albin) +- [FIX] The parser now correctly checks for separators in strings (Mschwager) + +0.4.2 +----- + +- [NEW] Factory ``get`` method now accepts a single ``Arrow`` argument. +- [NEW] Tokens SSSS, SSSSS and SSSSSS are supported in parsing. +- [NEW] ``Arrow`` objects have a ``float_timestamp`` property. +- [NEW] Vietnamese locale (Iu1nguoi) +- [NEW] Factory ``get`` method now accepts a list of format strings (Dgilland) +- [NEW] A MANIFEST.in file has been added (Pypingou) +- [NEW] Tests can be run directly from ``setup.py`` (Pypingou) +- [FIX] Arrow docs now list 'day of week' format tokens correctly (Rudolphfroger) +- [FIX] Several issues with the Korean locale have been resolved (Yoloseem) +- [FIX] ``humanize`` now correctly returns unicode (Shvechikov) +- [FIX] ``Arrow`` objects now pickle / unpickle correctly (Yoloseem) + +0.4.1 +----- + +- [NEW] Table / explanation of formatting & parsing tokens in docs +- [NEW] Brazilian locale (Augusto2112) +- [NEW] Dutch locale (OrangeTux) +- [NEW] Italian locale (Pertux) +- [NEW] Austrain locale (LeChewbacca) +- [NEW] Tagalog locale (Marksteve) +- [FIX] Corrected spelling and day numbers in German locale (LeChewbacca) +- [FIX] Factory ``get`` method should now handle unicode strings correctly (Bwells) +- [FIX] Midnight and noon should now parse and format correctly (Bwells) + +0.4.0 +----- + +- [NEW] Format-free ISO 8601 parsing in factory ``get`` method +- [NEW] Support for 'week' / 'weeks' in ``span``, ``range``, ``span_range``, ``floor`` and ``ceil`` +- [NEW] Support for 'weeks' in ``replace`` +- [NEW] Norwegian locale (Martinp) +- [NEW] Japanese locale (CortYuming) +- [FIX] Timezones no longer show the wrong sign when formatted (Bean) +- [FIX] Microseconds are parsed correctly from strings (Bsidhom) +- [FIX] Locale day-of-week is no longer off by one (Cynddl) +- [FIX] Corrected plurals of Ukrainian and Russian nouns (Catchagain) +- [CHANGE] Old 0.1 ``arrow`` module method removed +- [CHANGE] Dropped timestamp support in ``range`` and ``span_range`` (never worked correctly) +- [CHANGE] Dropped parsing of single string as tz string in factory ``get`` method (replaced by ISO 8601) + +0.3.5 +----- + +- [NEW] French locale (Cynddl) +- [NEW] Spanish locale (Slapresta) +- [FIX] Ranges handle multiple timezones correctly (Ftobia) + +0.3.4 +----- + +- [FIX] Humanize no longer sometimes returns the wrong month delta +- [FIX] ``__format__`` works correctly with no format string + +0.3.3 +----- + +- [NEW] Python 2.6 support +- [NEW] Initial support for locale-based parsing and formatting +- [NEW] ArrowFactory class, now proxied as the module API +- [NEW] ``factory`` api method to obtain a factory for a custom type +- [FIX] Python 3 support and tests completely ironed out + +0.3.2 +----- + +- [NEW] Python 3+ support + +0.3.1 +----- + +- [FIX] The old ``arrow`` module function handles timestamps correctly as it used to + +0.3.0 +----- + +- [NEW] ``Arrow.replace`` method +- [NEW] Accept timestamps, datetimes and Arrows for datetime inputs, where reasonable +- [FIX] ``range`` and ``span_range`` respect end and limit parameters correctly +- [CHANGE] Arrow objects are no longer mutable +- [CHANGE] Plural attribute name semantics altered: single -> absolute, plural -> relative +- [CHANGE] Plural names no longer supported as properties (e.g. ``arrow.utcnow().years``) + +0.2.1 +----- + +- [NEW] Support for localized humanization +- [NEW] English, Russian, Greek, Korean, Chinese locales + +0.2.0 +----- + +- **REWRITE** +- [NEW] Date parsing +- [NEW] Date formatting +- [NEW] ``floor``, ``ceil`` and ``span`` methods +- [NEW] ``datetime`` interface implementation +- [NEW] ``clone`` method +- [NEW] ``get``, ``now`` and ``utcnow`` API methods + +0.1.6 +----- + +- [NEW] Humanized time deltas +- [NEW] ``__eq__`` implemented +- [FIX] Issues with conversions related to daylight savings time resolved +- [CHANGE] ``__str__`` uses ISO formatting + +0.1.5 +----- + +- **Started tracking changes** +- [NEW] Parsing of ISO-formatted time zone offsets (e.g. '+02:30', '-05:00') +- [NEW] Resolved some issues with timestamps and delta / Olson time zones diff --git a/openpype/modules/ftrack/python2_vendor/arrow/LICENSE b/openpype/modules/ftrack/python2_vendor/arrow/LICENSE new file mode 100644 index 0000000000..2bef500de7 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Chris Smith + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/openpype/modules/ftrack/python2_vendor/arrow/MANIFEST.in b/openpype/modules/ftrack/python2_vendor/arrow/MANIFEST.in new file mode 100644 index 0000000000..d9955ed96a --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/MANIFEST.in @@ -0,0 +1,3 @@ +include LICENSE CHANGELOG.rst README.rst Makefile requirements.txt tox.ini +recursive-include tests *.py +recursive-include docs *.py *.rst *.bat Makefile diff --git a/openpype/modules/ftrack/python2_vendor/arrow/Makefile b/openpype/modules/ftrack/python2_vendor/arrow/Makefile new file mode 100644 index 0000000000..f294985dc6 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/Makefile @@ -0,0 +1,44 @@ +.PHONY: auto test docs clean + +auto: build38 + +build27: PYTHON_VER = python2.7 +build35: PYTHON_VER = python3.5 +build36: PYTHON_VER = python3.6 +build37: PYTHON_VER = python3.7 +build38: PYTHON_VER = python3.8 +build39: PYTHON_VER = python3.9 + +build27 build35 build36 build37 build38 build39: clean + virtualenv venv --python=$(PYTHON_VER) + . venv/bin/activate; \ + pip install -r requirements.txt; \ + pre-commit install + +test: + rm -f .coverage coverage.xml + . venv/bin/activate; pytest + +lint: + . venv/bin/activate; pre-commit run --all-files --show-diff-on-failure + +docs: + rm -rf docs/_build + . venv/bin/activate; cd docs; make html + +clean: clean-dist + rm -rf venv .pytest_cache ./**/__pycache__ + rm -f .coverage coverage.xml ./**/*.pyc + +clean-dist: + rm -rf dist build .egg .eggs arrow.egg-info + +build-dist: + . venv/bin/activate; \ + pip install -U setuptools twine wheel; \ + python setup.py sdist bdist_wheel + +upload-dist: + . venv/bin/activate; twine upload dist/* + +publish: test clean-dist build-dist upload-dist clean-dist diff --git a/openpype/modules/ftrack/python2_vendor/arrow/README.rst b/openpype/modules/ftrack/python2_vendor/arrow/README.rst new file mode 100644 index 0000000000..69f6c50d81 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/README.rst @@ -0,0 +1,133 @@ +Arrow: Better dates & times for Python +====================================== + +.. start-inclusion-marker-do-not-remove + +.. image:: https://github.com/arrow-py/arrow/workflows/tests/badge.svg?branch=master + :alt: Build Status + :target: https://github.com/arrow-py/arrow/actions?query=workflow%3Atests+branch%3Amaster + +.. image:: https://codecov.io/gh/arrow-py/arrow/branch/master/graph/badge.svg + :alt: Coverage + :target: https://codecov.io/gh/arrow-py/arrow + +.. image:: https://img.shields.io/pypi/v/arrow.svg + :alt: PyPI Version + :target: https://pypi.python.org/pypi/arrow + +.. image:: https://img.shields.io/pypi/pyversions/arrow.svg + :alt: Supported Python Versions + :target: https://pypi.python.org/pypi/arrow + +.. image:: https://img.shields.io/pypi/l/arrow.svg + :alt: License + :target: https://pypi.python.org/pypi/arrow + +.. image:: https://img.shields.io/badge/code%20style-black-000000.svg + :alt: Code Style: Black + :target: https://github.com/psf/black + + +**Arrow** is a Python library that offers a sensible and human-friendly approach to creating, manipulating, formatting and converting dates, times and timestamps. It implements and updates the datetime type, plugging gaps in functionality and providing an intelligent module API that supports many common creation scenarios. Simply put, it helps you work with dates and times with fewer imports and a lot less code. + +Arrow is named after the `arrow of time `_ and is heavily inspired by `moment.js `_ and `requests `_. + +Why use Arrow over built-in modules? +------------------------------------ + +Python's standard library and some other low-level modules have near-complete date, time and timezone functionality, but don't work very well from a usability perspective: + +- Too many modules: datetime, time, calendar, dateutil, pytz and more +- Too many types: date, time, datetime, tzinfo, timedelta, relativedelta, etc. +- Timezones and timestamp conversions are verbose and unpleasant +- Timezone naivety is the norm +- Gaps in functionality: ISO 8601 parsing, timespans, humanization + +Features +-------- + +- Fully-implemented, drop-in replacement for datetime +- Supports Python 2.7, 3.5, 3.6, 3.7, 3.8 and 3.9 +- Timezone-aware and UTC by default +- Provides super-simple creation options for many common input scenarios +- :code:`shift` method with support for relative offsets, including weeks +- Formats and parses strings automatically +- Wide support for ISO 8601 +- Timezone conversion +- Timestamp available as a property +- Generates time spans, ranges, floors and ceilings for time frames ranging from microsecond to year +- Humanizes and supports a growing list of contributed locales +- Extensible for your own Arrow-derived types + +Quick Start +----------- + +Installation +~~~~~~~~~~~~ + +To install Arrow, use `pip `_ or `pipenv `_: + +.. code-block:: console + + $ pip install -U arrow + +Example Usage +~~~~~~~~~~~~~ + +.. code-block:: python + + >>> import arrow + >>> arrow.get('2013-05-11T21:23:58.970460+07:00') + + + >>> utc = arrow.utcnow() + >>> utc + + + >>> utc = utc.shift(hours=-1) + >>> utc + + + >>> local = utc.to('US/Pacific') + >>> local + + + >>> local.timestamp + 1368303838 + + >>> local.format() + '2013-05-11 13:23:58 -07:00' + + >>> local.format('YYYY-MM-DD HH:mm:ss ZZ') + '2013-05-11 13:23:58 -07:00' + + >>> local.humanize() + 'an hour ago' + + >>> local.humanize(locale='ko_kr') + '1시간 전' + +.. end-inclusion-marker-do-not-remove + +Documentation +------------- + +For full documentation, please visit `arrow.readthedocs.io `_. + +Contributing +------------ + +Contributions are welcome for both code and localizations (adding and updating locales). Begin by gaining familiarity with the Arrow library and its features. Then, jump into contributing: + +#. Find an issue or feature to tackle on the `issue tracker `_. Issues marked with the `"good first issue" label `_ may be a great place to start! +#. Fork `this repository `_ on GitHub and begin making changes in a branch. +#. Add a few tests to ensure that the bug was fixed or the feature works as expected. +#. Run the entire test suite and linting checks by running one of the following commands: :code:`tox` (if you have `tox `_ installed) **OR** :code:`make build38 && make test && make lint` (if you do not have Python 3.8 installed, replace :code:`build38` with the latest Python version on your system). +#. Submit a pull request and await feedback 😃. + +If you have any questions along the way, feel free to ask them `here `_. + +Support Arrow +------------- + +`Open Collective `_ is an online funding platform that provides tools to raise money and share your finances with full transparency. It is the platform of choice for individuals and companies to make one-time or recurring donations directly to the project. If you are interested in making a financial contribution, please visit the `Arrow collective `_. diff --git a/openpype/modules/ftrack/python2_vendor/arrow/arrow/__init__.py b/openpype/modules/ftrack/python2_vendor/arrow/arrow/__init__.py new file mode 100644 index 0000000000..2883527be8 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/arrow/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from ._version import __version__ +from .api import get, now, utcnow +from .arrow import Arrow +from .factory import ArrowFactory +from .formatter import ( + FORMAT_ATOM, + FORMAT_COOKIE, + FORMAT_RFC822, + FORMAT_RFC850, + FORMAT_RFC1036, + FORMAT_RFC1123, + FORMAT_RFC2822, + FORMAT_RFC3339, + FORMAT_RSS, + FORMAT_W3C, +) +from .parser import ParserError diff --git a/openpype/modules/ftrack/python2_vendor/arrow/arrow/_version.py b/openpype/modules/ftrack/python2_vendor/arrow/arrow/_version.py new file mode 100644 index 0000000000..fd86b3ee91 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/arrow/_version.py @@ -0,0 +1 @@ +__version__ = "0.17.0" diff --git a/openpype/modules/ftrack/python2_vendor/arrow/arrow/api.py b/openpype/modules/ftrack/python2_vendor/arrow/arrow/api.py new file mode 100644 index 0000000000..a6b7be3de2 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/arrow/api.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +""" +Provides the default implementation of :class:`ArrowFactory ` +methods for use as a module API. + +""" + +from __future__ import absolute_import + +from arrow.factory import ArrowFactory + +# internal default factory. +_factory = ArrowFactory() + + +def get(*args, **kwargs): + """Calls the default :class:`ArrowFactory ` ``get`` method.""" + + return _factory.get(*args, **kwargs) + + +get.__doc__ = _factory.get.__doc__ + + +def utcnow(): + """Calls the default :class:`ArrowFactory ` ``utcnow`` method.""" + + return _factory.utcnow() + + +utcnow.__doc__ = _factory.utcnow.__doc__ + + +def now(tz=None): + """Calls the default :class:`ArrowFactory ` ``now`` method.""" + + return _factory.now(tz) + + +now.__doc__ = _factory.now.__doc__ + + +def factory(type): + """Returns an :class:`.ArrowFactory` for the specified :class:`Arrow ` + or derived type. + + :param type: the type, :class:`Arrow ` or derived. + + """ + + return ArrowFactory(type) + + +__all__ = ["get", "utcnow", "now", "factory"] diff --git a/openpype/modules/ftrack/python2_vendor/arrow/arrow/arrow.py b/openpype/modules/ftrack/python2_vendor/arrow/arrow/arrow.py new file mode 100644 index 0000000000..4fe9541789 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/arrow/arrow.py @@ -0,0 +1,1584 @@ +# -*- coding: utf-8 -*- +""" +Provides the :class:`Arrow ` class, an enhanced ``datetime`` +replacement. + +""" + +from __future__ import absolute_import + +import calendar +import sys +import warnings +from datetime import datetime, timedelta +from datetime import tzinfo as dt_tzinfo +from math import trunc + +from dateutil import tz as dateutil_tz +from dateutil.relativedelta import relativedelta + +from arrow import formatter, locales, parser, util + +if sys.version_info[:2] < (3, 6): # pragma: no cover + with warnings.catch_warnings(): + warnings.simplefilter("default", DeprecationWarning) + warnings.warn( + "Arrow will drop support for Python 2.7 and 3.5 in the upcoming v1.0.0 release. Please upgrade to " + "Python 3.6+ to continue receiving updates for Arrow.", + DeprecationWarning, + ) + + +class Arrow(object): + """An :class:`Arrow ` object. + + Implements the ``datetime`` interface, behaving as an aware ``datetime`` while implementing + additional functionality. + + :param year: the calendar year. + :param month: the calendar month. + :param day: the calendar day. + :param hour: (optional) the hour. Defaults to 0. + :param minute: (optional) the minute, Defaults to 0. + :param second: (optional) the second, Defaults to 0. + :param microsecond: (optional) the microsecond. Defaults to 0. + :param tzinfo: (optional) A timezone expression. Defaults to UTC. + :param fold: (optional) 0 or 1, used to disambiguate repeated times. Defaults to 0. + + .. _tz-expr: + + Recognized timezone expressions: + + - A ``tzinfo`` object. + - A ``str`` describing a timezone, similar to 'US/Pacific', or 'Europe/Berlin'. + - A ``str`` in ISO 8601 style, as in '+07:00'. + - A ``str``, one of the following: 'local', 'utc', 'UTC'. + + Usage:: + + >>> import arrow + >>> arrow.Arrow(2013, 5, 5, 12, 30, 45) + + + """ + + resolution = datetime.resolution + + _ATTRS = ["year", "month", "day", "hour", "minute", "second", "microsecond"] + _ATTRS_PLURAL = ["{}s".format(a) for a in _ATTRS] + _MONTHS_PER_QUARTER = 3 + _SECS_PER_MINUTE = float(60) + _SECS_PER_HOUR = float(60 * 60) + _SECS_PER_DAY = float(60 * 60 * 24) + _SECS_PER_WEEK = float(60 * 60 * 24 * 7) + _SECS_PER_MONTH = float(60 * 60 * 24 * 30.5) + _SECS_PER_YEAR = float(60 * 60 * 24 * 365.25) + + def __init__( + self, + year, + month, + day, + hour=0, + minute=0, + second=0, + microsecond=0, + tzinfo=None, + **kwargs + ): + if tzinfo is None: + tzinfo = dateutil_tz.tzutc() + # detect that tzinfo is a pytz object (issue #626) + elif ( + isinstance(tzinfo, dt_tzinfo) + and hasattr(tzinfo, "localize") + and hasattr(tzinfo, "zone") + and tzinfo.zone + ): + tzinfo = parser.TzinfoParser.parse(tzinfo.zone) + elif util.isstr(tzinfo): + tzinfo = parser.TzinfoParser.parse(tzinfo) + + fold = kwargs.get("fold", 0) + + # use enfold here to cover direct arrow.Arrow init on 2.7/3.5 + self._datetime = dateutil_tz.enfold( + datetime(year, month, day, hour, minute, second, microsecond, tzinfo), + fold=fold, + ) + + # factories: single object, both original and from datetime. + + @classmethod + def now(cls, tzinfo=None): + """Constructs an :class:`Arrow ` object, representing "now" in the given + timezone. + + :param tzinfo: (optional) a ``tzinfo`` object. Defaults to local time. + + Usage:: + + >>> arrow.now('Asia/Baku') + + + """ + + if tzinfo is None: + tzinfo = dateutil_tz.tzlocal() + + dt = datetime.now(tzinfo) + + return cls( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + dt.tzinfo, + fold=getattr(dt, "fold", 0), + ) + + @classmethod + def utcnow(cls): + """Constructs an :class:`Arrow ` object, representing "now" in UTC + time. + + Usage:: + + >>> arrow.utcnow() + + + """ + + dt = datetime.now(dateutil_tz.tzutc()) + + return cls( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + dt.tzinfo, + fold=getattr(dt, "fold", 0), + ) + + @classmethod + def fromtimestamp(cls, timestamp, tzinfo=None): + """Constructs an :class:`Arrow ` object from a timestamp, converted to + the given timezone. + + :param timestamp: an ``int`` or ``float`` timestamp, or a ``str`` that converts to either. + :param tzinfo: (optional) a ``tzinfo`` object. Defaults to local time. + """ + + if tzinfo is None: + tzinfo = dateutil_tz.tzlocal() + elif util.isstr(tzinfo): + tzinfo = parser.TzinfoParser.parse(tzinfo) + + if not util.is_timestamp(timestamp): + raise ValueError( + "The provided timestamp '{}' is invalid.".format(timestamp) + ) + + timestamp = util.normalize_timestamp(float(timestamp)) + dt = datetime.fromtimestamp(timestamp, tzinfo) + + return cls( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + dt.tzinfo, + fold=getattr(dt, "fold", 0), + ) + + @classmethod + def utcfromtimestamp(cls, timestamp): + """Constructs an :class:`Arrow ` object from a timestamp, in UTC time. + + :param timestamp: an ``int`` or ``float`` timestamp, or a ``str`` that converts to either. + + """ + + if not util.is_timestamp(timestamp): + raise ValueError( + "The provided timestamp '{}' is invalid.".format(timestamp) + ) + + timestamp = util.normalize_timestamp(float(timestamp)) + dt = datetime.utcfromtimestamp(timestamp) + + return cls( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + dateutil_tz.tzutc(), + fold=getattr(dt, "fold", 0), + ) + + @classmethod + def fromdatetime(cls, dt, tzinfo=None): + """Constructs an :class:`Arrow ` object from a ``datetime`` and + optional replacement timezone. + + :param dt: the ``datetime`` + :param tzinfo: (optional) A :ref:`timezone expression `. Defaults to ``dt``'s + timezone, or UTC if naive. + + If you only want to replace the timezone of naive datetimes:: + + >>> dt + datetime.datetime(2013, 5, 5, 0, 0, tzinfo=tzutc()) + >>> arrow.Arrow.fromdatetime(dt, dt.tzinfo or 'US/Pacific') + + + """ + + if tzinfo is None: + if dt.tzinfo is None: + tzinfo = dateutil_tz.tzutc() + else: + tzinfo = dt.tzinfo + + return cls( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + tzinfo, + fold=getattr(dt, "fold", 0), + ) + + @classmethod + def fromdate(cls, date, tzinfo=None): + """Constructs an :class:`Arrow ` object from a ``date`` and optional + replacement timezone. Time values are set to 0. + + :param date: the ``date`` + :param tzinfo: (optional) A :ref:`timezone expression `. Defaults to UTC. + """ + + if tzinfo is None: + tzinfo = dateutil_tz.tzutc() + + return cls(date.year, date.month, date.day, tzinfo=tzinfo) + + @classmethod + def strptime(cls, date_str, fmt, tzinfo=None): + """Constructs an :class:`Arrow ` object from a date string and format, + in the style of ``datetime.strptime``. Optionally replaces the parsed timezone. + + :param date_str: the date string. + :param fmt: the format string. + :param tzinfo: (optional) A :ref:`timezone expression `. Defaults to the parsed + timezone if ``fmt`` contains a timezone directive, otherwise UTC. + + Usage:: + + >>> arrow.Arrow.strptime('20-01-2019 15:49:10', '%d-%m-%Y %H:%M:%S') + + + """ + + dt = datetime.strptime(date_str, fmt) + if tzinfo is None: + tzinfo = dt.tzinfo + + return cls( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + tzinfo, + fold=getattr(dt, "fold", 0), + ) + + # factories: ranges and spans + + @classmethod + def range(cls, frame, start, end=None, tz=None, limit=None): + """Returns an iterator of :class:`Arrow ` objects, representing + points in time between two inputs. + + :param frame: The timeframe. Can be any ``datetime`` property (day, hour, minute...). + :param start: A datetime expression, the start of the range. + :param end: (optional) A datetime expression, the end of the range. + :param tz: (optional) A :ref:`timezone expression `. Defaults to + ``start``'s timezone, or UTC if ``start`` is naive. + :param limit: (optional) A maximum number of tuples to return. + + **NOTE**: The ``end`` or ``limit`` must be provided. Call with ``end`` alone to + return the entire range. Call with ``limit`` alone to return a maximum # of results from + the start. Call with both to cap a range at a maximum # of results. + + **NOTE**: ``tz`` internally **replaces** the timezones of both ``start`` and ``end`` before + iterating. As such, either call with naive objects and ``tz``, or aware objects from the + same timezone and no ``tz``. + + Supported frame values: year, quarter, month, week, day, hour, minute, second. + + Recognized datetime expressions: + + - An :class:`Arrow ` object. + - A ``datetime`` object. + + Usage:: + + >>> start = datetime(2013, 5, 5, 12, 30) + >>> end = datetime(2013, 5, 5, 17, 15) + >>> for r in arrow.Arrow.range('hour', start, end): + ... print(repr(r)) + ... + + + + + + + **NOTE**: Unlike Python's ``range``, ``end`` *may* be included in the returned iterator:: + + >>> start = datetime(2013, 5, 5, 12, 30) + >>> end = datetime(2013, 5, 5, 13, 30) + >>> for r in arrow.Arrow.range('hour', start, end): + ... print(repr(r)) + ... + + + + """ + + _, frame_relative, relative_steps = cls._get_frames(frame) + + tzinfo = cls._get_tzinfo(start.tzinfo if tz is None else tz) + + start = cls._get_datetime(start).replace(tzinfo=tzinfo) + end, limit = cls._get_iteration_params(end, limit) + end = cls._get_datetime(end).replace(tzinfo=tzinfo) + + current = cls.fromdatetime(start) + original_day = start.day + day_is_clipped = False + i = 0 + + while current <= end and i < limit: + i += 1 + yield current + + values = [getattr(current, f) for f in cls._ATTRS] + current = cls(*values, tzinfo=tzinfo).shift( + **{frame_relative: relative_steps} + ) + + if frame in ["month", "quarter", "year"] and current.day < original_day: + day_is_clipped = True + + if day_is_clipped and not cls._is_last_day_of_month(current): + current = current.replace(day=original_day) + + def span(self, frame, count=1, bounds="[)"): + """Returns two new :class:`Arrow ` objects, representing the timespan + of the :class:`Arrow ` object in a given timeframe. + + :param frame: the timeframe. Can be any ``datetime`` property (day, hour, minute...). + :param count: (optional) the number of frames to span. + :param bounds: (optional) a ``str`` of either '()', '(]', '[)', or '[]' that specifies + whether to include or exclude the start and end values in the span. '(' excludes + the start, '[' includes the start, ')' excludes the end, and ']' includes the end. + If the bounds are not specified, the default bound '[)' is used. + + Supported frame values: year, quarter, month, week, day, hour, minute, second. + + Usage:: + + >>> arrow.utcnow() + + + >>> arrow.utcnow().span('hour') + (, ) + + >>> arrow.utcnow().span('day') + (, ) + + >>> arrow.utcnow().span('day', count=2) + (, ) + + >>> arrow.utcnow().span('day', bounds='[]') + (, ) + + """ + + util.validate_bounds(bounds) + + frame_absolute, frame_relative, relative_steps = self._get_frames(frame) + + if frame_absolute == "week": + attr = "day" + elif frame_absolute == "quarter": + attr = "month" + else: + attr = frame_absolute + + index = self._ATTRS.index(attr) + frames = self._ATTRS[: index + 1] + + values = [getattr(self, f) for f in frames] + + for _ in range(3 - len(values)): + values.append(1) + + floor = self.__class__(*values, tzinfo=self.tzinfo) + + if frame_absolute == "week": + floor = floor.shift(days=-(self.isoweekday() - 1)) + elif frame_absolute == "quarter": + floor = floor.shift(months=-((self.month - 1) % 3)) + + ceil = floor.shift(**{frame_relative: count * relative_steps}) + + if bounds[0] == "(": + floor = floor.shift(microseconds=+1) + + if bounds[1] == ")": + ceil = ceil.shift(microseconds=-1) + + return floor, ceil + + def floor(self, frame): + """Returns a new :class:`Arrow ` object, representing the "floor" + of the timespan of the :class:`Arrow ` object in a given timeframe. + Equivalent to the first element in the 2-tuple returned by + :func:`span `. + + :param frame: the timeframe. Can be any ``datetime`` property (day, hour, minute...). + + Usage:: + + >>> arrow.utcnow().floor('hour') + + """ + + return self.span(frame)[0] + + def ceil(self, frame): + """Returns a new :class:`Arrow ` object, representing the "ceiling" + of the timespan of the :class:`Arrow ` object in a given timeframe. + Equivalent to the second element in the 2-tuple returned by + :func:`span `. + + :param frame: the timeframe. Can be any ``datetime`` property (day, hour, minute...). + + Usage:: + + >>> arrow.utcnow().ceil('hour') + + """ + + return self.span(frame)[1] + + @classmethod + def span_range(cls, frame, start, end, tz=None, limit=None, bounds="[)"): + """Returns an iterator of tuples, each :class:`Arrow ` objects, + representing a series of timespans between two inputs. + + :param frame: The timeframe. Can be any ``datetime`` property (day, hour, minute...). + :param start: A datetime expression, the start of the range. + :param end: (optional) A datetime expression, the end of the range. + :param tz: (optional) A :ref:`timezone expression `. Defaults to + ``start``'s timezone, or UTC if ``start`` is naive. + :param limit: (optional) A maximum number of tuples to return. + :param bounds: (optional) a ``str`` of either '()', '(]', '[)', or '[]' that specifies + whether to include or exclude the start and end values in each span in the range. '(' excludes + the start, '[' includes the start, ')' excludes the end, and ']' includes the end. + If the bounds are not specified, the default bound '[)' is used. + + **NOTE**: The ``end`` or ``limit`` must be provided. Call with ``end`` alone to + return the entire range. Call with ``limit`` alone to return a maximum # of results from + the start. Call with both to cap a range at a maximum # of results. + + **NOTE**: ``tz`` internally **replaces** the timezones of both ``start`` and ``end`` before + iterating. As such, either call with naive objects and ``tz``, or aware objects from the + same timezone and no ``tz``. + + Supported frame values: year, quarter, month, week, day, hour, minute, second. + + Recognized datetime expressions: + + - An :class:`Arrow ` object. + - A ``datetime`` object. + + **NOTE**: Unlike Python's ``range``, ``end`` will *always* be included in the returned + iterator of timespans. + + Usage: + + >>> start = datetime(2013, 5, 5, 12, 30) + >>> end = datetime(2013, 5, 5, 17, 15) + >>> for r in arrow.Arrow.span_range('hour', start, end): + ... print(r) + ... + (, ) + (, ) + (, ) + (, ) + (, ) + (, ) + + """ + + tzinfo = cls._get_tzinfo(start.tzinfo if tz is None else tz) + start = cls.fromdatetime(start, tzinfo).span(frame)[0] + _range = cls.range(frame, start, end, tz, limit) + return (r.span(frame, bounds=bounds) for r in _range) + + @classmethod + def interval(cls, frame, start, end, interval=1, tz=None, bounds="[)"): + """Returns an iterator of tuples, each :class:`Arrow ` objects, + representing a series of intervals between two inputs. + + :param frame: The timeframe. Can be any ``datetime`` property (day, hour, minute...). + :param start: A datetime expression, the start of the range. + :param end: (optional) A datetime expression, the end of the range. + :param interval: (optional) Time interval for the given time frame. + :param tz: (optional) A timezone expression. Defaults to UTC. + :param bounds: (optional) a ``str`` of either '()', '(]', '[)', or '[]' that specifies + whether to include or exclude the start and end values in the intervals. '(' excludes + the start, '[' includes the start, ')' excludes the end, and ']' includes the end. + If the bounds are not specified, the default bound '[)' is used. + + Supported frame values: year, quarter, month, week, day, hour, minute, second + + Recognized datetime expressions: + + - An :class:`Arrow ` object. + - A ``datetime`` object. + + Recognized timezone expressions: + + - A ``tzinfo`` object. + - A ``str`` describing a timezone, similar to 'US/Pacific', or 'Europe/Berlin'. + - A ``str`` in ISO 8601 style, as in '+07:00'. + - A ``str``, one of the following: 'local', 'utc', 'UTC'. + + Usage: + + >>> start = datetime(2013, 5, 5, 12, 30) + >>> end = datetime(2013, 5, 5, 17, 15) + >>> for r in arrow.Arrow.interval('hour', start, end, 2): + ... print r + ... + (, ) + (, ) + (, ) + """ + if interval < 1: + raise ValueError("interval has to be a positive integer") + + spanRange = iter(cls.span_range(frame, start, end, tz, bounds=bounds)) + while True: + try: + intvlStart, intvlEnd = next(spanRange) + for _ in range(interval - 1): + _, intvlEnd = next(spanRange) + yield intvlStart, intvlEnd + except StopIteration: + return + + # representations + + def __repr__(self): + return "<{} [{}]>".format(self.__class__.__name__, self.__str__()) + + def __str__(self): + return self._datetime.isoformat() + + def __format__(self, formatstr): + + if len(formatstr) > 0: + return self.format(formatstr) + + return str(self) + + def __hash__(self): + return self._datetime.__hash__() + + # attributes and properties + + def __getattr__(self, name): + + if name == "week": + return self.isocalendar()[1] + + if name == "quarter": + return int((self.month - 1) / self._MONTHS_PER_QUARTER) + 1 + + if not name.startswith("_"): + value = getattr(self._datetime, name, None) + + if value is not None: + return value + + return object.__getattribute__(self, name) + + @property + def tzinfo(self): + """Gets the ``tzinfo`` of the :class:`Arrow ` object. + + Usage:: + + >>> arw=arrow.utcnow() + >>> arw.tzinfo + tzutc() + + """ + + return self._datetime.tzinfo + + @tzinfo.setter + def tzinfo(self, tzinfo): + """ Sets the ``tzinfo`` of the :class:`Arrow ` object. """ + + self._datetime = self._datetime.replace(tzinfo=tzinfo) + + @property + def datetime(self): + """Returns a datetime representation of the :class:`Arrow ` object. + + Usage:: + + >>> arw=arrow.utcnow() + >>> arw.datetime + datetime.datetime(2019, 1, 24, 16, 35, 27, 276649, tzinfo=tzutc()) + + """ + + return self._datetime + + @property + def naive(self): + """Returns a naive datetime representation of the :class:`Arrow ` + object. + + Usage:: + + >>> nairobi = arrow.now('Africa/Nairobi') + >>> nairobi + + >>> nairobi.naive + datetime.datetime(2019, 1, 23, 19, 27, 12, 297999) + + """ + + return self._datetime.replace(tzinfo=None) + + @property + def timestamp(self): + """Returns a timestamp representation of the :class:`Arrow ` object, in + UTC time. + + Usage:: + + >>> arrow.utcnow().timestamp + 1548260567 + + """ + + warnings.warn( + "For compatibility with the datetime.timestamp() method this property will be replaced with a method in " + "the 1.0.0 release, please switch to the .int_timestamp property for identical behaviour as soon as " + "possible.", + DeprecationWarning, + ) + return calendar.timegm(self._datetime.utctimetuple()) + + @property + def int_timestamp(self): + """Returns a timestamp representation of the :class:`Arrow ` object, in + UTC time. + + Usage:: + + >>> arrow.utcnow().int_timestamp + 1548260567 + + """ + + return calendar.timegm(self._datetime.utctimetuple()) + + @property + def float_timestamp(self): + """Returns a floating-point representation of the :class:`Arrow ` + object, in UTC time. + + Usage:: + + >>> arrow.utcnow().float_timestamp + 1548260516.830896 + + """ + + # IDEA get rid of this in 1.0.0 and wrap datetime.timestamp() + # Or for compatibility retain this but make it call the timestamp method + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return self.timestamp + float(self.microsecond) / 1000000 + + @property + def fold(self): + """ Returns the ``fold`` value of the :class:`Arrow ` object. """ + + # in python < 3.6 _datetime will be a _DatetimeWithFold if fold=1 and a datetime with no fold attribute + # otherwise, so we need to return zero to cover the latter case + return getattr(self._datetime, "fold", 0) + + @property + def ambiguous(self): + """ Returns a boolean indicating whether the :class:`Arrow ` object is ambiguous.""" + + return dateutil_tz.datetime_ambiguous(self._datetime) + + @property + def imaginary(self): + """Indicates whether the :class: `Arrow ` object exists in the current timezone.""" + + return not dateutil_tz.datetime_exists(self._datetime) + + # mutation and duplication. + + def clone(self): + """Returns a new :class:`Arrow ` object, cloned from the current one. + + Usage: + + >>> arw = arrow.utcnow() + >>> cloned = arw.clone() + + """ + + return self.fromdatetime(self._datetime) + + def replace(self, **kwargs): + """Returns a new :class:`Arrow ` object with attributes updated + according to inputs. + + Use property names to set their value absolutely:: + + >>> import arrow + >>> arw = arrow.utcnow() + >>> arw + + >>> arw.replace(year=2014, month=6) + + + You can also replace the timezone without conversion, using a + :ref:`timezone expression `:: + + >>> arw.replace(tzinfo=tz.tzlocal()) + + + """ + + absolute_kwargs = {} + + for key, value in kwargs.items(): + + if key in self._ATTRS: + absolute_kwargs[key] = value + elif key in ["week", "quarter"]: + raise AttributeError("setting absolute {} is not supported".format(key)) + elif key not in ["tzinfo", "fold"]: + raise AttributeError('unknown attribute: "{}"'.format(key)) + + current = self._datetime.replace(**absolute_kwargs) + + tzinfo = kwargs.get("tzinfo") + + if tzinfo is not None: + tzinfo = self._get_tzinfo(tzinfo) + current = current.replace(tzinfo=tzinfo) + + fold = kwargs.get("fold") + + # TODO revisit this once we drop support for 2.7/3.5 + if fold is not None: + current = dateutil_tz.enfold(current, fold=fold) + + return self.fromdatetime(current) + + def shift(self, **kwargs): + """Returns a new :class:`Arrow ` object with attributes updated + according to inputs. + + Use pluralized property names to relatively shift their current value: + + >>> import arrow + >>> arw = arrow.utcnow() + >>> arw + + >>> arw.shift(years=1, months=-1) + + + Day-of-the-week relative shifting can use either Python's weekday numbers + (Monday = 0, Tuesday = 1 .. Sunday = 6) or using dateutil.relativedelta's + day instances (MO, TU .. SU). When using weekday numbers, the returned + date will always be greater than or equal to the starting date. + + Using the above code (which is a Saturday) and asking it to shift to Saturday: + + >>> arw.shift(weekday=5) + + + While asking for a Monday: + + >>> arw.shift(weekday=0) + + + """ + + relative_kwargs = {} + additional_attrs = ["weeks", "quarters", "weekday"] + + for key, value in kwargs.items(): + + if key in self._ATTRS_PLURAL or key in additional_attrs: + relative_kwargs[key] = value + else: + raise AttributeError( + "Invalid shift time frame. Please select one of the following: {}.".format( + ", ".join(self._ATTRS_PLURAL + additional_attrs) + ) + ) + + # core datetime does not support quarters, translate to months. + relative_kwargs.setdefault("months", 0) + relative_kwargs["months"] += ( + relative_kwargs.pop("quarters", 0) * self._MONTHS_PER_QUARTER + ) + + current = self._datetime + relativedelta(**relative_kwargs) + + if not dateutil_tz.datetime_exists(current): + current = dateutil_tz.resolve_imaginary(current) + + return self.fromdatetime(current) + + def to(self, tz): + """Returns a new :class:`Arrow ` object, converted + to the target timezone. + + :param tz: A :ref:`timezone expression `. + + Usage:: + + >>> utc = arrow.utcnow() + >>> utc + + + >>> utc.to('US/Pacific') + + + >>> utc.to(tz.tzlocal()) + + + >>> utc.to('-07:00') + + + >>> utc.to('local') + + + >>> utc.to('local').to('utc') + + + """ + + if not isinstance(tz, dt_tzinfo): + tz = parser.TzinfoParser.parse(tz) + + dt = self._datetime.astimezone(tz) + + return self.__class__( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + dt.tzinfo, + fold=getattr(dt, "fold", 0), + ) + + # string output and formatting + + def format(self, fmt="YYYY-MM-DD HH:mm:ssZZ", locale="en_us"): + """Returns a string representation of the :class:`Arrow ` object, + formatted according to a format string. + + :param fmt: the format string. + + Usage:: + + >>> arrow.utcnow().format('YYYY-MM-DD HH:mm:ss ZZ') + '2013-05-09 03:56:47 -00:00' + + >>> arrow.utcnow().format('X') + '1368071882' + + >>> arrow.utcnow().format('MMMM DD, YYYY') + 'May 09, 2013' + + >>> arrow.utcnow().format() + '2013-05-09 03:56:47 -00:00' + + """ + + return formatter.DateTimeFormatter(locale).format(self._datetime, fmt) + + def humanize( + self, other=None, locale="en_us", only_distance=False, granularity="auto" + ): + """Returns a localized, humanized representation of a relative difference in time. + + :param other: (optional) an :class:`Arrow ` or ``datetime`` object. + Defaults to now in the current :class:`Arrow ` object's timezone. + :param locale: (optional) a ``str`` specifying a locale. Defaults to 'en_us'. + :param only_distance: (optional) returns only time difference eg: "11 seconds" without "in" or "ago" part. + :param granularity: (optional) defines the precision of the output. Set it to strings 'second', 'minute', + 'hour', 'day', 'week', 'month' or 'year' or a list of any combination of these strings + + Usage:: + + >>> earlier = arrow.utcnow().shift(hours=-2) + >>> earlier.humanize() + '2 hours ago' + + >>> later = earlier.shift(hours=4) + >>> later.humanize(earlier) + 'in 4 hours' + + """ + + locale_name = locale + locale = locales.get_locale(locale) + + if other is None: + utc = datetime.utcnow().replace(tzinfo=dateutil_tz.tzutc()) + dt = utc.astimezone(self._datetime.tzinfo) + + elif isinstance(other, Arrow): + dt = other._datetime + + elif isinstance(other, datetime): + if other.tzinfo is None: + dt = other.replace(tzinfo=self._datetime.tzinfo) + else: + dt = other.astimezone(self._datetime.tzinfo) + + else: + raise TypeError( + "Invalid 'other' argument of type '{}'. " + "Argument must be of type None, Arrow, or datetime.".format( + type(other).__name__ + ) + ) + + if isinstance(granularity, list) and len(granularity) == 1: + granularity = granularity[0] + + delta = int(round(util.total_seconds(self._datetime - dt))) + sign = -1 if delta < 0 else 1 + diff = abs(delta) + delta = diff + + try: + if granularity == "auto": + if diff < 10: + return locale.describe("now", only_distance=only_distance) + + if diff < 45: + seconds = sign * delta + return locale.describe( + "seconds", seconds, only_distance=only_distance + ) + + elif diff < 90: + return locale.describe("minute", sign, only_distance=only_distance) + elif diff < 2700: + minutes = sign * int(max(delta / 60, 2)) + return locale.describe( + "minutes", minutes, only_distance=only_distance + ) + + elif diff < 5400: + return locale.describe("hour", sign, only_distance=only_distance) + elif diff < 79200: + hours = sign * int(max(delta / 3600, 2)) + return locale.describe("hours", hours, only_distance=only_distance) + + # anything less than 48 hours should be 1 day + elif diff < 172800: + return locale.describe("day", sign, only_distance=only_distance) + elif diff < 554400: + days = sign * int(max(delta / 86400, 2)) + return locale.describe("days", days, only_distance=only_distance) + + elif diff < 907200: + return locale.describe("week", sign, only_distance=only_distance) + elif diff < 2419200: + weeks = sign * int(max(delta / 604800, 2)) + return locale.describe("weeks", weeks, only_distance=only_distance) + + elif diff < 3888000: + return locale.describe("month", sign, only_distance=only_distance) + elif diff < 29808000: + self_months = self._datetime.year * 12 + self._datetime.month + other_months = dt.year * 12 + dt.month + + months = sign * int(max(abs(other_months - self_months), 2)) + + return locale.describe( + "months", months, only_distance=only_distance + ) + + elif diff < 47260800: + return locale.describe("year", sign, only_distance=only_distance) + else: + years = sign * int(max(delta / 31536000, 2)) + return locale.describe("years", years, only_distance=only_distance) + + elif util.isstr(granularity): + if granularity == "second": + delta = sign * delta + if abs(delta) < 2: + return locale.describe("now", only_distance=only_distance) + elif granularity == "minute": + delta = sign * delta / self._SECS_PER_MINUTE + elif granularity == "hour": + delta = sign * delta / self._SECS_PER_HOUR + elif granularity == "day": + delta = sign * delta / self._SECS_PER_DAY + elif granularity == "week": + delta = sign * delta / self._SECS_PER_WEEK + elif granularity == "month": + delta = sign * delta / self._SECS_PER_MONTH + elif granularity == "year": + delta = sign * delta / self._SECS_PER_YEAR + else: + raise AttributeError( + "Invalid level of granularity. Please select between 'second', 'minute', 'hour', 'day', 'week', 'month' or 'year'" + ) + + if trunc(abs(delta)) != 1: + granularity += "s" + return locale.describe(granularity, delta, only_distance=only_distance) + + else: + timeframes = [] + if "year" in granularity: + years = sign * delta / self._SECS_PER_YEAR + delta %= self._SECS_PER_YEAR + timeframes.append(["year", years]) + + if "month" in granularity: + months = sign * delta / self._SECS_PER_MONTH + delta %= self._SECS_PER_MONTH + timeframes.append(["month", months]) + + if "week" in granularity: + weeks = sign * delta / self._SECS_PER_WEEK + delta %= self._SECS_PER_WEEK + timeframes.append(["week", weeks]) + + if "day" in granularity: + days = sign * delta / self._SECS_PER_DAY + delta %= self._SECS_PER_DAY + timeframes.append(["day", days]) + + if "hour" in granularity: + hours = sign * delta / self._SECS_PER_HOUR + delta %= self._SECS_PER_HOUR + timeframes.append(["hour", hours]) + + if "minute" in granularity: + minutes = sign * delta / self._SECS_PER_MINUTE + delta %= self._SECS_PER_MINUTE + timeframes.append(["minute", minutes]) + + if "second" in granularity: + seconds = sign * delta + timeframes.append(["second", seconds]) + + if len(timeframes) < len(granularity): + raise AttributeError( + "Invalid level of granularity. " + "Please select between 'second', 'minute', 'hour', 'day', 'week', 'month' or 'year'." + ) + + for tf in timeframes: + # Make granularity plural if the delta is not equal to 1 + if trunc(abs(tf[1])) != 1: + tf[0] += "s" + return locale.describe_multi(timeframes, only_distance=only_distance) + + except KeyError as e: + raise ValueError( + "Humanization of the {} granularity is not currently translated in the '{}' locale. " + "Please consider making a contribution to this locale.".format( + e, locale_name + ) + ) + + # query functions + + def is_between(self, start, end, bounds="()"): + """Returns a boolean denoting whether the specified date and time is between + the start and end dates and times. + + :param start: an :class:`Arrow ` object. + :param end: an :class:`Arrow ` object. + :param bounds: (optional) a ``str`` of either '()', '(]', '[)', or '[]' that specifies + whether to include or exclude the start and end values in the range. '(' excludes + the start, '[' includes the start, ')' excludes the end, and ']' includes the end. + If the bounds are not specified, the default bound '()' is used. + + Usage:: + + >>> start = arrow.get(datetime(2013, 5, 5, 12, 30, 10)) + >>> end = arrow.get(datetime(2013, 5, 5, 12, 30, 36)) + >>> arrow.get(datetime(2013, 5, 5, 12, 30, 27)).is_between(start, end) + True + + >>> start = arrow.get(datetime(2013, 5, 5)) + >>> end = arrow.get(datetime(2013, 5, 8)) + >>> arrow.get(datetime(2013, 5, 8)).is_between(start, end, '[]') + True + + >>> start = arrow.get(datetime(2013, 5, 5)) + >>> end = arrow.get(datetime(2013, 5, 8)) + >>> arrow.get(datetime(2013, 5, 8)).is_between(start, end, '[)') + False + + """ + + util.validate_bounds(bounds) + + if not isinstance(start, Arrow): + raise TypeError( + "Can't parse start date argument type of '{}'".format(type(start)) + ) + + if not isinstance(end, Arrow): + raise TypeError( + "Can't parse end date argument type of '{}'".format(type(end)) + ) + + include_start = bounds[0] == "[" + include_end = bounds[1] == "]" + + target_timestamp = self.float_timestamp + start_timestamp = start.float_timestamp + end_timestamp = end.float_timestamp + + if include_start and include_end: + return ( + target_timestamp >= start_timestamp + and target_timestamp <= end_timestamp + ) + elif include_start and not include_end: + return ( + target_timestamp >= start_timestamp and target_timestamp < end_timestamp + ) + elif not include_start and include_end: + return ( + target_timestamp > start_timestamp and target_timestamp <= end_timestamp + ) + else: + return ( + target_timestamp > start_timestamp and target_timestamp < end_timestamp + ) + + # datetime methods + + def date(self): + """Returns a ``date`` object with the same year, month and day. + + Usage:: + + >>> arrow.utcnow().date() + datetime.date(2019, 1, 23) + + """ + + return self._datetime.date() + + def time(self): + """Returns a ``time`` object with the same hour, minute, second, microsecond. + + Usage:: + + >>> arrow.utcnow().time() + datetime.time(12, 15, 34, 68352) + + """ + + return self._datetime.time() + + def timetz(self): + """Returns a ``time`` object with the same hour, minute, second, microsecond and + tzinfo. + + Usage:: + + >>> arrow.utcnow().timetz() + datetime.time(12, 5, 18, 298893, tzinfo=tzutc()) + + """ + + return self._datetime.timetz() + + def astimezone(self, tz): + """Returns a ``datetime`` object, converted to the specified timezone. + + :param tz: a ``tzinfo`` object. + + Usage:: + + >>> pacific=arrow.now('US/Pacific') + >>> nyc=arrow.now('America/New_York').tzinfo + >>> pacific.astimezone(nyc) + datetime.datetime(2019, 1, 20, 10, 24, 22, 328172, tzinfo=tzfile('/usr/share/zoneinfo/America/New_York')) + + """ + + return self._datetime.astimezone(tz) + + def utcoffset(self): + """Returns a ``timedelta`` object representing the whole number of minutes difference from + UTC time. + + Usage:: + + >>> arrow.now('US/Pacific').utcoffset() + datetime.timedelta(-1, 57600) + + """ + + return self._datetime.utcoffset() + + def dst(self): + """Returns the daylight savings time adjustment. + + Usage:: + + >>> arrow.utcnow().dst() + datetime.timedelta(0) + + """ + + return self._datetime.dst() + + def timetuple(self): + """Returns a ``time.struct_time``, in the current timezone. + + Usage:: + + >>> arrow.utcnow().timetuple() + time.struct_time(tm_year=2019, tm_mon=1, tm_mday=20, tm_hour=15, tm_min=17, tm_sec=8, tm_wday=6, tm_yday=20, tm_isdst=0) + + """ + + return self._datetime.timetuple() + + def utctimetuple(self): + """Returns a ``time.struct_time``, in UTC time. + + Usage:: + + >>> arrow.utcnow().utctimetuple() + time.struct_time(tm_year=2019, tm_mon=1, tm_mday=19, tm_hour=21, tm_min=41, tm_sec=7, tm_wday=5, tm_yday=19, tm_isdst=0) + + """ + + return self._datetime.utctimetuple() + + def toordinal(self): + """Returns the proleptic Gregorian ordinal of the date. + + Usage:: + + >>> arrow.utcnow().toordinal() + 737078 + + """ + + return self._datetime.toordinal() + + def weekday(self): + """Returns the day of the week as an integer (0-6). + + Usage:: + + >>> arrow.utcnow().weekday() + 5 + + """ + + return self._datetime.weekday() + + def isoweekday(self): + """Returns the ISO day of the week as an integer (1-7). + + Usage:: + + >>> arrow.utcnow().isoweekday() + 6 + + """ + + return self._datetime.isoweekday() + + def isocalendar(self): + """Returns a 3-tuple, (ISO year, ISO week number, ISO weekday). + + Usage:: + + >>> arrow.utcnow().isocalendar() + (2019, 3, 6) + + """ + + return self._datetime.isocalendar() + + def isoformat(self, sep="T"): + """Returns an ISO 8601 formatted representation of the date and time. + + Usage:: + + >>> arrow.utcnow().isoformat() + '2019-01-19T18:30:52.442118+00:00' + + """ + + return self._datetime.isoformat(sep) + + def ctime(self): + """Returns a ctime formatted representation of the date and time. + + Usage:: + + >>> arrow.utcnow().ctime() + 'Sat Jan 19 18:26:50 2019' + + """ + + return self._datetime.ctime() + + def strftime(self, format): + """Formats in the style of ``datetime.strftime``. + + :param format: the format string. + + Usage:: + + >>> arrow.utcnow().strftime('%d-%m-%Y %H:%M:%S') + '23-01-2019 12:28:17' + + """ + + return self._datetime.strftime(format) + + def for_json(self): + """Serializes for the ``for_json`` protocol of simplejson. + + Usage:: + + >>> arrow.utcnow().for_json() + '2019-01-19T18:25:36.760079+00:00' + + """ + + return self.isoformat() + + # math + + def __add__(self, other): + + if isinstance(other, (timedelta, relativedelta)): + return self.fromdatetime(self._datetime + other, self._datetime.tzinfo) + + return NotImplemented + + def __radd__(self, other): + return self.__add__(other) + + def __sub__(self, other): + + if isinstance(other, (timedelta, relativedelta)): + return self.fromdatetime(self._datetime - other, self._datetime.tzinfo) + + elif isinstance(other, datetime): + return self._datetime - other + + elif isinstance(other, Arrow): + return self._datetime - other._datetime + + return NotImplemented + + def __rsub__(self, other): + + if isinstance(other, datetime): + return other - self._datetime + + return NotImplemented + + # comparisons + + def __eq__(self, other): + + if not isinstance(other, (Arrow, datetime)): + return False + + return self._datetime == self._get_datetime(other) + + def __ne__(self, other): + + if not isinstance(other, (Arrow, datetime)): + return True + + return not self.__eq__(other) + + def __gt__(self, other): + + if not isinstance(other, (Arrow, datetime)): + return NotImplemented + + return self._datetime > self._get_datetime(other) + + def __ge__(self, other): + + if not isinstance(other, (Arrow, datetime)): + return NotImplemented + + return self._datetime >= self._get_datetime(other) + + def __lt__(self, other): + + if not isinstance(other, (Arrow, datetime)): + return NotImplemented + + return self._datetime < self._get_datetime(other) + + def __le__(self, other): + + if not isinstance(other, (Arrow, datetime)): + return NotImplemented + + return self._datetime <= self._get_datetime(other) + + def __cmp__(self, other): + if sys.version_info[0] < 3: # pragma: no cover + if not isinstance(other, (Arrow, datetime)): + raise TypeError( + "can't compare '{}' to '{}'".format(type(self), type(other)) + ) + + # internal methods + + @staticmethod + def _get_tzinfo(tz_expr): + + if tz_expr is None: + return dateutil_tz.tzutc() + if isinstance(tz_expr, dt_tzinfo): + return tz_expr + else: + try: + return parser.TzinfoParser.parse(tz_expr) + except parser.ParserError: + raise ValueError("'{}' not recognized as a timezone".format(tz_expr)) + + @classmethod + def _get_datetime(cls, expr): + """Get datetime object for a specified expression.""" + if isinstance(expr, Arrow): + return expr.datetime + elif isinstance(expr, datetime): + return expr + elif util.is_timestamp(expr): + timestamp = float(expr) + return cls.utcfromtimestamp(timestamp).datetime + else: + raise ValueError( + "'{}' not recognized as a datetime or timestamp.".format(expr) + ) + + @classmethod + def _get_frames(cls, name): + + if name in cls._ATTRS: + return name, "{}s".format(name), 1 + elif name[-1] == "s" and name[:-1] in cls._ATTRS: + return name[:-1], name, 1 + elif name in ["week", "weeks"]: + return "week", "weeks", 1 + elif name in ["quarter", "quarters"]: + return "quarter", "months", 3 + + supported = ", ".join( + [ + "year(s)", + "month(s)", + "day(s)", + "hour(s)", + "minute(s)", + "second(s)", + "microsecond(s)", + "week(s)", + "quarter(s)", + ] + ) + raise AttributeError( + "range/span over frame {} not supported. Supported frames: {}".format( + name, supported + ) + ) + + @classmethod + def _get_iteration_params(cls, end, limit): + + if end is None: + + if limit is None: + raise ValueError("one of 'end' or 'limit' is required") + + return cls.max, limit + + else: + if limit is None: + return end, sys.maxsize + return end, limit + + @staticmethod + def _is_last_day_of_month(date): + return date.day == calendar.monthrange(date.year, date.month)[1] + + +Arrow.min = Arrow.fromdatetime(datetime.min) +Arrow.max = Arrow.fromdatetime(datetime.max) diff --git a/openpype/modules/ftrack/python2_vendor/arrow/arrow/constants.py b/openpype/modules/ftrack/python2_vendor/arrow/arrow/constants.py new file mode 100644 index 0000000000..81e37b26de --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/arrow/constants.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- + +# Output of time.mktime(datetime.max.timetuple()) on macOS +# This value must be hardcoded for compatibility with Windows +# Platform-independent max timestamps are hard to form +# https://stackoverflow.com/q/46133223 +MAX_TIMESTAMP = 253402318799.0 +MAX_TIMESTAMP_MS = MAX_TIMESTAMP * 1000 +MAX_TIMESTAMP_US = MAX_TIMESTAMP * 1000000 diff --git a/openpype/modules/ftrack/python2_vendor/arrow/arrow/factory.py b/openpype/modules/ftrack/python2_vendor/arrow/arrow/factory.py new file mode 100644 index 0000000000..05933e8151 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/arrow/factory.py @@ -0,0 +1,301 @@ +# -*- coding: utf-8 -*- +""" +Implements the :class:`ArrowFactory ` class, +providing factory methods for common :class:`Arrow ` +construction scenarios. + +""" + +from __future__ import absolute_import + +import calendar +from datetime import date, datetime +from datetime import tzinfo as dt_tzinfo +from time import struct_time + +from dateutil import tz as dateutil_tz + +from arrow import parser +from arrow.arrow import Arrow +from arrow.util import is_timestamp, iso_to_gregorian, isstr + + +class ArrowFactory(object): + """A factory for generating :class:`Arrow ` objects. + + :param type: (optional) the :class:`Arrow `-based class to construct from. + Defaults to :class:`Arrow `. + + """ + + def __init__(self, type=Arrow): + self.type = type + + def get(self, *args, **kwargs): + """Returns an :class:`Arrow ` object based on flexible inputs. + + :param locale: (optional) a ``str`` specifying a locale for the parser. Defaults to 'en_us'. + :param tzinfo: (optional) a :ref:`timezone expression ` or tzinfo object. + Replaces the timezone unless using an input form that is explicitly UTC or specifies + the timezone in a positional argument. Defaults to UTC. + :param normalize_whitespace: (optional) a ``bool`` specifying whether or not to normalize + redundant whitespace (spaces, tabs, and newlines) in a datetime string before parsing. + Defaults to false. + + Usage:: + + >>> import arrow + + **No inputs** to get current UTC time:: + + >>> arrow.get() + + + **None** to also get current UTC time:: + + >>> arrow.get(None) + + + **One** :class:`Arrow ` object, to get a copy. + + >>> arw = arrow.utcnow() + >>> arrow.get(arw) + + + **One** ``float`` or ``int``, convertible to a floating-point timestamp, to get + that timestamp in UTC:: + + >>> arrow.get(1367992474.293378) + + + >>> arrow.get(1367992474) + + + **One** ISO 8601-formatted ``str``, to parse it:: + + >>> arrow.get('2013-09-29T01:26:43.830580') + + + **One** ISO 8601-formatted ``str``, in basic format, to parse it:: + + >>> arrow.get('20160413T133656.456289') + + + **One** ``tzinfo``, to get the current time **converted** to that timezone:: + + >>> arrow.get(tz.tzlocal()) + + + **One** naive ``datetime``, to get that datetime in UTC:: + + >>> arrow.get(datetime(2013, 5, 5)) + + + **One** aware ``datetime``, to get that datetime:: + + >>> arrow.get(datetime(2013, 5, 5, tzinfo=tz.tzlocal())) + + + **One** naive ``date``, to get that date in UTC:: + + >>> arrow.get(date(2013, 5, 5)) + + + **One** time.struct time:: + + >>> arrow.get(gmtime(0)) + + + **One** iso calendar ``tuple``, to get that week date in UTC:: + + >>> arrow.get((2013, 18, 7)) + + + **Two** arguments, a naive or aware ``datetime``, and a replacement + :ref:`timezone expression `:: + + >>> arrow.get(datetime(2013, 5, 5), 'US/Pacific') + + + **Two** arguments, a naive ``date``, and a replacement + :ref:`timezone expression `:: + + >>> arrow.get(date(2013, 5, 5), 'US/Pacific') + + + **Two** arguments, both ``str``, to parse the first according to the format of the second:: + + >>> arrow.get('2013-05-05 12:30:45 America/Chicago', 'YYYY-MM-DD HH:mm:ss ZZZ') + + + **Two** arguments, first a ``str`` to parse and second a ``list`` of formats to try:: + + >>> arrow.get('2013-05-05 12:30:45', ['MM/DD/YYYY', 'YYYY-MM-DD HH:mm:ss']) + + + **Three or more** arguments, as for the constructor of a ``datetime``:: + + >>> arrow.get(2013, 5, 5, 12, 30, 45) + + + """ + + arg_count = len(args) + locale = kwargs.pop("locale", "en_us") + tz = kwargs.get("tzinfo", None) + normalize_whitespace = kwargs.pop("normalize_whitespace", False) + + # if kwargs given, send to constructor unless only tzinfo provided + if len(kwargs) > 1: + arg_count = 3 + + # tzinfo kwarg is not provided + if len(kwargs) == 1 and tz is None: + arg_count = 3 + + # () -> now, @ utc. + if arg_count == 0: + if isstr(tz): + tz = parser.TzinfoParser.parse(tz) + return self.type.now(tz) + + if isinstance(tz, dt_tzinfo): + return self.type.now(tz) + + return self.type.utcnow() + + if arg_count == 1: + arg = args[0] + + # (None) -> now, @ utc. + if arg is None: + return self.type.utcnow() + + # try (int, float) -> from timestamp with tz + elif not isstr(arg) and is_timestamp(arg): + if tz is None: + # set to UTC by default + tz = dateutil_tz.tzutc() + return self.type.fromtimestamp(arg, tzinfo=tz) + + # (Arrow) -> from the object's datetime. + elif isinstance(arg, Arrow): + return self.type.fromdatetime(arg.datetime) + + # (datetime) -> from datetime. + elif isinstance(arg, datetime): + return self.type.fromdatetime(arg) + + # (date) -> from date. + elif isinstance(arg, date): + return self.type.fromdate(arg) + + # (tzinfo) -> now, @ tzinfo. + elif isinstance(arg, dt_tzinfo): + return self.type.now(arg) + + # (str) -> parse. + elif isstr(arg): + dt = parser.DateTimeParser(locale).parse_iso(arg, normalize_whitespace) + return self.type.fromdatetime(dt, tz) + + # (struct_time) -> from struct_time + elif isinstance(arg, struct_time): + return self.type.utcfromtimestamp(calendar.timegm(arg)) + + # (iso calendar) -> convert then from date + elif isinstance(arg, tuple) and len(arg) == 3: + dt = iso_to_gregorian(*arg) + return self.type.fromdate(dt) + + else: + raise TypeError( + "Can't parse single argument of type '{}'".format(type(arg)) + ) + + elif arg_count == 2: + + arg_1, arg_2 = args[0], args[1] + + if isinstance(arg_1, datetime): + + # (datetime, tzinfo/str) -> fromdatetime replace tzinfo. + if isinstance(arg_2, dt_tzinfo) or isstr(arg_2): + return self.type.fromdatetime(arg_1, arg_2) + else: + raise TypeError( + "Can't parse two arguments of types 'datetime', '{}'".format( + type(arg_2) + ) + ) + + elif isinstance(arg_1, date): + + # (date, tzinfo/str) -> fromdate replace tzinfo. + if isinstance(arg_2, dt_tzinfo) or isstr(arg_2): + return self.type.fromdate(arg_1, tzinfo=arg_2) + else: + raise TypeError( + "Can't parse two arguments of types 'date', '{}'".format( + type(arg_2) + ) + ) + + # (str, format) -> parse. + elif isstr(arg_1) and (isstr(arg_2) or isinstance(arg_2, list)): + dt = parser.DateTimeParser(locale).parse( + args[0], args[1], normalize_whitespace + ) + return self.type.fromdatetime(dt, tzinfo=tz) + + else: + raise TypeError( + "Can't parse two arguments of types '{}' and '{}'".format( + type(arg_1), type(arg_2) + ) + ) + + # 3+ args -> datetime-like via constructor. + else: + return self.type(*args, **kwargs) + + def utcnow(self): + """Returns an :class:`Arrow ` object, representing "now" in UTC time. + + Usage:: + + >>> import arrow + >>> arrow.utcnow() + + """ + + return self.type.utcnow() + + def now(self, tz=None): + """Returns an :class:`Arrow ` object, representing "now" in the given + timezone. + + :param tz: (optional) A :ref:`timezone expression `. Defaults to local time. + + Usage:: + + >>> import arrow + >>> arrow.now() + + + >>> arrow.now('US/Pacific') + + + >>> arrow.now('+02:00') + + + >>> arrow.now('local') + + """ + + if tz is None: + tz = dateutil_tz.tzlocal() + elif not isinstance(tz, dt_tzinfo): + tz = parser.TzinfoParser.parse(tz) + + return self.type.now(tz) diff --git a/openpype/modules/ftrack/python2_vendor/arrow/arrow/formatter.py b/openpype/modules/ftrack/python2_vendor/arrow/arrow/formatter.py new file mode 100644 index 0000000000..9f9d7a44da --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/arrow/formatter.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division + +import calendar +import re + +from dateutil import tz as dateutil_tz + +from arrow import locales, util + +FORMAT_ATOM = "YYYY-MM-DD HH:mm:ssZZ" +FORMAT_COOKIE = "dddd, DD-MMM-YYYY HH:mm:ss ZZZ" +FORMAT_RFC822 = "ddd, DD MMM YY HH:mm:ss Z" +FORMAT_RFC850 = "dddd, DD-MMM-YY HH:mm:ss ZZZ" +FORMAT_RFC1036 = "ddd, DD MMM YY HH:mm:ss Z" +FORMAT_RFC1123 = "ddd, DD MMM YYYY HH:mm:ss Z" +FORMAT_RFC2822 = "ddd, DD MMM YYYY HH:mm:ss Z" +FORMAT_RFC3339 = "YYYY-MM-DD HH:mm:ssZZ" +FORMAT_RSS = "ddd, DD MMM YYYY HH:mm:ss Z" +FORMAT_W3C = "YYYY-MM-DD HH:mm:ssZZ" + + +class DateTimeFormatter(object): + + # This pattern matches characters enclosed in square brackets are matched as + # an atomic group. For more info on atomic groups and how to they are + # emulated in Python's re library, see https://stackoverflow.com/a/13577411/2701578 + + _FORMAT_RE = re.compile( + r"(\[(?:(?=(?P[^]]))(?P=literal))*\]|YYY?Y?|MM?M?M?|Do|DD?D?D?|d?dd?d?|HH?|hh?|mm?|ss?|SS?S?S?S?S?|ZZ?Z?|a|A|X|x|W)" + ) + + def __init__(self, locale="en_us"): + + self.locale = locales.get_locale(locale) + + def format(cls, dt, fmt): + + return cls._FORMAT_RE.sub(lambda m: cls._format_token(dt, m.group(0)), fmt) + + def _format_token(self, dt, token): + + if token and token.startswith("[") and token.endswith("]"): + return token[1:-1] + + if token == "YYYY": + return self.locale.year_full(dt.year) + if token == "YY": + return self.locale.year_abbreviation(dt.year) + + if token == "MMMM": + return self.locale.month_name(dt.month) + if token == "MMM": + return self.locale.month_abbreviation(dt.month) + if token == "MM": + return "{:02d}".format(dt.month) + if token == "M": + return str(dt.month) + + if token == "DDDD": + return "{:03d}".format(dt.timetuple().tm_yday) + if token == "DDD": + return str(dt.timetuple().tm_yday) + if token == "DD": + return "{:02d}".format(dt.day) + if token == "D": + return str(dt.day) + + if token == "Do": + return self.locale.ordinal_number(dt.day) + + if token == "dddd": + return self.locale.day_name(dt.isoweekday()) + if token == "ddd": + return self.locale.day_abbreviation(dt.isoweekday()) + if token == "d": + return str(dt.isoweekday()) + + if token == "HH": + return "{:02d}".format(dt.hour) + if token == "H": + return str(dt.hour) + if token == "hh": + return "{:02d}".format(dt.hour if 0 < dt.hour < 13 else abs(dt.hour - 12)) + if token == "h": + return str(dt.hour if 0 < dt.hour < 13 else abs(dt.hour - 12)) + + if token == "mm": + return "{:02d}".format(dt.minute) + if token == "m": + return str(dt.minute) + + if token == "ss": + return "{:02d}".format(dt.second) + if token == "s": + return str(dt.second) + + if token == "SSSSSS": + return str("{:06d}".format(int(dt.microsecond))) + if token == "SSSSS": + return str("{:05d}".format(int(dt.microsecond / 10))) + if token == "SSSS": + return str("{:04d}".format(int(dt.microsecond / 100))) + if token == "SSS": + return str("{:03d}".format(int(dt.microsecond / 1000))) + if token == "SS": + return str("{:02d}".format(int(dt.microsecond / 10000))) + if token == "S": + return str(int(dt.microsecond / 100000)) + + if token == "X": + # TODO: replace with a call to dt.timestamp() when we drop Python 2.7 + return str(calendar.timegm(dt.utctimetuple())) + + if token == "x": + # TODO: replace with a call to dt.timestamp() when we drop Python 2.7 + ts = calendar.timegm(dt.utctimetuple()) + (dt.microsecond / 1000000) + return str(int(ts * 1000000)) + + if token == "ZZZ": + return dt.tzname() + + if token in ["ZZ", "Z"]: + separator = ":" if token == "ZZ" else "" + tz = dateutil_tz.tzutc() if dt.tzinfo is None else dt.tzinfo + total_minutes = int(util.total_seconds(tz.utcoffset(dt)) / 60) + + sign = "+" if total_minutes >= 0 else "-" + total_minutes = abs(total_minutes) + hour, minute = divmod(total_minutes, 60) + + return "{}{:02d}{}{:02d}".format(sign, hour, separator, minute) + + if token in ("a", "A"): + return self.locale.meridian(dt.hour, token) + + if token == "W": + year, week, day = dt.isocalendar() + return "{}-W{:02d}-{}".format(year, week, day) diff --git a/openpype/modules/ftrack/python2_vendor/arrow/arrow/locales.py b/openpype/modules/ftrack/python2_vendor/arrow/arrow/locales.py new file mode 100644 index 0000000000..6833da5a78 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/arrow/locales.py @@ -0,0 +1,4267 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +import inspect +import sys +from math import trunc + + +def get_locale(name): + """Returns an appropriate :class:`Locale ` + corresponding to an inpute locale name. + + :param name: the name of the locale. + + """ + + locale_cls = _locales.get(name.lower()) + + if locale_cls is None: + raise ValueError("Unsupported locale '{}'".format(name)) + + return locale_cls() + + +def get_locale_by_class_name(name): + """Returns an appropriate :class:`Locale ` + corresponding to an locale class name. + + :param name: the name of the locale class. + + """ + locale_cls = globals().get(name) + + if locale_cls is None: + raise ValueError("Unsupported locale '{}'".format(name)) + + return locale_cls() + + +# base locale type. + + +class Locale(object): + """ Represents locale-specific data and functionality. """ + + names = [] + + timeframes = { + "now": "", + "second": "", + "seconds": "", + "minute": "", + "minutes": "", + "hour": "", + "hours": "", + "day": "", + "days": "", + "week": "", + "weeks": "", + "month": "", + "months": "", + "year": "", + "years": "", + } + + meridians = {"am": "", "pm": "", "AM": "", "PM": ""} + + past = None + future = None + and_word = None + + month_names = [] + month_abbreviations = [] + + day_names = [] + day_abbreviations = [] + + ordinal_day_re = r"(\d+)" + + def __init__(self): + + self._month_name_to_ordinal = None + + def describe(self, timeframe, delta=0, only_distance=False): + """Describes a delta within a timeframe in plain language. + + :param timeframe: a string representing a timeframe. + :param delta: a quantity representing a delta in a timeframe. + :param only_distance: return only distance eg: "11 seconds" without "in" or "ago" keywords + """ + + humanized = self._format_timeframe(timeframe, delta) + if not only_distance: + humanized = self._format_relative(humanized, timeframe, delta) + + return humanized + + def describe_multi(self, timeframes, only_distance=False): + """Describes a delta within multiple timeframes in plain language. + + :param timeframes: a list of string, quantity pairs each representing a timeframe and delta. + :param only_distance: return only distance eg: "2 hours and 11 seconds" without "in" or "ago" keywords + """ + + humanized = "" + for index, (timeframe, delta) in enumerate(timeframes): + humanized += self._format_timeframe(timeframe, delta) + if index == len(timeframes) - 2 and self.and_word: + humanized += " " + self.and_word + " " + elif index < len(timeframes) - 1: + humanized += " " + + if not only_distance: + humanized = self._format_relative(humanized, timeframe, delta) + + return humanized + + def day_name(self, day): + """Returns the day name for a specified day of the week. + + :param day: the ``int`` day of the week (1-7). + + """ + + return self.day_names[day] + + def day_abbreviation(self, day): + """Returns the day abbreviation for a specified day of the week. + + :param day: the ``int`` day of the week (1-7). + + """ + + return self.day_abbreviations[day] + + def month_name(self, month): + """Returns the month name for a specified month of the year. + + :param month: the ``int`` month of the year (1-12). + + """ + + return self.month_names[month] + + def month_abbreviation(self, month): + """Returns the month abbreviation for a specified month of the year. + + :param month: the ``int`` month of the year (1-12). + + """ + + return self.month_abbreviations[month] + + def month_number(self, name): + """Returns the month number for a month specified by name or abbreviation. + + :param name: the month name or abbreviation. + + """ + + if self._month_name_to_ordinal is None: + self._month_name_to_ordinal = self._name_to_ordinal(self.month_names) + self._month_name_to_ordinal.update( + self._name_to_ordinal(self.month_abbreviations) + ) + + return self._month_name_to_ordinal.get(name) + + def year_full(self, year): + """Returns the year for specific locale if available + + :param name: the ``int`` year (4-digit) + """ + return "{:04d}".format(year) + + def year_abbreviation(self, year): + """Returns the year for specific locale if available + + :param name: the ``int`` year (4-digit) + """ + return "{:04d}".format(year)[2:] + + def meridian(self, hour, token): + """Returns the meridian indicator for a specified hour and format token. + + :param hour: the ``int`` hour of the day. + :param token: the format token. + """ + + if token == "a": + return self.meridians["am"] if hour < 12 else self.meridians["pm"] + if token == "A": + return self.meridians["AM"] if hour < 12 else self.meridians["PM"] + + def ordinal_number(self, n): + """Returns the ordinal format of a given integer + + :param n: an integer + """ + return self._ordinal_number(n) + + def _ordinal_number(self, n): + return "{}".format(n) + + def _name_to_ordinal(self, lst): + return dict(map(lambda i: (i[1].lower(), i[0] + 1), enumerate(lst[1:]))) + + def _format_timeframe(self, timeframe, delta): + return self.timeframes[timeframe].format(trunc(abs(delta))) + + def _format_relative(self, humanized, timeframe, delta): + + if timeframe == "now": + return humanized + + direction = self.past if delta < 0 else self.future + + return direction.format(humanized) + + +# base locale type implementations. + + +class EnglishLocale(Locale): + + names = [ + "en", + "en_us", + "en_gb", + "en_au", + "en_be", + "en_jp", + "en_za", + "en_ca", + "en_ph", + ] + + past = "{0} ago" + future = "in {0}" + and_word = "and" + + timeframes = { + "now": "just now", + "second": "a second", + "seconds": "{0} seconds", + "minute": "a minute", + "minutes": "{0} minutes", + "hour": "an hour", + "hours": "{0} hours", + "day": "a day", + "days": "{0} days", + "week": "a week", + "weeks": "{0} weeks", + "month": "a month", + "months": "{0} months", + "year": "a year", + "years": "{0} years", + } + + meridians = {"am": "am", "pm": "pm", "AM": "AM", "PM": "PM"} + + month_names = [ + "", + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ] + month_abbreviations = [ + "", + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ] + + day_names = [ + "", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ] + day_abbreviations = ["", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + + ordinal_day_re = r"((?P[2-3]?1(?=st)|[2-3]?2(?=nd)|[2-3]?3(?=rd)|[1-3]?[04-9](?=th)|1[1-3](?=th))(st|nd|rd|th))" + + def _ordinal_number(self, n): + if n % 100 not in (11, 12, 13): + remainder = abs(n) % 10 + if remainder == 1: + return "{}st".format(n) + elif remainder == 2: + return "{}nd".format(n) + elif remainder == 3: + return "{}rd".format(n) + return "{}th".format(n) + + def describe(self, timeframe, delta=0, only_distance=False): + """Describes a delta within a timeframe in plain language. + + :param timeframe: a string representing a timeframe. + :param delta: a quantity representing a delta in a timeframe. + :param only_distance: return only distance eg: "11 seconds" without "in" or "ago" keywords + """ + + humanized = super(EnglishLocale, self).describe(timeframe, delta, only_distance) + if only_distance and timeframe == "now": + humanized = "instantly" + + return humanized + + +class ItalianLocale(Locale): + names = ["it", "it_it"] + past = "{0} fa" + future = "tra {0}" + and_word = "e" + + timeframes = { + "now": "adesso", + "second": "un secondo", + "seconds": "{0} qualche secondo", + "minute": "un minuto", + "minutes": "{0} minuti", + "hour": "un'ora", + "hours": "{0} ore", + "day": "un giorno", + "days": "{0} giorni", + "week": "una settimana,", + "weeks": "{0} settimane", + "month": "un mese", + "months": "{0} mesi", + "year": "un anno", + "years": "{0} anni", + } + + month_names = [ + "", + "gennaio", + "febbraio", + "marzo", + "aprile", + "maggio", + "giugno", + "luglio", + "agosto", + "settembre", + "ottobre", + "novembre", + "dicembre", + ] + month_abbreviations = [ + "", + "gen", + "feb", + "mar", + "apr", + "mag", + "giu", + "lug", + "ago", + "set", + "ott", + "nov", + "dic", + ] + + day_names = [ + "", + "lunedì", + "martedì", + "mercoledì", + "giovedì", + "venerdì", + "sabato", + "domenica", + ] + day_abbreviations = ["", "lun", "mar", "mer", "gio", "ven", "sab", "dom"] + + ordinal_day_re = r"((?P[1-3]?[0-9](?=[ºª]))[ºª])" + + def _ordinal_number(self, n): + return "{}º".format(n) + + +class SpanishLocale(Locale): + names = ["es", "es_es"] + past = "hace {0}" + future = "en {0}" + and_word = "y" + + timeframes = { + "now": "ahora", + "second": "un segundo", + "seconds": "{0} segundos", + "minute": "un minuto", + "minutes": "{0} minutos", + "hour": "una hora", + "hours": "{0} horas", + "day": "un día", + "days": "{0} días", + "week": "una semana", + "weeks": "{0} semanas", + "month": "un mes", + "months": "{0} meses", + "year": "un año", + "years": "{0} años", + } + + meridians = {"am": "am", "pm": "pm", "AM": "AM", "PM": "PM"} + + month_names = [ + "", + "enero", + "febrero", + "marzo", + "abril", + "mayo", + "junio", + "julio", + "agosto", + "septiembre", + "octubre", + "noviembre", + "diciembre", + ] + month_abbreviations = [ + "", + "ene", + "feb", + "mar", + "abr", + "may", + "jun", + "jul", + "ago", + "sep", + "oct", + "nov", + "dic", + ] + + day_names = [ + "", + "lunes", + "martes", + "miércoles", + "jueves", + "viernes", + "sábado", + "domingo", + ] + day_abbreviations = ["", "lun", "mar", "mie", "jue", "vie", "sab", "dom"] + + ordinal_day_re = r"((?P[1-3]?[0-9](?=[ºª]))[ºª])" + + def _ordinal_number(self, n): + return "{}º".format(n) + + +class FrenchBaseLocale(Locale): + + past = "il y a {0}" + future = "dans {0}" + and_word = "et" + + timeframes = { + "now": "maintenant", + "second": "une seconde", + "seconds": "{0} quelques secondes", + "minute": "une minute", + "minutes": "{0} minutes", + "hour": "une heure", + "hours": "{0} heures", + "day": "un jour", + "days": "{0} jours", + "week": "une semaine", + "weeks": "{0} semaines", + "month": "un mois", + "months": "{0} mois", + "year": "un an", + "years": "{0} ans", + } + + month_names = [ + "", + "janvier", + "février", + "mars", + "avril", + "mai", + "juin", + "juillet", + "août", + "septembre", + "octobre", + "novembre", + "décembre", + ] + + day_names = [ + "", + "lundi", + "mardi", + "mercredi", + "jeudi", + "vendredi", + "samedi", + "dimanche", + ] + day_abbreviations = ["", "lun", "mar", "mer", "jeu", "ven", "sam", "dim"] + + ordinal_day_re = ( + r"((?P\b1(?=er\b)|[1-3]?[02-9](?=e\b)|[1-3]1(?=e\b))(er|e)\b)" + ) + + def _ordinal_number(self, n): + if abs(n) == 1: + return "{}er".format(n) + return "{}e".format(n) + + +class FrenchLocale(FrenchBaseLocale, Locale): + + names = ["fr", "fr_fr"] + + month_abbreviations = [ + "", + "janv", + "févr", + "mars", + "avr", + "mai", + "juin", + "juil", + "août", + "sept", + "oct", + "nov", + "déc", + ] + + +class FrenchCanadianLocale(FrenchBaseLocale, Locale): + + names = ["fr_ca"] + + month_abbreviations = [ + "", + "janv", + "févr", + "mars", + "avr", + "mai", + "juin", + "juill", + "août", + "sept", + "oct", + "nov", + "déc", + ] + + +class GreekLocale(Locale): + + names = ["el", "el_gr"] + + past = "{0} πριν" + future = "σε {0}" + and_word = "και" + + timeframes = { + "now": "τώρα", + "second": "ένα δεύτερο", + "seconds": "{0} δευτερόλεπτα", + "minute": "ένα λεπτό", + "minutes": "{0} λεπτά", + "hour": "μία ώρα", + "hours": "{0} ώρες", + "day": "μία μέρα", + "days": "{0} μέρες", + "month": "ένα μήνα", + "months": "{0} μήνες", + "year": "ένα χρόνο", + "years": "{0} χρόνια", + } + + month_names = [ + "", + "Ιανουαρίου", + "Φεβρουαρίου", + "Μαρτίου", + "Απριλίου", + "Μαΐου", + "Ιουνίου", + "Ιουλίου", + "Αυγούστου", + "Σεπτεμβρίου", + "Οκτωβρίου", + "Νοεμβρίου", + "Δεκεμβρίου", + ] + month_abbreviations = [ + "", + "Ιαν", + "Φεβ", + "Μαρ", + "Απρ", + "Μαϊ", + "Ιον", + "Ιολ", + "Αυγ", + "Σεπ", + "Οκτ", + "Νοε", + "Δεκ", + ] + + day_names = [ + "", + "Δευτέρα", + "Τρίτη", + "Τετάρτη", + "Πέμπτη", + "Παρασκευή", + "Σάββατο", + "Κυριακή", + ] + day_abbreviations = ["", "Δευ", "Τρι", "Τετ", "Πεμ", "Παρ", "Σαβ", "Κυρ"] + + +class JapaneseLocale(Locale): + + names = ["ja", "ja_jp"] + + past = "{0}前" + future = "{0}後" + + timeframes = { + "now": "現在", + "second": "二番目の", + "seconds": "{0}数秒", + "minute": "1分", + "minutes": "{0}分", + "hour": "1時間", + "hours": "{0}時間", + "day": "1日", + "days": "{0}日", + "week": "1週間", + "weeks": "{0}週間", + "month": "1ヶ月", + "months": "{0}ヶ月", + "year": "1年", + "years": "{0}年", + } + + month_names = [ + "", + "1月", + "2月", + "3月", + "4月", + "5月", + "6月", + "7月", + "8月", + "9月", + "10月", + "11月", + "12月", + ] + month_abbreviations = [ + "", + " 1", + " 2", + " 3", + " 4", + " 5", + " 6", + " 7", + " 8", + " 9", + "10", + "11", + "12", + ] + + day_names = ["", "月曜日", "火曜日", "水曜日", "木曜日", "金曜日", "土曜日", "日曜日"] + day_abbreviations = ["", "月", "火", "水", "木", "金", "土", "日"] + + +class SwedishLocale(Locale): + + names = ["sv", "sv_se"] + + past = "för {0} sen" + future = "om {0}" + and_word = "och" + + timeframes = { + "now": "just nu", + "second": "en sekund", + "seconds": "{0} några sekunder", + "minute": "en minut", + "minutes": "{0} minuter", + "hour": "en timme", + "hours": "{0} timmar", + "day": "en dag", + "days": "{0} dagar", + "week": "en vecka", + "weeks": "{0} veckor", + "month": "en månad", + "months": "{0} månader", + "year": "ett år", + "years": "{0} år", + } + + month_names = [ + "", + "januari", + "februari", + "mars", + "april", + "maj", + "juni", + "juli", + "augusti", + "september", + "oktober", + "november", + "december", + ] + month_abbreviations = [ + "", + "jan", + "feb", + "mar", + "apr", + "maj", + "jun", + "jul", + "aug", + "sep", + "okt", + "nov", + "dec", + ] + + day_names = [ + "", + "måndag", + "tisdag", + "onsdag", + "torsdag", + "fredag", + "lördag", + "söndag", + ] + day_abbreviations = ["", "mån", "tis", "ons", "tor", "fre", "lör", "sön"] + + +class FinnishLocale(Locale): + + names = ["fi", "fi_fi"] + + # The finnish grammar is very complex, and its hard to convert + # 1-to-1 to something like English. + + past = "{0} sitten" + future = "{0} kuluttua" + + timeframes = { + "now": ["juuri nyt", "juuri nyt"], + "second": ["sekunti", "sekunti"], + "seconds": ["{0} muutama sekunti", "{0} muutaman sekunnin"], + "minute": ["minuutti", "minuutin"], + "minutes": ["{0} minuuttia", "{0} minuutin"], + "hour": ["tunti", "tunnin"], + "hours": ["{0} tuntia", "{0} tunnin"], + "day": ["päivä", "päivä"], + "days": ["{0} päivää", "{0} päivän"], + "month": ["kuukausi", "kuukauden"], + "months": ["{0} kuukautta", "{0} kuukauden"], + "year": ["vuosi", "vuoden"], + "years": ["{0} vuotta", "{0} vuoden"], + } + + # Months and days are lowercase in Finnish + month_names = [ + "", + "tammikuu", + "helmikuu", + "maaliskuu", + "huhtikuu", + "toukokuu", + "kesäkuu", + "heinäkuu", + "elokuu", + "syyskuu", + "lokakuu", + "marraskuu", + "joulukuu", + ] + + month_abbreviations = [ + "", + "tammi", + "helmi", + "maalis", + "huhti", + "touko", + "kesä", + "heinä", + "elo", + "syys", + "loka", + "marras", + "joulu", + ] + + day_names = [ + "", + "maanantai", + "tiistai", + "keskiviikko", + "torstai", + "perjantai", + "lauantai", + "sunnuntai", + ] + + day_abbreviations = ["", "ma", "ti", "ke", "to", "pe", "la", "su"] + + def _format_timeframe(self, timeframe, delta): + return ( + self.timeframes[timeframe][0].format(abs(delta)), + self.timeframes[timeframe][1].format(abs(delta)), + ) + + def _format_relative(self, humanized, timeframe, delta): + if timeframe == "now": + return humanized[0] + + direction = self.past if delta < 0 else self.future + which = 0 if delta < 0 else 1 + + return direction.format(humanized[which]) + + def _ordinal_number(self, n): + return "{}.".format(n) + + +class ChineseCNLocale(Locale): + + names = ["zh", "zh_cn"] + + past = "{0}前" + future = "{0}后" + + timeframes = { + "now": "刚才", + "second": "一秒", + "seconds": "{0}秒", + "minute": "1分钟", + "minutes": "{0}分钟", + "hour": "1小时", + "hours": "{0}小时", + "day": "1天", + "days": "{0}天", + "week": "一周", + "weeks": "{0}周", + "month": "1个月", + "months": "{0}个月", + "year": "1年", + "years": "{0}年", + } + + month_names = [ + "", + "一月", + "二月", + "三月", + "四月", + "五月", + "六月", + "七月", + "八月", + "九月", + "十月", + "十一月", + "十二月", + ] + month_abbreviations = [ + "", + " 1", + " 2", + " 3", + " 4", + " 5", + " 6", + " 7", + " 8", + " 9", + "10", + "11", + "12", + ] + + day_names = ["", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"] + day_abbreviations = ["", "一", "二", "三", "四", "五", "六", "日"] + + +class ChineseTWLocale(Locale): + + names = ["zh_tw"] + + past = "{0}前" + future = "{0}後" + and_word = "和" + + timeframes = { + "now": "剛才", + "second": "1秒", + "seconds": "{0}秒", + "minute": "1分鐘", + "minutes": "{0}分鐘", + "hour": "1小時", + "hours": "{0}小時", + "day": "1天", + "days": "{0}天", + "week": "1週", + "weeks": "{0}週", + "month": "1個月", + "months": "{0}個月", + "year": "1年", + "years": "{0}年", + } + + month_names = [ + "", + "1月", + "2月", + "3月", + "4月", + "5月", + "6月", + "7月", + "8月", + "9月", + "10月", + "11月", + "12月", + ] + month_abbreviations = [ + "", + " 1", + " 2", + " 3", + " 4", + " 5", + " 6", + " 7", + " 8", + " 9", + "10", + "11", + "12", + ] + + day_names = ["", "週一", "週二", "週三", "週四", "週五", "週六", "週日"] + day_abbreviations = ["", "一", "二", "三", "四", "五", "六", "日"] + + +class HongKongLocale(Locale): + + names = ["zh_hk"] + + past = "{0}前" + future = "{0}後" + + timeframes = { + "now": "剛才", + "second": "1秒", + "seconds": "{0}秒", + "minute": "1分鐘", + "minutes": "{0}分鐘", + "hour": "1小時", + "hours": "{0}小時", + "day": "1天", + "days": "{0}天", + "week": "1星期", + "weeks": "{0}星期", + "month": "1個月", + "months": "{0}個月", + "year": "1年", + "years": "{0}年", + } + + month_names = [ + "", + "1月", + "2月", + "3月", + "4月", + "5月", + "6月", + "7月", + "8月", + "9月", + "10月", + "11月", + "12月", + ] + month_abbreviations = [ + "", + " 1", + " 2", + " 3", + " 4", + " 5", + " 6", + " 7", + " 8", + " 9", + "10", + "11", + "12", + ] + + day_names = ["", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"] + day_abbreviations = ["", "一", "二", "三", "四", "五", "六", "日"] + + +class KoreanLocale(Locale): + + names = ["ko", "ko_kr"] + + past = "{0} 전" + future = "{0} 후" + + timeframes = { + "now": "지금", + "second": "1초", + "seconds": "{0}초", + "minute": "1분", + "minutes": "{0}분", + "hour": "한시간", + "hours": "{0}시간", + "day": "하루", + "days": "{0}일", + "week": "1주", + "weeks": "{0}주", + "month": "한달", + "months": "{0}개월", + "year": "1년", + "years": "{0}년", + } + + special_dayframes = { + -3: "그끄제", + -2: "그제", + -1: "어제", + 1: "내일", + 2: "모레", + 3: "글피", + 4: "그글피", + } + + special_yearframes = {-2: "제작년", -1: "작년", 1: "내년", 2: "내후년"} + + month_names = [ + "", + "1월", + "2월", + "3월", + "4월", + "5월", + "6월", + "7월", + "8월", + "9월", + "10월", + "11월", + "12월", + ] + month_abbreviations = [ + "", + " 1", + " 2", + " 3", + " 4", + " 5", + " 6", + " 7", + " 8", + " 9", + "10", + "11", + "12", + ] + + day_names = ["", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일", "일요일"] + day_abbreviations = ["", "월", "화", "수", "목", "금", "토", "일"] + + def _ordinal_number(self, n): + ordinals = ["0", "첫", "두", "세", "네", "다섯", "여섯", "일곱", "여덟", "아홉", "열"] + if n < len(ordinals): + return "{}번째".format(ordinals[n]) + return "{}번째".format(n) + + def _format_relative(self, humanized, timeframe, delta): + if timeframe in ("day", "days"): + special = self.special_dayframes.get(delta) + if special: + return special + elif timeframe in ("year", "years"): + special = self.special_yearframes.get(delta) + if special: + return special + + return super(KoreanLocale, self)._format_relative(humanized, timeframe, delta) + + +# derived locale types & implementations. +class DutchLocale(Locale): + + names = ["nl", "nl_nl"] + + past = "{0} geleden" + future = "over {0}" + + timeframes = { + "now": "nu", + "second": "een seconde", + "seconds": "{0} seconden", + "minute": "een minuut", + "minutes": "{0} minuten", + "hour": "een uur", + "hours": "{0} uur", + "day": "een dag", + "days": "{0} dagen", + "week": "een week", + "weeks": "{0} weken", + "month": "een maand", + "months": "{0} maanden", + "year": "een jaar", + "years": "{0} jaar", + } + + # In Dutch names of months and days are not starting with a capital letter + # like in the English language. + month_names = [ + "", + "januari", + "februari", + "maart", + "april", + "mei", + "juni", + "juli", + "augustus", + "september", + "oktober", + "november", + "december", + ] + month_abbreviations = [ + "", + "jan", + "feb", + "mrt", + "apr", + "mei", + "jun", + "jul", + "aug", + "sep", + "okt", + "nov", + "dec", + ] + + day_names = [ + "", + "maandag", + "dinsdag", + "woensdag", + "donderdag", + "vrijdag", + "zaterdag", + "zondag", + ] + day_abbreviations = ["", "ma", "di", "wo", "do", "vr", "za", "zo"] + + +class SlavicBaseLocale(Locale): + def _format_timeframe(self, timeframe, delta): + + form = self.timeframes[timeframe] + delta = abs(delta) + + if isinstance(form, list): + + if delta % 10 == 1 and delta % 100 != 11: + form = form[0] + elif 2 <= delta % 10 <= 4 and (delta % 100 < 10 or delta % 100 >= 20): + form = form[1] + else: + form = form[2] + + return form.format(delta) + + +class BelarusianLocale(SlavicBaseLocale): + + names = ["be", "be_by"] + + past = "{0} таму" + future = "праз {0}" + + timeframes = { + "now": "зараз", + "second": "секунду", + "seconds": "{0} некалькі секунд", + "minute": "хвіліну", + "minutes": ["{0} хвіліну", "{0} хвіліны", "{0} хвілін"], + "hour": "гадзіну", + "hours": ["{0} гадзіну", "{0} гадзіны", "{0} гадзін"], + "day": "дзень", + "days": ["{0} дзень", "{0} дні", "{0} дзён"], + "month": "месяц", + "months": ["{0} месяц", "{0} месяцы", "{0} месяцаў"], + "year": "год", + "years": ["{0} год", "{0} гады", "{0} гадоў"], + } + + month_names = [ + "", + "студзеня", + "лютага", + "сакавіка", + "красавіка", + "траўня", + "чэрвеня", + "ліпеня", + "жніўня", + "верасня", + "кастрычніка", + "лістапада", + "снежня", + ] + month_abbreviations = [ + "", + "студ", + "лют", + "сак", + "крас", + "трав", + "чэрв", + "ліп", + "жнів", + "вер", + "каст", + "ліст", + "снеж", + ] + + day_names = [ + "", + "панядзелак", + "аўторак", + "серада", + "чацвер", + "пятніца", + "субота", + "нядзеля", + ] + day_abbreviations = ["", "пн", "ат", "ср", "чц", "пт", "сб", "нд"] + + +class PolishLocale(SlavicBaseLocale): + + names = ["pl", "pl_pl"] + + past = "{0} temu" + future = "za {0}" + + # The nouns should be in genitive case (Polish: "dopełniacz") + # in order to correctly form `past` & `future` expressions. + timeframes = { + "now": "teraz", + "second": "sekundę", + "seconds": ["{0} sekund", "{0} sekundy", "{0} sekund"], + "minute": "minutę", + "minutes": ["{0} minut", "{0} minuty", "{0} minut"], + "hour": "godzinę", + "hours": ["{0} godzin", "{0} godziny", "{0} godzin"], + "day": "dzień", + "days": "{0} dni", + "week": "tydzień", + "weeks": ["{0} tygodni", "{0} tygodnie", "{0} tygodni"], + "month": "miesiąc", + "months": ["{0} miesięcy", "{0} miesiące", "{0} miesięcy"], + "year": "rok", + "years": ["{0} lat", "{0} lata", "{0} lat"], + } + + month_names = [ + "", + "styczeń", + "luty", + "marzec", + "kwiecień", + "maj", + "czerwiec", + "lipiec", + "sierpień", + "wrzesień", + "październik", + "listopad", + "grudzień", + ] + month_abbreviations = [ + "", + "sty", + "lut", + "mar", + "kwi", + "maj", + "cze", + "lip", + "sie", + "wrz", + "paź", + "lis", + "gru", + ] + + day_names = [ + "", + "poniedziałek", + "wtorek", + "środa", + "czwartek", + "piątek", + "sobota", + "niedziela", + ] + day_abbreviations = ["", "Pn", "Wt", "Śr", "Czw", "Pt", "So", "Nd"] + + +class RussianLocale(SlavicBaseLocale): + + names = ["ru", "ru_ru"] + + past = "{0} назад" + future = "через {0}" + + timeframes = { + "now": "сейчас", + "second": "Второй", + "seconds": "{0} несколько секунд", + "minute": "минуту", + "minutes": ["{0} минуту", "{0} минуты", "{0} минут"], + "hour": "час", + "hours": ["{0} час", "{0} часа", "{0} часов"], + "day": "день", + "days": ["{0} день", "{0} дня", "{0} дней"], + "week": "неделю", + "weeks": ["{0} неделю", "{0} недели", "{0} недель"], + "month": "месяц", + "months": ["{0} месяц", "{0} месяца", "{0} месяцев"], + "year": "год", + "years": ["{0} год", "{0} года", "{0} лет"], + } + + month_names = [ + "", + "января", + "февраля", + "марта", + "апреля", + "мая", + "июня", + "июля", + "августа", + "сентября", + "октября", + "ноября", + "декабря", + ] + month_abbreviations = [ + "", + "янв", + "фев", + "мар", + "апр", + "май", + "июн", + "июл", + "авг", + "сен", + "окт", + "ноя", + "дек", + ] + + day_names = [ + "", + "понедельник", + "вторник", + "среда", + "четверг", + "пятница", + "суббота", + "воскресенье", + ] + day_abbreviations = ["", "пн", "вт", "ср", "чт", "пт", "сб", "вс"] + + +class AfrikaansLocale(Locale): + + names = ["af", "af_nl"] + + past = "{0} gelede" + future = "in {0}" + + timeframes = { + "now": "nou", + "second": "n sekonde", + "seconds": "{0} sekondes", + "minute": "minuut", + "minutes": "{0} minute", + "hour": "uur", + "hours": "{0} ure", + "day": "een dag", + "days": "{0} dae", + "month": "een maand", + "months": "{0} maande", + "year": "een jaar", + "years": "{0} jaar", + } + + month_names = [ + "", + "Januarie", + "Februarie", + "Maart", + "April", + "Mei", + "Junie", + "Julie", + "Augustus", + "September", + "Oktober", + "November", + "Desember", + ] + month_abbreviations = [ + "", + "Jan", + "Feb", + "Mrt", + "Apr", + "Mei", + "Jun", + "Jul", + "Aug", + "Sep", + "Okt", + "Nov", + "Des", + ] + + day_names = [ + "", + "Maandag", + "Dinsdag", + "Woensdag", + "Donderdag", + "Vrydag", + "Saterdag", + "Sondag", + ] + day_abbreviations = ["", "Ma", "Di", "Wo", "Do", "Vr", "Za", "So"] + + +class BulgarianLocale(SlavicBaseLocale): + + names = ["bg", "bg_BG"] + + past = "{0} назад" + future = "напред {0}" + + timeframes = { + "now": "сега", + "second": "секунда", + "seconds": "{0} няколко секунди", + "minute": "минута", + "minutes": ["{0} минута", "{0} минути", "{0} минути"], + "hour": "час", + "hours": ["{0} час", "{0} часа", "{0} часа"], + "day": "ден", + "days": ["{0} ден", "{0} дни", "{0} дни"], + "month": "месец", + "months": ["{0} месец", "{0} месеца", "{0} месеца"], + "year": "година", + "years": ["{0} година", "{0} години", "{0} години"], + } + + month_names = [ + "", + "януари", + "февруари", + "март", + "април", + "май", + "юни", + "юли", + "август", + "септември", + "октомври", + "ноември", + "декември", + ] + month_abbreviations = [ + "", + "ян", + "февр", + "март", + "апр", + "май", + "юни", + "юли", + "авг", + "септ", + "окт", + "ноем", + "дек", + ] + + day_names = [ + "", + "понеделник", + "вторник", + "сряда", + "четвъртък", + "петък", + "събота", + "неделя", + ] + day_abbreviations = ["", "пон", "вт", "ср", "четв", "пет", "съб", "нед"] + + +class UkrainianLocale(SlavicBaseLocale): + + names = ["ua", "uk_ua"] + + past = "{0} тому" + future = "за {0}" + + timeframes = { + "now": "зараз", + "second": "секунда", + "seconds": "{0} кілька секунд", + "minute": "хвилину", + "minutes": ["{0} хвилину", "{0} хвилини", "{0} хвилин"], + "hour": "годину", + "hours": ["{0} годину", "{0} години", "{0} годин"], + "day": "день", + "days": ["{0} день", "{0} дні", "{0} днів"], + "month": "місяць", + "months": ["{0} місяць", "{0} місяці", "{0} місяців"], + "year": "рік", + "years": ["{0} рік", "{0} роки", "{0} років"], + } + + month_names = [ + "", + "січня", + "лютого", + "березня", + "квітня", + "травня", + "червня", + "липня", + "серпня", + "вересня", + "жовтня", + "листопада", + "грудня", + ] + month_abbreviations = [ + "", + "січ", + "лют", + "бер", + "квіт", + "трав", + "черв", + "лип", + "серп", + "вер", + "жовт", + "лист", + "груд", + ] + + day_names = [ + "", + "понеділок", + "вівторок", + "середа", + "четвер", + "п’ятниця", + "субота", + "неділя", + ] + day_abbreviations = ["", "пн", "вт", "ср", "чт", "пт", "сб", "нд"] + + +class MacedonianLocale(SlavicBaseLocale): + names = ["mk", "mk_mk"] + + past = "пред {0}" + future = "за {0}" + + timeframes = { + "now": "сега", + "second": "една секунда", + "seconds": ["{0} секунда", "{0} секунди", "{0} секунди"], + "minute": "една минута", + "minutes": ["{0} минута", "{0} минути", "{0} минути"], + "hour": "еден саат", + "hours": ["{0} саат", "{0} саати", "{0} саати"], + "day": "еден ден", + "days": ["{0} ден", "{0} дена", "{0} дена"], + "week": "една недела", + "weeks": ["{0} недела", "{0} недели", "{0} недели"], + "month": "еден месец", + "months": ["{0} месец", "{0} месеци", "{0} месеци"], + "year": "една година", + "years": ["{0} година", "{0} години", "{0} години"], + } + + meridians = {"am": "дп", "pm": "пп", "AM": "претпладне", "PM": "попладне"} + + month_names = [ + "", + "Јануари", + "Февруари", + "Март", + "Април", + "Мај", + "Јуни", + "Јули", + "Август", + "Септември", + "Октомври", + "Ноември", + "Декември", + ] + month_abbreviations = [ + "", + "Јан", + "Фев", + "Мар", + "Апр", + "Мај", + "Јун", + "Јул", + "Авг", + "Септ", + "Окт", + "Ноем", + "Декем", + ] + + day_names = [ + "", + "Понеделник", + "Вторник", + "Среда", + "Четврток", + "Петок", + "Сабота", + "Недела", + ] + day_abbreviations = [ + "", + "Пон", + "Вт", + "Сре", + "Чет", + "Пет", + "Саб", + "Нед", + ] + + +class GermanBaseLocale(Locale): + + past = "vor {0}" + future = "in {0}" + and_word = "und" + + timeframes = { + "now": "gerade eben", + "second": "eine Sekunde", + "seconds": "{0} Sekunden", + "minute": "einer Minute", + "minutes": "{0} Minuten", + "hour": "einer Stunde", + "hours": "{0} Stunden", + "day": "einem Tag", + "days": "{0} Tagen", + "week": "einer Woche", + "weeks": "{0} Wochen", + "month": "einem Monat", + "months": "{0} Monaten", + "year": "einem Jahr", + "years": "{0} Jahren", + } + + timeframes_only_distance = timeframes.copy() + timeframes_only_distance["minute"] = "eine Minute" + timeframes_only_distance["hour"] = "eine Stunde" + timeframes_only_distance["day"] = "ein Tag" + timeframes_only_distance["week"] = "eine Woche" + timeframes_only_distance["month"] = "ein Monat" + timeframes_only_distance["year"] = "ein Jahr" + + month_names = [ + "", + "Januar", + "Februar", + "März", + "April", + "Mai", + "Juni", + "Juli", + "August", + "September", + "Oktober", + "November", + "Dezember", + ] + + month_abbreviations = [ + "", + "Jan", + "Feb", + "Mär", + "Apr", + "Mai", + "Jun", + "Jul", + "Aug", + "Sep", + "Okt", + "Nov", + "Dez", + ] + + day_names = [ + "", + "Montag", + "Dienstag", + "Mittwoch", + "Donnerstag", + "Freitag", + "Samstag", + "Sonntag", + ] + + day_abbreviations = ["", "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] + + def _ordinal_number(self, n): + return "{}.".format(n) + + def describe(self, timeframe, delta=0, only_distance=False): + """Describes a delta within a timeframe in plain language. + + :param timeframe: a string representing a timeframe. + :param delta: a quantity representing a delta in a timeframe. + :param only_distance: return only distance eg: "11 seconds" without "in" or "ago" keywords + """ + + if not only_distance: + return super(GermanBaseLocale, self).describe( + timeframe, delta, only_distance + ) + + # German uses a different case without 'in' or 'ago' + humanized = self.timeframes_only_distance[timeframe].format(trunc(abs(delta))) + + return humanized + + +class GermanLocale(GermanBaseLocale, Locale): + + names = ["de", "de_de"] + + +class SwissLocale(GermanBaseLocale, Locale): + + names = ["de_ch"] + + +class AustrianLocale(GermanBaseLocale, Locale): + + names = ["de_at"] + + month_names = [ + "", + "Jänner", + "Februar", + "März", + "April", + "Mai", + "Juni", + "Juli", + "August", + "September", + "Oktober", + "November", + "Dezember", + ] + + +class NorwegianLocale(Locale): + + names = ["nb", "nb_no"] + + past = "for {0} siden" + future = "om {0}" + + timeframes = { + "now": "nå nettopp", + "second": "et sekund", + "seconds": "{0} noen sekunder", + "minute": "ett minutt", + "minutes": "{0} minutter", + "hour": "en time", + "hours": "{0} timer", + "day": "en dag", + "days": "{0} dager", + "month": "en måned", + "months": "{0} måneder", + "year": "ett år", + "years": "{0} år", + } + + month_names = [ + "", + "januar", + "februar", + "mars", + "april", + "mai", + "juni", + "juli", + "august", + "september", + "oktober", + "november", + "desember", + ] + month_abbreviations = [ + "", + "jan", + "feb", + "mar", + "apr", + "mai", + "jun", + "jul", + "aug", + "sep", + "okt", + "nov", + "des", + ] + + day_names = [ + "", + "mandag", + "tirsdag", + "onsdag", + "torsdag", + "fredag", + "lørdag", + "søndag", + ] + day_abbreviations = ["", "ma", "ti", "on", "to", "fr", "lø", "sø"] + + +class NewNorwegianLocale(Locale): + + names = ["nn", "nn_no"] + + past = "for {0} sidan" + future = "om {0}" + + timeframes = { + "now": "no nettopp", + "second": "et sekund", + "seconds": "{0} nokre sekund", + "minute": "ett minutt", + "minutes": "{0} minutt", + "hour": "ein time", + "hours": "{0} timar", + "day": "ein dag", + "days": "{0} dagar", + "month": "en månad", + "months": "{0} månader", + "year": "eit år", + "years": "{0} år", + } + + month_names = [ + "", + "januar", + "februar", + "mars", + "april", + "mai", + "juni", + "juli", + "august", + "september", + "oktober", + "november", + "desember", + ] + month_abbreviations = [ + "", + "jan", + "feb", + "mar", + "apr", + "mai", + "jun", + "jul", + "aug", + "sep", + "okt", + "nov", + "des", + ] + + day_names = [ + "", + "måndag", + "tysdag", + "onsdag", + "torsdag", + "fredag", + "laurdag", + "sundag", + ] + day_abbreviations = ["", "må", "ty", "on", "to", "fr", "la", "su"] + + +class PortugueseLocale(Locale): + names = ["pt", "pt_pt"] + + past = "há {0}" + future = "em {0}" + and_word = "e" + + timeframes = { + "now": "agora", + "second": "um segundo", + "seconds": "{0} segundos", + "minute": "um minuto", + "minutes": "{0} minutos", + "hour": "uma hora", + "hours": "{0} horas", + "day": "um dia", + "days": "{0} dias", + "week": "uma semana", + "weeks": "{0} semanas", + "month": "um mês", + "months": "{0} meses", + "year": "um ano", + "years": "{0} anos", + } + + month_names = [ + "", + "Janeiro", + "Fevereiro", + "Março", + "Abril", + "Maio", + "Junho", + "Julho", + "Agosto", + "Setembro", + "Outubro", + "Novembro", + "Dezembro", + ] + month_abbreviations = [ + "", + "Jan", + "Fev", + "Mar", + "Abr", + "Mai", + "Jun", + "Jul", + "Ago", + "Set", + "Out", + "Nov", + "Dez", + ] + + day_names = [ + "", + "Segunda-feira", + "Terça-feira", + "Quarta-feira", + "Quinta-feira", + "Sexta-feira", + "Sábado", + "Domingo", + ] + day_abbreviations = ["", "Seg", "Ter", "Qua", "Qui", "Sex", "Sab", "Dom"] + + +class BrazilianPortugueseLocale(PortugueseLocale): + names = ["pt_br"] + + past = "faz {0}" + + +class TagalogLocale(Locale): + + names = ["tl", "tl_ph"] + + past = "nakaraang {0}" + future = "{0} mula ngayon" + + timeframes = { + "now": "ngayon lang", + "second": "isang segundo", + "seconds": "{0} segundo", + "minute": "isang minuto", + "minutes": "{0} minuto", + "hour": "isang oras", + "hours": "{0} oras", + "day": "isang araw", + "days": "{0} araw", + "week": "isang linggo", + "weeks": "{0} linggo", + "month": "isang buwan", + "months": "{0} buwan", + "year": "isang taon", + "years": "{0} taon", + } + + month_names = [ + "", + "Enero", + "Pebrero", + "Marso", + "Abril", + "Mayo", + "Hunyo", + "Hulyo", + "Agosto", + "Setyembre", + "Oktubre", + "Nobyembre", + "Disyembre", + ] + month_abbreviations = [ + "", + "Ene", + "Peb", + "Mar", + "Abr", + "May", + "Hun", + "Hul", + "Ago", + "Set", + "Okt", + "Nob", + "Dis", + ] + + day_names = [ + "", + "Lunes", + "Martes", + "Miyerkules", + "Huwebes", + "Biyernes", + "Sabado", + "Linggo", + ] + day_abbreviations = ["", "Lun", "Mar", "Miy", "Huw", "Biy", "Sab", "Lin"] + + meridians = {"am": "nu", "pm": "nh", "AM": "ng umaga", "PM": "ng hapon"} + + def _ordinal_number(self, n): + return "ika-{}".format(n) + + +class VietnameseLocale(Locale): + + names = ["vi", "vi_vn"] + + past = "{0} trước" + future = "{0} nữa" + + timeframes = { + "now": "hiện tại", + "second": "một giây", + "seconds": "{0} giây", + "minute": "một phút", + "minutes": "{0} phút", + "hour": "một giờ", + "hours": "{0} giờ", + "day": "một ngày", + "days": "{0} ngày", + "week": "một tuần", + "weeks": "{0} tuần", + "month": "một tháng", + "months": "{0} tháng", + "year": "một năm", + "years": "{0} năm", + } + + month_names = [ + "", + "Tháng Một", + "Tháng Hai", + "Tháng Ba", + "Tháng Tư", + "Tháng Năm", + "Tháng Sáu", + "Tháng Bảy", + "Tháng Tám", + "Tháng Chín", + "Tháng Mười", + "Tháng Mười Một", + "Tháng Mười Hai", + ] + month_abbreviations = [ + "", + "Tháng 1", + "Tháng 2", + "Tháng 3", + "Tháng 4", + "Tháng 5", + "Tháng 6", + "Tháng 7", + "Tháng 8", + "Tháng 9", + "Tháng 10", + "Tháng 11", + "Tháng 12", + ] + + day_names = [ + "", + "Thứ Hai", + "Thứ Ba", + "Thứ Tư", + "Thứ Năm", + "Thứ Sáu", + "Thứ Bảy", + "Chủ Nhật", + ] + day_abbreviations = ["", "Thứ 2", "Thứ 3", "Thứ 4", "Thứ 5", "Thứ 6", "Thứ 7", "CN"] + + +class TurkishLocale(Locale): + + names = ["tr", "tr_tr"] + + past = "{0} önce" + future = "{0} sonra" + + timeframes = { + "now": "şimdi", + "second": "bir saniye", + "seconds": "{0} saniye", + "minute": "bir dakika", + "minutes": "{0} dakika", + "hour": "bir saat", + "hours": "{0} saat", + "day": "bir gün", + "days": "{0} gün", + "month": "bir ay", + "months": "{0} ay", + "year": "yıl", + "years": "{0} yıl", + } + + month_names = [ + "", + "Ocak", + "Şubat", + "Mart", + "Nisan", + "Mayıs", + "Haziran", + "Temmuz", + "Ağustos", + "Eylül", + "Ekim", + "Kasım", + "Aralık", + ] + month_abbreviations = [ + "", + "Oca", + "Şub", + "Mar", + "Nis", + "May", + "Haz", + "Tem", + "Ağu", + "Eyl", + "Eki", + "Kas", + "Ara", + ] + + day_names = [ + "", + "Pazartesi", + "Salı", + "Çarşamba", + "Perşembe", + "Cuma", + "Cumartesi", + "Pazar", + ] + day_abbreviations = ["", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt", "Paz"] + + +class AzerbaijaniLocale(Locale): + + names = ["az", "az_az"] + + past = "{0} əvvəl" + future = "{0} sonra" + + timeframes = { + "now": "indi", + "second": "saniyə", + "seconds": "{0} saniyə", + "minute": "bir dəqiqə", + "minutes": "{0} dəqiqə", + "hour": "bir saat", + "hours": "{0} saat", + "day": "bir gün", + "days": "{0} gün", + "month": "bir ay", + "months": "{0} ay", + "year": "il", + "years": "{0} il", + } + + month_names = [ + "", + "Yanvar", + "Fevral", + "Mart", + "Aprel", + "May", + "İyun", + "İyul", + "Avqust", + "Sentyabr", + "Oktyabr", + "Noyabr", + "Dekabr", + ] + month_abbreviations = [ + "", + "Yan", + "Fev", + "Mar", + "Apr", + "May", + "İyn", + "İyl", + "Avq", + "Sen", + "Okt", + "Noy", + "Dek", + ] + + day_names = [ + "", + "Bazar ertəsi", + "Çərşənbə axşamı", + "Çərşənbə", + "Cümə axşamı", + "Cümə", + "Şənbə", + "Bazar", + ] + day_abbreviations = ["", "Ber", "Çax", "Çər", "Cax", "Cüm", "Şnb", "Bzr"] + + +class ArabicLocale(Locale): + names = [ + "ar", + "ar_ae", + "ar_bh", + "ar_dj", + "ar_eg", + "ar_eh", + "ar_er", + "ar_km", + "ar_kw", + "ar_ly", + "ar_om", + "ar_qa", + "ar_sa", + "ar_sd", + "ar_so", + "ar_ss", + "ar_td", + "ar_ye", + ] + + past = "منذ {0}" + future = "خلال {0}" + + timeframes = { + "now": "الآن", + "second": "ثانية", + "seconds": {"double": "ثانيتين", "ten": "{0} ثوان", "higher": "{0} ثانية"}, + "minute": "دقيقة", + "minutes": {"double": "دقيقتين", "ten": "{0} دقائق", "higher": "{0} دقيقة"}, + "hour": "ساعة", + "hours": {"double": "ساعتين", "ten": "{0} ساعات", "higher": "{0} ساعة"}, + "day": "يوم", + "days": {"double": "يومين", "ten": "{0} أيام", "higher": "{0} يوم"}, + "month": "شهر", + "months": {"double": "شهرين", "ten": "{0} أشهر", "higher": "{0} شهر"}, + "year": "سنة", + "years": {"double": "سنتين", "ten": "{0} سنوات", "higher": "{0} سنة"}, + } + + month_names = [ + "", + "يناير", + "فبراير", + "مارس", + "أبريل", + "مايو", + "يونيو", + "يوليو", + "أغسطس", + "سبتمبر", + "أكتوبر", + "نوفمبر", + "ديسمبر", + ] + month_abbreviations = [ + "", + "يناير", + "فبراير", + "مارس", + "أبريل", + "مايو", + "يونيو", + "يوليو", + "أغسطس", + "سبتمبر", + "أكتوبر", + "نوفمبر", + "ديسمبر", + ] + + day_names = [ + "", + "الإثنين", + "الثلاثاء", + "الأربعاء", + "الخميس", + "الجمعة", + "السبت", + "الأحد", + ] + day_abbreviations = ["", "إثنين", "ثلاثاء", "أربعاء", "خميس", "جمعة", "سبت", "أحد"] + + def _format_timeframe(self, timeframe, delta): + form = self.timeframes[timeframe] + delta = abs(delta) + if isinstance(form, dict): + if delta == 2: + form = form["double"] + elif delta > 2 and delta <= 10: + form = form["ten"] + else: + form = form["higher"] + + return form.format(delta) + + +class LevantArabicLocale(ArabicLocale): + names = ["ar_iq", "ar_jo", "ar_lb", "ar_ps", "ar_sy"] + month_names = [ + "", + "كانون الثاني", + "شباط", + "آذار", + "نيسان", + "أيار", + "حزيران", + "تموز", + "آب", + "أيلول", + "تشرين الأول", + "تشرين الثاني", + "كانون الأول", + ] + month_abbreviations = [ + "", + "كانون الثاني", + "شباط", + "آذار", + "نيسان", + "أيار", + "حزيران", + "تموز", + "آب", + "أيلول", + "تشرين الأول", + "تشرين الثاني", + "كانون الأول", + ] + + +class AlgeriaTunisiaArabicLocale(ArabicLocale): + names = ["ar_tn", "ar_dz"] + month_names = [ + "", + "جانفي", + "فيفري", + "مارس", + "أفريل", + "ماي", + "جوان", + "جويلية", + "أوت", + "سبتمبر", + "أكتوبر", + "نوفمبر", + "ديسمبر", + ] + month_abbreviations = [ + "", + "جانفي", + "فيفري", + "مارس", + "أفريل", + "ماي", + "جوان", + "جويلية", + "أوت", + "سبتمبر", + "أكتوبر", + "نوفمبر", + "ديسمبر", + ] + + +class MauritaniaArabicLocale(ArabicLocale): + names = ["ar_mr"] + month_names = [ + "", + "يناير", + "فبراير", + "مارس", + "إبريل", + "مايو", + "يونيو", + "يوليو", + "أغشت", + "شتمبر", + "أكتوبر", + "نوفمبر", + "دجمبر", + ] + month_abbreviations = [ + "", + "يناير", + "فبراير", + "مارس", + "إبريل", + "مايو", + "يونيو", + "يوليو", + "أغشت", + "شتمبر", + "أكتوبر", + "نوفمبر", + "دجمبر", + ] + + +class MoroccoArabicLocale(ArabicLocale): + names = ["ar_ma"] + month_names = [ + "", + "يناير", + "فبراير", + "مارس", + "أبريل", + "ماي", + "يونيو", + "يوليوز", + "غشت", + "شتنبر", + "أكتوبر", + "نونبر", + "دجنبر", + ] + month_abbreviations = [ + "", + "يناير", + "فبراير", + "مارس", + "أبريل", + "ماي", + "يونيو", + "يوليوز", + "غشت", + "شتنبر", + "أكتوبر", + "نونبر", + "دجنبر", + ] + + +class IcelandicLocale(Locale): + def _format_timeframe(self, timeframe, delta): + + timeframe = self.timeframes[timeframe] + if delta < 0: + timeframe = timeframe[0] + elif delta > 0: + timeframe = timeframe[1] + + return timeframe.format(abs(delta)) + + names = ["is", "is_is"] + + past = "fyrir {0} síðan" + future = "eftir {0}" + + timeframes = { + "now": "rétt í þessu", + "second": ("sekúndu", "sekúndu"), + "seconds": ("{0} nokkrum sekúndum", "nokkrar sekúndur"), + "minute": ("einni mínútu", "eina mínútu"), + "minutes": ("{0} mínútum", "{0} mínútur"), + "hour": ("einum tíma", "einn tíma"), + "hours": ("{0} tímum", "{0} tíma"), + "day": ("einum degi", "einn dag"), + "days": ("{0} dögum", "{0} daga"), + "month": ("einum mánuði", "einn mánuð"), + "months": ("{0} mánuðum", "{0} mánuði"), + "year": ("einu ári", "eitt ár"), + "years": ("{0} árum", "{0} ár"), + } + + meridians = {"am": "f.h.", "pm": "e.h.", "AM": "f.h.", "PM": "e.h."} + + month_names = [ + "", + "janúar", + "febrúar", + "mars", + "apríl", + "maí", + "júní", + "júlí", + "ágúst", + "september", + "október", + "nóvember", + "desember", + ] + month_abbreviations = [ + "", + "jan", + "feb", + "mar", + "apr", + "maí", + "jún", + "júl", + "ágú", + "sep", + "okt", + "nóv", + "des", + ] + + day_names = [ + "", + "mánudagur", + "þriðjudagur", + "miðvikudagur", + "fimmtudagur", + "föstudagur", + "laugardagur", + "sunnudagur", + ] + day_abbreviations = ["", "mán", "þri", "mið", "fim", "fös", "lau", "sun"] + + +class DanishLocale(Locale): + + names = ["da", "da_dk"] + + past = "for {0} siden" + future = "efter {0}" + and_word = "og" + + timeframes = { + "now": "lige nu", + "second": "et sekund", + "seconds": "{0} et par sekunder", + "minute": "et minut", + "minutes": "{0} minutter", + "hour": "en time", + "hours": "{0} timer", + "day": "en dag", + "days": "{0} dage", + "month": "en måned", + "months": "{0} måneder", + "year": "et år", + "years": "{0} år", + } + + month_names = [ + "", + "januar", + "februar", + "marts", + "april", + "maj", + "juni", + "juli", + "august", + "september", + "oktober", + "november", + "december", + ] + month_abbreviations = [ + "", + "jan", + "feb", + "mar", + "apr", + "maj", + "jun", + "jul", + "aug", + "sep", + "okt", + "nov", + "dec", + ] + + day_names = [ + "", + "mandag", + "tirsdag", + "onsdag", + "torsdag", + "fredag", + "lørdag", + "søndag", + ] + day_abbreviations = ["", "man", "tir", "ons", "tor", "fre", "lør", "søn"] + + +class MalayalamLocale(Locale): + + names = ["ml"] + + past = "{0} മുമ്പ്" + future = "{0} ശേഷം" + + timeframes = { + "now": "ഇപ്പോൾ", + "second": "ഒരു നിമിഷം", + "seconds": "{0} സെക്കന്റ്‌", + "minute": "ഒരു മിനിറ്റ്", + "minutes": "{0} മിനിറ്റ്", + "hour": "ഒരു മണിക്കൂർ", + "hours": "{0} മണിക്കൂർ", + "day": "ഒരു ദിവസം ", + "days": "{0} ദിവസം ", + "month": "ഒരു മാസം ", + "months": "{0} മാസം ", + "year": "ഒരു വർഷം ", + "years": "{0} വർഷം ", + } + + meridians = { + "am": "രാവിലെ", + "pm": "ഉച്ചക്ക് ശേഷം", + "AM": "രാവിലെ", + "PM": "ഉച്ചക്ക് ശേഷം", + } + + month_names = [ + "", + "ജനുവരി", + "ഫെബ്രുവരി", + "മാർച്ച്‌", + "ഏപ്രിൽ ", + "മെയ്‌ ", + "ജൂണ്‍", + "ജൂലൈ", + "ഓഗസ്റ്റ്‌", + "സെപ്റ്റംബർ", + "ഒക്ടോബർ", + "നവംബർ", + "ഡിസംബർ", + ] + month_abbreviations = [ + "", + "ജനു", + "ഫെബ് ", + "മാർ", + "ഏപ്രിൽ", + "മേയ്", + "ജൂണ്‍", + "ജൂലൈ", + "ഓഗസ്റ", + "സെപ്റ്റ", + "ഒക്ടോ", + "നവം", + "ഡിസം", + ] + + day_names = ["", "തിങ്കള്‍", "ചൊവ്വ", "ബുധന്‍", "വ്യാഴം", "വെള്ളി", "ശനി", "ഞായര്‍"] + day_abbreviations = [ + "", + "തിങ്കള്‍", + "ചൊവ്വ", + "ബുധന്‍", + "വ്യാഴം", + "വെള്ളി", + "ശനി", + "ഞായര്‍", + ] + + +class HindiLocale(Locale): + + names = ["hi"] + + past = "{0} पहले" + future = "{0} बाद" + + timeframes = { + "now": "अभी", + "second": "एक पल", + "seconds": "{0} सेकंड्", + "minute": "एक मिनट ", + "minutes": "{0} मिनट ", + "hour": "एक घंटा", + "hours": "{0} घंटे", + "day": "एक दिन", + "days": "{0} दिन", + "month": "एक माह ", + "months": "{0} महीने ", + "year": "एक वर्ष ", + "years": "{0} साल ", + } + + meridians = {"am": "सुबह", "pm": "शाम", "AM": "सुबह", "PM": "शाम"} + + month_names = [ + "", + "जनवरी", + "फरवरी", + "मार्च", + "अप्रैल ", + "मई", + "जून", + "जुलाई", + "अगस्त", + "सितंबर", + "अक्टूबर", + "नवंबर", + "दिसंबर", + ] + month_abbreviations = [ + "", + "जन", + "फ़र", + "मार्च", + "अप्रै", + "मई", + "जून", + "जुलाई", + "आग", + "सित", + "अकत", + "नवे", + "दिस", + ] + + day_names = [ + "", + "सोमवार", + "मंगलवार", + "बुधवार", + "गुरुवार", + "शुक्रवार", + "शनिवार", + "रविवार", + ] + day_abbreviations = ["", "सोम", "मंगल", "बुध", "गुरुवार", "शुक्र", "शनि", "रवि"] + + +class CzechLocale(Locale): + names = ["cs", "cs_cz"] + + timeframes = { + "now": "Teď", + "second": {"past": "vteřina", "future": "vteřina", "zero": "vteřina"}, + "seconds": {"past": "{0} sekundami", "future": ["{0} sekundy", "{0} sekund"]}, + "minute": {"past": "minutou", "future": "minutu", "zero": "{0} minut"}, + "minutes": {"past": "{0} minutami", "future": ["{0} minuty", "{0} minut"]}, + "hour": {"past": "hodinou", "future": "hodinu", "zero": "{0} hodin"}, + "hours": {"past": "{0} hodinami", "future": ["{0} hodiny", "{0} hodin"]}, + "day": {"past": "dnem", "future": "den", "zero": "{0} dnů"}, + "days": {"past": "{0} dny", "future": ["{0} dny", "{0} dnů"]}, + "week": {"past": "týdnem", "future": "týden", "zero": "{0} týdnů"}, + "weeks": {"past": "{0} týdny", "future": ["{0} týdny", "{0} týdnů"]}, + "month": {"past": "měsícem", "future": "měsíc", "zero": "{0} měsíců"}, + "months": {"past": "{0} měsíci", "future": ["{0} měsíce", "{0} měsíců"]}, + "year": {"past": "rokem", "future": "rok", "zero": "{0} let"}, + "years": {"past": "{0} lety", "future": ["{0} roky", "{0} let"]}, + } + + past = "Před {0}" + future = "Za {0}" + + month_names = [ + "", + "leden", + "únor", + "březen", + "duben", + "květen", + "červen", + "červenec", + "srpen", + "září", + "říjen", + "listopad", + "prosinec", + ] + month_abbreviations = [ + "", + "led", + "úno", + "bře", + "dub", + "kvě", + "čvn", + "čvc", + "srp", + "zář", + "říj", + "lis", + "pro", + ] + + day_names = [ + "", + "pondělí", + "úterý", + "středa", + "čtvrtek", + "pátek", + "sobota", + "neděle", + ] + day_abbreviations = ["", "po", "út", "st", "čt", "pá", "so", "ne"] + + def _format_timeframe(self, timeframe, delta): + """Czech aware time frame format function, takes into account + the differences between past and future forms.""" + form = self.timeframes[timeframe] + if isinstance(form, dict): + if delta == 0: + form = form["zero"] # And *never* use 0 in the singular! + elif delta > 0: + form = form["future"] + else: + form = form["past"] + delta = abs(delta) + + if isinstance(form, list): + if 2 <= delta % 10 <= 4 and (delta % 100 < 10 or delta % 100 >= 20): + form = form[0] + else: + form = form[1] + + return form.format(delta) + + +class SlovakLocale(Locale): + names = ["sk", "sk_sk"] + + timeframes = { + "now": "Teraz", + "second": {"past": "sekundou", "future": "sekundu", "zero": "{0} sekúnd"}, + "seconds": {"past": "{0} sekundami", "future": ["{0} sekundy", "{0} sekúnd"]}, + "minute": {"past": "minútou", "future": "minútu", "zero": "{0} minút"}, + "minutes": {"past": "{0} minútami", "future": ["{0} minúty", "{0} minút"]}, + "hour": {"past": "hodinou", "future": "hodinu", "zero": "{0} hodín"}, + "hours": {"past": "{0} hodinami", "future": ["{0} hodiny", "{0} hodín"]}, + "day": {"past": "dňom", "future": "deň", "zero": "{0} dní"}, + "days": {"past": "{0} dňami", "future": ["{0} dni", "{0} dní"]}, + "week": {"past": "týždňom", "future": "týždeň", "zero": "{0} týždňov"}, + "weeks": {"past": "{0} týždňami", "future": ["{0} týždne", "{0} týždňov"]}, + "month": {"past": "mesiacom", "future": "mesiac", "zero": "{0} mesiacov"}, + "months": {"past": "{0} mesiacmi", "future": ["{0} mesiace", "{0} mesiacov"]}, + "year": {"past": "rokom", "future": "rok", "zero": "{0} rokov"}, + "years": {"past": "{0} rokmi", "future": ["{0} roky", "{0} rokov"]}, + } + + past = "Pred {0}" + future = "O {0}" + and_word = "a" + + month_names = [ + "", + "január", + "február", + "marec", + "apríl", + "máj", + "jún", + "júl", + "august", + "september", + "október", + "november", + "december", + ] + month_abbreviations = [ + "", + "jan", + "feb", + "mar", + "apr", + "máj", + "jún", + "júl", + "aug", + "sep", + "okt", + "nov", + "dec", + ] + + day_names = [ + "", + "pondelok", + "utorok", + "streda", + "štvrtok", + "piatok", + "sobota", + "nedeľa", + ] + day_abbreviations = ["", "po", "ut", "st", "št", "pi", "so", "ne"] + + def _format_timeframe(self, timeframe, delta): + """Slovak aware time frame format function, takes into account + the differences between past and future forms.""" + form = self.timeframes[timeframe] + if isinstance(form, dict): + if delta == 0: + form = form["zero"] # And *never* use 0 in the singular! + elif delta > 0: + form = form["future"] + else: + form = form["past"] + delta = abs(delta) + + if isinstance(form, list): + if 2 <= delta % 10 <= 4 and (delta % 100 < 10 or delta % 100 >= 20): + form = form[0] + else: + form = form[1] + + return form.format(delta) + + +class FarsiLocale(Locale): + + names = ["fa", "fa_ir"] + + past = "{0} قبل" + future = "در {0}" + + timeframes = { + "now": "اکنون", + "second": "یک لحظه", + "seconds": "{0} ثانیه", + "minute": "یک دقیقه", + "minutes": "{0} دقیقه", + "hour": "یک ساعت", + "hours": "{0} ساعت", + "day": "یک روز", + "days": "{0} روز", + "month": "یک ماه", + "months": "{0} ماه", + "year": "یک سال", + "years": "{0} سال", + } + + meridians = { + "am": "قبل از ظهر", + "pm": "بعد از ظهر", + "AM": "قبل از ظهر", + "PM": "بعد از ظهر", + } + + month_names = [ + "", + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ] + month_abbreviations = [ + "", + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ] + + day_names = [ + "", + "دو شنبه", + "سه شنبه", + "چهارشنبه", + "پنجشنبه", + "جمعه", + "شنبه", + "یکشنبه", + ] + day_abbreviations = ["", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + + +class HebrewLocale(Locale): + + names = ["he", "he_IL"] + + past = "לפני {0}" + future = "בעוד {0}" + and_word = "ו" + + timeframes = { + "now": "הרגע", + "second": "שנייה", + "seconds": "{0} שניות", + "minute": "דקה", + "minutes": "{0} דקות", + "hour": "שעה", + "hours": "{0} שעות", + "2-hours": "שעתיים", + "day": "יום", + "days": "{0} ימים", + "2-days": "יומיים", + "week": "שבוע", + "weeks": "{0} שבועות", + "2-weeks": "שבועיים", + "month": "חודש", + "months": "{0} חודשים", + "2-months": "חודשיים", + "year": "שנה", + "years": "{0} שנים", + "2-years": "שנתיים", + } + + meridians = { + "am": 'לפנ"צ', + "pm": 'אחר"צ', + "AM": "לפני הצהריים", + "PM": "אחרי הצהריים", + } + + month_names = [ + "", + "ינואר", + "פברואר", + "מרץ", + "אפריל", + "מאי", + "יוני", + "יולי", + "אוגוסט", + "ספטמבר", + "אוקטובר", + "נובמבר", + "דצמבר", + ] + month_abbreviations = [ + "", + "ינו׳", + "פבר׳", + "מרץ", + "אפר׳", + "מאי", + "יוני", + "יולי", + "אוג׳", + "ספט׳", + "אוק׳", + "נוב׳", + "דצמ׳", + ] + + day_names = ["", "שני", "שלישי", "רביעי", "חמישי", "שישי", "שבת", "ראשון"] + day_abbreviations = ["", "ב׳", "ג׳", "ד׳", "ה׳", "ו׳", "ש׳", "א׳"] + + def _format_timeframe(self, timeframe, delta): + """Hebrew couple of aware""" + couple = "2-{}".format(timeframe) + single = timeframe.rstrip("s") + if abs(delta) == 2 and couple in self.timeframes: + key = couple + elif abs(delta) == 1 and single in self.timeframes: + key = single + else: + key = timeframe + + return self.timeframes[key].format(trunc(abs(delta))) + + def describe_multi(self, timeframes, only_distance=False): + """Describes a delta within multiple timeframes in plain language. + In Hebrew, the and word behaves a bit differently. + + :param timeframes: a list of string, quantity pairs each representing a timeframe and delta. + :param only_distance: return only distance eg: "2 hours and 11 seconds" without "in" or "ago" keywords + """ + + humanized = "" + for index, (timeframe, delta) in enumerate(timeframes): + last_humanized = self._format_timeframe(timeframe, delta) + if index == 0: + humanized = last_humanized + elif index == len(timeframes) - 1: # Must have at least 2 items + humanized += " " + self.and_word + if last_humanized[0].isdecimal(): + humanized += "־" + humanized += last_humanized + else: # Don't add for the last one + humanized += ", " + last_humanized + + if not only_distance: + humanized = self._format_relative(humanized, timeframe, delta) + + return humanized + + +class MarathiLocale(Locale): + + names = ["mr"] + + past = "{0} आधी" + future = "{0} नंतर" + + timeframes = { + "now": "सद्य", + "second": "एक सेकंद", + "seconds": "{0} सेकंद", + "minute": "एक मिनिट ", + "minutes": "{0} मिनिट ", + "hour": "एक तास", + "hours": "{0} तास", + "day": "एक दिवस", + "days": "{0} दिवस", + "month": "एक महिना ", + "months": "{0} महिने ", + "year": "एक वर्ष ", + "years": "{0} वर्ष ", + } + + meridians = {"am": "सकाळ", "pm": "संध्याकाळ", "AM": "सकाळ", "PM": "संध्याकाळ"} + + month_names = [ + "", + "जानेवारी", + "फेब्रुवारी", + "मार्च", + "एप्रिल", + "मे", + "जून", + "जुलै", + "अॉगस्ट", + "सप्टेंबर", + "अॉक्टोबर", + "नोव्हेंबर", + "डिसेंबर", + ] + month_abbreviations = [ + "", + "जान", + "फेब्रु", + "मार्च", + "एप्रि", + "मे", + "जून", + "जुलै", + "अॉग", + "सप्टें", + "अॉक्टो", + "नोव्हें", + "डिसें", + ] + + day_names = [ + "", + "सोमवार", + "मंगळवार", + "बुधवार", + "गुरुवार", + "शुक्रवार", + "शनिवार", + "रविवार", + ] + day_abbreviations = ["", "सोम", "मंगळ", "बुध", "गुरु", "शुक्र", "शनि", "रवि"] + + +def _map_locales(): + + locales = {} + + for _, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass): + if issubclass(cls, Locale): # pragma: no branch + for name in cls.names: + locales[name.lower()] = cls + + return locales + + +class CatalanLocale(Locale): + names = ["ca", "ca_es", "ca_ad", "ca_fr", "ca_it"] + past = "Fa {0}" + future = "En {0}" + and_word = "i" + + timeframes = { + "now": "Ara mateix", + "second": "un segon", + "seconds": "{0} segons", + "minute": "1 minut", + "minutes": "{0} minuts", + "hour": "una hora", + "hours": "{0} hores", + "day": "un dia", + "days": "{0} dies", + "month": "un mes", + "months": "{0} mesos", + "year": "un any", + "years": "{0} anys", + } + + month_names = [ + "", + "gener", + "febrer", + "març", + "abril", + "maig", + "juny", + "juliol", + "agost", + "setembre", + "octubre", + "novembre", + "desembre", + ] + month_abbreviations = [ + "", + "gen.", + "febr.", + "març", + "abr.", + "maig", + "juny", + "jul.", + "ag.", + "set.", + "oct.", + "nov.", + "des.", + ] + day_names = [ + "", + "dilluns", + "dimarts", + "dimecres", + "dijous", + "divendres", + "dissabte", + "diumenge", + ] + day_abbreviations = [ + "", + "dl.", + "dt.", + "dc.", + "dj.", + "dv.", + "ds.", + "dg.", + ] + + +class BasqueLocale(Locale): + names = ["eu", "eu_eu"] + past = "duela {0}" + future = "{0}" # I don't know what's the right phrase in Basque for the future. + + timeframes = { + "now": "Orain", + "second": "segundo bat", + "seconds": "{0} segundu", + "minute": "minutu bat", + "minutes": "{0} minutu", + "hour": "ordu bat", + "hours": "{0} ordu", + "day": "egun bat", + "days": "{0} egun", + "month": "hilabete bat", + "months": "{0} hilabet", + "year": "urte bat", + "years": "{0} urte", + } + + month_names = [ + "", + "urtarrilak", + "otsailak", + "martxoak", + "apirilak", + "maiatzak", + "ekainak", + "uztailak", + "abuztuak", + "irailak", + "urriak", + "azaroak", + "abenduak", + ] + month_abbreviations = [ + "", + "urt", + "ots", + "mar", + "api", + "mai", + "eka", + "uzt", + "abu", + "ira", + "urr", + "aza", + "abe", + ] + day_names = [ + "", + "astelehena", + "asteartea", + "asteazkena", + "osteguna", + "ostirala", + "larunbata", + "igandea", + ] + day_abbreviations = ["", "al", "ar", "az", "og", "ol", "lr", "ig"] + + +class HungarianLocale(Locale): + + names = ["hu", "hu_hu"] + + past = "{0} ezelőtt" + future = "{0} múlva" + + timeframes = { + "now": "éppen most", + "second": {"past": "egy második", "future": "egy második"}, + "seconds": {"past": "{0} másodpercekkel", "future": "{0} pár másodperc"}, + "minute": {"past": "egy perccel", "future": "egy perc"}, + "minutes": {"past": "{0} perccel", "future": "{0} perc"}, + "hour": {"past": "egy órával", "future": "egy óra"}, + "hours": {"past": "{0} órával", "future": "{0} óra"}, + "day": {"past": "egy nappal", "future": "egy nap"}, + "days": {"past": "{0} nappal", "future": "{0} nap"}, + "month": {"past": "egy hónappal", "future": "egy hónap"}, + "months": {"past": "{0} hónappal", "future": "{0} hónap"}, + "year": {"past": "egy évvel", "future": "egy év"}, + "years": {"past": "{0} évvel", "future": "{0} év"}, + } + + month_names = [ + "", + "január", + "február", + "március", + "április", + "május", + "június", + "július", + "augusztus", + "szeptember", + "október", + "november", + "december", + ] + month_abbreviations = [ + "", + "jan", + "febr", + "márc", + "ápr", + "máj", + "jún", + "júl", + "aug", + "szept", + "okt", + "nov", + "dec", + ] + + day_names = [ + "", + "hétfő", + "kedd", + "szerda", + "csütörtök", + "péntek", + "szombat", + "vasárnap", + ] + day_abbreviations = ["", "hét", "kedd", "szer", "csüt", "pént", "szom", "vas"] + + meridians = {"am": "de", "pm": "du", "AM": "DE", "PM": "DU"} + + def _format_timeframe(self, timeframe, delta): + form = self.timeframes[timeframe] + + if isinstance(form, dict): + if delta > 0: + form = form["future"] + else: + form = form["past"] + + return form.format(abs(delta)) + + +class EsperantoLocale(Locale): + names = ["eo", "eo_xx"] + past = "antaŭ {0}" + future = "post {0}" + + timeframes = { + "now": "nun", + "second": "sekundo", + "seconds": "{0} kelkaj sekundoj", + "minute": "unu minuto", + "minutes": "{0} minutoj", + "hour": "un horo", + "hours": "{0} horoj", + "day": "unu tago", + "days": "{0} tagoj", + "month": "unu monato", + "months": "{0} monatoj", + "year": "unu jaro", + "years": "{0} jaroj", + } + + month_names = [ + "", + "januaro", + "februaro", + "marto", + "aprilo", + "majo", + "junio", + "julio", + "aŭgusto", + "septembro", + "oktobro", + "novembro", + "decembro", + ] + month_abbreviations = [ + "", + "jan", + "feb", + "mar", + "apr", + "maj", + "jun", + "jul", + "aŭg", + "sep", + "okt", + "nov", + "dec", + ] + + day_names = [ + "", + "lundo", + "mardo", + "merkredo", + "ĵaŭdo", + "vendredo", + "sabato", + "dimanĉo", + ] + day_abbreviations = ["", "lun", "mar", "mer", "ĵaŭ", "ven", "sab", "dim"] + + meridians = {"am": "atm", "pm": "ptm", "AM": "ATM", "PM": "PTM"} + + ordinal_day_re = r"((?P[1-3]?[0-9](?=a))a)" + + def _ordinal_number(self, n): + return "{}a".format(n) + + +class ThaiLocale(Locale): + + names = ["th", "th_th"] + + past = "{0}{1}ที่ผ่านมา" + future = "ในอีก{1}{0}" + + timeframes = { + "now": "ขณะนี้", + "second": "วินาที", + "seconds": "{0} ไม่กี่วินาที", + "minute": "1 นาที", + "minutes": "{0} นาที", + "hour": "1 ชั่วโมง", + "hours": "{0} ชั่วโมง", + "day": "1 วัน", + "days": "{0} วัน", + "month": "1 เดือน", + "months": "{0} เดือน", + "year": "1 ปี", + "years": "{0} ปี", + } + + month_names = [ + "", + "มกราคม", + "กุมภาพันธ์", + "มีนาคม", + "เมษายน", + "พฤษภาคม", + "มิถุนายน", + "กรกฎาคม", + "สิงหาคม", + "กันยายน", + "ตุลาคม", + "พฤศจิกายน", + "ธันวาคม", + ] + month_abbreviations = [ + "", + "ม.ค.", + "ก.พ.", + "มี.ค.", + "เม.ย.", + "พ.ค.", + "มิ.ย.", + "ก.ค.", + "ส.ค.", + "ก.ย.", + "ต.ค.", + "พ.ย.", + "ธ.ค.", + ] + + day_names = ["", "จันทร์", "อังคาร", "พุธ", "พฤหัสบดี", "ศุกร์", "เสาร์", "อาทิตย์"] + day_abbreviations = ["", "จ", "อ", "พ", "พฤ", "ศ", "ส", "อา"] + + meridians = {"am": "am", "pm": "pm", "AM": "AM", "PM": "PM"} + + BE_OFFSET = 543 + + def year_full(self, year): + """Thai always use Buddhist Era (BE) which is CE + 543""" + year += self.BE_OFFSET + return "{:04d}".format(year) + + def year_abbreviation(self, year): + """Thai always use Buddhist Era (BE) which is CE + 543""" + year += self.BE_OFFSET + return "{:04d}".format(year)[2:] + + def _format_relative(self, humanized, timeframe, delta): + """Thai normally doesn't have any space between words""" + if timeframe == "now": + return humanized + space = "" if timeframe == "seconds" else " " + direction = self.past if delta < 0 else self.future + + return direction.format(humanized, space) + + +class BengaliLocale(Locale): + + names = ["bn", "bn_bd", "bn_in"] + + past = "{0} আগে" + future = "{0} পরে" + + timeframes = { + "now": "এখন", + "second": "একটি দ্বিতীয়", + "seconds": "{0} সেকেন্ড", + "minute": "এক মিনিট", + "minutes": "{0} মিনিট", + "hour": "এক ঘণ্টা", + "hours": "{0} ঘণ্টা", + "day": "এক দিন", + "days": "{0} দিন", + "month": "এক মাস", + "months": "{0} মাস ", + "year": "এক বছর", + "years": "{0} বছর", + } + + meridians = {"am": "সকাল", "pm": "বিকাল", "AM": "সকাল", "PM": "বিকাল"} + + month_names = [ + "", + "জানুয়ারি", + "ফেব্রুয়ারি", + "মার্চ", + "এপ্রিল", + "মে", + "জুন", + "জুলাই", + "আগস্ট", + "সেপ্টেম্বর", + "অক্টোবর", + "নভেম্বর", + "ডিসেম্বর", + ] + month_abbreviations = [ + "", + "জানু", + "ফেব", + "মার্চ", + "এপ্রি", + "মে", + "জুন", + "জুল", + "অগা", + "সেপ্ট", + "অক্টো", + "নভে", + "ডিসে", + ] + + day_names = [ + "", + "সোমবার", + "মঙ্গলবার", + "বুধবার", + "বৃহস্পতিবার", + "শুক্রবার", + "শনিবার", + "রবিবার", + ] + day_abbreviations = ["", "সোম", "মঙ্গল", "বুধ", "বৃহঃ", "শুক্র", "শনি", "রবি"] + + def _ordinal_number(self, n): + if n > 10 or n == 0: + return "{}তম".format(n) + if n in [1, 5, 7, 8, 9, 10]: + return "{}ম".format(n) + if n in [2, 3]: + return "{}য়".format(n) + if n == 4: + return "{}র্থ".format(n) + if n == 6: + return "{}ষ্ঠ".format(n) + + +class RomanshLocale(Locale): + + names = ["rm", "rm_ch"] + + past = "avant {0}" + future = "en {0}" + + timeframes = { + "now": "en quest mument", + "second": "in secunda", + "seconds": "{0} secundas", + "minute": "ina minuta", + "minutes": "{0} minutas", + "hour": "in'ura", + "hours": "{0} ura", + "day": "in di", + "days": "{0} dis", + "month": "in mais", + "months": "{0} mais", + "year": "in onn", + "years": "{0} onns", + } + + month_names = [ + "", + "schaner", + "favrer", + "mars", + "avrigl", + "matg", + "zercladur", + "fanadur", + "avust", + "settember", + "october", + "november", + "december", + ] + + month_abbreviations = [ + "", + "schan", + "fav", + "mars", + "avr", + "matg", + "zer", + "fan", + "avu", + "set", + "oct", + "nov", + "dec", + ] + + day_names = [ + "", + "glindesdi", + "mardi", + "mesemna", + "gievgia", + "venderdi", + "sonda", + "dumengia", + ] + + day_abbreviations = ["", "gli", "ma", "me", "gie", "ve", "so", "du"] + + +class RomanianLocale(Locale): + names = ["ro", "ro_ro"] + + past = "{0} în urmă" + future = "peste {0}" + and_word = "și" + + timeframes = { + "now": "acum", + "second": "o secunda", + "seconds": "{0} câteva secunde", + "minute": "un minut", + "minutes": "{0} minute", + "hour": "o oră", + "hours": "{0} ore", + "day": "o zi", + "days": "{0} zile", + "month": "o lună", + "months": "{0} luni", + "year": "un an", + "years": "{0} ani", + } + + month_names = [ + "", + "ianuarie", + "februarie", + "martie", + "aprilie", + "mai", + "iunie", + "iulie", + "august", + "septembrie", + "octombrie", + "noiembrie", + "decembrie", + ] + month_abbreviations = [ + "", + "ian", + "febr", + "mart", + "apr", + "mai", + "iun", + "iul", + "aug", + "sept", + "oct", + "nov", + "dec", + ] + + day_names = [ + "", + "luni", + "marți", + "miercuri", + "joi", + "vineri", + "sâmbătă", + "duminică", + ] + day_abbreviations = ["", "Lun", "Mar", "Mie", "Joi", "Vin", "Sâm", "Dum"] + + +class SlovenianLocale(Locale): + names = ["sl", "sl_si"] + + past = "pred {0}" + future = "čez {0}" + and_word = "in" + + timeframes = { + "now": "zdaj", + "second": "sekundo", + "seconds": "{0} sekund", + "minute": "minuta", + "minutes": "{0} minutami", + "hour": "uro", + "hours": "{0} ur", + "day": "dan", + "days": "{0} dni", + "month": "mesec", + "months": "{0} mesecev", + "year": "leto", + "years": "{0} let", + } + + meridians = {"am": "", "pm": "", "AM": "", "PM": ""} + + month_names = [ + "", + "Januar", + "Februar", + "Marec", + "April", + "Maj", + "Junij", + "Julij", + "Avgust", + "September", + "Oktober", + "November", + "December", + ] + + month_abbreviations = [ + "", + "Jan", + "Feb", + "Mar", + "Apr", + "Maj", + "Jun", + "Jul", + "Avg", + "Sep", + "Okt", + "Nov", + "Dec", + ] + + day_names = [ + "", + "Ponedeljek", + "Torek", + "Sreda", + "Četrtek", + "Petek", + "Sobota", + "Nedelja", + ] + + day_abbreviations = ["", "Pon", "Tor", "Sre", "Čet", "Pet", "Sob", "Ned"] + + +class IndonesianLocale(Locale): + + names = ["id", "id_id"] + + past = "{0} yang lalu" + future = "dalam {0}" + and_word = "dan" + + timeframes = { + "now": "baru saja", + "second": "1 sebentar", + "seconds": "{0} detik", + "minute": "1 menit", + "minutes": "{0} menit", + "hour": "1 jam", + "hours": "{0} jam", + "day": "1 hari", + "days": "{0} hari", + "month": "1 bulan", + "months": "{0} bulan", + "year": "1 tahun", + "years": "{0} tahun", + } + + meridians = {"am": "", "pm": "", "AM": "", "PM": ""} + + month_names = [ + "", + "Januari", + "Februari", + "Maret", + "April", + "Mei", + "Juni", + "Juli", + "Agustus", + "September", + "Oktober", + "November", + "Desember", + ] + + month_abbreviations = [ + "", + "Jan", + "Feb", + "Mar", + "Apr", + "Mei", + "Jun", + "Jul", + "Ags", + "Sept", + "Okt", + "Nov", + "Des", + ] + + day_names = ["", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu", "Minggu"] + + day_abbreviations = [ + "", + "Senin", + "Selasa", + "Rabu", + "Kamis", + "Jumat", + "Sabtu", + "Minggu", + ] + + +class NepaliLocale(Locale): + names = ["ne", "ne_np"] + + past = "{0} पहिले" + future = "{0} पछी" + + timeframes = { + "now": "अहिले", + "second": "एक सेकेन्ड", + "seconds": "{0} सेकण्ड", + "minute": "मिनेट", + "minutes": "{0} मिनेट", + "hour": "एक घण्टा", + "hours": "{0} घण्टा", + "day": "एक दिन", + "days": "{0} दिन", + "month": "एक महिना", + "months": "{0} महिना", + "year": "एक बर्ष", + "years": "बर्ष", + } + + meridians = {"am": "पूर्वाह्न", "pm": "अपरान्ह", "AM": "पूर्वाह्न", "PM": "अपरान्ह"} + + month_names = [ + "", + "जनवरी", + "फेब्रुअरी", + "मार्च", + "एप्रील", + "मे", + "जुन", + "जुलाई", + "अगष्ट", + "सेप्टेम्बर", + "अक्टोबर", + "नोवेम्बर", + "डिसेम्बर", + ] + month_abbreviations = [ + "", + "जन", + "फेब", + "मार्च", + "एप्रील", + "मे", + "जुन", + "जुलाई", + "अग", + "सेप", + "अक्ट", + "नोव", + "डिस", + ] + + day_names = [ + "", + "सोमवार", + "मंगलवार", + "बुधवार", + "बिहिवार", + "शुक्रवार", + "शनिवार", + "आइतवार", + ] + + day_abbreviations = ["", "सोम", "मंगल", "बुध", "बिहि", "शुक्र", "शनि", "आइत"] + + +class EstonianLocale(Locale): + names = ["ee", "et"] + + past = "{0} tagasi" + future = "{0} pärast" + and_word = "ja" + + timeframes = { + "now": {"past": "just nüüd", "future": "just nüüd"}, + "second": {"past": "üks sekund", "future": "ühe sekundi"}, + "seconds": {"past": "{0} sekundit", "future": "{0} sekundi"}, + "minute": {"past": "üks minut", "future": "ühe minuti"}, + "minutes": {"past": "{0} minutit", "future": "{0} minuti"}, + "hour": {"past": "tund aega", "future": "tunni aja"}, + "hours": {"past": "{0} tundi", "future": "{0} tunni"}, + "day": {"past": "üks päev", "future": "ühe päeva"}, + "days": {"past": "{0} päeva", "future": "{0} päeva"}, + "month": {"past": "üks kuu", "future": "ühe kuu"}, + "months": {"past": "{0} kuud", "future": "{0} kuu"}, + "year": {"past": "üks aasta", "future": "ühe aasta"}, + "years": {"past": "{0} aastat", "future": "{0} aasta"}, + } + + month_names = [ + "", + "Jaanuar", + "Veebruar", + "Märts", + "Aprill", + "Mai", + "Juuni", + "Juuli", + "August", + "September", + "Oktoober", + "November", + "Detsember", + ] + month_abbreviations = [ + "", + "Jan", + "Veb", + "Mär", + "Apr", + "Mai", + "Jun", + "Jul", + "Aug", + "Sep", + "Okt", + "Nov", + "Dets", + ] + + day_names = [ + "", + "Esmaspäev", + "Teisipäev", + "Kolmapäev", + "Neljapäev", + "Reede", + "Laupäev", + "Pühapäev", + ] + day_abbreviations = ["", "Esm", "Teis", "Kolm", "Nelj", "Re", "Lau", "Püh"] + + def _format_timeframe(self, timeframe, delta): + form = self.timeframes[timeframe] + if delta > 0: + form = form["future"] + else: + form = form["past"] + return form.format(abs(delta)) + + +class SwahiliLocale(Locale): + + names = [ + "sw", + "sw_ke", + "sw_tz", + ] + + past = "{0} iliyopita" + future = "muda wa {0}" + and_word = "na" + + timeframes = { + "now": "sasa hivi", + "second": "sekunde", + "seconds": "sekunde {0}", + "minute": "dakika moja", + "minutes": "dakika {0}", + "hour": "saa moja", + "hours": "saa {0}", + "day": "siku moja", + "days": "siku {0}", + "week": "wiki moja", + "weeks": "wiki {0}", + "month": "mwezi moja", + "months": "miezi {0}", + "year": "mwaka moja", + "years": "miaka {0}", + } + + meridians = {"am": "asu", "pm": "mch", "AM": "ASU", "PM": "MCH"} + + month_names = [ + "", + "Januari", + "Februari", + "Machi", + "Aprili", + "Mei", + "Juni", + "Julai", + "Agosti", + "Septemba", + "Oktoba", + "Novemba", + "Desemba", + ] + month_abbreviations = [ + "", + "Jan", + "Feb", + "Mac", + "Apr", + "Mei", + "Jun", + "Jul", + "Ago", + "Sep", + "Okt", + "Nov", + "Des", + ] + + day_names = [ + "", + "Jumatatu", + "Jumanne", + "Jumatano", + "Alhamisi", + "Ijumaa", + "Jumamosi", + "Jumapili", + ] + day_abbreviations = [ + "", + "Jumatatu", + "Jumanne", + "Jumatano", + "Alhamisi", + "Ijumaa", + "Jumamosi", + "Jumapili", + ] + + +_locales = _map_locales() diff --git a/openpype/modules/ftrack/python2_vendor/arrow/arrow/parser.py b/openpype/modules/ftrack/python2_vendor/arrow/arrow/parser.py new file mode 100644 index 0000000000..243fd1721c --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/arrow/parser.py @@ -0,0 +1,596 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +import re +from datetime import datetime, timedelta + +from dateutil import tz + +from arrow import locales +from arrow.util import iso_to_gregorian, next_weekday, normalize_timestamp + +try: + from functools import lru_cache +except ImportError: # pragma: no cover + from backports.functools_lru_cache import lru_cache # pragma: no cover + + +class ParserError(ValueError): + pass + + +# Allows for ParserErrors to be propagated from _build_datetime() +# when day_of_year errors occur. +# Before this, the ParserErrors were caught by the try/except in +# _parse_multiformat() and the appropriate error message was not +# transmitted to the user. +class ParserMatchError(ParserError): + pass + + +class DateTimeParser(object): + + _FORMAT_RE = re.compile( + r"(YYY?Y?|MM?M?M?|Do|DD?D?D?|d?d?d?d|HH?|hh?|mm?|ss?|S+|ZZ?Z?|a|A|x|X|W)" + ) + _ESCAPE_RE = re.compile(r"\[[^\[\]]*\]") + + _ONE_OR_TWO_DIGIT_RE = re.compile(r"\d{1,2}") + _ONE_OR_TWO_OR_THREE_DIGIT_RE = re.compile(r"\d{1,3}") + _ONE_OR_MORE_DIGIT_RE = re.compile(r"\d+") + _TWO_DIGIT_RE = re.compile(r"\d{2}") + _THREE_DIGIT_RE = re.compile(r"\d{3}") + _FOUR_DIGIT_RE = re.compile(r"\d{4}") + _TZ_Z_RE = re.compile(r"([\+\-])(\d{2})(?:(\d{2}))?|Z") + _TZ_ZZ_RE = re.compile(r"([\+\-])(\d{2})(?:\:(\d{2}))?|Z") + _TZ_NAME_RE = re.compile(r"\w[\w+\-/]+") + # NOTE: timestamps cannot be parsed from natural language strings (by removing the ^...$) because it will + # break cases like "15 Jul 2000" and a format list (see issue #447) + _TIMESTAMP_RE = re.compile(r"^\-?\d+\.?\d+$") + _TIMESTAMP_EXPANDED_RE = re.compile(r"^\-?\d+$") + _TIME_RE = re.compile(r"^(\d{2})(?:\:?(\d{2}))?(?:\:?(\d{2}))?(?:([\.\,])(\d+))?$") + _WEEK_DATE_RE = re.compile(r"(?P\d{4})[\-]?W(?P\d{2})[\-]?(?P\d)?") + + _BASE_INPUT_RE_MAP = { + "YYYY": _FOUR_DIGIT_RE, + "YY": _TWO_DIGIT_RE, + "MM": _TWO_DIGIT_RE, + "M": _ONE_OR_TWO_DIGIT_RE, + "DDDD": _THREE_DIGIT_RE, + "DDD": _ONE_OR_TWO_OR_THREE_DIGIT_RE, + "DD": _TWO_DIGIT_RE, + "D": _ONE_OR_TWO_DIGIT_RE, + "HH": _TWO_DIGIT_RE, + "H": _ONE_OR_TWO_DIGIT_RE, + "hh": _TWO_DIGIT_RE, + "h": _ONE_OR_TWO_DIGIT_RE, + "mm": _TWO_DIGIT_RE, + "m": _ONE_OR_TWO_DIGIT_RE, + "ss": _TWO_DIGIT_RE, + "s": _ONE_OR_TWO_DIGIT_RE, + "X": _TIMESTAMP_RE, + "x": _TIMESTAMP_EXPANDED_RE, + "ZZZ": _TZ_NAME_RE, + "ZZ": _TZ_ZZ_RE, + "Z": _TZ_Z_RE, + "S": _ONE_OR_MORE_DIGIT_RE, + "W": _WEEK_DATE_RE, + } + + SEPARATORS = ["-", "/", "."] + + def __init__(self, locale="en_us", cache_size=0): + + self.locale = locales.get_locale(locale) + self._input_re_map = self._BASE_INPUT_RE_MAP.copy() + self._input_re_map.update( + { + "MMMM": self._generate_choice_re( + self.locale.month_names[1:], re.IGNORECASE + ), + "MMM": self._generate_choice_re( + self.locale.month_abbreviations[1:], re.IGNORECASE + ), + "Do": re.compile(self.locale.ordinal_day_re), + "dddd": self._generate_choice_re( + self.locale.day_names[1:], re.IGNORECASE + ), + "ddd": self._generate_choice_re( + self.locale.day_abbreviations[1:], re.IGNORECASE + ), + "d": re.compile(r"[1-7]"), + "a": self._generate_choice_re( + (self.locale.meridians["am"], self.locale.meridians["pm"]) + ), + # note: 'A' token accepts both 'am/pm' and 'AM/PM' formats to + # ensure backwards compatibility of this token + "A": self._generate_choice_re(self.locale.meridians.values()), + } + ) + if cache_size > 0: + self._generate_pattern_re = lru_cache(maxsize=cache_size)( + self._generate_pattern_re + ) + + # TODO: since we support more than ISO 8601, we should rename this function + # IDEA: break into multiple functions + def parse_iso(self, datetime_string, normalize_whitespace=False): + + if normalize_whitespace: + datetime_string = re.sub(r"\s+", " ", datetime_string.strip()) + + has_space_divider = " " in datetime_string + has_t_divider = "T" in datetime_string + + num_spaces = datetime_string.count(" ") + if has_space_divider and num_spaces != 1 or has_t_divider and num_spaces > 0: + raise ParserError( + "Expected an ISO 8601-like string, but was given '{}'. Try passing in a format string to resolve this.".format( + datetime_string + ) + ) + + has_time = has_space_divider or has_t_divider + has_tz = False + + # date formats (ISO 8601 and others) to test against + # NOTE: YYYYMM is omitted to avoid confusion with YYMMDD (no longer part of ISO 8601, but is still often used) + formats = [ + "YYYY-MM-DD", + "YYYY-M-DD", + "YYYY-M-D", + "YYYY/MM/DD", + "YYYY/M/DD", + "YYYY/M/D", + "YYYY.MM.DD", + "YYYY.M.DD", + "YYYY.M.D", + "YYYYMMDD", + "YYYY-DDDD", + "YYYYDDDD", + "YYYY-MM", + "YYYY/MM", + "YYYY.MM", + "YYYY", + "W", + ] + + if has_time: + + if has_space_divider: + date_string, time_string = datetime_string.split(" ", 1) + else: + date_string, time_string = datetime_string.split("T", 1) + + time_parts = re.split(r"[\+\-Z]", time_string, 1, re.IGNORECASE) + + time_components = self._TIME_RE.match(time_parts[0]) + + if time_components is None: + raise ParserError( + "Invalid time component provided. Please specify a format or provide a valid time component in the basic or extended ISO 8601 time format." + ) + + ( + hours, + minutes, + seconds, + subseconds_sep, + subseconds, + ) = time_components.groups() + + has_tz = len(time_parts) == 2 + has_minutes = minutes is not None + has_seconds = seconds is not None + has_subseconds = subseconds is not None + + is_basic_time_format = ":" not in time_parts[0] + tz_format = "Z" + + # use 'ZZ' token instead since tz offset is present in non-basic format + if has_tz and ":" in time_parts[1]: + tz_format = "ZZ" + + time_sep = "" if is_basic_time_format else ":" + + if has_subseconds: + time_string = "HH{time_sep}mm{time_sep}ss{subseconds_sep}S".format( + time_sep=time_sep, subseconds_sep=subseconds_sep + ) + elif has_seconds: + time_string = "HH{time_sep}mm{time_sep}ss".format(time_sep=time_sep) + elif has_minutes: + time_string = "HH{time_sep}mm".format(time_sep=time_sep) + else: + time_string = "HH" + + if has_space_divider: + formats = ["{} {}".format(f, time_string) for f in formats] + else: + formats = ["{}T{}".format(f, time_string) for f in formats] + + if has_time and has_tz: + # Add "Z" or "ZZ" to the format strings to indicate to + # _parse_token() that a timezone needs to be parsed + formats = ["{}{}".format(f, tz_format) for f in formats] + + return self._parse_multiformat(datetime_string, formats) + + def parse(self, datetime_string, fmt, normalize_whitespace=False): + + if normalize_whitespace: + datetime_string = re.sub(r"\s+", " ", datetime_string) + + if isinstance(fmt, list): + return self._parse_multiformat(datetime_string, fmt) + + fmt_tokens, fmt_pattern_re = self._generate_pattern_re(fmt) + + match = fmt_pattern_re.search(datetime_string) + + if match is None: + raise ParserMatchError( + "Failed to match '{}' when parsing '{}'".format(fmt, datetime_string) + ) + + parts = {} + for token in fmt_tokens: + if token == "Do": + value = match.group("value") + elif token == "W": + value = (match.group("year"), match.group("week"), match.group("day")) + else: + value = match.group(token) + self._parse_token(token, value, parts) + + return self._build_datetime(parts) + + def _generate_pattern_re(self, fmt): + + # fmt is a string of tokens like 'YYYY-MM-DD' + # we construct a new string by replacing each + # token by its pattern: + # 'YYYY-MM-DD' -> '(?P\d{4})-(?P\d{2})-(?P
\d{2})' + tokens = [] + offset = 0 + + # Escape all special RegEx chars + escaped_fmt = re.escape(fmt) + + # Extract the bracketed expressions to be reinserted later. + escaped_fmt = re.sub(self._ESCAPE_RE, "#", escaped_fmt) + + # Any number of S is the same as one. + # TODO: allow users to specify the number of digits to parse + escaped_fmt = re.sub(r"S+", "S", escaped_fmt) + + escaped_data = re.findall(self._ESCAPE_RE, fmt) + + fmt_pattern = escaped_fmt + + for m in self._FORMAT_RE.finditer(escaped_fmt): + token = m.group(0) + try: + input_re = self._input_re_map[token] + except KeyError: + raise ParserError("Unrecognized token '{}'".format(token)) + input_pattern = "(?P<{}>{})".format(token, input_re.pattern) + tokens.append(token) + # a pattern doesn't have the same length as the token + # it replaces! We keep the difference in the offset variable. + # This works because the string is scanned left-to-right and matches + # are returned in the order found by finditer. + fmt_pattern = ( + fmt_pattern[: m.start() + offset] + + input_pattern + + fmt_pattern[m.end() + offset :] + ) + offset += len(input_pattern) - (m.end() - m.start()) + + final_fmt_pattern = "" + split_fmt = fmt_pattern.split(r"\#") + + # Due to the way Python splits, 'split_fmt' will always be longer + for i in range(len(split_fmt)): + final_fmt_pattern += split_fmt[i] + if i < len(escaped_data): + final_fmt_pattern += escaped_data[i][1:-1] + + # Wrap final_fmt_pattern in a custom word boundary to strictly + # match the formatting pattern and filter out date and time formats + # that include junk such as: blah1998-09-12 blah, blah 1998-09-12blah, + # blah1998-09-12blah. The custom word boundary matches every character + # that is not a whitespace character to allow for searching for a date + # and time string in a natural language sentence. Therefore, searching + # for a string of the form YYYY-MM-DD in "blah 1998-09-12 blah" will + # work properly. + # Certain punctuation before or after the target pattern such as + # "1998-09-12," is permitted. For the full list of valid punctuation, + # see the documentation. + + starting_word_boundary = ( + r"(?\s])" # This is the list of punctuation that is ok before the pattern (i.e. "It can't not be these characters before the pattern") + r"(\b|^)" # The \b is to block cases like 1201912 but allow 201912 for pattern YYYYMM. The ^ was necessary to allow a negative number through i.e. before epoch numbers + ) + ending_word_boundary = ( + r"(?=[\,\.\;\:\?\!\"\'\`\[\]\{\}\(\)\<\>]?" # Positive lookahead stating that these punctuation marks can appear after the pattern at most 1 time + r"(?!\S))" # Don't allow any non-whitespace character after the punctuation + ) + bounded_fmt_pattern = r"{}{}{}".format( + starting_word_boundary, final_fmt_pattern, ending_word_boundary + ) + + return tokens, re.compile(bounded_fmt_pattern, flags=re.IGNORECASE) + + def _parse_token(self, token, value, parts): + + if token == "YYYY": + parts["year"] = int(value) + + elif token == "YY": + value = int(value) + parts["year"] = 1900 + value if value > 68 else 2000 + value + + elif token in ["MMMM", "MMM"]: + parts["month"] = self.locale.month_number(value.lower()) + + elif token in ["MM", "M"]: + parts["month"] = int(value) + + elif token in ["DDDD", "DDD"]: + parts["day_of_year"] = int(value) + + elif token in ["DD", "D"]: + parts["day"] = int(value) + + elif token == "Do": + parts["day"] = int(value) + + elif token == "dddd": + # locale day names are 1-indexed + day_of_week = [x.lower() for x in self.locale.day_names].index( + value.lower() + ) + parts["day_of_week"] = day_of_week - 1 + + elif token == "ddd": + # locale day abbreviations are 1-indexed + day_of_week = [x.lower() for x in self.locale.day_abbreviations].index( + value.lower() + ) + parts["day_of_week"] = day_of_week - 1 + + elif token.upper() in ["HH", "H"]: + parts["hour"] = int(value) + + elif token in ["mm", "m"]: + parts["minute"] = int(value) + + elif token in ["ss", "s"]: + parts["second"] = int(value) + + elif token == "S": + # We have the *most significant* digits of an arbitrary-precision integer. + # We want the six most significant digits as an integer, rounded. + # IDEA: add nanosecond support somehow? Need datetime support for it first. + value = value.ljust(7, str("0")) + + # floating-point (IEEE-754) defaults to half-to-even rounding + seventh_digit = int(value[6]) + if seventh_digit == 5: + rounding = int(value[5]) % 2 + elif seventh_digit > 5: + rounding = 1 + else: + rounding = 0 + + parts["microsecond"] = int(value[:6]) + rounding + + elif token == "X": + parts["timestamp"] = float(value) + + elif token == "x": + parts["expanded_timestamp"] = int(value) + + elif token in ["ZZZ", "ZZ", "Z"]: + parts["tzinfo"] = TzinfoParser.parse(value) + + elif token in ["a", "A"]: + if value in (self.locale.meridians["am"], self.locale.meridians["AM"]): + parts["am_pm"] = "am" + elif value in (self.locale.meridians["pm"], self.locale.meridians["PM"]): + parts["am_pm"] = "pm" + + elif token == "W": + parts["weekdate"] = value + + @staticmethod + def _build_datetime(parts): + + weekdate = parts.get("weekdate") + + if weekdate is not None: + # we can use strptime (%G, %V, %u) in python 3.6 but these tokens aren't available before that + year, week = int(weekdate[0]), int(weekdate[1]) + + if weekdate[2] is not None: + day = int(weekdate[2]) + else: + # day not given, default to 1 + day = 1 + + dt = iso_to_gregorian(year, week, day) + parts["year"] = dt.year + parts["month"] = dt.month + parts["day"] = dt.day + + timestamp = parts.get("timestamp") + + if timestamp is not None: + return datetime.fromtimestamp(timestamp, tz=tz.tzutc()) + + expanded_timestamp = parts.get("expanded_timestamp") + + if expanded_timestamp is not None: + return datetime.fromtimestamp( + normalize_timestamp(expanded_timestamp), + tz=tz.tzutc(), + ) + + day_of_year = parts.get("day_of_year") + + if day_of_year is not None: + year = parts.get("year") + month = parts.get("month") + if year is None: + raise ParserError( + "Year component is required with the DDD and DDDD tokens." + ) + + if month is not None: + raise ParserError( + "Month component is not allowed with the DDD and DDDD tokens." + ) + + date_string = "{}-{}".format(year, day_of_year) + try: + dt = datetime.strptime(date_string, "%Y-%j") + except ValueError: + raise ParserError( + "The provided day of year '{}' is invalid.".format(day_of_year) + ) + + parts["year"] = dt.year + parts["month"] = dt.month + parts["day"] = dt.day + + day_of_week = parts.get("day_of_week") + day = parts.get("day") + + # If day is passed, ignore day of week + if day_of_week is not None and day is None: + year = parts.get("year", 1970) + month = parts.get("month", 1) + day = 1 + + # dddd => first day of week after epoch + # dddd YYYY => first day of week in specified year + # dddd MM YYYY => first day of week in specified year and month + # dddd MM => first day after epoch in specified month + next_weekday_dt = next_weekday(datetime(year, month, day), day_of_week) + parts["year"] = next_weekday_dt.year + parts["month"] = next_weekday_dt.month + parts["day"] = next_weekday_dt.day + + am_pm = parts.get("am_pm") + hour = parts.get("hour", 0) + + if am_pm == "pm" and hour < 12: + hour += 12 + elif am_pm == "am" and hour == 12: + hour = 0 + + # Support for midnight at the end of day + if hour == 24: + if parts.get("minute", 0) != 0: + raise ParserError("Midnight at the end of day must not contain minutes") + if parts.get("second", 0) != 0: + raise ParserError("Midnight at the end of day must not contain seconds") + if parts.get("microsecond", 0) != 0: + raise ParserError( + "Midnight at the end of day must not contain microseconds" + ) + hour = 0 + day_increment = 1 + else: + day_increment = 0 + + # account for rounding up to 1000000 + microsecond = parts.get("microsecond", 0) + if microsecond == 1000000: + microsecond = 0 + second_increment = 1 + else: + second_increment = 0 + + increment = timedelta(days=day_increment, seconds=second_increment) + + return ( + datetime( + year=parts.get("year", 1), + month=parts.get("month", 1), + day=parts.get("day", 1), + hour=hour, + minute=parts.get("minute", 0), + second=parts.get("second", 0), + microsecond=microsecond, + tzinfo=parts.get("tzinfo"), + ) + + increment + ) + + def _parse_multiformat(self, string, formats): + + _datetime = None + + for fmt in formats: + try: + _datetime = self.parse(string, fmt) + break + except ParserMatchError: + pass + + if _datetime is None: + raise ParserError( + "Could not match input '{}' to any of the following formats: {}".format( + string, ", ".join(formats) + ) + ) + + return _datetime + + # generates a capture group of choices separated by an OR operator + @staticmethod + def _generate_choice_re(choices, flags=0): + return re.compile(r"({})".format("|".join(choices)), flags=flags) + + +class TzinfoParser(object): + _TZINFO_RE = re.compile(r"^([\+\-])?(\d{2})(?:\:?(\d{2}))?$") + + @classmethod + def parse(cls, tzinfo_string): + + tzinfo = None + + if tzinfo_string == "local": + tzinfo = tz.tzlocal() + + elif tzinfo_string in ["utc", "UTC", "Z"]: + tzinfo = tz.tzutc() + + else: + + iso_match = cls._TZINFO_RE.match(tzinfo_string) + + if iso_match: + sign, hours, minutes = iso_match.groups() + if minutes is None: + minutes = 0 + seconds = int(hours) * 3600 + int(minutes) * 60 + + if sign == "-": + seconds *= -1 + + tzinfo = tz.tzoffset(None, seconds) + + else: + tzinfo = tz.gettz(tzinfo_string) + + if tzinfo is None: + raise ParserError( + 'Could not parse timezone expression "{}"'.format(tzinfo_string) + ) + + return tzinfo diff --git a/openpype/modules/ftrack/python2_vendor/arrow/arrow/util.py b/openpype/modules/ftrack/python2_vendor/arrow/arrow/util.py new file mode 100644 index 0000000000..acce8878df --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/arrow/util.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +import datetime +import numbers + +from dateutil.rrule import WEEKLY, rrule + +from arrow.constants import MAX_TIMESTAMP, MAX_TIMESTAMP_MS, MAX_TIMESTAMP_US + + +def next_weekday(start_date, weekday): + """Get next weekday from the specified start date. + + :param start_date: Datetime object representing the start date. + :param weekday: Next weekday to obtain. Can be a value between 0 (Monday) and 6 (Sunday). + :return: Datetime object corresponding to the next weekday after start_date. + + Usage:: + + # Get first Monday after epoch + >>> next_weekday(datetime(1970, 1, 1), 0) + 1970-01-05 00:00:00 + + # Get first Thursday after epoch + >>> next_weekday(datetime(1970, 1, 1), 3) + 1970-01-01 00:00:00 + + # Get first Sunday after epoch + >>> next_weekday(datetime(1970, 1, 1), 6) + 1970-01-04 00:00:00 + """ + if weekday < 0 or weekday > 6: + raise ValueError("Weekday must be between 0 (Monday) and 6 (Sunday).") + return rrule(freq=WEEKLY, dtstart=start_date, byweekday=weekday, count=1)[0] + + +def total_seconds(td): + """Get total seconds for timedelta.""" + return td.total_seconds() + + +def is_timestamp(value): + """Check if value is a valid timestamp.""" + if isinstance(value, bool): + return False + if not ( + isinstance(value, numbers.Integral) + or isinstance(value, float) + or isinstance(value, str) + ): + return False + try: + float(value) + return True + except ValueError: + return False + + +def normalize_timestamp(timestamp): + """Normalize millisecond and microsecond timestamps into normal timestamps.""" + if timestamp > MAX_TIMESTAMP: + if timestamp < MAX_TIMESTAMP_MS: + timestamp /= 1e3 + elif timestamp < MAX_TIMESTAMP_US: + timestamp /= 1e6 + else: + raise ValueError( + "The specified timestamp '{}' is too large.".format(timestamp) + ) + return timestamp + + +# Credit to https://stackoverflow.com/a/1700069 +def iso_to_gregorian(iso_year, iso_week, iso_day): + """Converts an ISO week date tuple into a datetime object.""" + + if not 1 <= iso_week <= 53: + raise ValueError("ISO Calendar week value must be between 1-53.") + + if not 1 <= iso_day <= 7: + raise ValueError("ISO Calendar day value must be between 1-7") + + # The first week of the year always contains 4 Jan. + fourth_jan = datetime.date(iso_year, 1, 4) + delta = datetime.timedelta(fourth_jan.isoweekday() - 1) + year_start = fourth_jan - delta + gregorian = year_start + datetime.timedelta(days=iso_day - 1, weeks=iso_week - 1) + + return gregorian + + +def validate_bounds(bounds): + if bounds != "()" and bounds != "(]" and bounds != "[)" and bounds != "[]": + raise ValueError( + 'Invalid bounds. Please select between "()", "(]", "[)", or "[]".' + ) + + +# Python 2.7 / 3.0+ definitions for isstr function. + +try: # pragma: no cover + basestring + + def isstr(s): + return isinstance(s, basestring) # noqa: F821 + + +except NameError: # pragma: no cover + + def isstr(s): + return isinstance(s, str) + + +__all__ = ["next_weekday", "total_seconds", "is_timestamp", "isstr", "iso_to_gregorian"] diff --git a/openpype/modules/ftrack/python2_vendor/arrow/docs/Makefile b/openpype/modules/ftrack/python2_vendor/arrow/docs/Makefile new file mode 100644 index 0000000000..d4bb2cbb9e --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/openpype/modules/ftrack/python2_vendor/arrow/docs/conf.py b/openpype/modules/ftrack/python2_vendor/arrow/docs/conf.py new file mode 100644 index 0000000000..aaf3c50822 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/docs/conf.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- + +# -- Path setup -------------------------------------------------------------- + +import io +import os +import sys + +sys.path.insert(0, os.path.abspath("..")) + +about = {} +with io.open("../arrow/_version.py", "r", encoding="utf-8") as f: + exec(f.read(), about) + +# -- Project information ----------------------------------------------------- + +project = u"Arrow 🏹" +copyright = "2020, Chris Smith" +author = "Chris Smith" + +release = about["__version__"] + +# -- General configuration --------------------------------------------------- + +extensions = ["sphinx.ext.autodoc"] + +templates_path = [] + +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +master_doc = "index" +source_suffix = ".rst" +pygments_style = "sphinx" + +language = None + +# -- Options for HTML output ------------------------------------------------- + +html_theme = "alabaster" +html_theme_path = [] +html_static_path = [] + +html_show_sourcelink = False +html_show_sphinx = False +html_show_copyright = True + +# https://alabaster.readthedocs.io/en/latest/customization.html +html_theme_options = { + "description": "Arrow is a sensible and human-friendly approach to dates, times and timestamps.", + "github_user": "arrow-py", + "github_repo": "arrow", + "github_banner": True, + "show_related": False, + "show_powered_by": False, + "github_button": True, + "github_type": "star", + "github_count": "true", # must be a string +} + +html_sidebars = { + "**": ["about.html", "localtoc.html", "relations.html", "searchbox.html"] +} diff --git a/openpype/modules/ftrack/python2_vendor/arrow/docs/index.rst b/openpype/modules/ftrack/python2_vendor/arrow/docs/index.rst new file mode 100644 index 0000000000..e2830b04f3 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/docs/index.rst @@ -0,0 +1,566 @@ +Arrow: Better dates & times for Python +====================================== + +Release v\ |release| (`Installation`_) (`Changelog `_) + +.. include:: ../README.rst + :start-after: start-inclusion-marker-do-not-remove + :end-before: end-inclusion-marker-do-not-remove + +User's Guide +------------ + +Creation +~~~~~~~~ + +Get 'now' easily: + +.. code-block:: python + + >>> arrow.utcnow() + + + >>> arrow.now() + + + >>> arrow.now('US/Pacific') + + +Create from timestamps (:code:`int` or :code:`float`): + +.. code-block:: python + + >>> arrow.get(1367900664) + + + >>> arrow.get(1367900664.152325) + + +Use a naive or timezone-aware datetime, or flexibly specify a timezone: + +.. code-block:: python + + >>> arrow.get(datetime.utcnow()) + + + >>> arrow.get(datetime(2013, 5, 5), 'US/Pacific') + + + >>> from dateutil import tz + >>> arrow.get(datetime(2013, 5, 5), tz.gettz('US/Pacific')) + + + >>> arrow.get(datetime.now(tz.gettz('US/Pacific'))) + + +Parse from a string: + +.. code-block:: python + + >>> arrow.get('2013-05-05 12:30:45', 'YYYY-MM-DD HH:mm:ss') + + +Search a date in a string: + +.. code-block:: python + + >>> arrow.get('June was born in May 1980', 'MMMM YYYY') + + +Some ISO 8601 compliant strings are recognized and parsed without a format string: + + >>> arrow.get('2013-09-30T15:34:00.000-07:00') + + +Arrow objects can be instantiated directly too, with the same arguments as a datetime: + +.. code-block:: python + + >>> arrow.get(2013, 5, 5) + + + >>> arrow.Arrow(2013, 5, 5) + + +Properties +~~~~~~~~~~ + +Get a datetime or timestamp representation: + +.. code-block:: python + + >>> a = arrow.utcnow() + >>> a.datetime + datetime.datetime(2013, 5, 7, 4, 38, 15, 447644, tzinfo=tzutc()) + + >>> a.timestamp + 1367901495 + +Get a naive datetime, and tzinfo: + +.. code-block:: python + + >>> a.naive + datetime.datetime(2013, 5, 7, 4, 38, 15, 447644) + + >>> a.tzinfo + tzutc() + +Get any datetime value: + +.. code-block:: python + + >>> a.year + 2013 + +Call datetime functions that return properties: + +.. code-block:: python + + >>> a.date() + datetime.date(2013, 5, 7) + + >>> a.time() + datetime.time(4, 38, 15, 447644) + +Replace & Shift +~~~~~~~~~~~~~~~ + +Get a new :class:`Arrow ` object, with altered attributes, just as you would with a datetime: + +.. code-block:: python + + >>> arw = arrow.utcnow() + >>> arw + + + >>> arw.replace(hour=4, minute=40) + + +Or, get one with attributes shifted forward or backward: + +.. code-block:: python + + >>> arw.shift(weeks=+3) + + +Even replace the timezone without altering other attributes: + +.. code-block:: python + + >>> arw.replace(tzinfo='US/Pacific') + + +Move between the earlier and later moments of an ambiguous time: + +.. code-block:: python + + >>> paris_transition = arrow.Arrow(2019, 10, 27, 2, tzinfo="Europe/Paris", fold=0) + >>> paris_transition + + >>> paris_transition.ambiguous + True + >>> paris_transition.replace(fold=1) + + +Format +~~~~~~ + +.. code-block:: python + + >>> arrow.utcnow().format('YYYY-MM-DD HH:mm:ss ZZ') + '2013-05-07 05:23:16 -00:00' + +Convert +~~~~~~~ + +Convert from UTC to other timezones by name or tzinfo: + +.. code-block:: python + + >>> utc = arrow.utcnow() + >>> utc + + + >>> utc.to('US/Pacific') + + + >>> utc.to(tz.gettz('US/Pacific')) + + +Or using shorthand: + +.. code-block:: python + + >>> utc.to('local') + + + >>> utc.to('local').to('utc') + + + +Humanize +~~~~~~~~ + +Humanize relative to now: + +.. code-block:: python + + >>> past = arrow.utcnow().shift(hours=-1) + >>> past.humanize() + 'an hour ago' + +Or another Arrow, or datetime: + +.. code-block:: python + + >>> present = arrow.utcnow() + >>> future = present.shift(hours=2) + >>> future.humanize(present) + 'in 2 hours' + +Indicate time as relative or include only the distance + +.. code-block:: python + + >>> present = arrow.utcnow() + >>> future = present.shift(hours=2) + >>> future.humanize(present) + 'in 2 hours' + >>> future.humanize(present, only_distance=True) + '2 hours' + + +Indicate a specific time granularity (or multiple): + +.. code-block:: python + + >>> present = arrow.utcnow() + >>> future = present.shift(minutes=66) + >>> future.humanize(present, granularity="minute") + 'in 66 minutes' + >>> future.humanize(present, granularity=["hour", "minute"]) + 'in an hour and 6 minutes' + >>> present.humanize(future, granularity=["hour", "minute"]) + 'an hour and 6 minutes ago' + >>> future.humanize(present, only_distance=True, granularity=["hour", "minute"]) + 'an hour and 6 minutes' + +Support for a growing number of locales (see ``locales.py`` for supported languages): + +.. code-block:: python + + + >>> future = arrow.utcnow().shift(hours=1) + >>> future.humanize(a, locale='ru') + 'через 2 час(а,ов)' + + +Ranges & Spans +~~~~~~~~~~~~~~ + +Get the time span of any unit: + +.. code-block:: python + + >>> arrow.utcnow().span('hour') + (, ) + +Or just get the floor and ceiling: + +.. code-block:: python + + >>> arrow.utcnow().floor('hour') + + + >>> arrow.utcnow().ceil('hour') + + +You can also get a range of time spans: + +.. code-block:: python + + >>> start = datetime(2013, 5, 5, 12, 30) + >>> end = datetime(2013, 5, 5, 17, 15) + >>> for r in arrow.Arrow.span_range('hour', start, end): + ... print r + ... + (, ) + (, ) + (, ) + (, ) + (, ) + +Or just iterate over a range of time: + +.. code-block:: python + + >>> start = datetime(2013, 5, 5, 12, 30) + >>> end = datetime(2013, 5, 5, 17, 15) + >>> for r in arrow.Arrow.range('hour', start, end): + ... print repr(r) + ... + + + + + + +.. toctree:: + :maxdepth: 2 + +Factories +~~~~~~~~~ + +Use factories to harness Arrow's module API for a custom Arrow-derived type. First, derive your type: + +.. code-block:: python + + >>> class CustomArrow(arrow.Arrow): + ... + ... def days_till_xmas(self): + ... + ... xmas = arrow.Arrow(self.year, 12, 25) + ... + ... if self > xmas: + ... xmas = xmas.shift(years=1) + ... + ... return (xmas - self).days + + +Then get and use a factory for it: + +.. code-block:: python + + >>> factory = arrow.ArrowFactory(CustomArrow) + >>> custom = factory.utcnow() + >>> custom + >>> + + >>> custom.days_till_xmas() + >>> 211 + +Supported Tokens +~~~~~~~~~~~~~~~~ + +Use the following tokens for parsing and formatting. Note that they are **not** the same as the tokens for `strptime `_: + ++--------------------------------+--------------+-------------------------------------------+ +| |Token |Output | ++================================+==============+===========================================+ +|**Year** |YYYY |2000, 2001, 2002 ... 2012, 2013 | ++--------------------------------+--------------+-------------------------------------------+ +| |YY |00, 01, 02 ... 12, 13 | ++--------------------------------+--------------+-------------------------------------------+ +|**Month** |MMMM |January, February, March ... [#t1]_ | ++--------------------------------+--------------+-------------------------------------------+ +| |MMM |Jan, Feb, Mar ... [#t1]_ | ++--------------------------------+--------------+-------------------------------------------+ +| |MM |01, 02, 03 ... 11, 12 | ++--------------------------------+--------------+-------------------------------------------+ +| |M |1, 2, 3 ... 11, 12 | ++--------------------------------+--------------+-------------------------------------------+ +|**Day of Year** |DDDD |001, 002, 003 ... 364, 365 | ++--------------------------------+--------------+-------------------------------------------+ +| |DDD |1, 2, 3 ... 364, 365 | ++--------------------------------+--------------+-------------------------------------------+ +|**Day of Month** |DD |01, 02, 03 ... 30, 31 | ++--------------------------------+--------------+-------------------------------------------+ +| |D |1, 2, 3 ... 30, 31 | ++--------------------------------+--------------+-------------------------------------------+ +| |Do |1st, 2nd, 3rd ... 30th, 31st | ++--------------------------------+--------------+-------------------------------------------+ +|**Day of Week** |dddd |Monday, Tuesday, Wednesday ... [#t2]_ | ++--------------------------------+--------------+-------------------------------------------+ +| |ddd |Mon, Tue, Wed ... [#t2]_ | ++--------------------------------+--------------+-------------------------------------------+ +| |d |1, 2, 3 ... 6, 7 | ++--------------------------------+--------------+-------------------------------------------+ +|**ISO week date** |W |2011-W05-4, 2019-W17 | ++--------------------------------+--------------+-------------------------------------------+ +|**Hour** |HH |00, 01, 02 ... 23, 24 | ++--------------------------------+--------------+-------------------------------------------+ +| |H |0, 1, 2 ... 23, 24 | ++--------------------------------+--------------+-------------------------------------------+ +| |hh |01, 02, 03 ... 11, 12 | ++--------------------------------+--------------+-------------------------------------------+ +| |h |1, 2, 3 ... 11, 12 | ++--------------------------------+--------------+-------------------------------------------+ +|**AM / PM** |A |AM, PM, am, pm [#t1]_ | ++--------------------------------+--------------+-------------------------------------------+ +| |a |am, pm [#t1]_ | ++--------------------------------+--------------+-------------------------------------------+ +|**Minute** |mm |00, 01, 02 ... 58, 59 | ++--------------------------------+--------------+-------------------------------------------+ +| |m |0, 1, 2 ... 58, 59 | ++--------------------------------+--------------+-------------------------------------------+ +|**Second** |ss |00, 01, 02 ... 58, 59 | ++--------------------------------+--------------+-------------------------------------------+ +| |s |0, 1, 2 ... 58, 59 | ++--------------------------------+--------------+-------------------------------------------+ +|**Sub-second** |S... |0, 02, 003, 000006, 123123123123... [#t3]_ | ++--------------------------------+--------------+-------------------------------------------+ +|**Timezone** |ZZZ |Asia/Baku, Europe/Warsaw, GMT ... [#t4]_ | ++--------------------------------+--------------+-------------------------------------------+ +| |ZZ |-07:00, -06:00 ... +06:00, +07:00, +08, Z | ++--------------------------------+--------------+-------------------------------------------+ +| |Z |-0700, -0600 ... +0600, +0700, +08, Z | ++--------------------------------+--------------+-------------------------------------------+ +|**Seconds Timestamp** |X |1381685817, 1381685817.915482 ... [#t5]_ | ++--------------------------------+--------------+-------------------------------------------+ +|**ms or µs Timestamp** |x |1569980330813, 1569980330813221 | ++--------------------------------+--------------+-------------------------------------------+ + +.. rubric:: Footnotes + +.. [#t1] localization support for parsing and formatting +.. [#t2] localization support only for formatting +.. [#t3] the result is truncated to microseconds, with `half-to-even rounding `_. +.. [#t4] timezone names from `tz database `_ provided via dateutil package, note that abbreviations such as MST, PDT, BRST are unlikely to parse due to ambiguity. Use the full IANA zone name instead (Asia/Shanghai, Europe/London, America/Chicago etc). +.. [#t5] this token cannot be used for parsing timestamps out of natural language strings due to compatibility reasons + +Built-in Formats +++++++++++++++++ + +There are several formatting standards that are provided as built-in tokens. + +.. code-block:: python + + >>> arw = arrow.utcnow() + >>> arw.format(arrow.FORMAT_ATOM) + '2020-05-27 10:30:35+00:00' + >>> arw.format(arrow.FORMAT_COOKIE) + 'Wednesday, 27-May-2020 10:30:35 UTC' + >>> arw.format(arrow.FORMAT_RSS) + 'Wed, 27 May 2020 10:30:35 +0000' + >>> arw.format(arrow.FORMAT_RFC822) + 'Wed, 27 May 20 10:30:35 +0000' + >>> arw.format(arrow.FORMAT_RFC850) + 'Wednesday, 27-May-20 10:30:35 UTC' + >>> arw.format(arrow.FORMAT_RFC1036) + 'Wed, 27 May 20 10:30:35 +0000' + >>> arw.format(arrow.FORMAT_RFC1123) + 'Wed, 27 May 2020 10:30:35 +0000' + >>> arw.format(arrow.FORMAT_RFC2822) + 'Wed, 27 May 2020 10:30:35 +0000' + >>> arw.format(arrow.FORMAT_RFC3339) + '2020-05-27 10:30:35+00:00' + >>> arw.format(arrow.FORMAT_W3C) + '2020-05-27 10:30:35+00:00' + +Escaping Formats +~~~~~~~~~~~~~~~~ + +Tokens, phrases, and regular expressions in a format string can be escaped when parsing and formatting by enclosing them within square brackets. + +Tokens & Phrases +++++++++++++++++ + +Any `token `_ or phrase can be escaped as follows: + +.. code-block:: python + + >>> fmt = "YYYY-MM-DD h [h] m" + >>> arw = arrow.get("2018-03-09 8 h 40", fmt) + + >>> arw.format(fmt) + '2018-03-09 8 h 40' + + >>> fmt = "YYYY-MM-DD h [hello] m" + >>> arw = arrow.get("2018-03-09 8 hello 40", fmt) + + >>> arw.format(fmt) + '2018-03-09 8 hello 40' + + >>> fmt = "YYYY-MM-DD h [hello world] m" + >>> arw = arrow.get("2018-03-09 8 hello world 40", fmt) + + >>> arw.format(fmt) + '2018-03-09 8 hello world 40' + +This can be useful for parsing dates in different locales such as French, in which it is common to format time strings as "8 h 40" rather than "8:40". + +Regular Expressions ++++++++++++++++++++ + +You can also escape regular expressions by enclosing them within square brackets. In the following example, we are using the regular expression :code:`\s+` to match any number of whitespace characters that separate the tokens. This is useful if you do not know the number of spaces between tokens ahead of time (e.g. in log files). + +.. code-block:: python + + >>> fmt = r"ddd[\s+]MMM[\s+]DD[\s+]HH:mm:ss[\s+]YYYY" + >>> arrow.get("Mon Sep 08 16:41:45 2014", fmt) + + + >>> arrow.get("Mon \tSep 08 16:41:45 2014", fmt) + + + >>> arrow.get("Mon Sep 08 16:41:45 2014", fmt) + + +Punctuation +~~~~~~~~~~~ + +Date and time formats may be fenced on either side by one punctuation character from the following list: ``, . ; : ? ! " \` ' [ ] { } ( ) < >`` + +.. code-block:: python + + >>> arrow.get("Cool date: 2019-10-31T09:12:45.123456+04:30.", "YYYY-MM-DDTHH:mm:ss.SZZ") + + + >>> arrow.get("Tomorrow (2019-10-31) is Halloween!", "YYYY-MM-DD") + + + >>> arrow.get("Halloween is on 2019.10.31.", "YYYY.MM.DD") + + + >>> arrow.get("It's Halloween tomorrow (2019-10-31)!", "YYYY-MM-DD") + # Raises exception because there are multiple punctuation marks following the date + +Redundant Whitespace +~~~~~~~~~~~~~~~~~~~~ + +Redundant whitespace characters (spaces, tabs, and newlines) can be normalized automatically by passing in the ``normalize_whitespace`` flag to ``arrow.get``: + +.. code-block:: python + + >>> arrow.get('\t \n 2013-05-05T12:30:45.123456 \t \n', normalize_whitespace=True) + + + >>> arrow.get('2013-05-05 T \n 12:30:45\t123456', 'YYYY-MM-DD T HH:mm:ss S', normalize_whitespace=True) + + +API Guide +--------- + +arrow.arrow +~~~~~~~~~~~ + +.. automodule:: arrow.arrow + :members: + +arrow.factory +~~~~~~~~~~~~~ + +.. automodule:: arrow.factory + :members: + +arrow.api +~~~~~~~~~ + +.. automodule:: arrow.api + :members: + +arrow.locale +~~~~~~~~~~~~ + +.. automodule:: arrow.locales + :members: + :undoc-members: + +Release History +--------------- + +.. toctree:: + :maxdepth: 2 + + releases diff --git a/openpype/modules/ftrack/python2_vendor/arrow/docs/make.bat b/openpype/modules/ftrack/python2_vendor/arrow/docs/make.bat new file mode 100644 index 0000000000..922152e96a --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/openpype/modules/ftrack/python2_vendor/arrow/docs/releases.rst b/openpype/modules/ftrack/python2_vendor/arrow/docs/releases.rst new file mode 100644 index 0000000000..22e1e59c8c --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/docs/releases.rst @@ -0,0 +1,3 @@ +.. _releases: + +.. include:: ../CHANGELOG.rst diff --git a/openpype/modules/ftrack/python2_vendor/arrow/requirements.txt b/openpype/modules/ftrack/python2_vendor/arrow/requirements.txt new file mode 100644 index 0000000000..df565d8384 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/requirements.txt @@ -0,0 +1,14 @@ +backports.functools_lru_cache==1.6.1; python_version == "2.7" +dateparser==0.7.* +pre-commit==1.21.*; python_version <= "3.5" +pre-commit==2.6.*; python_version >= "3.6" +pytest==4.6.*; python_version == "2.7" +pytest==6.0.*; python_version >= "3.5" +pytest-cov==2.10.* +pytest-mock==2.0.*; python_version == "2.7" +pytest-mock==3.2.*; python_version >= "3.5" +python-dateutil==2.8.* +pytz==2019.* +simplejson==3.17.* +sphinx==1.8.*; python_version == "2.7" +sphinx==3.2.*; python_version >= "3.5" diff --git a/openpype/modules/ftrack/python2_vendor/arrow/setup.cfg b/openpype/modules/ftrack/python2_vendor/arrow/setup.cfg new file mode 100644 index 0000000000..2a9acf13da --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/openpype/modules/ftrack/python2_vendor/arrow/setup.py b/openpype/modules/ftrack/python2_vendor/arrow/setup.py new file mode 100644 index 0000000000..dc4f0e77d5 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/setup.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +import io + +from setuptools import setup + +with io.open("README.rst", "r", encoding="utf-8") as f: + readme = f.read() + +about = {} +with io.open("arrow/_version.py", "r", encoding="utf-8") as f: + exec(f.read(), about) + +setup( + name="arrow", + version=about["__version__"], + description="Better dates & times for Python", + long_description=readme, + long_description_content_type="text/x-rst", + url="https://arrow.readthedocs.io", + author="Chris Smith", + author_email="crsmithdev@gmail.com", + license="Apache 2.0", + packages=["arrow"], + zip_safe=False, + python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", + install_requires=[ + "python-dateutil>=2.7.0", + "backports.functools_lru_cache>=1.2.1;python_version=='2.7'", + ], + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Topic :: Software Development :: Libraries :: Python Modules", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + ], + keywords="arrow date time datetime timestamp timezone humanize", + project_urls={ + "Repository": "https://github.com/arrow-py/arrow", + "Bug Reports": "https://github.com/arrow-py/arrow/issues", + "Documentation": "https://arrow.readthedocs.io", + }, +) diff --git a/openpype/modules/ftrack/python2_vendor/arrow/tests/__init__.py b/openpype/modules/ftrack/python2_vendor/arrow/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/modules/ftrack/python2_vendor/arrow/tests/conftest.py b/openpype/modules/ftrack/python2_vendor/arrow/tests/conftest.py new file mode 100644 index 0000000000..5bc8a4af2e --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/tests/conftest.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +from datetime import datetime + +import pytest +from dateutil import tz as dateutil_tz + +from arrow import arrow, factory, formatter, locales, parser + + +@pytest.fixture(scope="class") +def time_utcnow(request): + request.cls.arrow = arrow.Arrow.utcnow() + + +@pytest.fixture(scope="class") +def time_2013_01_01(request): + request.cls.now = arrow.Arrow.utcnow() + request.cls.arrow = arrow.Arrow(2013, 1, 1) + request.cls.datetime = datetime(2013, 1, 1) + + +@pytest.fixture(scope="class") +def time_2013_02_03(request): + request.cls.arrow = arrow.Arrow(2013, 2, 3, 12, 30, 45, 1) + + +@pytest.fixture(scope="class") +def time_2013_02_15(request): + request.cls.datetime = datetime(2013, 2, 15, 3, 41, 22, 8923) + request.cls.arrow = arrow.Arrow.fromdatetime(request.cls.datetime) + + +@pytest.fixture(scope="class") +def time_1975_12_25(request): + request.cls.datetime = datetime( + 1975, 12, 25, 14, 15, 16, tzinfo=dateutil_tz.gettz("America/New_York") + ) + request.cls.arrow = arrow.Arrow.fromdatetime(request.cls.datetime) + + +@pytest.fixture(scope="class") +def arrow_formatter(request): + request.cls.formatter = formatter.DateTimeFormatter() + + +@pytest.fixture(scope="class") +def arrow_factory(request): + request.cls.factory = factory.ArrowFactory() + + +@pytest.fixture(scope="class") +def lang_locales(request): + request.cls.locales = locales._locales + + +@pytest.fixture(scope="class") +def lang_locale(request): + # As locale test classes are prefixed with Test, we are dynamically getting the locale by the test class name. + # TestEnglishLocale -> EnglishLocale + name = request.cls.__name__[4:] + request.cls.locale = locales.get_locale_by_class_name(name) + + +@pytest.fixture(scope="class") +def dt_parser(request): + request.cls.parser = parser.DateTimeParser() + + +@pytest.fixture(scope="class") +def dt_parser_regex(request): + request.cls.format_regex = parser.DateTimeParser._FORMAT_RE + + +@pytest.fixture(scope="class") +def tzinfo_parser(request): + request.cls.parser = parser.TzinfoParser() diff --git a/openpype/modules/ftrack/python2_vendor/arrow/tests/test_api.py b/openpype/modules/ftrack/python2_vendor/arrow/tests/test_api.py new file mode 100644 index 0000000000..9b19a27cd9 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/tests/test_api.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +import arrow + + +class TestModule: + def test_get(self, mocker): + mocker.patch("arrow.api._factory.get", return_value="result") + + assert arrow.api.get() == "result" + + def test_utcnow(self, mocker): + mocker.patch("arrow.api._factory.utcnow", return_value="utcnow") + + assert arrow.api.utcnow() == "utcnow" + + def test_now(self, mocker): + mocker.patch("arrow.api._factory.now", tz="tz", return_value="now") + + assert arrow.api.now("tz") == "now" + + def test_factory(self): + class MockCustomArrowClass(arrow.Arrow): + pass + + result = arrow.api.factory(MockCustomArrowClass) + + assert isinstance(result, arrow.factory.ArrowFactory) + assert isinstance(result.utcnow(), MockCustomArrowClass) diff --git a/openpype/modules/ftrack/python2_vendor/arrow/tests/test_arrow.py b/openpype/modules/ftrack/python2_vendor/arrow/tests/test_arrow.py new file mode 100644 index 0000000000..b0bd20a5e3 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/tests/test_arrow.py @@ -0,0 +1,2150 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +import calendar +import pickle +import sys +import time +from datetime import date, datetime, timedelta + +import dateutil +import pytest +import pytz +import simplejson as json +from dateutil import tz +from dateutil.relativedelta import FR, MO, SA, SU, TH, TU, WE + +from arrow import arrow + +from .utils import assert_datetime_equality + + +class TestTestArrowInit: + def test_init_bad_input(self): + + with pytest.raises(TypeError): + arrow.Arrow(2013) + + with pytest.raises(TypeError): + arrow.Arrow(2013, 2) + + with pytest.raises(ValueError): + arrow.Arrow(2013, 2, 2, 12, 30, 45, 9999999) + + def test_init(self): + + result = arrow.Arrow(2013, 2, 2) + self.expected = datetime(2013, 2, 2, tzinfo=tz.tzutc()) + assert result._datetime == self.expected + + result = arrow.Arrow(2013, 2, 2, 12) + self.expected = datetime(2013, 2, 2, 12, tzinfo=tz.tzutc()) + assert result._datetime == self.expected + + result = arrow.Arrow(2013, 2, 2, 12, 30) + self.expected = datetime(2013, 2, 2, 12, 30, tzinfo=tz.tzutc()) + assert result._datetime == self.expected + + result = arrow.Arrow(2013, 2, 2, 12, 30, 45) + self.expected = datetime(2013, 2, 2, 12, 30, 45, tzinfo=tz.tzutc()) + assert result._datetime == self.expected + + result = arrow.Arrow(2013, 2, 2, 12, 30, 45, 999999) + self.expected = datetime(2013, 2, 2, 12, 30, 45, 999999, tzinfo=tz.tzutc()) + assert result._datetime == self.expected + + result = arrow.Arrow( + 2013, 2, 2, 12, 30, 45, 999999, tzinfo=tz.gettz("Europe/Paris") + ) + self.expected = datetime( + 2013, 2, 2, 12, 30, 45, 999999, tzinfo=tz.gettz("Europe/Paris") + ) + assert result._datetime == self.expected + + # regression tests for issue #626 + def test_init_pytz_timezone(self): + + result = arrow.Arrow( + 2013, 2, 2, 12, 30, 45, 999999, tzinfo=pytz.timezone("Europe/Paris") + ) + self.expected = datetime( + 2013, 2, 2, 12, 30, 45, 999999, tzinfo=tz.gettz("Europe/Paris") + ) + assert result._datetime == self.expected + assert_datetime_equality(result._datetime, self.expected, 1) + + def test_init_with_fold(self): + before = arrow.Arrow(2017, 10, 29, 2, 0, tzinfo="Europe/Stockholm") + after = arrow.Arrow(2017, 10, 29, 2, 0, tzinfo="Europe/Stockholm", fold=1) + + assert hasattr(before, "fold") + assert hasattr(after, "fold") + + # PEP-495 requires the comparisons below to be true + assert before == after + assert before.utcoffset() != after.utcoffset() + + +class TestTestArrowFactory: + def test_now(self): + + result = arrow.Arrow.now() + + assert_datetime_equality( + result._datetime, datetime.now().replace(tzinfo=tz.tzlocal()) + ) + + def test_utcnow(self): + + result = arrow.Arrow.utcnow() + + assert_datetime_equality( + result._datetime, datetime.utcnow().replace(tzinfo=tz.tzutc()) + ) + + assert result.fold == 0 + + def test_fromtimestamp(self): + + timestamp = time.time() + + result = arrow.Arrow.fromtimestamp(timestamp) + assert_datetime_equality( + result._datetime, datetime.now().replace(tzinfo=tz.tzlocal()) + ) + + result = arrow.Arrow.fromtimestamp(timestamp, tzinfo=tz.gettz("Europe/Paris")) + assert_datetime_equality( + result._datetime, + datetime.fromtimestamp(timestamp, tz.gettz("Europe/Paris")), + ) + + result = arrow.Arrow.fromtimestamp(timestamp, tzinfo="Europe/Paris") + assert_datetime_equality( + result._datetime, + datetime.fromtimestamp(timestamp, tz.gettz("Europe/Paris")), + ) + + with pytest.raises(ValueError): + arrow.Arrow.fromtimestamp("invalid timestamp") + + def test_utcfromtimestamp(self): + + timestamp = time.time() + + result = arrow.Arrow.utcfromtimestamp(timestamp) + assert_datetime_equality( + result._datetime, datetime.utcnow().replace(tzinfo=tz.tzutc()) + ) + + with pytest.raises(ValueError): + arrow.Arrow.utcfromtimestamp("invalid timestamp") + + def test_fromdatetime(self): + + dt = datetime(2013, 2, 3, 12, 30, 45, 1) + + result = arrow.Arrow.fromdatetime(dt) + + assert result._datetime == dt.replace(tzinfo=tz.tzutc()) + + def test_fromdatetime_dt_tzinfo(self): + + dt = datetime(2013, 2, 3, 12, 30, 45, 1, tzinfo=tz.gettz("US/Pacific")) + + result = arrow.Arrow.fromdatetime(dt) + + assert result._datetime == dt.replace(tzinfo=tz.gettz("US/Pacific")) + + def test_fromdatetime_tzinfo_arg(self): + + dt = datetime(2013, 2, 3, 12, 30, 45, 1) + + result = arrow.Arrow.fromdatetime(dt, tz.gettz("US/Pacific")) + + assert result._datetime == dt.replace(tzinfo=tz.gettz("US/Pacific")) + + def test_fromdate(self): + + dt = date(2013, 2, 3) + + result = arrow.Arrow.fromdate(dt, tz.gettz("US/Pacific")) + + assert result._datetime == datetime(2013, 2, 3, tzinfo=tz.gettz("US/Pacific")) + + def test_strptime(self): + + formatted = datetime(2013, 2, 3, 12, 30, 45).strftime("%Y-%m-%d %H:%M:%S") + + result = arrow.Arrow.strptime(formatted, "%Y-%m-%d %H:%M:%S") + assert result._datetime == datetime(2013, 2, 3, 12, 30, 45, tzinfo=tz.tzutc()) + + result = arrow.Arrow.strptime( + formatted, "%Y-%m-%d %H:%M:%S", tzinfo=tz.gettz("Europe/Paris") + ) + assert result._datetime == datetime( + 2013, 2, 3, 12, 30, 45, tzinfo=tz.gettz("Europe/Paris") + ) + + +@pytest.mark.usefixtures("time_2013_02_03") +class TestTestArrowRepresentation: + def test_repr(self): + + result = self.arrow.__repr__() + + assert result == "".format(self.arrow._datetime.isoformat()) + + def test_str(self): + + result = self.arrow.__str__() + + assert result == self.arrow._datetime.isoformat() + + def test_hash(self): + + result = self.arrow.__hash__() + + assert result == self.arrow._datetime.__hash__() + + def test_format(self): + + result = "{:YYYY-MM-DD}".format(self.arrow) + + assert result == "2013-02-03" + + def test_bare_format(self): + + result = self.arrow.format() + + assert result == "2013-02-03 12:30:45+00:00" + + def test_format_no_format_string(self): + + result = "{}".format(self.arrow) + + assert result == str(self.arrow) + + def test_clone(self): + + result = self.arrow.clone() + + assert result is not self.arrow + assert result._datetime == self.arrow._datetime + + +@pytest.mark.usefixtures("time_2013_01_01") +class TestArrowAttribute: + def test_getattr_base(self): + + with pytest.raises(AttributeError): + self.arrow.prop + + def test_getattr_week(self): + + assert self.arrow.week == 1 + + def test_getattr_quarter(self): + # start dates + q1 = arrow.Arrow(2013, 1, 1) + q2 = arrow.Arrow(2013, 4, 1) + q3 = arrow.Arrow(2013, 8, 1) + q4 = arrow.Arrow(2013, 10, 1) + assert q1.quarter == 1 + assert q2.quarter == 2 + assert q3.quarter == 3 + assert q4.quarter == 4 + + # end dates + q1 = arrow.Arrow(2013, 3, 31) + q2 = arrow.Arrow(2013, 6, 30) + q3 = arrow.Arrow(2013, 9, 30) + q4 = arrow.Arrow(2013, 12, 31) + assert q1.quarter == 1 + assert q2.quarter == 2 + assert q3.quarter == 3 + assert q4.quarter == 4 + + def test_getattr_dt_value(self): + + assert self.arrow.year == 2013 + + def test_tzinfo(self): + + self.arrow.tzinfo = tz.gettz("PST") + assert self.arrow.tzinfo == tz.gettz("PST") + + def test_naive(self): + + assert self.arrow.naive == self.arrow._datetime.replace(tzinfo=None) + + def test_timestamp(self): + + assert self.arrow.timestamp == calendar.timegm( + self.arrow._datetime.utctimetuple() + ) + + with pytest.warns(DeprecationWarning): + self.arrow.timestamp + + def test_int_timestamp(self): + + assert self.arrow.int_timestamp == calendar.timegm( + self.arrow._datetime.utctimetuple() + ) + + def test_float_timestamp(self): + + result = self.arrow.float_timestamp - self.arrow.timestamp + + assert result == self.arrow.microsecond + + def test_getattr_fold(self): + + # UTC is always unambiguous + assert self.now.fold == 0 + + ambiguous_dt = arrow.Arrow( + 2017, 10, 29, 2, 0, tzinfo="Europe/Stockholm", fold=1 + ) + assert ambiguous_dt.fold == 1 + + with pytest.raises(AttributeError): + ambiguous_dt.fold = 0 + + def test_getattr_ambiguous(self): + + assert not self.now.ambiguous + + ambiguous_dt = arrow.Arrow(2017, 10, 29, 2, 0, tzinfo="Europe/Stockholm") + + assert ambiguous_dt.ambiguous + + def test_getattr_imaginary(self): + + assert not self.now.imaginary + + imaginary_dt = arrow.Arrow(2013, 3, 31, 2, 30, tzinfo="Europe/Paris") + + assert imaginary_dt.imaginary + + +@pytest.mark.usefixtures("time_utcnow") +class TestArrowComparison: + def test_eq(self): + + assert self.arrow == self.arrow + assert self.arrow == self.arrow.datetime + assert not (self.arrow == "abc") + + def test_ne(self): + + assert not (self.arrow != self.arrow) + assert not (self.arrow != self.arrow.datetime) + assert self.arrow != "abc" + + def test_gt(self): + + arrow_cmp = self.arrow.shift(minutes=1) + + assert not (self.arrow > self.arrow) + assert not (self.arrow > self.arrow.datetime) + + with pytest.raises(TypeError): + self.arrow > "abc" + + assert self.arrow < arrow_cmp + assert self.arrow < arrow_cmp.datetime + + def test_ge(self): + + with pytest.raises(TypeError): + self.arrow >= "abc" + + assert self.arrow >= self.arrow + assert self.arrow >= self.arrow.datetime + + def test_lt(self): + + arrow_cmp = self.arrow.shift(minutes=1) + + assert not (self.arrow < self.arrow) + assert not (self.arrow < self.arrow.datetime) + + with pytest.raises(TypeError): + self.arrow < "abc" + + assert self.arrow < arrow_cmp + assert self.arrow < arrow_cmp.datetime + + def test_le(self): + + with pytest.raises(TypeError): + self.arrow <= "abc" + + assert self.arrow <= self.arrow + assert self.arrow <= self.arrow.datetime + + +@pytest.mark.usefixtures("time_2013_01_01") +class TestArrowMath: + def test_add_timedelta(self): + + result = self.arrow.__add__(timedelta(days=1)) + + assert result._datetime == datetime(2013, 1, 2, tzinfo=tz.tzutc()) + + def test_add_other(self): + + with pytest.raises(TypeError): + self.arrow + 1 + + def test_radd(self): + + result = self.arrow.__radd__(timedelta(days=1)) + + assert result._datetime == datetime(2013, 1, 2, tzinfo=tz.tzutc()) + + def test_sub_timedelta(self): + + result = self.arrow.__sub__(timedelta(days=1)) + + assert result._datetime == datetime(2012, 12, 31, tzinfo=tz.tzutc()) + + def test_sub_datetime(self): + + result = self.arrow.__sub__(datetime(2012, 12, 21, tzinfo=tz.tzutc())) + + assert result == timedelta(days=11) + + def test_sub_arrow(self): + + result = self.arrow.__sub__(arrow.Arrow(2012, 12, 21, tzinfo=tz.tzutc())) + + assert result == timedelta(days=11) + + def test_sub_other(self): + + with pytest.raises(TypeError): + self.arrow - object() + + def test_rsub_datetime(self): + + result = self.arrow.__rsub__(datetime(2012, 12, 21, tzinfo=tz.tzutc())) + + assert result == timedelta(days=-11) + + def test_rsub_other(self): + + with pytest.raises(TypeError): + timedelta(days=1) - self.arrow + + +@pytest.mark.usefixtures("time_utcnow") +class TestArrowDatetimeInterface: + def test_date(self): + + result = self.arrow.date() + + assert result == self.arrow._datetime.date() + + def test_time(self): + + result = self.arrow.time() + + assert result == self.arrow._datetime.time() + + def test_timetz(self): + + result = self.arrow.timetz() + + assert result == self.arrow._datetime.timetz() + + def test_astimezone(self): + + other_tz = tz.gettz("US/Pacific") + + result = self.arrow.astimezone(other_tz) + + assert result == self.arrow._datetime.astimezone(other_tz) + + def test_utcoffset(self): + + result = self.arrow.utcoffset() + + assert result == self.arrow._datetime.utcoffset() + + def test_dst(self): + + result = self.arrow.dst() + + assert result == self.arrow._datetime.dst() + + def test_timetuple(self): + + result = self.arrow.timetuple() + + assert result == self.arrow._datetime.timetuple() + + def test_utctimetuple(self): + + result = self.arrow.utctimetuple() + + assert result == self.arrow._datetime.utctimetuple() + + def test_toordinal(self): + + result = self.arrow.toordinal() + + assert result == self.arrow._datetime.toordinal() + + def test_weekday(self): + + result = self.arrow.weekday() + + assert result == self.arrow._datetime.weekday() + + def test_isoweekday(self): + + result = self.arrow.isoweekday() + + assert result == self.arrow._datetime.isoweekday() + + def test_isocalendar(self): + + result = self.arrow.isocalendar() + + assert result == self.arrow._datetime.isocalendar() + + def test_isoformat(self): + + result = self.arrow.isoformat() + + assert result == self.arrow._datetime.isoformat() + + def test_simplejson(self): + + result = json.dumps({"v": self.arrow.for_json()}, for_json=True) + + assert json.loads(result)["v"] == self.arrow._datetime.isoformat() + + def test_ctime(self): + + result = self.arrow.ctime() + + assert result == self.arrow._datetime.ctime() + + def test_strftime(self): + + result = self.arrow.strftime("%Y") + + assert result == self.arrow._datetime.strftime("%Y") + + +class TestArrowFalsePositiveDst: + """These tests relate to issues #376 and #551. + The key points in both issues are that arrow will assign a UTC timezone if none is provided and + .to() will change other attributes to be correct whereas .replace() only changes the specified attribute. + + Issue 376 + >>> arrow.get('2016-11-06').to('America/New_York').ceil('day') + < Arrow [2016-11-05T23:59:59.999999-04:00] > + + Issue 551 + >>> just_before = arrow.get('2018-11-04T01:59:59.999999') + >>> just_before + 2018-11-04T01:59:59.999999+00:00 + >>> just_after = just_before.shift(microseconds=1) + >>> just_after + 2018-11-04T02:00:00+00:00 + >>> just_before_eastern = just_before.replace(tzinfo='US/Eastern') + >>> just_before_eastern + 2018-11-04T01:59:59.999999-04:00 + >>> just_after_eastern = just_after.replace(tzinfo='US/Eastern') + >>> just_after_eastern + 2018-11-04T02:00:00-05:00 + """ + + def test_dst(self): + self.before_1 = arrow.Arrow( + 2016, 11, 6, 3, 59, tzinfo=tz.gettz("America/New_York") + ) + self.before_2 = arrow.Arrow(2016, 11, 6, tzinfo=tz.gettz("America/New_York")) + self.after_1 = arrow.Arrow(2016, 11, 6, 4, tzinfo=tz.gettz("America/New_York")) + self.after_2 = arrow.Arrow( + 2016, 11, 6, 23, 59, tzinfo=tz.gettz("America/New_York") + ) + self.before_3 = arrow.Arrow( + 2018, 11, 4, 3, 59, tzinfo=tz.gettz("America/New_York") + ) + self.before_4 = arrow.Arrow(2018, 11, 4, tzinfo=tz.gettz("America/New_York")) + self.after_3 = arrow.Arrow(2018, 11, 4, 4, tzinfo=tz.gettz("America/New_York")) + self.after_4 = arrow.Arrow( + 2018, 11, 4, 23, 59, tzinfo=tz.gettz("America/New_York") + ) + assert self.before_1.day == self.before_2.day + assert self.after_1.day == self.after_2.day + assert self.before_3.day == self.before_4.day + assert self.after_3.day == self.after_4.day + + +class TestArrowConversion: + def test_to(self): + + dt_from = datetime.now() + arrow_from = arrow.Arrow.fromdatetime(dt_from, tz.gettz("US/Pacific")) + + self.expected = dt_from.replace(tzinfo=tz.gettz("US/Pacific")).astimezone( + tz.tzutc() + ) + + assert arrow_from.to("UTC").datetime == self.expected + assert arrow_from.to(tz.tzutc()).datetime == self.expected + + # issue #368 + def test_to_pacific_then_utc(self): + result = arrow.Arrow(2018, 11, 4, 1, tzinfo="-08:00").to("US/Pacific").to("UTC") + assert result == arrow.Arrow(2018, 11, 4, 9) + + # issue #368 + def test_to_amsterdam_then_utc(self): + result = arrow.Arrow(2016, 10, 30).to("Europe/Amsterdam") + assert result.utcoffset() == timedelta(seconds=7200) + + # regression test for #690 + def test_to_israel_same_offset(self): + + result = arrow.Arrow(2019, 10, 27, 2, 21, 1, tzinfo="+03:00").to("Israel") + expected = arrow.Arrow(2019, 10, 27, 1, 21, 1, tzinfo="Israel") + + assert result == expected + assert result.utcoffset() != expected.utcoffset() + + # issue 315 + def test_anchorage_dst(self): + before = arrow.Arrow(2016, 3, 13, 1, 59, tzinfo="America/Anchorage") + after = arrow.Arrow(2016, 3, 13, 2, 1, tzinfo="America/Anchorage") + + assert before.utcoffset() != after.utcoffset() + + # issue 476 + def test_chicago_fall(self): + + result = arrow.Arrow(2017, 11, 5, 2, 1, tzinfo="-05:00").to("America/Chicago") + expected = arrow.Arrow(2017, 11, 5, 1, 1, tzinfo="America/Chicago") + + assert result == expected + assert result.utcoffset() != expected.utcoffset() + + def test_toronto_gap(self): + + before = arrow.Arrow(2011, 3, 13, 6, 30, tzinfo="UTC").to("America/Toronto") + after = arrow.Arrow(2011, 3, 13, 7, 30, tzinfo="UTC").to("America/Toronto") + + assert before.datetime.replace(tzinfo=None) == datetime(2011, 3, 13, 1, 30) + assert after.datetime.replace(tzinfo=None) == datetime(2011, 3, 13, 3, 30) + + assert before.utcoffset() != after.utcoffset() + + def test_sydney_gap(self): + + before = arrow.Arrow(2012, 10, 6, 15, 30, tzinfo="UTC").to("Australia/Sydney") + after = arrow.Arrow(2012, 10, 6, 16, 30, tzinfo="UTC").to("Australia/Sydney") + + assert before.datetime.replace(tzinfo=None) == datetime(2012, 10, 7, 1, 30) + assert after.datetime.replace(tzinfo=None) == datetime(2012, 10, 7, 3, 30) + + assert before.utcoffset() != after.utcoffset() + + +class TestArrowPickling: + def test_pickle_and_unpickle(self): + + dt = arrow.Arrow.utcnow() + + pickled = pickle.dumps(dt) + + unpickled = pickle.loads(pickled) + + assert unpickled == dt + + +class TestArrowReplace: + def test_not_attr(self): + + with pytest.raises(AttributeError): + arrow.Arrow.utcnow().replace(abc=1) + + def test_replace(self): + + arw = arrow.Arrow(2013, 5, 5, 12, 30, 45) + + assert arw.replace(year=2012) == arrow.Arrow(2012, 5, 5, 12, 30, 45) + assert arw.replace(month=1) == arrow.Arrow(2013, 1, 5, 12, 30, 45) + assert arw.replace(day=1) == arrow.Arrow(2013, 5, 1, 12, 30, 45) + assert arw.replace(hour=1) == arrow.Arrow(2013, 5, 5, 1, 30, 45) + assert arw.replace(minute=1) == arrow.Arrow(2013, 5, 5, 12, 1, 45) + assert arw.replace(second=1) == arrow.Arrow(2013, 5, 5, 12, 30, 1) + + def test_replace_tzinfo(self): + + arw = arrow.Arrow.utcnow().to("US/Eastern") + + result = arw.replace(tzinfo=tz.gettz("US/Pacific")) + + assert result == arw.datetime.replace(tzinfo=tz.gettz("US/Pacific")) + + def test_replace_fold(self): + + before = arrow.Arrow(2017, 11, 5, 1, tzinfo="America/New_York") + after = before.replace(fold=1) + + assert before.fold == 0 + assert after.fold == 1 + assert before == after + assert before.utcoffset() != after.utcoffset() + + def test_replace_fold_and_other(self): + + arw = arrow.Arrow(2013, 5, 5, 12, 30, 45) + + assert arw.replace(fold=1, minute=50) == arrow.Arrow(2013, 5, 5, 12, 50, 45) + assert arw.replace(minute=50, fold=1) == arrow.Arrow(2013, 5, 5, 12, 50, 45) + + def test_replace_week(self): + + with pytest.raises(AttributeError): + arrow.Arrow.utcnow().replace(week=1) + + def test_replace_quarter(self): + + with pytest.raises(AttributeError): + arrow.Arrow.utcnow().replace(quarter=1) + + def test_replace_quarter_and_fold(self): + with pytest.raises(AttributeError): + arrow.utcnow().replace(fold=1, quarter=1) + + with pytest.raises(AttributeError): + arrow.utcnow().replace(quarter=1, fold=1) + + def test_replace_other_kwargs(self): + + with pytest.raises(AttributeError): + arrow.utcnow().replace(abc="def") + + +class TestArrowShift: + def test_not_attr(self): + + now = arrow.Arrow.utcnow() + + with pytest.raises(AttributeError): + now.shift(abc=1) + + with pytest.raises(AttributeError): + now.shift(week=1) + + def test_shift(self): + + arw = arrow.Arrow(2013, 5, 5, 12, 30, 45) + + assert arw.shift(years=1) == arrow.Arrow(2014, 5, 5, 12, 30, 45) + assert arw.shift(quarters=1) == arrow.Arrow(2013, 8, 5, 12, 30, 45) + assert arw.shift(quarters=1, months=1) == arrow.Arrow(2013, 9, 5, 12, 30, 45) + assert arw.shift(months=1) == arrow.Arrow(2013, 6, 5, 12, 30, 45) + assert arw.shift(weeks=1) == arrow.Arrow(2013, 5, 12, 12, 30, 45) + assert arw.shift(days=1) == arrow.Arrow(2013, 5, 6, 12, 30, 45) + assert arw.shift(hours=1) == arrow.Arrow(2013, 5, 5, 13, 30, 45) + assert arw.shift(minutes=1) == arrow.Arrow(2013, 5, 5, 12, 31, 45) + assert arw.shift(seconds=1) == arrow.Arrow(2013, 5, 5, 12, 30, 46) + assert arw.shift(microseconds=1) == arrow.Arrow(2013, 5, 5, 12, 30, 45, 1) + + # Remember: Python's weekday 0 is Monday + assert arw.shift(weekday=0) == arrow.Arrow(2013, 5, 6, 12, 30, 45) + assert arw.shift(weekday=1) == arrow.Arrow(2013, 5, 7, 12, 30, 45) + assert arw.shift(weekday=2) == arrow.Arrow(2013, 5, 8, 12, 30, 45) + assert arw.shift(weekday=3) == arrow.Arrow(2013, 5, 9, 12, 30, 45) + assert arw.shift(weekday=4) == arrow.Arrow(2013, 5, 10, 12, 30, 45) + assert arw.shift(weekday=5) == arrow.Arrow(2013, 5, 11, 12, 30, 45) + assert arw.shift(weekday=6) == arw + + with pytest.raises(IndexError): + arw.shift(weekday=7) + + # Use dateutil.relativedelta's convenient day instances + assert arw.shift(weekday=MO) == arrow.Arrow(2013, 5, 6, 12, 30, 45) + assert arw.shift(weekday=MO(0)) == arrow.Arrow(2013, 5, 6, 12, 30, 45) + assert arw.shift(weekday=MO(1)) == arrow.Arrow(2013, 5, 6, 12, 30, 45) + assert arw.shift(weekday=MO(2)) == arrow.Arrow(2013, 5, 13, 12, 30, 45) + assert arw.shift(weekday=TU) == arrow.Arrow(2013, 5, 7, 12, 30, 45) + assert arw.shift(weekday=TU(0)) == arrow.Arrow(2013, 5, 7, 12, 30, 45) + assert arw.shift(weekday=TU(1)) == arrow.Arrow(2013, 5, 7, 12, 30, 45) + assert arw.shift(weekday=TU(2)) == arrow.Arrow(2013, 5, 14, 12, 30, 45) + assert arw.shift(weekday=WE) == arrow.Arrow(2013, 5, 8, 12, 30, 45) + assert arw.shift(weekday=WE(0)) == arrow.Arrow(2013, 5, 8, 12, 30, 45) + assert arw.shift(weekday=WE(1)) == arrow.Arrow(2013, 5, 8, 12, 30, 45) + assert arw.shift(weekday=WE(2)) == arrow.Arrow(2013, 5, 15, 12, 30, 45) + assert arw.shift(weekday=TH) == arrow.Arrow(2013, 5, 9, 12, 30, 45) + assert arw.shift(weekday=TH(0)) == arrow.Arrow(2013, 5, 9, 12, 30, 45) + assert arw.shift(weekday=TH(1)) == arrow.Arrow(2013, 5, 9, 12, 30, 45) + assert arw.shift(weekday=TH(2)) == arrow.Arrow(2013, 5, 16, 12, 30, 45) + assert arw.shift(weekday=FR) == arrow.Arrow(2013, 5, 10, 12, 30, 45) + assert arw.shift(weekday=FR(0)) == arrow.Arrow(2013, 5, 10, 12, 30, 45) + assert arw.shift(weekday=FR(1)) == arrow.Arrow(2013, 5, 10, 12, 30, 45) + assert arw.shift(weekday=FR(2)) == arrow.Arrow(2013, 5, 17, 12, 30, 45) + assert arw.shift(weekday=SA) == arrow.Arrow(2013, 5, 11, 12, 30, 45) + assert arw.shift(weekday=SA(0)) == arrow.Arrow(2013, 5, 11, 12, 30, 45) + assert arw.shift(weekday=SA(1)) == arrow.Arrow(2013, 5, 11, 12, 30, 45) + assert arw.shift(weekday=SA(2)) == arrow.Arrow(2013, 5, 18, 12, 30, 45) + assert arw.shift(weekday=SU) == arw + assert arw.shift(weekday=SU(0)) == arw + assert arw.shift(weekday=SU(1)) == arw + assert arw.shift(weekday=SU(2)) == arrow.Arrow(2013, 5, 12, 12, 30, 45) + + def test_shift_negative(self): + + arw = arrow.Arrow(2013, 5, 5, 12, 30, 45) + + assert arw.shift(years=-1) == arrow.Arrow(2012, 5, 5, 12, 30, 45) + assert arw.shift(quarters=-1) == arrow.Arrow(2013, 2, 5, 12, 30, 45) + assert arw.shift(quarters=-1, months=-1) == arrow.Arrow(2013, 1, 5, 12, 30, 45) + assert arw.shift(months=-1) == arrow.Arrow(2013, 4, 5, 12, 30, 45) + assert arw.shift(weeks=-1) == arrow.Arrow(2013, 4, 28, 12, 30, 45) + assert arw.shift(days=-1) == arrow.Arrow(2013, 5, 4, 12, 30, 45) + assert arw.shift(hours=-1) == arrow.Arrow(2013, 5, 5, 11, 30, 45) + assert arw.shift(minutes=-1) == arrow.Arrow(2013, 5, 5, 12, 29, 45) + assert arw.shift(seconds=-1) == arrow.Arrow(2013, 5, 5, 12, 30, 44) + assert arw.shift(microseconds=-1) == arrow.Arrow(2013, 5, 5, 12, 30, 44, 999999) + + # Not sure how practical these negative weekdays are + assert arw.shift(weekday=-1) == arw.shift(weekday=SU) + assert arw.shift(weekday=-2) == arw.shift(weekday=SA) + assert arw.shift(weekday=-3) == arw.shift(weekday=FR) + assert arw.shift(weekday=-4) == arw.shift(weekday=TH) + assert arw.shift(weekday=-5) == arw.shift(weekday=WE) + assert arw.shift(weekday=-6) == arw.shift(weekday=TU) + assert arw.shift(weekday=-7) == arw.shift(weekday=MO) + + with pytest.raises(IndexError): + arw.shift(weekday=-8) + + assert arw.shift(weekday=MO(-1)) == arrow.Arrow(2013, 4, 29, 12, 30, 45) + assert arw.shift(weekday=TU(-1)) == arrow.Arrow(2013, 4, 30, 12, 30, 45) + assert arw.shift(weekday=WE(-1)) == arrow.Arrow(2013, 5, 1, 12, 30, 45) + assert arw.shift(weekday=TH(-1)) == arrow.Arrow(2013, 5, 2, 12, 30, 45) + assert arw.shift(weekday=FR(-1)) == arrow.Arrow(2013, 5, 3, 12, 30, 45) + assert arw.shift(weekday=SA(-1)) == arrow.Arrow(2013, 5, 4, 12, 30, 45) + assert arw.shift(weekday=SU(-1)) == arw + assert arw.shift(weekday=SU(-2)) == arrow.Arrow(2013, 4, 28, 12, 30, 45) + + def test_shift_quarters_bug(self): + + arw = arrow.Arrow(2013, 5, 5, 12, 30, 45) + + # The value of the last-read argument was used instead of the ``quarters`` argument. + # Recall that the keyword argument dict, like all dicts, is unordered, so only certain + # combinations of arguments would exhibit this. + assert arw.shift(quarters=0, years=1) == arrow.Arrow(2014, 5, 5, 12, 30, 45) + assert arw.shift(quarters=0, months=1) == arrow.Arrow(2013, 6, 5, 12, 30, 45) + assert arw.shift(quarters=0, weeks=1) == arrow.Arrow(2013, 5, 12, 12, 30, 45) + assert arw.shift(quarters=0, days=1) == arrow.Arrow(2013, 5, 6, 12, 30, 45) + assert arw.shift(quarters=0, hours=1) == arrow.Arrow(2013, 5, 5, 13, 30, 45) + assert arw.shift(quarters=0, minutes=1) == arrow.Arrow(2013, 5, 5, 12, 31, 45) + assert arw.shift(quarters=0, seconds=1) == arrow.Arrow(2013, 5, 5, 12, 30, 46) + assert arw.shift(quarters=0, microseconds=1) == arrow.Arrow( + 2013, 5, 5, 12, 30, 45, 1 + ) + + def test_shift_positive_imaginary(self): + + # Avoid shifting into imaginary datetimes, take into account DST and other timezone changes. + + new_york = arrow.Arrow(2017, 3, 12, 1, 30, tzinfo="America/New_York") + assert new_york.shift(hours=+1) == arrow.Arrow( + 2017, 3, 12, 3, 30, tzinfo="America/New_York" + ) + + # pendulum example + paris = arrow.Arrow(2013, 3, 31, 1, 50, tzinfo="Europe/Paris") + assert paris.shift(minutes=+20) == arrow.Arrow( + 2013, 3, 31, 3, 10, tzinfo="Europe/Paris" + ) + + canberra = arrow.Arrow(2018, 10, 7, 1, 30, tzinfo="Australia/Canberra") + assert canberra.shift(hours=+1) == arrow.Arrow( + 2018, 10, 7, 3, 30, tzinfo="Australia/Canberra" + ) + + kiev = arrow.Arrow(2018, 3, 25, 2, 30, tzinfo="Europe/Kiev") + assert kiev.shift(hours=+1) == arrow.Arrow( + 2018, 3, 25, 4, 30, tzinfo="Europe/Kiev" + ) + + # Edge case, the entire day of 2011-12-30 is imaginary in this zone! + apia = arrow.Arrow(2011, 12, 29, 23, tzinfo="Pacific/Apia") + assert apia.shift(hours=+2) == arrow.Arrow( + 2011, 12, 31, 1, tzinfo="Pacific/Apia" + ) + + def test_shift_negative_imaginary(self): + + new_york = arrow.Arrow(2011, 3, 13, 3, 30, tzinfo="America/New_York") + assert new_york.shift(hours=-1) == arrow.Arrow( + 2011, 3, 13, 3, 30, tzinfo="America/New_York" + ) + assert new_york.shift(hours=-2) == arrow.Arrow( + 2011, 3, 13, 1, 30, tzinfo="America/New_York" + ) + + london = arrow.Arrow(2019, 3, 31, 2, tzinfo="Europe/London") + assert london.shift(hours=-1) == arrow.Arrow( + 2019, 3, 31, 2, tzinfo="Europe/London" + ) + assert london.shift(hours=-2) == arrow.Arrow( + 2019, 3, 31, 0, tzinfo="Europe/London" + ) + + # edge case, crossing the international dateline + apia = arrow.Arrow(2011, 12, 31, 1, tzinfo="Pacific/Apia") + assert apia.shift(hours=-2) == arrow.Arrow( + 2011, 12, 31, 23, tzinfo="Pacific/Apia" + ) + + @pytest.mark.skipif( + dateutil.__version__ < "2.7.1", reason="old tz database (2018d needed)" + ) + def test_shift_kiritimati(self): + # corrected 2018d tz database release, will fail in earlier versions + + kiritimati = arrow.Arrow(1994, 12, 30, 12, 30, tzinfo="Pacific/Kiritimati") + assert kiritimati.shift(days=+1) == arrow.Arrow( + 1995, 1, 1, 12, 30, tzinfo="Pacific/Kiritimati" + ) + + @pytest.mark.skipif( + sys.version_info < (3, 6), reason="unsupported before python 3.6" + ) + def shift_imaginary_seconds(self): + # offset has a seconds component + monrovia = arrow.Arrow(1972, 1, 6, 23, tzinfo="Africa/Monrovia") + assert monrovia.shift(hours=+1, minutes=+30) == arrow.Arrow( + 1972, 1, 7, 1, 14, 30, tzinfo="Africa/Monrovia" + ) + + +class TestArrowRange: + def test_year(self): + + result = list( + arrow.Arrow.range( + "year", datetime(2013, 1, 2, 3, 4, 5), datetime(2016, 4, 5, 6, 7, 8) + ) + ) + + assert result == [ + arrow.Arrow(2013, 1, 2, 3, 4, 5), + arrow.Arrow(2014, 1, 2, 3, 4, 5), + arrow.Arrow(2015, 1, 2, 3, 4, 5), + arrow.Arrow(2016, 1, 2, 3, 4, 5), + ] + + def test_quarter(self): + + result = list( + arrow.Arrow.range( + "quarter", datetime(2013, 2, 3, 4, 5, 6), datetime(2013, 5, 6, 7, 8, 9) + ) + ) + + assert result == [ + arrow.Arrow(2013, 2, 3, 4, 5, 6), + arrow.Arrow(2013, 5, 3, 4, 5, 6), + ] + + def test_month(self): + + result = list( + arrow.Arrow.range( + "month", datetime(2013, 2, 3, 4, 5, 6), datetime(2013, 5, 6, 7, 8, 9) + ) + ) + + assert result == [ + arrow.Arrow(2013, 2, 3, 4, 5, 6), + arrow.Arrow(2013, 3, 3, 4, 5, 6), + arrow.Arrow(2013, 4, 3, 4, 5, 6), + arrow.Arrow(2013, 5, 3, 4, 5, 6), + ] + + def test_week(self): + + result = list( + arrow.Arrow.range( + "week", datetime(2013, 9, 1, 2, 3, 4), datetime(2013, 10, 1, 2, 3, 4) + ) + ) + + assert result == [ + arrow.Arrow(2013, 9, 1, 2, 3, 4), + arrow.Arrow(2013, 9, 8, 2, 3, 4), + arrow.Arrow(2013, 9, 15, 2, 3, 4), + arrow.Arrow(2013, 9, 22, 2, 3, 4), + arrow.Arrow(2013, 9, 29, 2, 3, 4), + ] + + def test_day(self): + + result = list( + arrow.Arrow.range( + "day", datetime(2013, 1, 2, 3, 4, 5), datetime(2013, 1, 5, 6, 7, 8) + ) + ) + + assert result == [ + arrow.Arrow(2013, 1, 2, 3, 4, 5), + arrow.Arrow(2013, 1, 3, 3, 4, 5), + arrow.Arrow(2013, 1, 4, 3, 4, 5), + arrow.Arrow(2013, 1, 5, 3, 4, 5), + ] + + def test_hour(self): + + result = list( + arrow.Arrow.range( + "hour", datetime(2013, 1, 2, 3, 4, 5), datetime(2013, 1, 2, 6, 7, 8) + ) + ) + + assert result == [ + arrow.Arrow(2013, 1, 2, 3, 4, 5), + arrow.Arrow(2013, 1, 2, 4, 4, 5), + arrow.Arrow(2013, 1, 2, 5, 4, 5), + arrow.Arrow(2013, 1, 2, 6, 4, 5), + ] + + result = list( + arrow.Arrow.range( + "hour", datetime(2013, 1, 2, 3, 4, 5), datetime(2013, 1, 2, 3, 4, 5) + ) + ) + + assert result == [arrow.Arrow(2013, 1, 2, 3, 4, 5)] + + def test_minute(self): + + result = list( + arrow.Arrow.range( + "minute", datetime(2013, 1, 2, 3, 4, 5), datetime(2013, 1, 2, 3, 7, 8) + ) + ) + + assert result == [ + arrow.Arrow(2013, 1, 2, 3, 4, 5), + arrow.Arrow(2013, 1, 2, 3, 5, 5), + arrow.Arrow(2013, 1, 2, 3, 6, 5), + arrow.Arrow(2013, 1, 2, 3, 7, 5), + ] + + def test_second(self): + + result = list( + arrow.Arrow.range( + "second", datetime(2013, 1, 2, 3, 4, 5), datetime(2013, 1, 2, 3, 4, 8) + ) + ) + + assert result == [ + arrow.Arrow(2013, 1, 2, 3, 4, 5), + arrow.Arrow(2013, 1, 2, 3, 4, 6), + arrow.Arrow(2013, 1, 2, 3, 4, 7), + arrow.Arrow(2013, 1, 2, 3, 4, 8), + ] + + def test_arrow(self): + + result = list( + arrow.Arrow.range( + "day", + arrow.Arrow(2013, 1, 2, 3, 4, 5), + arrow.Arrow(2013, 1, 5, 6, 7, 8), + ) + ) + + assert result == [ + arrow.Arrow(2013, 1, 2, 3, 4, 5), + arrow.Arrow(2013, 1, 3, 3, 4, 5), + arrow.Arrow(2013, 1, 4, 3, 4, 5), + arrow.Arrow(2013, 1, 5, 3, 4, 5), + ] + + def test_naive_tz(self): + + result = arrow.Arrow.range( + "year", datetime(2013, 1, 2, 3), datetime(2016, 4, 5, 6), "US/Pacific" + ) + + for r in result: + assert r.tzinfo == tz.gettz("US/Pacific") + + def test_aware_same_tz(self): + + result = arrow.Arrow.range( + "day", + arrow.Arrow(2013, 1, 1, tzinfo=tz.gettz("US/Pacific")), + arrow.Arrow(2013, 1, 3, tzinfo=tz.gettz("US/Pacific")), + ) + + for r in result: + assert r.tzinfo == tz.gettz("US/Pacific") + + def test_aware_different_tz(self): + + result = arrow.Arrow.range( + "day", + datetime(2013, 1, 1, tzinfo=tz.gettz("US/Eastern")), + datetime(2013, 1, 3, tzinfo=tz.gettz("US/Pacific")), + ) + + for r in result: + assert r.tzinfo == tz.gettz("US/Eastern") + + def test_aware_tz(self): + + result = arrow.Arrow.range( + "day", + datetime(2013, 1, 1, tzinfo=tz.gettz("US/Eastern")), + datetime(2013, 1, 3, tzinfo=tz.gettz("US/Pacific")), + tz=tz.gettz("US/Central"), + ) + + for r in result: + assert r.tzinfo == tz.gettz("US/Central") + + def test_imaginary(self): + # issue #72, avoid duplication in utc column + + before = arrow.Arrow(2018, 3, 10, 23, tzinfo="US/Pacific") + after = arrow.Arrow(2018, 3, 11, 4, tzinfo="US/Pacific") + + pacific_range = [t for t in arrow.Arrow.range("hour", before, after)] + utc_range = [t.to("utc") for t in arrow.Arrow.range("hour", before, after)] + + assert len(pacific_range) == len(set(pacific_range)) + assert len(utc_range) == len(set(utc_range)) + + def test_unsupported(self): + + with pytest.raises(AttributeError): + next(arrow.Arrow.range("abc", datetime.utcnow(), datetime.utcnow())) + + def test_range_over_months_ending_on_different_days(self): + # regression test for issue #842 + result = list(arrow.Arrow.range("month", datetime(2015, 1, 31), limit=4)) + assert result == [ + arrow.Arrow(2015, 1, 31), + arrow.Arrow(2015, 2, 28), + arrow.Arrow(2015, 3, 31), + arrow.Arrow(2015, 4, 30), + ] + + result = list(arrow.Arrow.range("month", datetime(2015, 1, 30), limit=3)) + assert result == [ + arrow.Arrow(2015, 1, 30), + arrow.Arrow(2015, 2, 28), + arrow.Arrow(2015, 3, 30), + ] + + result = list(arrow.Arrow.range("month", datetime(2015, 2, 28), limit=3)) + assert result == [ + arrow.Arrow(2015, 2, 28), + arrow.Arrow(2015, 3, 28), + arrow.Arrow(2015, 4, 28), + ] + + result = list(arrow.Arrow.range("month", datetime(2015, 3, 31), limit=3)) + assert result == [ + arrow.Arrow(2015, 3, 31), + arrow.Arrow(2015, 4, 30), + arrow.Arrow(2015, 5, 31), + ] + + def test_range_over_quarter_months_ending_on_different_days(self): + result = list(arrow.Arrow.range("quarter", datetime(2014, 11, 30), limit=3)) + assert result == [ + arrow.Arrow(2014, 11, 30), + arrow.Arrow(2015, 2, 28), + arrow.Arrow(2015, 5, 30), + ] + + def test_range_over_year_maintains_end_date_across_leap_year(self): + result = list(arrow.Arrow.range("year", datetime(2012, 2, 29), limit=5)) + assert result == [ + arrow.Arrow(2012, 2, 29), + arrow.Arrow(2013, 2, 28), + arrow.Arrow(2014, 2, 28), + arrow.Arrow(2015, 2, 28), + arrow.Arrow(2016, 2, 29), + ] + + +class TestArrowSpanRange: + def test_year(self): + + result = list( + arrow.Arrow.span_range("year", datetime(2013, 2, 1), datetime(2016, 3, 31)) + ) + + assert result == [ + ( + arrow.Arrow(2013, 1, 1), + arrow.Arrow(2013, 12, 31, 23, 59, 59, 999999), + ), + ( + arrow.Arrow(2014, 1, 1), + arrow.Arrow(2014, 12, 31, 23, 59, 59, 999999), + ), + ( + arrow.Arrow(2015, 1, 1), + arrow.Arrow(2015, 12, 31, 23, 59, 59, 999999), + ), + ( + arrow.Arrow(2016, 1, 1), + arrow.Arrow(2016, 12, 31, 23, 59, 59, 999999), + ), + ] + + def test_quarter(self): + + result = list( + arrow.Arrow.span_range( + "quarter", datetime(2013, 2, 2), datetime(2013, 5, 15) + ) + ) + + assert result == [ + (arrow.Arrow(2013, 1, 1), arrow.Arrow(2013, 3, 31, 23, 59, 59, 999999)), + (arrow.Arrow(2013, 4, 1), arrow.Arrow(2013, 6, 30, 23, 59, 59, 999999)), + ] + + def test_month(self): + + result = list( + arrow.Arrow.span_range("month", datetime(2013, 1, 2), datetime(2013, 4, 15)) + ) + + assert result == [ + (arrow.Arrow(2013, 1, 1), arrow.Arrow(2013, 1, 31, 23, 59, 59, 999999)), + (arrow.Arrow(2013, 2, 1), arrow.Arrow(2013, 2, 28, 23, 59, 59, 999999)), + (arrow.Arrow(2013, 3, 1), arrow.Arrow(2013, 3, 31, 23, 59, 59, 999999)), + (arrow.Arrow(2013, 4, 1), arrow.Arrow(2013, 4, 30, 23, 59, 59, 999999)), + ] + + def test_week(self): + + result = list( + arrow.Arrow.span_range("week", datetime(2013, 2, 2), datetime(2013, 2, 28)) + ) + + assert result == [ + (arrow.Arrow(2013, 1, 28), arrow.Arrow(2013, 2, 3, 23, 59, 59, 999999)), + (arrow.Arrow(2013, 2, 4), arrow.Arrow(2013, 2, 10, 23, 59, 59, 999999)), + ( + arrow.Arrow(2013, 2, 11), + arrow.Arrow(2013, 2, 17, 23, 59, 59, 999999), + ), + ( + arrow.Arrow(2013, 2, 18), + arrow.Arrow(2013, 2, 24, 23, 59, 59, 999999), + ), + (arrow.Arrow(2013, 2, 25), arrow.Arrow(2013, 3, 3, 23, 59, 59, 999999)), + ] + + def test_day(self): + + result = list( + arrow.Arrow.span_range( + "day", datetime(2013, 1, 1, 12), datetime(2013, 1, 4, 12) + ) + ) + + assert result == [ + ( + arrow.Arrow(2013, 1, 1, 0), + arrow.Arrow(2013, 1, 1, 23, 59, 59, 999999), + ), + ( + arrow.Arrow(2013, 1, 2, 0), + arrow.Arrow(2013, 1, 2, 23, 59, 59, 999999), + ), + ( + arrow.Arrow(2013, 1, 3, 0), + arrow.Arrow(2013, 1, 3, 23, 59, 59, 999999), + ), + ( + arrow.Arrow(2013, 1, 4, 0), + arrow.Arrow(2013, 1, 4, 23, 59, 59, 999999), + ), + ] + + def test_days(self): + + result = list( + arrow.Arrow.span_range( + "days", datetime(2013, 1, 1, 12), datetime(2013, 1, 4, 12) + ) + ) + + assert result == [ + ( + arrow.Arrow(2013, 1, 1, 0), + arrow.Arrow(2013, 1, 1, 23, 59, 59, 999999), + ), + ( + arrow.Arrow(2013, 1, 2, 0), + arrow.Arrow(2013, 1, 2, 23, 59, 59, 999999), + ), + ( + arrow.Arrow(2013, 1, 3, 0), + arrow.Arrow(2013, 1, 3, 23, 59, 59, 999999), + ), + ( + arrow.Arrow(2013, 1, 4, 0), + arrow.Arrow(2013, 1, 4, 23, 59, 59, 999999), + ), + ] + + def test_hour(self): + + result = list( + arrow.Arrow.span_range( + "hour", datetime(2013, 1, 1, 0, 30), datetime(2013, 1, 1, 3, 30) + ) + ) + + assert result == [ + ( + arrow.Arrow(2013, 1, 1, 0), + arrow.Arrow(2013, 1, 1, 0, 59, 59, 999999), + ), + ( + arrow.Arrow(2013, 1, 1, 1), + arrow.Arrow(2013, 1, 1, 1, 59, 59, 999999), + ), + ( + arrow.Arrow(2013, 1, 1, 2), + arrow.Arrow(2013, 1, 1, 2, 59, 59, 999999), + ), + ( + arrow.Arrow(2013, 1, 1, 3), + arrow.Arrow(2013, 1, 1, 3, 59, 59, 999999), + ), + ] + + result = list( + arrow.Arrow.span_range( + "hour", datetime(2013, 1, 1, 3, 30), datetime(2013, 1, 1, 3, 30) + ) + ) + + assert result == [ + (arrow.Arrow(2013, 1, 1, 3), arrow.Arrow(2013, 1, 1, 3, 59, 59, 999999)) + ] + + def test_minute(self): + + result = list( + arrow.Arrow.span_range( + "minute", datetime(2013, 1, 1, 0, 0, 30), datetime(2013, 1, 1, 0, 3, 30) + ) + ) + + assert result == [ + ( + arrow.Arrow(2013, 1, 1, 0, 0), + arrow.Arrow(2013, 1, 1, 0, 0, 59, 999999), + ), + ( + arrow.Arrow(2013, 1, 1, 0, 1), + arrow.Arrow(2013, 1, 1, 0, 1, 59, 999999), + ), + ( + arrow.Arrow(2013, 1, 1, 0, 2), + arrow.Arrow(2013, 1, 1, 0, 2, 59, 999999), + ), + ( + arrow.Arrow(2013, 1, 1, 0, 3), + arrow.Arrow(2013, 1, 1, 0, 3, 59, 999999), + ), + ] + + def test_second(self): + + result = list( + arrow.Arrow.span_range( + "second", datetime(2013, 1, 1), datetime(2013, 1, 1, 0, 0, 3) + ) + ) + + assert result == [ + ( + arrow.Arrow(2013, 1, 1, 0, 0, 0), + arrow.Arrow(2013, 1, 1, 0, 0, 0, 999999), + ), + ( + arrow.Arrow(2013, 1, 1, 0, 0, 1), + arrow.Arrow(2013, 1, 1, 0, 0, 1, 999999), + ), + ( + arrow.Arrow(2013, 1, 1, 0, 0, 2), + arrow.Arrow(2013, 1, 1, 0, 0, 2, 999999), + ), + ( + arrow.Arrow(2013, 1, 1, 0, 0, 3), + arrow.Arrow(2013, 1, 1, 0, 0, 3, 999999), + ), + ] + + def test_naive_tz(self): + + tzinfo = tz.gettz("US/Pacific") + + result = arrow.Arrow.span_range( + "hour", datetime(2013, 1, 1, 0), datetime(2013, 1, 1, 3, 59), "US/Pacific" + ) + + for f, c in result: + assert f.tzinfo == tzinfo + assert c.tzinfo == tzinfo + + def test_aware_same_tz(self): + + tzinfo = tz.gettz("US/Pacific") + + result = arrow.Arrow.span_range( + "hour", + datetime(2013, 1, 1, 0, tzinfo=tzinfo), + datetime(2013, 1, 1, 2, 59, tzinfo=tzinfo), + ) + + for f, c in result: + assert f.tzinfo == tzinfo + assert c.tzinfo == tzinfo + + def test_aware_different_tz(self): + + tzinfo1 = tz.gettz("US/Pacific") + tzinfo2 = tz.gettz("US/Eastern") + + result = arrow.Arrow.span_range( + "hour", + datetime(2013, 1, 1, 0, tzinfo=tzinfo1), + datetime(2013, 1, 1, 2, 59, tzinfo=tzinfo2), + ) + + for f, c in result: + assert f.tzinfo == tzinfo1 + assert c.tzinfo == tzinfo1 + + def test_aware_tz(self): + + result = arrow.Arrow.span_range( + "hour", + datetime(2013, 1, 1, 0, tzinfo=tz.gettz("US/Eastern")), + datetime(2013, 1, 1, 2, 59, tzinfo=tz.gettz("US/Eastern")), + tz="US/Central", + ) + + for f, c in result: + assert f.tzinfo == tz.gettz("US/Central") + assert c.tzinfo == tz.gettz("US/Central") + + def test_bounds_param_is_passed(self): + + result = list( + arrow.Arrow.span_range( + "quarter", datetime(2013, 2, 2), datetime(2013, 5, 15), bounds="[]" + ) + ) + + assert result == [ + (arrow.Arrow(2013, 1, 1), arrow.Arrow(2013, 4, 1)), + (arrow.Arrow(2013, 4, 1), arrow.Arrow(2013, 7, 1)), + ] + + +class TestArrowInterval: + def test_incorrect_input(self): + with pytest.raises(ValueError): + list( + arrow.Arrow.interval( + "month", datetime(2013, 1, 2), datetime(2013, 4, 15), 0 + ) + ) + + def test_correct(self): + result = list( + arrow.Arrow.interval( + "hour", datetime(2013, 5, 5, 12, 30), datetime(2013, 5, 5, 17, 15), 2 + ) + ) + + assert result == [ + ( + arrow.Arrow(2013, 5, 5, 12), + arrow.Arrow(2013, 5, 5, 13, 59, 59, 999999), + ), + ( + arrow.Arrow(2013, 5, 5, 14), + arrow.Arrow(2013, 5, 5, 15, 59, 59, 999999), + ), + ( + arrow.Arrow(2013, 5, 5, 16), + arrow.Arrow(2013, 5, 5, 17, 59, 59, 999999), + ), + ] + + def test_bounds_param_is_passed(self): + result = list( + arrow.Arrow.interval( + "hour", + datetime(2013, 5, 5, 12, 30), + datetime(2013, 5, 5, 17, 15), + 2, + bounds="[]", + ) + ) + + assert result == [ + (arrow.Arrow(2013, 5, 5, 12), arrow.Arrow(2013, 5, 5, 14)), + (arrow.Arrow(2013, 5, 5, 14), arrow.Arrow(2013, 5, 5, 16)), + (arrow.Arrow(2013, 5, 5, 16), arrow.Arrow(2013, 5, 5, 18)), + ] + + +@pytest.mark.usefixtures("time_2013_02_15") +class TestArrowSpan: + def test_span_attribute(self): + + with pytest.raises(AttributeError): + self.arrow.span("span") + + def test_span_year(self): + + floor, ceil = self.arrow.span("year") + + assert floor == datetime(2013, 1, 1, tzinfo=tz.tzutc()) + assert ceil == datetime(2013, 12, 31, 23, 59, 59, 999999, tzinfo=tz.tzutc()) + + def test_span_quarter(self): + + floor, ceil = self.arrow.span("quarter") + + assert floor == datetime(2013, 1, 1, tzinfo=tz.tzutc()) + assert ceil == datetime(2013, 3, 31, 23, 59, 59, 999999, tzinfo=tz.tzutc()) + + def test_span_quarter_count(self): + + floor, ceil = self.arrow.span("quarter", 2) + + assert floor == datetime(2013, 1, 1, tzinfo=tz.tzutc()) + assert ceil == datetime(2013, 6, 30, 23, 59, 59, 999999, tzinfo=tz.tzutc()) + + def test_span_year_count(self): + + floor, ceil = self.arrow.span("year", 2) + + assert floor == datetime(2013, 1, 1, tzinfo=tz.tzutc()) + assert ceil == datetime(2014, 12, 31, 23, 59, 59, 999999, tzinfo=tz.tzutc()) + + def test_span_month(self): + + floor, ceil = self.arrow.span("month") + + assert floor == datetime(2013, 2, 1, tzinfo=tz.tzutc()) + assert ceil == datetime(2013, 2, 28, 23, 59, 59, 999999, tzinfo=tz.tzutc()) + + def test_span_week(self): + + floor, ceil = self.arrow.span("week") + + assert floor == datetime(2013, 2, 11, tzinfo=tz.tzutc()) + assert ceil == datetime(2013, 2, 17, 23, 59, 59, 999999, tzinfo=tz.tzutc()) + + def test_span_day(self): + + floor, ceil = self.arrow.span("day") + + assert floor == datetime(2013, 2, 15, tzinfo=tz.tzutc()) + assert ceil == datetime(2013, 2, 15, 23, 59, 59, 999999, tzinfo=tz.tzutc()) + + def test_span_hour(self): + + floor, ceil = self.arrow.span("hour") + + assert floor == datetime(2013, 2, 15, 3, tzinfo=tz.tzutc()) + assert ceil == datetime(2013, 2, 15, 3, 59, 59, 999999, tzinfo=tz.tzutc()) + + def test_span_minute(self): + + floor, ceil = self.arrow.span("minute") + + assert floor == datetime(2013, 2, 15, 3, 41, tzinfo=tz.tzutc()) + assert ceil == datetime(2013, 2, 15, 3, 41, 59, 999999, tzinfo=tz.tzutc()) + + def test_span_second(self): + + floor, ceil = self.arrow.span("second") + + assert floor == datetime(2013, 2, 15, 3, 41, 22, tzinfo=tz.tzutc()) + assert ceil == datetime(2013, 2, 15, 3, 41, 22, 999999, tzinfo=tz.tzutc()) + + def test_span_microsecond(self): + + floor, ceil = self.arrow.span("microsecond") + + assert floor == datetime(2013, 2, 15, 3, 41, 22, 8923, tzinfo=tz.tzutc()) + assert ceil == datetime(2013, 2, 15, 3, 41, 22, 8923, tzinfo=tz.tzutc()) + + def test_floor(self): + + floor, ceil = self.arrow.span("month") + + assert floor == self.arrow.floor("month") + assert ceil == self.arrow.ceil("month") + + def test_span_inclusive_inclusive(self): + + floor, ceil = self.arrow.span("hour", bounds="[]") + + assert floor == datetime(2013, 2, 15, 3, tzinfo=tz.tzutc()) + assert ceil == datetime(2013, 2, 15, 4, tzinfo=tz.tzutc()) + + def test_span_exclusive_inclusive(self): + + floor, ceil = self.arrow.span("hour", bounds="(]") + + assert floor == datetime(2013, 2, 15, 3, 0, 0, 1, tzinfo=tz.tzutc()) + assert ceil == datetime(2013, 2, 15, 4, tzinfo=tz.tzutc()) + + def test_span_exclusive_exclusive(self): + + floor, ceil = self.arrow.span("hour", bounds="()") + + assert floor == datetime(2013, 2, 15, 3, 0, 0, 1, tzinfo=tz.tzutc()) + assert ceil == datetime(2013, 2, 15, 3, 59, 59, 999999, tzinfo=tz.tzutc()) + + def test_bounds_are_validated(self): + + with pytest.raises(ValueError): + floor, ceil = self.arrow.span("hour", bounds="][") + + +@pytest.mark.usefixtures("time_2013_01_01") +class TestArrowHumanize: + def test_granularity(self): + + assert self.now.humanize(granularity="second") == "just now" + + later1 = self.now.shift(seconds=1) + assert self.now.humanize(later1, granularity="second") == "just now" + assert later1.humanize(self.now, granularity="second") == "just now" + assert self.now.humanize(later1, granularity="minute") == "0 minutes ago" + assert later1.humanize(self.now, granularity="minute") == "in 0 minutes" + + later100 = self.now.shift(seconds=100) + assert self.now.humanize(later100, granularity="second") == "100 seconds ago" + assert later100.humanize(self.now, granularity="second") == "in 100 seconds" + assert self.now.humanize(later100, granularity="minute") == "a minute ago" + assert later100.humanize(self.now, granularity="minute") == "in a minute" + assert self.now.humanize(later100, granularity="hour") == "0 hours ago" + assert later100.humanize(self.now, granularity="hour") == "in 0 hours" + + later4000 = self.now.shift(seconds=4000) + assert self.now.humanize(later4000, granularity="minute") == "66 minutes ago" + assert later4000.humanize(self.now, granularity="minute") == "in 66 minutes" + assert self.now.humanize(later4000, granularity="hour") == "an hour ago" + assert later4000.humanize(self.now, granularity="hour") == "in an hour" + assert self.now.humanize(later4000, granularity="day") == "0 days ago" + assert later4000.humanize(self.now, granularity="day") == "in 0 days" + + later105 = self.now.shift(seconds=10 ** 5) + assert self.now.humanize(later105, granularity="hour") == "27 hours ago" + assert later105.humanize(self.now, granularity="hour") == "in 27 hours" + assert self.now.humanize(later105, granularity="day") == "a day ago" + assert later105.humanize(self.now, granularity="day") == "in a day" + assert self.now.humanize(later105, granularity="week") == "0 weeks ago" + assert later105.humanize(self.now, granularity="week") == "in 0 weeks" + assert self.now.humanize(later105, granularity="month") == "0 months ago" + assert later105.humanize(self.now, granularity="month") == "in 0 months" + assert self.now.humanize(later105, granularity=["month"]) == "0 months ago" + assert later105.humanize(self.now, granularity=["month"]) == "in 0 months" + + later106 = self.now.shift(seconds=3 * 10 ** 6) + assert self.now.humanize(later106, granularity="day") == "34 days ago" + assert later106.humanize(self.now, granularity="day") == "in 34 days" + assert self.now.humanize(later106, granularity="week") == "4 weeks ago" + assert later106.humanize(self.now, granularity="week") == "in 4 weeks" + assert self.now.humanize(later106, granularity="month") == "a month ago" + assert later106.humanize(self.now, granularity="month") == "in a month" + assert self.now.humanize(later106, granularity="year") == "0 years ago" + assert later106.humanize(self.now, granularity="year") == "in 0 years" + + later506 = self.now.shift(seconds=50 * 10 ** 6) + assert self.now.humanize(later506, granularity="week") == "82 weeks ago" + assert later506.humanize(self.now, granularity="week") == "in 82 weeks" + assert self.now.humanize(later506, granularity="month") == "18 months ago" + assert later506.humanize(self.now, granularity="month") == "in 18 months" + assert self.now.humanize(later506, granularity="year") == "a year ago" + assert later506.humanize(self.now, granularity="year") == "in a year" + + later108 = self.now.shift(seconds=10 ** 8) + assert self.now.humanize(later108, granularity="year") == "3 years ago" + assert later108.humanize(self.now, granularity="year") == "in 3 years" + + later108onlydistance = self.now.shift(seconds=10 ** 8) + assert ( + self.now.humanize( + later108onlydistance, only_distance=True, granularity="year" + ) + == "3 years" + ) + assert ( + later108onlydistance.humanize( + self.now, only_distance=True, granularity="year" + ) + == "3 years" + ) + + with pytest.raises(AttributeError): + self.now.humanize(later108, granularity="years") + + def test_multiple_granularity(self): + assert self.now.humanize(granularity="second") == "just now" + assert self.now.humanize(granularity=["second"]) == "just now" + assert ( + self.now.humanize(granularity=["year", "month", "day", "hour", "second"]) + == "in 0 years 0 months 0 days 0 hours and 0 seconds" + ) + + later4000 = self.now.shift(seconds=4000) + assert ( + later4000.humanize(self.now, granularity=["hour", "minute"]) + == "in an hour and 6 minutes" + ) + assert ( + self.now.humanize(later4000, granularity=["hour", "minute"]) + == "an hour and 6 minutes ago" + ) + assert ( + later4000.humanize( + self.now, granularity=["hour", "minute"], only_distance=True + ) + == "an hour and 6 minutes" + ) + assert ( + later4000.humanize(self.now, granularity=["day", "hour", "minute"]) + == "in 0 days an hour and 6 minutes" + ) + assert ( + self.now.humanize(later4000, granularity=["day", "hour", "minute"]) + == "0 days an hour and 6 minutes ago" + ) + + later105 = self.now.shift(seconds=10 ** 5) + assert ( + self.now.humanize(later105, granularity=["hour", "day", "minute"]) + == "a day 3 hours and 46 minutes ago" + ) + with pytest.raises(AttributeError): + self.now.humanize(later105, granularity=["error", "second"]) + + later108onlydistance = self.now.shift(seconds=10 ** 8) + assert ( + self.now.humanize( + later108onlydistance, only_distance=True, granularity=["year"] + ) + == "3 years" + ) + assert ( + self.now.humanize( + later108onlydistance, only_distance=True, granularity=["month", "week"] + ) + == "37 months and 4 weeks" + ) + assert ( + self.now.humanize( + later108onlydistance, only_distance=True, granularity=["year", "second"] + ) + == "3 years and 5327200 seconds" + ) + + one_min_one_sec_ago = self.now.shift(minutes=-1, seconds=-1) + assert ( + one_min_one_sec_ago.humanize(self.now, granularity=["minute", "second"]) + == "a minute and a second ago" + ) + + one_min_two_secs_ago = self.now.shift(minutes=-1, seconds=-2) + assert ( + one_min_two_secs_ago.humanize(self.now, granularity=["minute", "second"]) + == "a minute and 2 seconds ago" + ) + + def test_seconds(self): + + later = self.now.shift(seconds=10) + + # regression test for issue #727 + assert self.now.humanize(later) == "10 seconds ago" + assert later.humanize(self.now) == "in 10 seconds" + + assert self.now.humanize(later, only_distance=True) == "10 seconds" + assert later.humanize(self.now, only_distance=True) == "10 seconds" + + def test_minute(self): + + later = self.now.shift(minutes=1) + + assert self.now.humanize(later) == "a minute ago" + assert later.humanize(self.now) == "in a minute" + + assert self.now.humanize(later, only_distance=True) == "a minute" + assert later.humanize(self.now, only_distance=True) == "a minute" + + def test_minutes(self): + + later = self.now.shift(minutes=2) + + assert self.now.humanize(later) == "2 minutes ago" + assert later.humanize(self.now) == "in 2 minutes" + + assert self.now.humanize(later, only_distance=True) == "2 minutes" + assert later.humanize(self.now, only_distance=True) == "2 minutes" + + def test_hour(self): + + later = self.now.shift(hours=1) + + assert self.now.humanize(later) == "an hour ago" + assert later.humanize(self.now) == "in an hour" + + assert self.now.humanize(later, only_distance=True) == "an hour" + assert later.humanize(self.now, only_distance=True) == "an hour" + + def test_hours(self): + + later = self.now.shift(hours=2) + + assert self.now.humanize(later) == "2 hours ago" + assert later.humanize(self.now) == "in 2 hours" + + assert self.now.humanize(later, only_distance=True) == "2 hours" + assert later.humanize(self.now, only_distance=True) == "2 hours" + + def test_day(self): + + later = self.now.shift(days=1) + + assert self.now.humanize(later) == "a day ago" + assert later.humanize(self.now) == "in a day" + + # regression test for issue #697 + less_than_48_hours = self.now.shift( + days=1, hours=23, seconds=59, microseconds=999999 + ) + assert self.now.humanize(less_than_48_hours) == "a day ago" + assert less_than_48_hours.humanize(self.now) == "in a day" + + less_than_48_hours_date = less_than_48_hours._datetime.date() + with pytest.raises(TypeError): + # humanize other argument does not take raw datetime.date objects + self.now.humanize(less_than_48_hours_date) + + # convert from date to arrow object + less_than_48_hours_date = arrow.Arrow.fromdate(less_than_48_hours_date) + assert self.now.humanize(less_than_48_hours_date) == "a day ago" + assert less_than_48_hours_date.humanize(self.now) == "in a day" + + assert self.now.humanize(later, only_distance=True) == "a day" + assert later.humanize(self.now, only_distance=True) == "a day" + + def test_days(self): + + later = self.now.shift(days=2) + + assert self.now.humanize(later) == "2 days ago" + assert later.humanize(self.now) == "in 2 days" + + assert self.now.humanize(later, only_distance=True) == "2 days" + assert later.humanize(self.now, only_distance=True) == "2 days" + + # Regression tests for humanize bug referenced in issue 541 + later = self.now.shift(days=3) + assert later.humanize(self.now) == "in 3 days" + + later = self.now.shift(days=3, seconds=1) + assert later.humanize(self.now) == "in 3 days" + + later = self.now.shift(days=4) + assert later.humanize(self.now) == "in 4 days" + + def test_week(self): + + later = self.now.shift(weeks=1) + + assert self.now.humanize(later) == "a week ago" + assert later.humanize(self.now) == "in a week" + + assert self.now.humanize(later, only_distance=True) == "a week" + assert later.humanize(self.now, only_distance=True) == "a week" + + def test_weeks(self): + + later = self.now.shift(weeks=2) + + assert self.now.humanize(later) == "2 weeks ago" + assert later.humanize(self.now) == "in 2 weeks" + + assert self.now.humanize(later, only_distance=True) == "2 weeks" + assert later.humanize(self.now, only_distance=True) == "2 weeks" + + def test_month(self): + + later = self.now.shift(months=1) + + assert self.now.humanize(later) == "a month ago" + assert later.humanize(self.now) == "in a month" + + assert self.now.humanize(later, only_distance=True) == "a month" + assert later.humanize(self.now, only_distance=True) == "a month" + + def test_months(self): + + later = self.now.shift(months=2) + earlier = self.now.shift(months=-2) + + assert earlier.humanize(self.now) == "2 months ago" + assert later.humanize(self.now) == "in 2 months" + + assert self.now.humanize(later, only_distance=True) == "2 months" + assert later.humanize(self.now, only_distance=True) == "2 months" + + def test_year(self): + + later = self.now.shift(years=1) + + assert self.now.humanize(later) == "a year ago" + assert later.humanize(self.now) == "in a year" + + assert self.now.humanize(later, only_distance=True) == "a year" + assert later.humanize(self.now, only_distance=True) == "a year" + + def test_years(self): + + later = self.now.shift(years=2) + + assert self.now.humanize(later) == "2 years ago" + assert later.humanize(self.now) == "in 2 years" + + assert self.now.humanize(later, only_distance=True) == "2 years" + assert later.humanize(self.now, only_distance=True) == "2 years" + + arw = arrow.Arrow(2014, 7, 2) + + result = arw.humanize(self.datetime) + + assert result == "in 2 years" + + def test_arrow(self): + + arw = arrow.Arrow.fromdatetime(self.datetime) + + result = arw.humanize(arrow.Arrow.fromdatetime(self.datetime)) + + assert result == "just now" + + def test_datetime_tzinfo(self): + + arw = arrow.Arrow.fromdatetime(self.datetime) + + result = arw.humanize(self.datetime.replace(tzinfo=tz.tzutc())) + + assert result == "just now" + + def test_other(self): + + arw = arrow.Arrow.fromdatetime(self.datetime) + + with pytest.raises(TypeError): + arw.humanize(object()) + + def test_invalid_locale(self): + + arw = arrow.Arrow.fromdatetime(self.datetime) + + with pytest.raises(ValueError): + arw.humanize(locale="klingon") + + def test_none(self): + + arw = arrow.Arrow.utcnow() + + result = arw.humanize() + + assert result == "just now" + + result = arw.humanize(None) + + assert result == "just now" + + def test_untranslated_granularity(self, mocker): + + arw = arrow.Arrow.utcnow() + later = arw.shift(weeks=1) + + # simulate an untranslated timeframe key + mocker.patch.dict("arrow.locales.EnglishLocale.timeframes") + del arrow.locales.EnglishLocale.timeframes["week"] + with pytest.raises(ValueError): + arw.humanize(later, granularity="week") + + +@pytest.mark.usefixtures("time_2013_01_01") +class TestArrowHumanizeTestsWithLocale: + def test_now(self): + + arw = arrow.Arrow(2013, 1, 1, 0, 0, 0) + + result = arw.humanize(self.datetime, locale="ru") + + assert result == "сейчас" + + def test_seconds(self): + arw = arrow.Arrow(2013, 1, 1, 0, 0, 44) + + result = arw.humanize(self.datetime, locale="ru") + + assert result == "через 44 несколько секунд" + + def test_years(self): + + arw = arrow.Arrow(2011, 7, 2) + + result = arw.humanize(self.datetime, locale="ru") + + assert result == "2 года назад" + + +class TestArrowIsBetween: + def test_start_before_end(self): + target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) + start = arrow.Arrow.fromdatetime(datetime(2013, 5, 8)) + end = arrow.Arrow.fromdatetime(datetime(2013, 5, 5)) + result = target.is_between(start, end) + assert not result + + def test_exclusive_exclusive_bounds(self): + target = arrow.Arrow.fromdatetime(datetime(2013, 5, 5, 12, 30, 27)) + start = arrow.Arrow.fromdatetime(datetime(2013, 5, 5, 12, 30, 10)) + end = arrow.Arrow.fromdatetime(datetime(2013, 5, 5, 12, 30, 36)) + result = target.is_between(start, end, "()") + assert result + result = target.is_between(start, end) + assert result + + def test_exclusive_exclusive_bounds_same_date(self): + target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) + start = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) + end = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) + result = target.is_between(start, end, "()") + assert not result + + def test_inclusive_exclusive_bounds(self): + target = arrow.Arrow.fromdatetime(datetime(2013, 5, 6)) + start = arrow.Arrow.fromdatetime(datetime(2013, 5, 4)) + end = arrow.Arrow.fromdatetime(datetime(2013, 5, 6)) + result = target.is_between(start, end, "[)") + assert not result + + def test_exclusive_inclusive_bounds(self): + target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) + start = arrow.Arrow.fromdatetime(datetime(2013, 5, 5)) + end = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) + result = target.is_between(start, end, "(]") + assert result + + def test_inclusive_inclusive_bounds_same_date(self): + target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) + start = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) + end = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) + result = target.is_between(start, end, "[]") + assert result + + def test_type_error_exception(self): + with pytest.raises(TypeError): + target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) + start = datetime(2013, 5, 5) + end = arrow.Arrow.fromdatetime(datetime(2013, 5, 8)) + target.is_between(start, end) + + with pytest.raises(TypeError): + target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) + start = arrow.Arrow.fromdatetime(datetime(2013, 5, 5)) + end = datetime(2013, 5, 8) + target.is_between(start, end) + + with pytest.raises(TypeError): + target.is_between(None, None) + + def test_value_error_exception(self): + target = arrow.Arrow.fromdatetime(datetime(2013, 5, 7)) + start = arrow.Arrow.fromdatetime(datetime(2013, 5, 5)) + end = arrow.Arrow.fromdatetime(datetime(2013, 5, 8)) + with pytest.raises(ValueError): + target.is_between(start, end, "][") + with pytest.raises(ValueError): + target.is_between(start, end, "") + with pytest.raises(ValueError): + target.is_between(start, end, "]") + with pytest.raises(ValueError): + target.is_between(start, end, "[") + with pytest.raises(ValueError): + target.is_between(start, end, "hello") + + +class TestArrowUtil: + def test_get_datetime(self): + + get_datetime = arrow.Arrow._get_datetime + + arw = arrow.Arrow.utcnow() + dt = datetime.utcnow() + timestamp = time.time() + + assert get_datetime(arw) == arw.datetime + assert get_datetime(dt) == dt + assert ( + get_datetime(timestamp) == arrow.Arrow.utcfromtimestamp(timestamp).datetime + ) + + with pytest.raises(ValueError) as raise_ctx: + get_datetime("abc") + assert "not recognized as a datetime or timestamp" in str(raise_ctx.value) + + def test_get_tzinfo(self): + + get_tzinfo = arrow.Arrow._get_tzinfo + + with pytest.raises(ValueError) as raise_ctx: + get_tzinfo("abc") + assert "not recognized as a timezone" in str(raise_ctx.value) + + def test_get_iteration_params(self): + + assert arrow.Arrow._get_iteration_params("end", None) == ("end", sys.maxsize) + assert arrow.Arrow._get_iteration_params(None, 100) == (arrow.Arrow.max, 100) + assert arrow.Arrow._get_iteration_params(100, 120) == (100, 120) + + with pytest.raises(ValueError): + arrow.Arrow._get_iteration_params(None, None) diff --git a/openpype/modules/ftrack/python2_vendor/arrow/tests/test_factory.py b/openpype/modules/ftrack/python2_vendor/arrow/tests/test_factory.py new file mode 100644 index 0000000000..2b8df5168f --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/tests/test_factory.py @@ -0,0 +1,390 @@ +# -*- coding: utf-8 -*- +import time +from datetime import date, datetime + +import pytest +from dateutil import tz + +from arrow.parser import ParserError + +from .utils import assert_datetime_equality + + +@pytest.mark.usefixtures("arrow_factory") +class TestGet: + def test_no_args(self): + + assert_datetime_equality( + self.factory.get(), datetime.utcnow().replace(tzinfo=tz.tzutc()) + ) + + def test_timestamp_one_arg_no_arg(self): + + no_arg = self.factory.get(1406430900).timestamp + one_arg = self.factory.get("1406430900", "X").timestamp + + assert no_arg == one_arg + + def test_one_arg_none(self): + + assert_datetime_equality( + self.factory.get(None), datetime.utcnow().replace(tzinfo=tz.tzutc()) + ) + + def test_struct_time(self): + + assert_datetime_equality( + self.factory.get(time.gmtime()), + datetime.utcnow().replace(tzinfo=tz.tzutc()), + ) + + def test_one_arg_timestamp(self): + + int_timestamp = int(time.time()) + timestamp_dt = datetime.utcfromtimestamp(int_timestamp).replace( + tzinfo=tz.tzutc() + ) + + assert self.factory.get(int_timestamp) == timestamp_dt + + with pytest.raises(ParserError): + self.factory.get(str(int_timestamp)) + + float_timestamp = time.time() + timestamp_dt = datetime.utcfromtimestamp(float_timestamp).replace( + tzinfo=tz.tzutc() + ) + + assert self.factory.get(float_timestamp) == timestamp_dt + + with pytest.raises(ParserError): + self.factory.get(str(float_timestamp)) + + # Regression test for issue #216 + # Python 3 raises OverflowError, Python 2 raises ValueError + timestamp = 99999999999999999999999999.99999999999999999999999999 + with pytest.raises((OverflowError, ValueError)): + self.factory.get(timestamp) + + def test_one_arg_expanded_timestamp(self): + + millisecond_timestamp = 1591328104308 + microsecond_timestamp = 1591328104308505 + + # Regression test for issue #796 + assert self.factory.get(millisecond_timestamp) == datetime.utcfromtimestamp( + 1591328104.308 + ).replace(tzinfo=tz.tzutc()) + assert self.factory.get(microsecond_timestamp) == datetime.utcfromtimestamp( + 1591328104.308505 + ).replace(tzinfo=tz.tzutc()) + + def test_one_arg_timestamp_with_tzinfo(self): + + timestamp = time.time() + timestamp_dt = datetime.fromtimestamp(timestamp, tz=tz.tzutc()).astimezone( + tz.gettz("US/Pacific") + ) + timezone = tz.gettz("US/Pacific") + + assert_datetime_equality( + self.factory.get(timestamp, tzinfo=timezone), timestamp_dt + ) + + def test_one_arg_arrow(self): + + arw = self.factory.utcnow() + result = self.factory.get(arw) + + assert arw == result + + def test_one_arg_datetime(self): + + dt = datetime.utcnow().replace(tzinfo=tz.tzutc()) + + assert self.factory.get(dt) == dt + + def test_one_arg_date(self): + + d = date.today() + dt = datetime(d.year, d.month, d.day, tzinfo=tz.tzutc()) + + assert self.factory.get(d) == dt + + def test_one_arg_tzinfo(self): + + self.expected = ( + datetime.utcnow() + .replace(tzinfo=tz.tzutc()) + .astimezone(tz.gettz("US/Pacific")) + ) + + assert_datetime_equality( + self.factory.get(tz.gettz("US/Pacific")), self.expected + ) + + # regression test for issue #658 + def test_one_arg_dateparser_datetime(self): + dateparser = pytest.importorskip("dateparser") + expected = datetime(1990, 1, 1).replace(tzinfo=tz.tzutc()) + # dateparser outputs: datetime.datetime(1990, 1, 1, 0, 0, tzinfo=) + parsed_date = dateparser.parse("1990-01-01T00:00:00+00:00") + dt_output = self.factory.get(parsed_date)._datetime.replace(tzinfo=tz.tzutc()) + assert dt_output == expected + + def test_kwarg_tzinfo(self): + + self.expected = ( + datetime.utcnow() + .replace(tzinfo=tz.tzutc()) + .astimezone(tz.gettz("US/Pacific")) + ) + + assert_datetime_equality( + self.factory.get(tzinfo=tz.gettz("US/Pacific")), self.expected + ) + + def test_kwarg_tzinfo_string(self): + + self.expected = ( + datetime.utcnow() + .replace(tzinfo=tz.tzutc()) + .astimezone(tz.gettz("US/Pacific")) + ) + + assert_datetime_equality(self.factory.get(tzinfo="US/Pacific"), self.expected) + + with pytest.raises(ParserError): + self.factory.get(tzinfo="US/PacificInvalidTzinfo") + + def test_kwarg_normalize_whitespace(self): + result = self.factory.get( + "Jun 1 2005 1:33PM", + "MMM D YYYY H:mmA", + tzinfo=tz.tzutc(), + normalize_whitespace=True, + ) + assert result._datetime == datetime(2005, 6, 1, 13, 33, tzinfo=tz.tzutc()) + + result = self.factory.get( + "\t 2013-05-05T12:30:45.123456 \t \n", + tzinfo=tz.tzutc(), + normalize_whitespace=True, + ) + assert result._datetime == datetime( + 2013, 5, 5, 12, 30, 45, 123456, tzinfo=tz.tzutc() + ) + + def test_one_arg_iso_str(self): + + dt = datetime.utcnow() + + assert_datetime_equality( + self.factory.get(dt.isoformat()), dt.replace(tzinfo=tz.tzutc()) + ) + + def test_one_arg_iso_calendar(self): + + pairs = [ + (datetime(2004, 1, 4), (2004, 1, 7)), + (datetime(2008, 12, 30), (2009, 1, 2)), + (datetime(2010, 1, 2), (2009, 53, 6)), + (datetime(2000, 2, 29), (2000, 9, 2)), + (datetime(2005, 1, 1), (2004, 53, 6)), + (datetime(2010, 1, 4), (2010, 1, 1)), + (datetime(2010, 1, 3), (2009, 53, 7)), + (datetime(2003, 12, 29), (2004, 1, 1)), + ] + + for pair in pairs: + dt, iso = pair + assert self.factory.get(iso) == self.factory.get(dt) + + with pytest.raises(TypeError): + self.factory.get((2014, 7, 1, 4)) + + with pytest.raises(TypeError): + self.factory.get((2014, 7)) + + with pytest.raises(ValueError): + self.factory.get((2014, 70, 1)) + + with pytest.raises(ValueError): + self.factory.get((2014, 7, 10)) + + def test_one_arg_other(self): + + with pytest.raises(TypeError): + self.factory.get(object()) + + def test_one_arg_bool(self): + + with pytest.raises(TypeError): + self.factory.get(False) + + with pytest.raises(TypeError): + self.factory.get(True) + + def test_two_args_datetime_tzinfo(self): + + result = self.factory.get(datetime(2013, 1, 1), tz.gettz("US/Pacific")) + + assert result._datetime == datetime(2013, 1, 1, tzinfo=tz.gettz("US/Pacific")) + + def test_two_args_datetime_tz_str(self): + + result = self.factory.get(datetime(2013, 1, 1), "US/Pacific") + + assert result._datetime == datetime(2013, 1, 1, tzinfo=tz.gettz("US/Pacific")) + + def test_two_args_date_tzinfo(self): + + result = self.factory.get(date(2013, 1, 1), tz.gettz("US/Pacific")) + + assert result._datetime == datetime(2013, 1, 1, tzinfo=tz.gettz("US/Pacific")) + + def test_two_args_date_tz_str(self): + + result = self.factory.get(date(2013, 1, 1), "US/Pacific") + + assert result._datetime == datetime(2013, 1, 1, tzinfo=tz.gettz("US/Pacific")) + + def test_two_args_datetime_other(self): + + with pytest.raises(TypeError): + self.factory.get(datetime.utcnow(), object()) + + def test_two_args_date_other(self): + + with pytest.raises(TypeError): + self.factory.get(date.today(), object()) + + def test_two_args_str_str(self): + + result = self.factory.get("2013-01-01", "YYYY-MM-DD") + + assert result._datetime == datetime(2013, 1, 1, tzinfo=tz.tzutc()) + + def test_two_args_str_tzinfo(self): + + result = self.factory.get("2013-01-01", tzinfo=tz.gettz("US/Pacific")) + + assert_datetime_equality( + result._datetime, datetime(2013, 1, 1, tzinfo=tz.gettz("US/Pacific")) + ) + + def test_two_args_twitter_format(self): + + # format returned by twitter API for created_at: + twitter_date = "Fri Apr 08 21:08:54 +0000 2016" + result = self.factory.get(twitter_date, "ddd MMM DD HH:mm:ss Z YYYY") + + assert result._datetime == datetime(2016, 4, 8, 21, 8, 54, tzinfo=tz.tzutc()) + + def test_two_args_str_list(self): + + result = self.factory.get("2013-01-01", ["MM/DD/YYYY", "YYYY-MM-DD"]) + + assert result._datetime == datetime(2013, 1, 1, tzinfo=tz.tzutc()) + + def test_two_args_unicode_unicode(self): + + result = self.factory.get(u"2013-01-01", u"YYYY-MM-DD") + + assert result._datetime == datetime(2013, 1, 1, tzinfo=tz.tzutc()) + + def test_two_args_other(self): + + with pytest.raises(TypeError): + self.factory.get(object(), object()) + + def test_three_args_with_tzinfo(self): + + timefmt = "YYYYMMDD" + d = "20150514" + + assert self.factory.get(d, timefmt, tzinfo=tz.tzlocal()) == datetime( + 2015, 5, 14, tzinfo=tz.tzlocal() + ) + + def test_three_args(self): + + assert self.factory.get(2013, 1, 1) == datetime(2013, 1, 1, tzinfo=tz.tzutc()) + + def test_full_kwargs(self): + + assert ( + self.factory.get( + year=2016, + month=7, + day=14, + hour=7, + minute=16, + second=45, + microsecond=631092, + ) + == datetime(2016, 7, 14, 7, 16, 45, 631092, tzinfo=tz.tzutc()) + ) + + def test_three_kwargs(self): + + assert self.factory.get(year=2016, month=7, day=14) == datetime( + 2016, 7, 14, 0, 0, tzinfo=tz.tzutc() + ) + + def test_tzinfo_string_kwargs(self): + result = self.factory.get("2019072807", "YYYYMMDDHH", tzinfo="UTC") + assert result._datetime == datetime(2019, 7, 28, 7, 0, 0, 0, tzinfo=tz.tzutc()) + + def test_insufficient_kwargs(self): + + with pytest.raises(TypeError): + self.factory.get(year=2016) + + with pytest.raises(TypeError): + self.factory.get(year=2016, month=7) + + def test_locale(self): + result = self.factory.get("2010", "YYYY", locale="ja") + assert result._datetime == datetime(2010, 1, 1, 0, 0, 0, 0, tzinfo=tz.tzutc()) + + # regression test for issue #701 + result = self.factory.get( + "Montag, 9. September 2019, 16:15-20:00", "dddd, D. MMMM YYYY", locale="de" + ) + assert result._datetime == datetime(2019, 9, 9, 0, 0, 0, 0, tzinfo=tz.tzutc()) + + def test_locale_kwarg_only(self): + res = self.factory.get(locale="ja") + assert res.tzinfo == tz.tzutc() + + def test_locale_with_tzinfo(self): + res = self.factory.get(locale="ja", tzinfo=tz.gettz("Asia/Tokyo")) + assert res.tzinfo == tz.gettz("Asia/Tokyo") + + +@pytest.mark.usefixtures("arrow_factory") +class TestUtcNow: + def test_utcnow(self): + + assert_datetime_equality( + self.factory.utcnow()._datetime, + datetime.utcnow().replace(tzinfo=tz.tzutc()), + ) + + +@pytest.mark.usefixtures("arrow_factory") +class TestNow: + def test_no_tz(self): + + assert_datetime_equality(self.factory.now(), datetime.now(tz.tzlocal())) + + def test_tzinfo(self): + + assert_datetime_equality( + self.factory.now(tz.gettz("EST")), datetime.now(tz.gettz("EST")) + ) + + def test_tz_str(self): + + assert_datetime_equality(self.factory.now("EST"), datetime.now(tz.gettz("EST"))) diff --git a/openpype/modules/ftrack/python2_vendor/arrow/tests/test_formatter.py b/openpype/modules/ftrack/python2_vendor/arrow/tests/test_formatter.py new file mode 100644 index 0000000000..e97aeb5dcc --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/tests/test_formatter.py @@ -0,0 +1,282 @@ +# -*- coding: utf-8 -*- +from datetime import datetime + +import pytest +import pytz +from dateutil import tz as dateutil_tz + +from arrow import ( + FORMAT_ATOM, + FORMAT_COOKIE, + FORMAT_RFC822, + FORMAT_RFC850, + FORMAT_RFC1036, + FORMAT_RFC1123, + FORMAT_RFC2822, + FORMAT_RFC3339, + FORMAT_RSS, + FORMAT_W3C, +) + +from .utils import make_full_tz_list + + +@pytest.mark.usefixtures("arrow_formatter") +class TestFormatterFormatToken: + def test_format(self): + + dt = datetime(2013, 2, 5, 12, 32, 51) + + result = self.formatter.format(dt, "MM-DD-YYYY hh:mm:ss a") + + assert result == "02-05-2013 12:32:51 pm" + + def test_year(self): + + dt = datetime(2013, 1, 1) + assert self.formatter._format_token(dt, "YYYY") == "2013" + assert self.formatter._format_token(dt, "YY") == "13" + + def test_month(self): + + dt = datetime(2013, 1, 1) + assert self.formatter._format_token(dt, "MMMM") == "January" + assert self.formatter._format_token(dt, "MMM") == "Jan" + assert self.formatter._format_token(dt, "MM") == "01" + assert self.formatter._format_token(dt, "M") == "1" + + def test_day(self): + + dt = datetime(2013, 2, 1) + assert self.formatter._format_token(dt, "DDDD") == "032" + assert self.formatter._format_token(dt, "DDD") == "32" + assert self.formatter._format_token(dt, "DD") == "01" + assert self.formatter._format_token(dt, "D") == "1" + assert self.formatter._format_token(dt, "Do") == "1st" + + assert self.formatter._format_token(dt, "dddd") == "Friday" + assert self.formatter._format_token(dt, "ddd") == "Fri" + assert self.formatter._format_token(dt, "d") == "5" + + def test_hour(self): + + dt = datetime(2013, 1, 1, 2) + assert self.formatter._format_token(dt, "HH") == "02" + assert self.formatter._format_token(dt, "H") == "2" + + dt = datetime(2013, 1, 1, 13) + assert self.formatter._format_token(dt, "HH") == "13" + assert self.formatter._format_token(dt, "H") == "13" + + dt = datetime(2013, 1, 1, 2) + assert self.formatter._format_token(dt, "hh") == "02" + assert self.formatter._format_token(dt, "h") == "2" + + dt = datetime(2013, 1, 1, 13) + assert self.formatter._format_token(dt, "hh") == "01" + assert self.formatter._format_token(dt, "h") == "1" + + # test that 12-hour time converts to '12' at midnight + dt = datetime(2013, 1, 1, 0) + assert self.formatter._format_token(dt, "hh") == "12" + assert self.formatter._format_token(dt, "h") == "12" + + def test_minute(self): + + dt = datetime(2013, 1, 1, 0, 1) + assert self.formatter._format_token(dt, "mm") == "01" + assert self.formatter._format_token(dt, "m") == "1" + + def test_second(self): + + dt = datetime(2013, 1, 1, 0, 0, 1) + assert self.formatter._format_token(dt, "ss") == "01" + assert self.formatter._format_token(dt, "s") == "1" + + def test_sub_second(self): + + dt = datetime(2013, 1, 1, 0, 0, 0, 123456) + assert self.formatter._format_token(dt, "SSSSSS") == "123456" + assert self.formatter._format_token(dt, "SSSSS") == "12345" + assert self.formatter._format_token(dt, "SSSS") == "1234" + assert self.formatter._format_token(dt, "SSS") == "123" + assert self.formatter._format_token(dt, "SS") == "12" + assert self.formatter._format_token(dt, "S") == "1" + + dt = datetime(2013, 1, 1, 0, 0, 0, 2000) + assert self.formatter._format_token(dt, "SSSSSS") == "002000" + assert self.formatter._format_token(dt, "SSSSS") == "00200" + assert self.formatter._format_token(dt, "SSSS") == "0020" + assert self.formatter._format_token(dt, "SSS") == "002" + assert self.formatter._format_token(dt, "SS") == "00" + assert self.formatter._format_token(dt, "S") == "0" + + def test_timestamp(self): + + timestamp = 1588437009.8952794 + dt = datetime.utcfromtimestamp(timestamp) + expected = str(int(timestamp)) + assert self.formatter._format_token(dt, "X") == expected + + # Must round because time.time() may return a float with greater + # than 6 digits of precision + expected = str(int(timestamp * 1000000)) + assert self.formatter._format_token(dt, "x") == expected + + def test_timezone(self): + + dt = datetime.utcnow().replace(tzinfo=dateutil_tz.gettz("US/Pacific")) + + result = self.formatter._format_token(dt, "ZZ") + assert result == "-07:00" or result == "-08:00" + + result = self.formatter._format_token(dt, "Z") + assert result == "-0700" or result == "-0800" + + @pytest.mark.parametrize("full_tz_name", make_full_tz_list()) + def test_timezone_formatter(self, full_tz_name): + + # This test will fail if we use "now" as date as soon as we change from/to DST + dt = datetime(1986, 2, 14, tzinfo=pytz.timezone("UTC")).replace( + tzinfo=dateutil_tz.gettz(full_tz_name) + ) + abbreviation = dt.tzname() + + result = self.formatter._format_token(dt, "ZZZ") + assert result == abbreviation + + def test_am_pm(self): + + dt = datetime(2012, 1, 1, 11) + assert self.formatter._format_token(dt, "a") == "am" + assert self.formatter._format_token(dt, "A") == "AM" + + dt = datetime(2012, 1, 1, 13) + assert self.formatter._format_token(dt, "a") == "pm" + assert self.formatter._format_token(dt, "A") == "PM" + + def test_week(self): + dt = datetime(2017, 5, 19) + assert self.formatter._format_token(dt, "W") == "2017-W20-5" + + # make sure week is zero padded when needed + dt_early = datetime(2011, 1, 20) + assert self.formatter._format_token(dt_early, "W") == "2011-W03-4" + + def test_nonsense(self): + dt = datetime(2012, 1, 1, 11) + assert self.formatter._format_token(dt, None) is None + assert self.formatter._format_token(dt, "NONSENSE") is None + + def test_escape(self): + + assert ( + self.formatter.format( + datetime(2015, 12, 10, 17, 9), "MMMM D, YYYY [at] h:mma" + ) + == "December 10, 2015 at 5:09pm" + ) + + assert ( + self.formatter.format( + datetime(2015, 12, 10, 17, 9), "[MMMM] M D, YYYY [at] h:mma" + ) + == "MMMM 12 10, 2015 at 5:09pm" + ) + + assert ( + self.formatter.format( + datetime(1990, 11, 25), + "[It happened on] MMMM Do [in the year] YYYY [a long time ago]", + ) + == "It happened on November 25th in the year 1990 a long time ago" + ) + + assert ( + self.formatter.format( + datetime(1990, 11, 25), + "[It happened on] MMMM Do [in the][ year] YYYY [a long time ago]", + ) + == "It happened on November 25th in the year 1990 a long time ago" + ) + + assert ( + self.formatter.format( + datetime(1, 1, 1), "[I'm][ entirely][ escaped,][ weee!]" + ) + == "I'm entirely escaped, weee!" + ) + + # Special RegEx characters + assert ( + self.formatter.format( + datetime(2017, 12, 31, 2, 0), "MMM DD, YYYY |^${}().*+?<>-& h:mm A" + ) + == "Dec 31, 2017 |^${}().*+?<>-& 2:00 AM" + ) + + # Escaping is atomic: brackets inside brackets are treated literally + assert self.formatter.format(datetime(1, 1, 1), "[[[ ]]") == "[[ ]" + + +@pytest.mark.usefixtures("arrow_formatter", "time_1975_12_25") +class TestFormatterBuiltinFormats: + def test_atom(self): + assert ( + self.formatter.format(self.datetime, FORMAT_ATOM) + == "1975-12-25 14:15:16-05:00" + ) + + def test_cookie(self): + assert ( + self.formatter.format(self.datetime, FORMAT_COOKIE) + == "Thursday, 25-Dec-1975 14:15:16 EST" + ) + + def test_rfc_822(self): + assert ( + self.formatter.format(self.datetime, FORMAT_RFC822) + == "Thu, 25 Dec 75 14:15:16 -0500" + ) + + def test_rfc_850(self): + assert ( + self.formatter.format(self.datetime, FORMAT_RFC850) + == "Thursday, 25-Dec-75 14:15:16 EST" + ) + + def test_rfc_1036(self): + assert ( + self.formatter.format(self.datetime, FORMAT_RFC1036) + == "Thu, 25 Dec 75 14:15:16 -0500" + ) + + def test_rfc_1123(self): + assert ( + self.formatter.format(self.datetime, FORMAT_RFC1123) + == "Thu, 25 Dec 1975 14:15:16 -0500" + ) + + def test_rfc_2822(self): + assert ( + self.formatter.format(self.datetime, FORMAT_RFC2822) + == "Thu, 25 Dec 1975 14:15:16 -0500" + ) + + def test_rfc3339(self): + assert ( + self.formatter.format(self.datetime, FORMAT_RFC3339) + == "1975-12-25 14:15:16-05:00" + ) + + def test_rss(self): + assert ( + self.formatter.format(self.datetime, FORMAT_RSS) + == "Thu, 25 Dec 1975 14:15:16 -0500" + ) + + def test_w3c(self): + assert ( + self.formatter.format(self.datetime, FORMAT_W3C) + == "1975-12-25 14:15:16-05:00" + ) diff --git a/openpype/modules/ftrack/python2_vendor/arrow/tests/test_locales.py b/openpype/modules/ftrack/python2_vendor/arrow/tests/test_locales.py new file mode 100644 index 0000000000..006ccdd5ba --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/tests/test_locales.py @@ -0,0 +1,1352 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import pytest + +from arrow import arrow, locales + + +@pytest.mark.usefixtures("lang_locales") +class TestLocaleValidation: + """Validate locales to ensure that translations are valid and complete""" + + def test_locale_validation(self): + + for _, locale_cls in self.locales.items(): + # 7 days + 1 spacer to allow for 1-indexing of months + assert len(locale_cls.day_names) == 8 + assert locale_cls.day_names[0] == "" + # ensure that all string from index 1 onward are valid (not blank or None) + assert all(locale_cls.day_names[1:]) + + assert len(locale_cls.day_abbreviations) == 8 + assert locale_cls.day_abbreviations[0] == "" + assert all(locale_cls.day_abbreviations[1:]) + + # 12 months + 1 spacer to allow for 1-indexing of months + assert len(locale_cls.month_names) == 13 + assert locale_cls.month_names[0] == "" + assert all(locale_cls.month_names[1:]) + + assert len(locale_cls.month_abbreviations) == 13 + assert locale_cls.month_abbreviations[0] == "" + assert all(locale_cls.month_abbreviations[1:]) + + assert len(locale_cls.names) > 0 + assert locale_cls.past is not None + assert locale_cls.future is not None + + +class TestModule: + def test_get_locale(self, mocker): + mock_locale = mocker.Mock() + mock_locale_cls = mocker.Mock() + mock_locale_cls.return_value = mock_locale + + with pytest.raises(ValueError): + arrow.locales.get_locale("locale_name") + + cls_dict = arrow.locales._locales + mocker.patch.dict(cls_dict, {"locale_name": mock_locale_cls}) + + result = arrow.locales.get_locale("locale_name") + + assert result == mock_locale + + def test_get_locale_by_class_name(self, mocker): + mock_locale_cls = mocker.Mock() + mock_locale_obj = mock_locale_cls.return_value = mocker.Mock() + + globals_fn = mocker.Mock() + globals_fn.return_value = {"NonExistentLocale": mock_locale_cls} + + with pytest.raises(ValueError): + arrow.locales.get_locale_by_class_name("NonExistentLocale") + + mocker.patch.object(locales, "globals", globals_fn) + result = arrow.locales.get_locale_by_class_name("NonExistentLocale") + + mock_locale_cls.assert_called_once_with() + assert result == mock_locale_obj + + def test_locales(self): + + assert len(locales._locales) > 0 + + +@pytest.mark.usefixtures("lang_locale") +class TestEnglishLocale: + def test_describe(self): + assert self.locale.describe("now", only_distance=True) == "instantly" + assert self.locale.describe("now", only_distance=False) == "just now" + + def test_format_timeframe(self): + + assert self.locale._format_timeframe("hours", 2) == "2 hours" + assert self.locale._format_timeframe("hour", 0) == "an hour" + + def test_format_relative_now(self): + + result = self.locale._format_relative("just now", "now", 0) + + assert result == "just now" + + def test_format_relative_past(self): + + result = self.locale._format_relative("an hour", "hour", 1) + + assert result == "in an hour" + + def test_format_relative_future(self): + + result = self.locale._format_relative("an hour", "hour", -1) + + assert result == "an hour ago" + + def test_ordinal_number(self): + assert self.locale.ordinal_number(0) == "0th" + assert self.locale.ordinal_number(1) == "1st" + assert self.locale.ordinal_number(2) == "2nd" + assert self.locale.ordinal_number(3) == "3rd" + assert self.locale.ordinal_number(4) == "4th" + assert self.locale.ordinal_number(10) == "10th" + assert self.locale.ordinal_number(11) == "11th" + assert self.locale.ordinal_number(12) == "12th" + assert self.locale.ordinal_number(13) == "13th" + assert self.locale.ordinal_number(14) == "14th" + assert self.locale.ordinal_number(21) == "21st" + assert self.locale.ordinal_number(22) == "22nd" + assert self.locale.ordinal_number(23) == "23rd" + assert self.locale.ordinal_number(24) == "24th" + + assert self.locale.ordinal_number(100) == "100th" + assert self.locale.ordinal_number(101) == "101st" + assert self.locale.ordinal_number(102) == "102nd" + assert self.locale.ordinal_number(103) == "103rd" + assert self.locale.ordinal_number(104) == "104th" + assert self.locale.ordinal_number(110) == "110th" + assert self.locale.ordinal_number(111) == "111th" + assert self.locale.ordinal_number(112) == "112th" + assert self.locale.ordinal_number(113) == "113th" + assert self.locale.ordinal_number(114) == "114th" + assert self.locale.ordinal_number(121) == "121st" + assert self.locale.ordinal_number(122) == "122nd" + assert self.locale.ordinal_number(123) == "123rd" + assert self.locale.ordinal_number(124) == "124th" + + def test_meridian_invalid_token(self): + assert self.locale.meridian(7, None) is None + assert self.locale.meridian(7, "B") is None + assert self.locale.meridian(7, "NONSENSE") is None + + +@pytest.mark.usefixtures("lang_locale") +class TestItalianLocale: + def test_ordinal_number(self): + assert self.locale.ordinal_number(1) == "1º" + + +@pytest.mark.usefixtures("lang_locale") +class TestSpanishLocale: + def test_ordinal_number(self): + assert self.locale.ordinal_number(1) == "1º" + + def test_format_timeframe(self): + assert self.locale._format_timeframe("now", 0) == "ahora" + assert self.locale._format_timeframe("seconds", 1) == "1 segundos" + assert self.locale._format_timeframe("seconds", 3) == "3 segundos" + assert self.locale._format_timeframe("seconds", 30) == "30 segundos" + assert self.locale._format_timeframe("minute", 1) == "un minuto" + assert self.locale._format_timeframe("minutes", 4) == "4 minutos" + assert self.locale._format_timeframe("minutes", 40) == "40 minutos" + assert self.locale._format_timeframe("hour", 1) == "una hora" + assert self.locale._format_timeframe("hours", 5) == "5 horas" + assert self.locale._format_timeframe("hours", 23) == "23 horas" + assert self.locale._format_timeframe("day", 1) == "un día" + assert self.locale._format_timeframe("days", 6) == "6 días" + assert self.locale._format_timeframe("days", 12) == "12 días" + assert self.locale._format_timeframe("week", 1) == "una semana" + assert self.locale._format_timeframe("weeks", 2) == "2 semanas" + assert self.locale._format_timeframe("weeks", 3) == "3 semanas" + assert self.locale._format_timeframe("month", 1) == "un mes" + assert self.locale._format_timeframe("months", 7) == "7 meses" + assert self.locale._format_timeframe("months", 11) == "11 meses" + assert self.locale._format_timeframe("year", 1) == "un año" + assert self.locale._format_timeframe("years", 8) == "8 años" + assert self.locale._format_timeframe("years", 12) == "12 años" + + assert self.locale._format_timeframe("now", 0) == "ahora" + assert self.locale._format_timeframe("seconds", -1) == "1 segundos" + assert self.locale._format_timeframe("seconds", -9) == "9 segundos" + assert self.locale._format_timeframe("seconds", -12) == "12 segundos" + assert self.locale._format_timeframe("minute", -1) == "un minuto" + assert self.locale._format_timeframe("minutes", -2) == "2 minutos" + assert self.locale._format_timeframe("minutes", -10) == "10 minutos" + assert self.locale._format_timeframe("hour", -1) == "una hora" + assert self.locale._format_timeframe("hours", -3) == "3 horas" + assert self.locale._format_timeframe("hours", -11) == "11 horas" + assert self.locale._format_timeframe("day", -1) == "un día" + assert self.locale._format_timeframe("days", -2) == "2 días" + assert self.locale._format_timeframe("days", -12) == "12 días" + assert self.locale._format_timeframe("week", -1) == "una semana" + assert self.locale._format_timeframe("weeks", -2) == "2 semanas" + assert self.locale._format_timeframe("weeks", -3) == "3 semanas" + assert self.locale._format_timeframe("month", -1) == "un mes" + assert self.locale._format_timeframe("months", -3) == "3 meses" + assert self.locale._format_timeframe("months", -13) == "13 meses" + assert self.locale._format_timeframe("year", -1) == "un año" + assert self.locale._format_timeframe("years", -4) == "4 años" + assert self.locale._format_timeframe("years", -14) == "14 años" + + +@pytest.mark.usefixtures("lang_locale") +class TestFrenchLocale: + def test_ordinal_number(self): + assert self.locale.ordinal_number(1) == "1er" + assert self.locale.ordinal_number(2) == "2e" + + def test_month_abbreviation(self): + assert "juil" in self.locale.month_abbreviations + + +@pytest.mark.usefixtures("lang_locale") +class TestFrenchCanadianLocale: + def test_month_abbreviation(self): + assert "juill" in self.locale.month_abbreviations + + +@pytest.mark.usefixtures("lang_locale") +class TestRussianLocale: + def test_plurals2(self): + assert self.locale._format_timeframe("hours", 0) == "0 часов" + assert self.locale._format_timeframe("hours", 1) == "1 час" + assert self.locale._format_timeframe("hours", 2) == "2 часа" + assert self.locale._format_timeframe("hours", 4) == "4 часа" + assert self.locale._format_timeframe("hours", 5) == "5 часов" + assert self.locale._format_timeframe("hours", 21) == "21 час" + assert self.locale._format_timeframe("hours", 22) == "22 часа" + assert self.locale._format_timeframe("hours", 25) == "25 часов" + + # feminine grammatical gender should be tested separately + assert self.locale._format_timeframe("minutes", 0) == "0 минут" + assert self.locale._format_timeframe("minutes", 1) == "1 минуту" + assert self.locale._format_timeframe("minutes", 2) == "2 минуты" + assert self.locale._format_timeframe("minutes", 4) == "4 минуты" + assert self.locale._format_timeframe("minutes", 5) == "5 минут" + assert self.locale._format_timeframe("minutes", 21) == "21 минуту" + assert self.locale._format_timeframe("minutes", 22) == "22 минуты" + assert self.locale._format_timeframe("minutes", 25) == "25 минут" + + +@pytest.mark.usefixtures("lang_locale") +class TestPolishLocale: + def test_plurals(self): + + assert self.locale._format_timeframe("seconds", 0) == "0 sekund" + assert self.locale._format_timeframe("second", 1) == "sekundę" + assert self.locale._format_timeframe("seconds", 2) == "2 sekundy" + assert self.locale._format_timeframe("seconds", 5) == "5 sekund" + assert self.locale._format_timeframe("seconds", 21) == "21 sekund" + assert self.locale._format_timeframe("seconds", 22) == "22 sekundy" + assert self.locale._format_timeframe("seconds", 25) == "25 sekund" + + assert self.locale._format_timeframe("minutes", 0) == "0 minut" + assert self.locale._format_timeframe("minute", 1) == "minutę" + assert self.locale._format_timeframe("minutes", 2) == "2 minuty" + assert self.locale._format_timeframe("minutes", 5) == "5 minut" + assert self.locale._format_timeframe("minutes", 21) == "21 minut" + assert self.locale._format_timeframe("minutes", 22) == "22 minuty" + assert self.locale._format_timeframe("minutes", 25) == "25 minut" + + assert self.locale._format_timeframe("hours", 0) == "0 godzin" + assert self.locale._format_timeframe("hour", 1) == "godzinę" + assert self.locale._format_timeframe("hours", 2) == "2 godziny" + assert self.locale._format_timeframe("hours", 5) == "5 godzin" + assert self.locale._format_timeframe("hours", 21) == "21 godzin" + assert self.locale._format_timeframe("hours", 22) == "22 godziny" + assert self.locale._format_timeframe("hours", 25) == "25 godzin" + + assert self.locale._format_timeframe("weeks", 0) == "0 tygodni" + assert self.locale._format_timeframe("week", 1) == "tydzień" + assert self.locale._format_timeframe("weeks", 2) == "2 tygodnie" + assert self.locale._format_timeframe("weeks", 5) == "5 tygodni" + assert self.locale._format_timeframe("weeks", 21) == "21 tygodni" + assert self.locale._format_timeframe("weeks", 22) == "22 tygodnie" + assert self.locale._format_timeframe("weeks", 25) == "25 tygodni" + + assert self.locale._format_timeframe("months", 0) == "0 miesięcy" + assert self.locale._format_timeframe("month", 1) == "miesiąc" + assert self.locale._format_timeframe("months", 2) == "2 miesiące" + assert self.locale._format_timeframe("months", 5) == "5 miesięcy" + assert self.locale._format_timeframe("months", 21) == "21 miesięcy" + assert self.locale._format_timeframe("months", 22) == "22 miesiące" + assert self.locale._format_timeframe("months", 25) == "25 miesięcy" + + assert self.locale._format_timeframe("years", 0) == "0 lat" + assert self.locale._format_timeframe("year", 1) == "rok" + assert self.locale._format_timeframe("years", 2) == "2 lata" + assert self.locale._format_timeframe("years", 5) == "5 lat" + assert self.locale._format_timeframe("years", 21) == "21 lat" + assert self.locale._format_timeframe("years", 22) == "22 lata" + assert self.locale._format_timeframe("years", 25) == "25 lat" + + +@pytest.mark.usefixtures("lang_locale") +class TestIcelandicLocale: + def test_format_timeframe(self): + + assert self.locale._format_timeframe("minute", -1) == "einni mínútu" + assert self.locale._format_timeframe("minute", 1) == "eina mínútu" + + assert self.locale._format_timeframe("hours", -2) == "2 tímum" + assert self.locale._format_timeframe("hours", 2) == "2 tíma" + assert self.locale._format_timeframe("now", 0) == "rétt í þessu" + + +@pytest.mark.usefixtures("lang_locale") +class TestMalayalamLocale: + def test_format_timeframe(self): + + assert self.locale._format_timeframe("hours", 2) == "2 മണിക്കൂർ" + assert self.locale._format_timeframe("hour", 0) == "ഒരു മണിക്കൂർ" + + def test_format_relative_now(self): + + result = self.locale._format_relative("ഇപ്പോൾ", "now", 0) + + assert result == "ഇപ്പോൾ" + + def test_format_relative_past(self): + + result = self.locale._format_relative("ഒരു മണിക്കൂർ", "hour", 1) + assert result == "ഒരു മണിക്കൂർ ശേഷം" + + def test_format_relative_future(self): + + result = self.locale._format_relative("ഒരു മണിക്കൂർ", "hour", -1) + assert result == "ഒരു മണിക്കൂർ മുമ്പ്" + + +@pytest.mark.usefixtures("lang_locale") +class TestHindiLocale: + def test_format_timeframe(self): + + assert self.locale._format_timeframe("hours", 2) == "2 घंटे" + assert self.locale._format_timeframe("hour", 0) == "एक घंटा" + + def test_format_relative_now(self): + + result = self.locale._format_relative("अभी", "now", 0) + assert result == "अभी" + + def test_format_relative_past(self): + + result = self.locale._format_relative("एक घंटा", "hour", 1) + assert result == "एक घंटा बाद" + + def test_format_relative_future(self): + + result = self.locale._format_relative("एक घंटा", "hour", -1) + assert result == "एक घंटा पहले" + + +@pytest.mark.usefixtures("lang_locale") +class TestCzechLocale: + def test_format_timeframe(self): + + assert self.locale._format_timeframe("hours", 2) == "2 hodiny" + assert self.locale._format_timeframe("hours", 5) == "5 hodin" + assert self.locale._format_timeframe("hour", 0) == "0 hodin" + assert self.locale._format_timeframe("hours", -2) == "2 hodinami" + assert self.locale._format_timeframe("hours", -5) == "5 hodinami" + assert self.locale._format_timeframe("now", 0) == "Teď" + + assert self.locale._format_timeframe("weeks", 2) == "2 týdny" + assert self.locale._format_timeframe("weeks", 5) == "5 týdnů" + assert self.locale._format_timeframe("week", 0) == "0 týdnů" + assert self.locale._format_timeframe("weeks", -2) == "2 týdny" + assert self.locale._format_timeframe("weeks", -5) == "5 týdny" + + def test_format_relative_now(self): + + result = self.locale._format_relative("Teď", "now", 0) + assert result == "Teď" + + def test_format_relative_future(self): + + result = self.locale._format_relative("hodinu", "hour", 1) + assert result == "Za hodinu" + + def test_format_relative_past(self): + + result = self.locale._format_relative("hodinou", "hour", -1) + assert result == "Před hodinou" + + +@pytest.mark.usefixtures("lang_locale") +class TestSlovakLocale: + def test_format_timeframe(self): + + assert self.locale._format_timeframe("seconds", -5) == "5 sekundami" + assert self.locale._format_timeframe("seconds", -2) == "2 sekundami" + assert self.locale._format_timeframe("second", -1) == "sekundou" + assert self.locale._format_timeframe("second", 0) == "0 sekúnd" + assert self.locale._format_timeframe("second", 1) == "sekundu" + assert self.locale._format_timeframe("seconds", 2) == "2 sekundy" + assert self.locale._format_timeframe("seconds", 5) == "5 sekúnd" + + assert self.locale._format_timeframe("minutes", -5) == "5 minútami" + assert self.locale._format_timeframe("minutes", -2) == "2 minútami" + assert self.locale._format_timeframe("minute", -1) == "minútou" + assert self.locale._format_timeframe("minute", 0) == "0 minút" + assert self.locale._format_timeframe("minute", 1) == "minútu" + assert self.locale._format_timeframe("minutes", 2) == "2 minúty" + assert self.locale._format_timeframe("minutes", 5) == "5 minút" + + assert self.locale._format_timeframe("hours", -5) == "5 hodinami" + assert self.locale._format_timeframe("hours", -2) == "2 hodinami" + assert self.locale._format_timeframe("hour", -1) == "hodinou" + assert self.locale._format_timeframe("hour", 0) == "0 hodín" + assert self.locale._format_timeframe("hour", 1) == "hodinu" + assert self.locale._format_timeframe("hours", 2) == "2 hodiny" + assert self.locale._format_timeframe("hours", 5) == "5 hodín" + + assert self.locale._format_timeframe("days", -5) == "5 dňami" + assert self.locale._format_timeframe("days", -2) == "2 dňami" + assert self.locale._format_timeframe("day", -1) == "dňom" + assert self.locale._format_timeframe("day", 0) == "0 dní" + assert self.locale._format_timeframe("day", 1) == "deň" + assert self.locale._format_timeframe("days", 2) == "2 dni" + assert self.locale._format_timeframe("days", 5) == "5 dní" + + assert self.locale._format_timeframe("weeks", -5) == "5 týždňami" + assert self.locale._format_timeframe("weeks", -2) == "2 týždňami" + assert self.locale._format_timeframe("week", -1) == "týždňom" + assert self.locale._format_timeframe("week", 0) == "0 týždňov" + assert self.locale._format_timeframe("week", 1) == "týždeň" + assert self.locale._format_timeframe("weeks", 2) == "2 týždne" + assert self.locale._format_timeframe("weeks", 5) == "5 týždňov" + + assert self.locale._format_timeframe("months", -5) == "5 mesiacmi" + assert self.locale._format_timeframe("months", -2) == "2 mesiacmi" + assert self.locale._format_timeframe("month", -1) == "mesiacom" + assert self.locale._format_timeframe("month", 0) == "0 mesiacov" + assert self.locale._format_timeframe("month", 1) == "mesiac" + assert self.locale._format_timeframe("months", 2) == "2 mesiace" + assert self.locale._format_timeframe("months", 5) == "5 mesiacov" + + assert self.locale._format_timeframe("years", -5) == "5 rokmi" + assert self.locale._format_timeframe("years", -2) == "2 rokmi" + assert self.locale._format_timeframe("year", -1) == "rokom" + assert self.locale._format_timeframe("year", 0) == "0 rokov" + assert self.locale._format_timeframe("year", 1) == "rok" + assert self.locale._format_timeframe("years", 2) == "2 roky" + assert self.locale._format_timeframe("years", 5) == "5 rokov" + + assert self.locale._format_timeframe("now", 0) == "Teraz" + + def test_format_relative_now(self): + + result = self.locale._format_relative("Teraz", "now", 0) + assert result == "Teraz" + + def test_format_relative_future(self): + + result = self.locale._format_relative("hodinu", "hour", 1) + assert result == "O hodinu" + + def test_format_relative_past(self): + + result = self.locale._format_relative("hodinou", "hour", -1) + assert result == "Pred hodinou" + + +@pytest.mark.usefixtures("lang_locale") +class TestBulgarianLocale: + def test_plurals2(self): + assert self.locale._format_timeframe("hours", 0) == "0 часа" + assert self.locale._format_timeframe("hours", 1) == "1 час" + assert self.locale._format_timeframe("hours", 2) == "2 часа" + assert self.locale._format_timeframe("hours", 4) == "4 часа" + assert self.locale._format_timeframe("hours", 5) == "5 часа" + assert self.locale._format_timeframe("hours", 21) == "21 час" + assert self.locale._format_timeframe("hours", 22) == "22 часа" + assert self.locale._format_timeframe("hours", 25) == "25 часа" + + # feminine grammatical gender should be tested separately + assert self.locale._format_timeframe("minutes", 0) == "0 минути" + assert self.locale._format_timeframe("minutes", 1) == "1 минута" + assert self.locale._format_timeframe("minutes", 2) == "2 минути" + assert self.locale._format_timeframe("minutes", 4) == "4 минути" + assert self.locale._format_timeframe("minutes", 5) == "5 минути" + assert self.locale._format_timeframe("minutes", 21) == "21 минута" + assert self.locale._format_timeframe("minutes", 22) == "22 минути" + assert self.locale._format_timeframe("minutes", 25) == "25 минути" + + +@pytest.mark.usefixtures("lang_locale") +class TestMacedonianLocale: + def test_singles_mk(self): + assert self.locale._format_timeframe("second", 1) == "една секунда" + assert self.locale._format_timeframe("minute", 1) == "една минута" + assert self.locale._format_timeframe("hour", 1) == "еден саат" + assert self.locale._format_timeframe("day", 1) == "еден ден" + assert self.locale._format_timeframe("week", 1) == "една недела" + assert self.locale._format_timeframe("month", 1) == "еден месец" + assert self.locale._format_timeframe("year", 1) == "една година" + + def test_meridians_mk(self): + assert self.locale.meridian(7, "A") == "претпладне" + assert self.locale.meridian(18, "A") == "попладне" + assert self.locale.meridian(10, "a") == "дп" + assert self.locale.meridian(22, "a") == "пп" + + def test_describe_mk(self): + assert self.locale.describe("second", only_distance=True) == "една секунда" + assert self.locale.describe("second", only_distance=False) == "за една секунда" + assert self.locale.describe("minute", only_distance=True) == "една минута" + assert self.locale.describe("minute", only_distance=False) == "за една минута" + assert self.locale.describe("hour", only_distance=True) == "еден саат" + assert self.locale.describe("hour", only_distance=False) == "за еден саат" + assert self.locale.describe("day", only_distance=True) == "еден ден" + assert self.locale.describe("day", only_distance=False) == "за еден ден" + assert self.locale.describe("week", only_distance=True) == "една недела" + assert self.locale.describe("week", only_distance=False) == "за една недела" + assert self.locale.describe("month", only_distance=True) == "еден месец" + assert self.locale.describe("month", only_distance=False) == "за еден месец" + assert self.locale.describe("year", only_distance=True) == "една година" + assert self.locale.describe("year", only_distance=False) == "за една година" + + def test_relative_mk(self): + # time + assert self.locale._format_relative("сега", "now", 0) == "сега" + assert self.locale._format_relative("1 секунда", "seconds", 1) == "за 1 секунда" + assert self.locale._format_relative("1 минута", "minutes", 1) == "за 1 минута" + assert self.locale._format_relative("1 саат", "hours", 1) == "за 1 саат" + assert self.locale._format_relative("1 ден", "days", 1) == "за 1 ден" + assert self.locale._format_relative("1 недела", "weeks", 1) == "за 1 недела" + assert self.locale._format_relative("1 месец", "months", 1) == "за 1 месец" + assert self.locale._format_relative("1 година", "years", 1) == "за 1 година" + assert ( + self.locale._format_relative("1 секунда", "seconds", -1) == "пред 1 секунда" + ) + assert ( + self.locale._format_relative("1 минута", "minutes", -1) == "пред 1 минута" + ) + assert self.locale._format_relative("1 саат", "hours", -1) == "пред 1 саат" + assert self.locale._format_relative("1 ден", "days", -1) == "пред 1 ден" + assert self.locale._format_relative("1 недела", "weeks", -1) == "пред 1 недела" + assert self.locale._format_relative("1 месец", "months", -1) == "пред 1 месец" + assert self.locale._format_relative("1 година", "years", -1) == "пред 1 година" + + def test_plurals_mk(self): + # Seconds + assert self.locale._format_timeframe("seconds", 0) == "0 секунди" + assert self.locale._format_timeframe("seconds", 1) == "1 секунда" + assert self.locale._format_timeframe("seconds", 2) == "2 секунди" + assert self.locale._format_timeframe("seconds", 4) == "4 секунди" + assert self.locale._format_timeframe("seconds", 5) == "5 секунди" + assert self.locale._format_timeframe("seconds", 21) == "21 секунда" + assert self.locale._format_timeframe("seconds", 22) == "22 секунди" + assert self.locale._format_timeframe("seconds", 25) == "25 секунди" + + # Minutes + assert self.locale._format_timeframe("minutes", 0) == "0 минути" + assert self.locale._format_timeframe("minutes", 1) == "1 минута" + assert self.locale._format_timeframe("minutes", 2) == "2 минути" + assert self.locale._format_timeframe("minutes", 4) == "4 минути" + assert self.locale._format_timeframe("minutes", 5) == "5 минути" + assert self.locale._format_timeframe("minutes", 21) == "21 минута" + assert self.locale._format_timeframe("minutes", 22) == "22 минути" + assert self.locale._format_timeframe("minutes", 25) == "25 минути" + + # Hours + assert self.locale._format_timeframe("hours", 0) == "0 саати" + assert self.locale._format_timeframe("hours", 1) == "1 саат" + assert self.locale._format_timeframe("hours", 2) == "2 саати" + assert self.locale._format_timeframe("hours", 4) == "4 саати" + assert self.locale._format_timeframe("hours", 5) == "5 саати" + assert self.locale._format_timeframe("hours", 21) == "21 саат" + assert self.locale._format_timeframe("hours", 22) == "22 саати" + assert self.locale._format_timeframe("hours", 25) == "25 саати" + + # Days + assert self.locale._format_timeframe("days", 0) == "0 дена" + assert self.locale._format_timeframe("days", 1) == "1 ден" + assert self.locale._format_timeframe("days", 2) == "2 дена" + assert self.locale._format_timeframe("days", 3) == "3 дена" + assert self.locale._format_timeframe("days", 21) == "21 ден" + + # Weeks + assert self.locale._format_timeframe("weeks", 0) == "0 недели" + assert self.locale._format_timeframe("weeks", 1) == "1 недела" + assert self.locale._format_timeframe("weeks", 2) == "2 недели" + assert self.locale._format_timeframe("weeks", 4) == "4 недели" + assert self.locale._format_timeframe("weeks", 5) == "5 недели" + assert self.locale._format_timeframe("weeks", 21) == "21 недела" + assert self.locale._format_timeframe("weeks", 22) == "22 недели" + assert self.locale._format_timeframe("weeks", 25) == "25 недели" + + # Months + assert self.locale._format_timeframe("months", 0) == "0 месеци" + assert self.locale._format_timeframe("months", 1) == "1 месец" + assert self.locale._format_timeframe("months", 2) == "2 месеци" + assert self.locale._format_timeframe("months", 4) == "4 месеци" + assert self.locale._format_timeframe("months", 5) == "5 месеци" + assert self.locale._format_timeframe("months", 21) == "21 месец" + assert self.locale._format_timeframe("months", 22) == "22 месеци" + assert self.locale._format_timeframe("months", 25) == "25 месеци" + + # Years + assert self.locale._format_timeframe("years", 1) == "1 година" + assert self.locale._format_timeframe("years", 2) == "2 години" + assert self.locale._format_timeframe("years", 5) == "5 години" + + def test_multi_describe_mk(self): + describe = self.locale.describe_multi + + fulltest = [("years", 5), ("weeks", 1), ("hours", 1), ("minutes", 6)] + assert describe(fulltest) == "за 5 години 1 недела 1 саат 6 минути" + seconds4000_0days = [("days", 0), ("hours", 1), ("minutes", 6)] + assert describe(seconds4000_0days) == "за 0 дена 1 саат 6 минути" + seconds4000 = [("hours", 1), ("minutes", 6)] + assert describe(seconds4000) == "за 1 саат 6 минути" + assert describe(seconds4000, only_distance=True) == "1 саат 6 минути" + seconds3700 = [("hours", 1), ("minutes", 1)] + assert describe(seconds3700) == "за 1 саат 1 минута" + seconds300_0hours = [("hours", 0), ("minutes", 5)] + assert describe(seconds300_0hours) == "за 0 саати 5 минути" + seconds300 = [("minutes", 5)] + assert describe(seconds300) == "за 5 минути" + seconds60 = [("minutes", 1)] + assert describe(seconds60) == "за 1 минута" + assert describe(seconds60, only_distance=True) == "1 минута" + seconds60 = [("seconds", 1)] + assert describe(seconds60) == "за 1 секунда" + assert describe(seconds60, only_distance=True) == "1 секунда" + + +@pytest.mark.usefixtures("time_2013_01_01") +@pytest.mark.usefixtures("lang_locale") +class TestHebrewLocale: + def test_couple_of_timeframe(self): + assert self.locale._format_timeframe("days", 1) == "יום" + assert self.locale._format_timeframe("days", 2) == "יומיים" + assert self.locale._format_timeframe("days", 3) == "3 ימים" + + assert self.locale._format_timeframe("hours", 1) == "שעה" + assert self.locale._format_timeframe("hours", 2) == "שעתיים" + assert self.locale._format_timeframe("hours", 3) == "3 שעות" + + assert self.locale._format_timeframe("week", 1) == "שבוע" + assert self.locale._format_timeframe("weeks", 2) == "שבועיים" + assert self.locale._format_timeframe("weeks", 3) == "3 שבועות" + + assert self.locale._format_timeframe("months", 1) == "חודש" + assert self.locale._format_timeframe("months", 2) == "חודשיים" + assert self.locale._format_timeframe("months", 4) == "4 חודשים" + + assert self.locale._format_timeframe("years", 1) == "שנה" + assert self.locale._format_timeframe("years", 2) == "שנתיים" + assert self.locale._format_timeframe("years", 5) == "5 שנים" + + def test_describe_multi(self): + describe = self.locale.describe_multi + + fulltest = [("years", 5), ("weeks", 1), ("hours", 1), ("minutes", 6)] + assert describe(fulltest) == "בעוד 5 שנים, שבוע, שעה ו־6 דקות" + seconds4000_0days = [("days", 0), ("hours", 1), ("minutes", 6)] + assert describe(seconds4000_0days) == "בעוד 0 ימים, שעה ו־6 דקות" + seconds4000 = [("hours", 1), ("minutes", 6)] + assert describe(seconds4000) == "בעוד שעה ו־6 דקות" + assert describe(seconds4000, only_distance=True) == "שעה ו־6 דקות" + seconds3700 = [("hours", 1), ("minutes", 1)] + assert describe(seconds3700) == "בעוד שעה ודקה" + seconds300_0hours = [("hours", 0), ("minutes", 5)] + assert describe(seconds300_0hours) == "בעוד 0 שעות ו־5 דקות" + seconds300 = [("minutes", 5)] + assert describe(seconds300) == "בעוד 5 דקות" + seconds60 = [("minutes", 1)] + assert describe(seconds60) == "בעוד דקה" + assert describe(seconds60, only_distance=True) == "דקה" + + +@pytest.mark.usefixtures("lang_locale") +class TestMarathiLocale: + def test_dateCoreFunctionality(self): + dt = arrow.Arrow(2015, 4, 11, 17, 30, 00) + assert self.locale.month_name(dt.month) == "एप्रिल" + assert self.locale.month_abbreviation(dt.month) == "एप्रि" + assert self.locale.day_name(dt.isoweekday()) == "शनिवार" + assert self.locale.day_abbreviation(dt.isoweekday()) == "शनि" + + def test_format_timeframe(self): + assert self.locale._format_timeframe("hours", 2) == "2 तास" + assert self.locale._format_timeframe("hour", 0) == "एक तास" + + def test_format_relative_now(self): + result = self.locale._format_relative("सद्य", "now", 0) + assert result == "सद्य" + + def test_format_relative_past(self): + result = self.locale._format_relative("एक तास", "hour", 1) + assert result == "एक तास नंतर" + + def test_format_relative_future(self): + result = self.locale._format_relative("एक तास", "hour", -1) + assert result == "एक तास आधी" + + # Not currently implemented + def test_ordinal_number(self): + assert self.locale.ordinal_number(1) == "1" + + +@pytest.mark.usefixtures("lang_locale") +class TestFinnishLocale: + def test_format_timeframe(self): + assert self.locale._format_timeframe("hours", 2) == ("2 tuntia", "2 tunnin") + assert self.locale._format_timeframe("hour", 0) == ("tunti", "tunnin") + + def test_format_relative_now(self): + result = self.locale._format_relative(["juuri nyt", "juuri nyt"], "now", 0) + assert result == "juuri nyt" + + def test_format_relative_past(self): + result = self.locale._format_relative(["tunti", "tunnin"], "hour", 1) + assert result == "tunnin kuluttua" + + def test_format_relative_future(self): + result = self.locale._format_relative(["tunti", "tunnin"], "hour", -1) + assert result == "tunti sitten" + + def test_ordinal_number(self): + assert self.locale.ordinal_number(1) == "1." + + +@pytest.mark.usefixtures("lang_locale") +class TestGermanLocale: + def test_ordinal_number(self): + assert self.locale.ordinal_number(1) == "1." + + def test_define(self): + assert self.locale.describe("minute", only_distance=True) == "eine Minute" + assert self.locale.describe("minute", only_distance=False) == "in einer Minute" + assert self.locale.describe("hour", only_distance=True) == "eine Stunde" + assert self.locale.describe("hour", only_distance=False) == "in einer Stunde" + assert self.locale.describe("day", only_distance=True) == "ein Tag" + assert self.locale.describe("day", only_distance=False) == "in einem Tag" + assert self.locale.describe("week", only_distance=True) == "eine Woche" + assert self.locale.describe("week", only_distance=False) == "in einer Woche" + assert self.locale.describe("month", only_distance=True) == "ein Monat" + assert self.locale.describe("month", only_distance=False) == "in einem Monat" + assert self.locale.describe("year", only_distance=True) == "ein Jahr" + assert self.locale.describe("year", only_distance=False) == "in einem Jahr" + + def test_weekday(self): + dt = arrow.Arrow(2015, 4, 11, 17, 30, 00) + assert self.locale.day_name(dt.isoweekday()) == "Samstag" + assert self.locale.day_abbreviation(dt.isoweekday()) == "Sa" + + +@pytest.mark.usefixtures("lang_locale") +class TestHungarianLocale: + def test_format_timeframe(self): + assert self.locale._format_timeframe("hours", 2) == "2 óra" + assert self.locale._format_timeframe("hour", 0) == "egy órával" + assert self.locale._format_timeframe("hours", -2) == "2 órával" + assert self.locale._format_timeframe("now", 0) == "éppen most" + + +@pytest.mark.usefixtures("lang_locale") +class TestEsperantoLocale: + def test_format_timeframe(self): + assert self.locale._format_timeframe("hours", 2) == "2 horoj" + assert self.locale._format_timeframe("hour", 0) == "un horo" + assert self.locale._format_timeframe("hours", -2) == "2 horoj" + assert self.locale._format_timeframe("now", 0) == "nun" + + def test_ordinal_number(self): + assert self.locale.ordinal_number(1) == "1a" + + +@pytest.mark.usefixtures("lang_locale") +class TestThaiLocale: + def test_year_full(self): + assert self.locale.year_full(2015) == "2558" + + def test_year_abbreviation(self): + assert self.locale.year_abbreviation(2015) == "58" + + def test_format_relative_now(self): + result = self.locale._format_relative("ขณะนี้", "now", 0) + assert result == "ขณะนี้" + + def test_format_relative_past(self): + result = self.locale._format_relative("1 ชั่วโมง", "hour", 1) + assert result == "ในอีก 1 ชั่วโมง" + result = self.locale._format_relative("{0} ชั่วโมง", "hours", 2) + assert result == "ในอีก {0} ชั่วโมง" + result = self.locale._format_relative("ไม่กี่วินาที", "seconds", 42) + assert result == "ในอีกไม่กี่วินาที" + + def test_format_relative_future(self): + result = self.locale._format_relative("1 ชั่วโมง", "hour", -1) + assert result == "1 ชั่วโมง ที่ผ่านมา" + + +@pytest.mark.usefixtures("lang_locale") +class TestBengaliLocale: + def test_ordinal_number(self): + assert self.locale._ordinal_number(0) == "0তম" + assert self.locale._ordinal_number(1) == "1ম" + assert self.locale._ordinal_number(3) == "3য়" + assert self.locale._ordinal_number(4) == "4র্থ" + assert self.locale._ordinal_number(5) == "5ম" + assert self.locale._ordinal_number(6) == "6ষ্ঠ" + assert self.locale._ordinal_number(10) == "10ম" + assert self.locale._ordinal_number(11) == "11তম" + assert self.locale._ordinal_number(42) == "42তম" + assert self.locale._ordinal_number(-1) is None + + +@pytest.mark.usefixtures("lang_locale") +class TestRomanianLocale: + def test_timeframes(self): + + assert self.locale._format_timeframe("hours", 2) == "2 ore" + assert self.locale._format_timeframe("months", 2) == "2 luni" + + assert self.locale._format_timeframe("days", 2) == "2 zile" + assert self.locale._format_timeframe("years", 2) == "2 ani" + + assert self.locale._format_timeframe("hours", 3) == "3 ore" + assert self.locale._format_timeframe("months", 4) == "4 luni" + assert self.locale._format_timeframe("days", 3) == "3 zile" + assert self.locale._format_timeframe("years", 5) == "5 ani" + + def test_relative_timeframes(self): + assert self.locale._format_relative("acum", "now", 0) == "acum" + assert self.locale._format_relative("o oră", "hour", 1) == "peste o oră" + assert self.locale._format_relative("o oră", "hour", -1) == "o oră în urmă" + assert self.locale._format_relative("un minut", "minute", 1) == "peste un minut" + assert ( + self.locale._format_relative("un minut", "minute", -1) == "un minut în urmă" + ) + assert ( + self.locale._format_relative("câteva secunde", "seconds", -1) + == "câteva secunde în urmă" + ) + assert ( + self.locale._format_relative("câteva secunde", "seconds", 1) + == "peste câteva secunde" + ) + assert self.locale._format_relative("o zi", "day", -1) == "o zi în urmă" + assert self.locale._format_relative("o zi", "day", 1) == "peste o zi" + + +@pytest.mark.usefixtures("lang_locale") +class TestArabicLocale: + def test_timeframes(self): + + # single + assert self.locale._format_timeframe("minute", 1) == "دقيقة" + assert self.locale._format_timeframe("hour", 1) == "ساعة" + assert self.locale._format_timeframe("day", 1) == "يوم" + assert self.locale._format_timeframe("month", 1) == "شهر" + assert self.locale._format_timeframe("year", 1) == "سنة" + + # double + assert self.locale._format_timeframe("minutes", 2) == "دقيقتين" + assert self.locale._format_timeframe("hours", 2) == "ساعتين" + assert self.locale._format_timeframe("days", 2) == "يومين" + assert self.locale._format_timeframe("months", 2) == "شهرين" + assert self.locale._format_timeframe("years", 2) == "سنتين" + + # up to ten + assert self.locale._format_timeframe("minutes", 3) == "3 دقائق" + assert self.locale._format_timeframe("hours", 4) == "4 ساعات" + assert self.locale._format_timeframe("days", 5) == "5 أيام" + assert self.locale._format_timeframe("months", 6) == "6 أشهر" + assert self.locale._format_timeframe("years", 10) == "10 سنوات" + + # more than ten + assert self.locale._format_timeframe("minutes", 11) == "11 دقيقة" + assert self.locale._format_timeframe("hours", 19) == "19 ساعة" + assert self.locale._format_timeframe("months", 24) == "24 شهر" + assert self.locale._format_timeframe("days", 50) == "50 يوم" + assert self.locale._format_timeframe("years", 115) == "115 سنة" + + +@pytest.mark.usefixtures("lang_locale") +class TestNepaliLocale: + def test_format_timeframe(self): + assert self.locale._format_timeframe("hours", 3) == "3 घण्टा" + assert self.locale._format_timeframe("hour", 0) == "एक घण्टा" + + def test_format_relative_now(self): + result = self.locale._format_relative("अहिले", "now", 0) + assert result == "अहिले" + + def test_format_relative_future(self): + result = self.locale._format_relative("एक घण्टा", "hour", 1) + assert result == "एक घण्टा पछी" + + def test_format_relative_past(self): + result = self.locale._format_relative("एक घण्टा", "hour", -1) + assert result == "एक घण्टा पहिले" + + +@pytest.mark.usefixtures("lang_locale") +class TestIndonesianLocale: + def test_timeframes(self): + assert self.locale._format_timeframe("hours", 2) == "2 jam" + assert self.locale._format_timeframe("months", 2) == "2 bulan" + + assert self.locale._format_timeframe("days", 2) == "2 hari" + assert self.locale._format_timeframe("years", 2) == "2 tahun" + + assert self.locale._format_timeframe("hours", 3) == "3 jam" + assert self.locale._format_timeframe("months", 4) == "4 bulan" + assert self.locale._format_timeframe("days", 3) == "3 hari" + assert self.locale._format_timeframe("years", 5) == "5 tahun" + + def test_format_relative_now(self): + assert self.locale._format_relative("baru saja", "now", 0) == "baru saja" + + def test_format_relative_past(self): + assert self.locale._format_relative("1 jam", "hour", 1) == "dalam 1 jam" + assert self.locale._format_relative("1 detik", "seconds", 1) == "dalam 1 detik" + + def test_format_relative_future(self): + assert self.locale._format_relative("1 jam", "hour", -1) == "1 jam yang lalu" + + +@pytest.mark.usefixtures("lang_locale") +class TestTagalogLocale: + def test_singles_tl(self): + assert self.locale._format_timeframe("second", 1) == "isang segundo" + assert self.locale._format_timeframe("minute", 1) == "isang minuto" + assert self.locale._format_timeframe("hour", 1) == "isang oras" + assert self.locale._format_timeframe("day", 1) == "isang araw" + assert self.locale._format_timeframe("week", 1) == "isang linggo" + assert self.locale._format_timeframe("month", 1) == "isang buwan" + assert self.locale._format_timeframe("year", 1) == "isang taon" + + def test_meridians_tl(self): + assert self.locale.meridian(7, "A") == "ng umaga" + assert self.locale.meridian(18, "A") == "ng hapon" + assert self.locale.meridian(10, "a") == "nu" + assert self.locale.meridian(22, "a") == "nh" + + def test_describe_tl(self): + assert self.locale.describe("second", only_distance=True) == "isang segundo" + assert ( + self.locale.describe("second", only_distance=False) + == "isang segundo mula ngayon" + ) + assert self.locale.describe("minute", only_distance=True) == "isang minuto" + assert ( + self.locale.describe("minute", only_distance=False) + == "isang minuto mula ngayon" + ) + assert self.locale.describe("hour", only_distance=True) == "isang oras" + assert ( + self.locale.describe("hour", only_distance=False) + == "isang oras mula ngayon" + ) + assert self.locale.describe("day", only_distance=True) == "isang araw" + assert ( + self.locale.describe("day", only_distance=False) == "isang araw mula ngayon" + ) + assert self.locale.describe("week", only_distance=True) == "isang linggo" + assert ( + self.locale.describe("week", only_distance=False) + == "isang linggo mula ngayon" + ) + assert self.locale.describe("month", only_distance=True) == "isang buwan" + assert ( + self.locale.describe("month", only_distance=False) + == "isang buwan mula ngayon" + ) + assert self.locale.describe("year", only_distance=True) == "isang taon" + assert ( + self.locale.describe("year", only_distance=False) + == "isang taon mula ngayon" + ) + + def test_relative_tl(self): + # time + assert self.locale._format_relative("ngayon", "now", 0) == "ngayon" + assert ( + self.locale._format_relative("1 segundo", "seconds", 1) + == "1 segundo mula ngayon" + ) + assert ( + self.locale._format_relative("1 minuto", "minutes", 1) + == "1 minuto mula ngayon" + ) + assert ( + self.locale._format_relative("1 oras", "hours", 1) == "1 oras mula ngayon" + ) + assert self.locale._format_relative("1 araw", "days", 1) == "1 araw mula ngayon" + assert ( + self.locale._format_relative("1 linggo", "weeks", 1) + == "1 linggo mula ngayon" + ) + assert ( + self.locale._format_relative("1 buwan", "months", 1) + == "1 buwan mula ngayon" + ) + assert ( + self.locale._format_relative("1 taon", "years", 1) == "1 taon mula ngayon" + ) + assert ( + self.locale._format_relative("1 segundo", "seconds", -1) + == "nakaraang 1 segundo" + ) + assert ( + self.locale._format_relative("1 minuto", "minutes", -1) + == "nakaraang 1 minuto" + ) + assert self.locale._format_relative("1 oras", "hours", -1) == "nakaraang 1 oras" + assert self.locale._format_relative("1 araw", "days", -1) == "nakaraang 1 araw" + assert ( + self.locale._format_relative("1 linggo", "weeks", -1) + == "nakaraang 1 linggo" + ) + assert ( + self.locale._format_relative("1 buwan", "months", -1) == "nakaraang 1 buwan" + ) + assert self.locale._format_relative("1 taon", "years", -1) == "nakaraang 1 taon" + + def test_plurals_tl(self): + # Seconds + assert self.locale._format_timeframe("seconds", 0) == "0 segundo" + assert self.locale._format_timeframe("seconds", 1) == "1 segundo" + assert self.locale._format_timeframe("seconds", 2) == "2 segundo" + assert self.locale._format_timeframe("seconds", 4) == "4 segundo" + assert self.locale._format_timeframe("seconds", 5) == "5 segundo" + assert self.locale._format_timeframe("seconds", 21) == "21 segundo" + assert self.locale._format_timeframe("seconds", 22) == "22 segundo" + assert self.locale._format_timeframe("seconds", 25) == "25 segundo" + + # Minutes + assert self.locale._format_timeframe("minutes", 0) == "0 minuto" + assert self.locale._format_timeframe("minutes", 1) == "1 minuto" + assert self.locale._format_timeframe("minutes", 2) == "2 minuto" + assert self.locale._format_timeframe("minutes", 4) == "4 minuto" + assert self.locale._format_timeframe("minutes", 5) == "5 minuto" + assert self.locale._format_timeframe("minutes", 21) == "21 minuto" + assert self.locale._format_timeframe("minutes", 22) == "22 minuto" + assert self.locale._format_timeframe("minutes", 25) == "25 minuto" + + # Hours + assert self.locale._format_timeframe("hours", 0) == "0 oras" + assert self.locale._format_timeframe("hours", 1) == "1 oras" + assert self.locale._format_timeframe("hours", 2) == "2 oras" + assert self.locale._format_timeframe("hours", 4) == "4 oras" + assert self.locale._format_timeframe("hours", 5) == "5 oras" + assert self.locale._format_timeframe("hours", 21) == "21 oras" + assert self.locale._format_timeframe("hours", 22) == "22 oras" + assert self.locale._format_timeframe("hours", 25) == "25 oras" + + # Days + assert self.locale._format_timeframe("days", 0) == "0 araw" + assert self.locale._format_timeframe("days", 1) == "1 araw" + assert self.locale._format_timeframe("days", 2) == "2 araw" + assert self.locale._format_timeframe("days", 3) == "3 araw" + assert self.locale._format_timeframe("days", 21) == "21 araw" + + # Weeks + assert self.locale._format_timeframe("weeks", 0) == "0 linggo" + assert self.locale._format_timeframe("weeks", 1) == "1 linggo" + assert self.locale._format_timeframe("weeks", 2) == "2 linggo" + assert self.locale._format_timeframe("weeks", 4) == "4 linggo" + assert self.locale._format_timeframe("weeks", 5) == "5 linggo" + assert self.locale._format_timeframe("weeks", 21) == "21 linggo" + assert self.locale._format_timeframe("weeks", 22) == "22 linggo" + assert self.locale._format_timeframe("weeks", 25) == "25 linggo" + + # Months + assert self.locale._format_timeframe("months", 0) == "0 buwan" + assert self.locale._format_timeframe("months", 1) == "1 buwan" + assert self.locale._format_timeframe("months", 2) == "2 buwan" + assert self.locale._format_timeframe("months", 4) == "4 buwan" + assert self.locale._format_timeframe("months", 5) == "5 buwan" + assert self.locale._format_timeframe("months", 21) == "21 buwan" + assert self.locale._format_timeframe("months", 22) == "22 buwan" + assert self.locale._format_timeframe("months", 25) == "25 buwan" + + # Years + assert self.locale._format_timeframe("years", 1) == "1 taon" + assert self.locale._format_timeframe("years", 2) == "2 taon" + assert self.locale._format_timeframe("years", 5) == "5 taon" + + def test_multi_describe_tl(self): + describe = self.locale.describe_multi + + fulltest = [("years", 5), ("weeks", 1), ("hours", 1), ("minutes", 6)] + assert describe(fulltest) == "5 taon 1 linggo 1 oras 6 minuto mula ngayon" + seconds4000_0days = [("days", 0), ("hours", 1), ("minutes", 6)] + assert describe(seconds4000_0days) == "0 araw 1 oras 6 minuto mula ngayon" + seconds4000 = [("hours", 1), ("minutes", 6)] + assert describe(seconds4000) == "1 oras 6 minuto mula ngayon" + assert describe(seconds4000, only_distance=True) == "1 oras 6 minuto" + seconds3700 = [("hours", 1), ("minutes", 1)] + assert describe(seconds3700) == "1 oras 1 minuto mula ngayon" + seconds300_0hours = [("hours", 0), ("minutes", 5)] + assert describe(seconds300_0hours) == "0 oras 5 minuto mula ngayon" + seconds300 = [("minutes", 5)] + assert describe(seconds300) == "5 minuto mula ngayon" + seconds60 = [("minutes", 1)] + assert describe(seconds60) == "1 minuto mula ngayon" + assert describe(seconds60, only_distance=True) == "1 minuto" + seconds60 = [("seconds", 1)] + assert describe(seconds60) == "1 segundo mula ngayon" + assert describe(seconds60, only_distance=True) == "1 segundo" + + def test_ordinal_number_tl(self): + assert self.locale.ordinal_number(0) == "ika-0" + assert self.locale.ordinal_number(1) == "ika-1" + assert self.locale.ordinal_number(2) == "ika-2" + assert self.locale.ordinal_number(3) == "ika-3" + assert self.locale.ordinal_number(10) == "ika-10" + assert self.locale.ordinal_number(23) == "ika-23" + assert self.locale.ordinal_number(100) == "ika-100" + assert self.locale.ordinal_number(103) == "ika-103" + assert self.locale.ordinal_number(114) == "ika-114" + + +@pytest.mark.usefixtures("lang_locale") +class TestEstonianLocale: + def test_format_timeframe(self): + assert self.locale._format_timeframe("now", 0) == "just nüüd" + assert self.locale._format_timeframe("second", 1) == "ühe sekundi" + assert self.locale._format_timeframe("seconds", 3) == "3 sekundi" + assert self.locale._format_timeframe("seconds", 30) == "30 sekundi" + assert self.locale._format_timeframe("minute", 1) == "ühe minuti" + assert self.locale._format_timeframe("minutes", 4) == "4 minuti" + assert self.locale._format_timeframe("minutes", 40) == "40 minuti" + assert self.locale._format_timeframe("hour", 1) == "tunni aja" + assert self.locale._format_timeframe("hours", 5) == "5 tunni" + assert self.locale._format_timeframe("hours", 23) == "23 tunni" + assert self.locale._format_timeframe("day", 1) == "ühe päeva" + assert self.locale._format_timeframe("days", 6) == "6 päeva" + assert self.locale._format_timeframe("days", 12) == "12 päeva" + assert self.locale._format_timeframe("month", 1) == "ühe kuu" + assert self.locale._format_timeframe("months", 7) == "7 kuu" + assert self.locale._format_timeframe("months", 11) == "11 kuu" + assert self.locale._format_timeframe("year", 1) == "ühe aasta" + assert self.locale._format_timeframe("years", 8) == "8 aasta" + assert self.locale._format_timeframe("years", 12) == "12 aasta" + + assert self.locale._format_timeframe("now", 0) == "just nüüd" + assert self.locale._format_timeframe("second", -1) == "üks sekund" + assert self.locale._format_timeframe("seconds", -9) == "9 sekundit" + assert self.locale._format_timeframe("seconds", -12) == "12 sekundit" + assert self.locale._format_timeframe("minute", -1) == "üks minut" + assert self.locale._format_timeframe("minutes", -2) == "2 minutit" + assert self.locale._format_timeframe("minutes", -10) == "10 minutit" + assert self.locale._format_timeframe("hour", -1) == "tund aega" + assert self.locale._format_timeframe("hours", -3) == "3 tundi" + assert self.locale._format_timeframe("hours", -11) == "11 tundi" + assert self.locale._format_timeframe("day", -1) == "üks päev" + assert self.locale._format_timeframe("days", -2) == "2 päeva" + assert self.locale._format_timeframe("days", -12) == "12 päeva" + assert self.locale._format_timeframe("month", -1) == "üks kuu" + assert self.locale._format_timeframe("months", -3) == "3 kuud" + assert self.locale._format_timeframe("months", -13) == "13 kuud" + assert self.locale._format_timeframe("year", -1) == "üks aasta" + assert self.locale._format_timeframe("years", -4) == "4 aastat" + assert self.locale._format_timeframe("years", -14) == "14 aastat" + + +@pytest.mark.usefixtures("lang_locale") +class TestPortugueseLocale: + def test_format_timeframe(self): + assert self.locale._format_timeframe("now", 0) == "agora" + assert self.locale._format_timeframe("second", 1) == "um segundo" + assert self.locale._format_timeframe("seconds", 30) == "30 segundos" + assert self.locale._format_timeframe("minute", 1) == "um minuto" + assert self.locale._format_timeframe("minutes", 40) == "40 minutos" + assert self.locale._format_timeframe("hour", 1) == "uma hora" + assert self.locale._format_timeframe("hours", 23) == "23 horas" + assert self.locale._format_timeframe("day", 1) == "um dia" + assert self.locale._format_timeframe("days", 12) == "12 dias" + assert self.locale._format_timeframe("month", 1) == "um mês" + assert self.locale._format_timeframe("months", 11) == "11 meses" + assert self.locale._format_timeframe("year", 1) == "um ano" + assert self.locale._format_timeframe("years", 12) == "12 anos" + + +@pytest.mark.usefixtures("lang_locale") +class TestBrazilianPortugueseLocale: + def test_format_timeframe(self): + assert self.locale._format_timeframe("now", 0) == "agora" + assert self.locale._format_timeframe("second", 1) == "um segundo" + assert self.locale._format_timeframe("seconds", 30) == "30 segundos" + assert self.locale._format_timeframe("minute", 1) == "um minuto" + assert self.locale._format_timeframe("minutes", 40) == "40 minutos" + assert self.locale._format_timeframe("hour", 1) == "uma hora" + assert self.locale._format_timeframe("hours", 23) == "23 horas" + assert self.locale._format_timeframe("day", 1) == "um dia" + assert self.locale._format_timeframe("days", 12) == "12 dias" + assert self.locale._format_timeframe("month", 1) == "um mês" + assert self.locale._format_timeframe("months", 11) == "11 meses" + assert self.locale._format_timeframe("year", 1) == "um ano" + assert self.locale._format_timeframe("years", 12) == "12 anos" + assert self.locale._format_relative("uma hora", "hour", -1) == "faz uma hora" + + +@pytest.mark.usefixtures("lang_locale") +class TestHongKongLocale: + def test_format_timeframe(self): + assert self.locale._format_timeframe("now", 0) == "剛才" + assert self.locale._format_timeframe("second", 1) == "1秒" + assert self.locale._format_timeframe("seconds", 30) == "30秒" + assert self.locale._format_timeframe("minute", 1) == "1分鐘" + assert self.locale._format_timeframe("minutes", 40) == "40分鐘" + assert self.locale._format_timeframe("hour", 1) == "1小時" + assert self.locale._format_timeframe("hours", 23) == "23小時" + assert self.locale._format_timeframe("day", 1) == "1天" + assert self.locale._format_timeframe("days", 12) == "12天" + assert self.locale._format_timeframe("week", 1) == "1星期" + assert self.locale._format_timeframe("weeks", 38) == "38星期" + assert self.locale._format_timeframe("month", 1) == "1個月" + assert self.locale._format_timeframe("months", 11) == "11個月" + assert self.locale._format_timeframe("year", 1) == "1年" + assert self.locale._format_timeframe("years", 12) == "12年" + + +@pytest.mark.usefixtures("lang_locale") +class TestChineseTWLocale: + def test_format_timeframe(self): + assert self.locale._format_timeframe("now", 0) == "剛才" + assert self.locale._format_timeframe("second", 1) == "1秒" + assert self.locale._format_timeframe("seconds", 30) == "30秒" + assert self.locale._format_timeframe("minute", 1) == "1分鐘" + assert self.locale._format_timeframe("minutes", 40) == "40分鐘" + assert self.locale._format_timeframe("hour", 1) == "1小時" + assert self.locale._format_timeframe("hours", 23) == "23小時" + assert self.locale._format_timeframe("day", 1) == "1天" + assert self.locale._format_timeframe("days", 12) == "12天" + assert self.locale._format_timeframe("week", 1) == "1週" + assert self.locale._format_timeframe("weeks", 38) == "38週" + assert self.locale._format_timeframe("month", 1) == "1個月" + assert self.locale._format_timeframe("months", 11) == "11個月" + assert self.locale._format_timeframe("year", 1) == "1年" + assert self.locale._format_timeframe("years", 12) == "12年" + + +@pytest.mark.usefixtures("lang_locale") +class TestSwahiliLocale: + def test_format_timeframe(self): + assert self.locale._format_timeframe("now", 0) == "sasa hivi" + assert self.locale._format_timeframe("second", 1) == "sekunde" + assert self.locale._format_timeframe("seconds", 3) == "sekunde 3" + assert self.locale._format_timeframe("seconds", 30) == "sekunde 30" + assert self.locale._format_timeframe("minute", 1) == "dakika moja" + assert self.locale._format_timeframe("minutes", 4) == "dakika 4" + assert self.locale._format_timeframe("minutes", 40) == "dakika 40" + assert self.locale._format_timeframe("hour", 1) == "saa moja" + assert self.locale._format_timeframe("hours", 5) == "saa 5" + assert self.locale._format_timeframe("hours", 23) == "saa 23" + assert self.locale._format_timeframe("day", 1) == "siku moja" + assert self.locale._format_timeframe("days", 6) == "siku 6" + assert self.locale._format_timeframe("days", 12) == "siku 12" + assert self.locale._format_timeframe("month", 1) == "mwezi moja" + assert self.locale._format_timeframe("months", 7) == "miezi 7" + assert self.locale._format_timeframe("week", 1) == "wiki moja" + assert self.locale._format_timeframe("weeks", 2) == "wiki 2" + assert self.locale._format_timeframe("months", 11) == "miezi 11" + assert self.locale._format_timeframe("year", 1) == "mwaka moja" + assert self.locale._format_timeframe("years", 8) == "miaka 8" + assert self.locale._format_timeframe("years", 12) == "miaka 12" + + def test_format_relative_now(self): + result = self.locale._format_relative("sasa hivi", "now", 0) + assert result == "sasa hivi" + + def test_format_relative_past(self): + result = self.locale._format_relative("saa moja", "hour", 1) + assert result == "muda wa saa moja" + + def test_format_relative_future(self): + result = self.locale._format_relative("saa moja", "hour", -1) + assert result == "saa moja iliyopita" + + +@pytest.mark.usefixtures("lang_locale") +class TestKoreanLocale: + def test_format_timeframe(self): + assert self.locale._format_timeframe("now", 0) == "지금" + assert self.locale._format_timeframe("second", 1) == "1초" + assert self.locale._format_timeframe("seconds", 2) == "2초" + assert self.locale._format_timeframe("minute", 1) == "1분" + assert self.locale._format_timeframe("minutes", 2) == "2분" + assert self.locale._format_timeframe("hour", 1) == "한시간" + assert self.locale._format_timeframe("hours", 2) == "2시간" + assert self.locale._format_timeframe("day", 1) == "하루" + assert self.locale._format_timeframe("days", 2) == "2일" + assert self.locale._format_timeframe("week", 1) == "1주" + assert self.locale._format_timeframe("weeks", 2) == "2주" + assert self.locale._format_timeframe("month", 1) == "한달" + assert self.locale._format_timeframe("months", 2) == "2개월" + assert self.locale._format_timeframe("year", 1) == "1년" + assert self.locale._format_timeframe("years", 2) == "2년" + + def test_format_relative(self): + assert self.locale._format_relative("지금", "now", 0) == "지금" + + assert self.locale._format_relative("1초", "second", 1) == "1초 후" + assert self.locale._format_relative("2초", "seconds", 2) == "2초 후" + assert self.locale._format_relative("1분", "minute", 1) == "1분 후" + assert self.locale._format_relative("2분", "minutes", 2) == "2분 후" + assert self.locale._format_relative("한시간", "hour", 1) == "한시간 후" + assert self.locale._format_relative("2시간", "hours", 2) == "2시간 후" + assert self.locale._format_relative("하루", "day", 1) == "내일" + assert self.locale._format_relative("2일", "days", 2) == "모레" + assert self.locale._format_relative("3일", "days", 3) == "글피" + assert self.locale._format_relative("4일", "days", 4) == "그글피" + assert self.locale._format_relative("5일", "days", 5) == "5일 후" + assert self.locale._format_relative("1주", "week", 1) == "1주 후" + assert self.locale._format_relative("2주", "weeks", 2) == "2주 후" + assert self.locale._format_relative("한달", "month", 1) == "한달 후" + assert self.locale._format_relative("2개월", "months", 2) == "2개월 후" + assert self.locale._format_relative("1년", "year", 1) == "내년" + assert self.locale._format_relative("2년", "years", 2) == "내후년" + assert self.locale._format_relative("3년", "years", 3) == "3년 후" + + assert self.locale._format_relative("1초", "second", -1) == "1초 전" + assert self.locale._format_relative("2초", "seconds", -2) == "2초 전" + assert self.locale._format_relative("1분", "minute", -1) == "1분 전" + assert self.locale._format_relative("2분", "minutes", -2) == "2분 전" + assert self.locale._format_relative("한시간", "hour", -1) == "한시간 전" + assert self.locale._format_relative("2시간", "hours", -2) == "2시간 전" + assert self.locale._format_relative("하루", "day", -1) == "어제" + assert self.locale._format_relative("2일", "days", -2) == "그제" + assert self.locale._format_relative("3일", "days", -3) == "그끄제" + assert self.locale._format_relative("4일", "days", -4) == "4일 전" + assert self.locale._format_relative("1주", "week", -1) == "1주 전" + assert self.locale._format_relative("2주", "weeks", -2) == "2주 전" + assert self.locale._format_relative("한달", "month", -1) == "한달 전" + assert self.locale._format_relative("2개월", "months", -2) == "2개월 전" + assert self.locale._format_relative("1년", "year", -1) == "작년" + assert self.locale._format_relative("2년", "years", -2) == "제작년" + assert self.locale._format_relative("3년", "years", -3) == "3년 전" + + def test_ordinal_number(self): + assert self.locale.ordinal_number(0) == "0번째" + assert self.locale.ordinal_number(1) == "첫번째" + assert self.locale.ordinal_number(2) == "두번째" + assert self.locale.ordinal_number(3) == "세번째" + assert self.locale.ordinal_number(4) == "네번째" + assert self.locale.ordinal_number(5) == "다섯번째" + assert self.locale.ordinal_number(6) == "여섯번째" + assert self.locale.ordinal_number(7) == "일곱번째" + assert self.locale.ordinal_number(8) == "여덟번째" + assert self.locale.ordinal_number(9) == "아홉번째" + assert self.locale.ordinal_number(10) == "열번째" + assert self.locale.ordinal_number(11) == "11번째" + assert self.locale.ordinal_number(12) == "12번째" + assert self.locale.ordinal_number(100) == "100번째" diff --git a/openpype/modules/ftrack/python2_vendor/arrow/tests/test_parser.py b/openpype/modules/ftrack/python2_vendor/arrow/tests/test_parser.py new file mode 100644 index 0000000000..9fb4e68f3c --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/tests/test_parser.py @@ -0,0 +1,1657 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import calendar +import os +import time +from datetime import datetime + +import pytest +from dateutil import tz + +import arrow +from arrow import formatter, parser +from arrow.constants import MAX_TIMESTAMP_US +from arrow.parser import DateTimeParser, ParserError, ParserMatchError + +from .utils import make_full_tz_list + + +@pytest.mark.usefixtures("dt_parser") +class TestDateTimeParser: + def test_parse_multiformat(self, mocker): + mocker.patch( + "arrow.parser.DateTimeParser.parse", + string="str", + fmt="fmt_a", + side_effect=parser.ParserError, + ) + + with pytest.raises(parser.ParserError): + self.parser._parse_multiformat("str", ["fmt_a"]) + + mock_datetime = mocker.Mock() + mocker.patch( + "arrow.parser.DateTimeParser.parse", + string="str", + fmt="fmt_b", + return_value=mock_datetime, + ) + + result = self.parser._parse_multiformat("str", ["fmt_a", "fmt_b"]) + assert result == mock_datetime + + def test_parse_multiformat_all_fail(self, mocker): + mocker.patch( + "arrow.parser.DateTimeParser.parse", + string="str", + fmt="fmt_a", + side_effect=parser.ParserError, + ) + + mocker.patch( + "arrow.parser.DateTimeParser.parse", + string="str", + fmt="fmt_b", + side_effect=parser.ParserError, + ) + + with pytest.raises(parser.ParserError): + self.parser._parse_multiformat("str", ["fmt_a", "fmt_b"]) + + def test_parse_multiformat_unself_expected_fail(self, mocker): + class UnselfExpectedError(Exception): + pass + + mocker.patch( + "arrow.parser.DateTimeParser.parse", + string="str", + fmt="fmt_a", + side_effect=UnselfExpectedError, + ) + + with pytest.raises(UnselfExpectedError): + self.parser._parse_multiformat("str", ["fmt_a", "fmt_b"]) + + def test_parse_token_nonsense(self): + parts = {} + self.parser._parse_token("NONSENSE", "1900", parts) + assert parts == {} + + def test_parse_token_invalid_meridians(self): + parts = {} + self.parser._parse_token("A", "a..m", parts) + assert parts == {} + self.parser._parse_token("a", "p..m", parts) + assert parts == {} + + def test_parser_no_caching(self, mocker): + + mocked_parser = mocker.patch( + "arrow.parser.DateTimeParser._generate_pattern_re", fmt="fmt_a" + ) + self.parser = parser.DateTimeParser(cache_size=0) + for _ in range(100): + self.parser._generate_pattern_re("fmt_a") + assert mocked_parser.call_count == 100 + + def test_parser_1_line_caching(self, mocker): + mocked_parser = mocker.patch("arrow.parser.DateTimeParser._generate_pattern_re") + self.parser = parser.DateTimeParser(cache_size=1) + + for _ in range(100): + self.parser._generate_pattern_re(fmt="fmt_a") + assert mocked_parser.call_count == 1 + assert mocked_parser.call_args_list[0] == mocker.call(fmt="fmt_a") + + for _ in range(100): + self.parser._generate_pattern_re(fmt="fmt_b") + assert mocked_parser.call_count == 2 + assert mocked_parser.call_args_list[1] == mocker.call(fmt="fmt_b") + + for _ in range(100): + self.parser._generate_pattern_re(fmt="fmt_a") + assert mocked_parser.call_count == 3 + assert mocked_parser.call_args_list[2] == mocker.call(fmt="fmt_a") + + def test_parser_multiple_line_caching(self, mocker): + mocked_parser = mocker.patch("arrow.parser.DateTimeParser._generate_pattern_re") + self.parser = parser.DateTimeParser(cache_size=2) + + for _ in range(100): + self.parser._generate_pattern_re(fmt="fmt_a") + assert mocked_parser.call_count == 1 + assert mocked_parser.call_args_list[0] == mocker.call(fmt="fmt_a") + + for _ in range(100): + self.parser._generate_pattern_re(fmt="fmt_b") + assert mocked_parser.call_count == 2 + assert mocked_parser.call_args_list[1] == mocker.call(fmt="fmt_b") + + # fmt_a and fmt_b are in the cache, so no new calls should be made + for _ in range(100): + self.parser._generate_pattern_re(fmt="fmt_a") + for _ in range(100): + self.parser._generate_pattern_re(fmt="fmt_b") + assert mocked_parser.call_count == 2 + assert mocked_parser.call_args_list[0] == mocker.call(fmt="fmt_a") + assert mocked_parser.call_args_list[1] == mocker.call(fmt="fmt_b") + + def test_YY_and_YYYY_format_list(self): + + assert self.parser.parse("15/01/19", ["DD/MM/YY", "DD/MM/YYYY"]) == datetime( + 2019, 1, 15 + ) + + # Regression test for issue #580 + assert self.parser.parse("15/01/2019", ["DD/MM/YY", "DD/MM/YYYY"]) == datetime( + 2019, 1, 15 + ) + + assert ( + self.parser.parse( + "15/01/2019T04:05:06.789120Z", + ["D/M/YYThh:mm:ss.SZ", "D/M/YYYYThh:mm:ss.SZ"], + ) + == datetime(2019, 1, 15, 4, 5, 6, 789120, tzinfo=tz.tzutc()) + ) + + # regression test for issue #447 + def test_timestamp_format_list(self): + # should not match on the "X" token + assert ( + self.parser.parse( + "15 Jul 2000", + ["MM/DD/YYYY", "YYYY-MM-DD", "X", "DD-MMMM-YYYY", "D MMM YYYY"], + ) + == datetime(2000, 7, 15) + ) + + with pytest.raises(ParserError): + self.parser.parse("15 Jul", "X") + + +@pytest.mark.usefixtures("dt_parser") +class TestDateTimeParserParse: + def test_parse_list(self, mocker): + + mocker.patch( + "arrow.parser.DateTimeParser._parse_multiformat", + string="str", + formats=["fmt_a", "fmt_b"], + return_value="result", + ) + + result = self.parser.parse("str", ["fmt_a", "fmt_b"]) + assert result == "result" + + def test_parse_unrecognized_token(self, mocker): + + mocker.patch.dict("arrow.parser.DateTimeParser._BASE_INPUT_RE_MAP") + del arrow.parser.DateTimeParser._BASE_INPUT_RE_MAP["YYYY"] + + # need to make another local parser to apply patch changes + _parser = parser.DateTimeParser() + with pytest.raises(parser.ParserError): + _parser.parse("2013-01-01", "YYYY-MM-DD") + + def test_parse_parse_no_match(self): + + with pytest.raises(ParserError): + self.parser.parse("01-01", "YYYY-MM-DD") + + def test_parse_separators(self): + + with pytest.raises(ParserError): + self.parser.parse("1403549231", "YYYY-MM-DD") + + def test_parse_numbers(self): + + self.expected = datetime(2012, 1, 1, 12, 5, 10) + assert ( + self.parser.parse("2012-01-01 12:05:10", "YYYY-MM-DD HH:mm:ss") + == self.expected + ) + + def test_parse_year_two_digit(self): + + self.expected = datetime(1979, 1, 1, 12, 5, 10) + assert ( + self.parser.parse("79-01-01 12:05:10", "YY-MM-DD HH:mm:ss") == self.expected + ) + + def test_parse_timestamp(self): + + tz_utc = tz.tzutc() + int_timestamp = int(time.time()) + self.expected = datetime.fromtimestamp(int_timestamp, tz=tz_utc) + assert self.parser.parse("{:d}".format(int_timestamp), "X") == self.expected + + float_timestamp = time.time() + self.expected = datetime.fromtimestamp(float_timestamp, tz=tz_utc) + assert self.parser.parse("{:f}".format(float_timestamp), "X") == self.expected + + # test handling of ns timestamp (arrow will round to 6 digits regardless) + self.expected = datetime.fromtimestamp(float_timestamp, tz=tz_utc) + assert ( + self.parser.parse("{:f}123".format(float_timestamp), "X") == self.expected + ) + + # test ps timestamp (arrow will round to 6 digits regardless) + self.expected = datetime.fromtimestamp(float_timestamp, tz=tz_utc) + assert ( + self.parser.parse("{:f}123456".format(float_timestamp), "X") + == self.expected + ) + + # NOTE: negative timestamps cannot be handled by datetime on Window + # Must use timedelta to handle them. ref: https://stackoverflow.com/questions/36179914 + if os.name != "nt": + # regression test for issue #662 + negative_int_timestamp = -int_timestamp + self.expected = datetime.fromtimestamp(negative_int_timestamp, tz=tz_utc) + assert ( + self.parser.parse("{:d}".format(negative_int_timestamp), "X") + == self.expected + ) + + negative_float_timestamp = -float_timestamp + self.expected = datetime.fromtimestamp(negative_float_timestamp, tz=tz_utc) + assert ( + self.parser.parse("{:f}".format(negative_float_timestamp), "X") + == self.expected + ) + + # NOTE: timestamps cannot be parsed from natural language strings (by removing the ^...$) because it will + # break cases like "15 Jul 2000" and a format list (see issue #447) + with pytest.raises(ParserError): + natural_lang_string = "Meet me at {} at the restaurant.".format( + float_timestamp + ) + self.parser.parse(natural_lang_string, "X") + + with pytest.raises(ParserError): + self.parser.parse("1565982019.", "X") + + with pytest.raises(ParserError): + self.parser.parse(".1565982019", "X") + + def test_parse_expanded_timestamp(self): + # test expanded timestamps that include milliseconds + # and microseconds as multiples rather than decimals + # requested in issue #357 + + tz_utc = tz.tzutc() + timestamp = 1569982581.413132 + timestamp_milli = int(round(timestamp * 1000)) + timestamp_micro = int(round(timestamp * 1000000)) + + # "x" token should parse integer timestamps below MAX_TIMESTAMP normally + self.expected = datetime.fromtimestamp(int(timestamp), tz=tz_utc) + assert self.parser.parse("{:d}".format(int(timestamp)), "x") == self.expected + + self.expected = datetime.fromtimestamp(round(timestamp, 3), tz=tz_utc) + assert self.parser.parse("{:d}".format(timestamp_milli), "x") == self.expected + + self.expected = datetime.fromtimestamp(timestamp, tz=tz_utc) + assert self.parser.parse("{:d}".format(timestamp_micro), "x") == self.expected + + # anything above max µs timestamp should fail + with pytest.raises(ValueError): + self.parser.parse("{:d}".format(int(MAX_TIMESTAMP_US) + 1), "x") + + # floats are not allowed with the "x" token + with pytest.raises(ParserMatchError): + self.parser.parse("{:f}".format(timestamp), "x") + + def test_parse_names(self): + + self.expected = datetime(2012, 1, 1) + + assert self.parser.parse("January 1, 2012", "MMMM D, YYYY") == self.expected + assert self.parser.parse("Jan 1, 2012", "MMM D, YYYY") == self.expected + + def test_parse_pm(self): + + self.expected = datetime(1, 1, 1, 13, 0, 0) + assert self.parser.parse("1 pm", "H a") == self.expected + assert self.parser.parse("1 pm", "h a") == self.expected + + self.expected = datetime(1, 1, 1, 1, 0, 0) + assert self.parser.parse("1 am", "H A") == self.expected + assert self.parser.parse("1 am", "h A") == self.expected + + self.expected = datetime(1, 1, 1, 0, 0, 0) + assert self.parser.parse("12 am", "H A") == self.expected + assert self.parser.parse("12 am", "h A") == self.expected + + self.expected = datetime(1, 1, 1, 12, 0, 0) + assert self.parser.parse("12 pm", "H A") == self.expected + assert self.parser.parse("12 pm", "h A") == self.expected + + def test_parse_tz_hours_only(self): + + self.expected = datetime(2025, 10, 17, 5, 30, 10, tzinfo=tz.tzoffset(None, 0)) + parsed = self.parser.parse("2025-10-17 05:30:10+00", "YYYY-MM-DD HH:mm:ssZ") + assert parsed == self.expected + + def test_parse_tz_zz(self): + + self.expected = datetime(2013, 1, 1, tzinfo=tz.tzoffset(None, -7 * 3600)) + assert self.parser.parse("2013-01-01 -07:00", "YYYY-MM-DD ZZ") == self.expected + + @pytest.mark.parametrize("full_tz_name", make_full_tz_list()) + def test_parse_tz_name_zzz(self, full_tz_name): + + self.expected = datetime(2013, 1, 1, tzinfo=tz.gettz(full_tz_name)) + assert ( + self.parser.parse("2013-01-01 {}".format(full_tz_name), "YYYY-MM-DD ZZZ") + == self.expected + ) + + # note that offsets are not timezones + with pytest.raises(ParserError): + self.parser.parse("2013-01-01 12:30:45.9+1000", "YYYY-MM-DDZZZ") + + with pytest.raises(ParserError): + self.parser.parse("2013-01-01 12:30:45.9+10:00", "YYYY-MM-DDZZZ") + + with pytest.raises(ParserError): + self.parser.parse("2013-01-01 12:30:45.9-10", "YYYY-MM-DDZZZ") + + def test_parse_subsecond(self): + self.expected = datetime(2013, 1, 1, 12, 30, 45, 900000) + assert ( + self.parser.parse("2013-01-01 12:30:45.9", "YYYY-MM-DD HH:mm:ss.S") + == self.expected + ) + + self.expected = datetime(2013, 1, 1, 12, 30, 45, 980000) + assert ( + self.parser.parse("2013-01-01 12:30:45.98", "YYYY-MM-DD HH:mm:ss.SS") + == self.expected + ) + + self.expected = datetime(2013, 1, 1, 12, 30, 45, 987000) + assert ( + self.parser.parse("2013-01-01 12:30:45.987", "YYYY-MM-DD HH:mm:ss.SSS") + == self.expected + ) + + self.expected = datetime(2013, 1, 1, 12, 30, 45, 987600) + assert ( + self.parser.parse("2013-01-01 12:30:45.9876", "YYYY-MM-DD HH:mm:ss.SSSS") + == self.expected + ) + + self.expected = datetime(2013, 1, 1, 12, 30, 45, 987650) + assert ( + self.parser.parse("2013-01-01 12:30:45.98765", "YYYY-MM-DD HH:mm:ss.SSSSS") + == self.expected + ) + + self.expected = datetime(2013, 1, 1, 12, 30, 45, 987654) + assert ( + self.parser.parse( + "2013-01-01 12:30:45.987654", "YYYY-MM-DD HH:mm:ss.SSSSSS" + ) + == self.expected + ) + + def test_parse_subsecond_rounding(self): + self.expected = datetime(2013, 1, 1, 12, 30, 45, 987654) + datetime_format = "YYYY-MM-DD HH:mm:ss.S" + + # round up + string = "2013-01-01 12:30:45.9876539" + assert self.parser.parse(string, datetime_format) == self.expected + assert self.parser.parse_iso(string) == self.expected + + # round down + string = "2013-01-01 12:30:45.98765432" + assert self.parser.parse(string, datetime_format) == self.expected + assert self.parser.parse_iso(string) == self.expected + + # round half-up + string = "2013-01-01 12:30:45.987653521" + assert self.parser.parse(string, datetime_format) == self.expected + assert self.parser.parse_iso(string) == self.expected + + # round half-down + string = "2013-01-01 12:30:45.9876545210" + assert self.parser.parse(string, datetime_format) == self.expected + assert self.parser.parse_iso(string) == self.expected + + # overflow (zero out the subseconds and increment the seconds) + # regression tests for issue #636 + def test_parse_subsecond_rounding_overflow(self): + datetime_format = "YYYY-MM-DD HH:mm:ss.S" + + self.expected = datetime(2013, 1, 1, 12, 30, 46) + string = "2013-01-01 12:30:45.9999995" + assert self.parser.parse(string, datetime_format) == self.expected + assert self.parser.parse_iso(string) == self.expected + + self.expected = datetime(2013, 1, 1, 12, 31, 0) + string = "2013-01-01 12:30:59.9999999" + assert self.parser.parse(string, datetime_format) == self.expected + assert self.parser.parse_iso(string) == self.expected + + self.expected = datetime(2013, 1, 2, 0, 0, 0) + string = "2013-01-01 23:59:59.9999999" + assert self.parser.parse(string, datetime_format) == self.expected + assert self.parser.parse_iso(string) == self.expected + + # 6 digits should remain unrounded + self.expected = datetime(2013, 1, 1, 12, 30, 45, 999999) + string = "2013-01-01 12:30:45.999999" + assert self.parser.parse(string, datetime_format) == self.expected + assert self.parser.parse_iso(string) == self.expected + + # Regression tests for issue #560 + def test_parse_long_year(self): + with pytest.raises(ParserError): + self.parser.parse("09 January 123456789101112", "DD MMMM YYYY") + + with pytest.raises(ParserError): + self.parser.parse("123456789101112 09 January", "YYYY DD MMMM") + + with pytest.raises(ParserError): + self.parser.parse("68096653015/01/19", "YY/M/DD") + + def test_parse_with_extra_words_at_start_and_end_invalid(self): + input_format_pairs = [ + ("blah2016", "YYYY"), + ("blah2016blah", "YYYY"), + ("2016blah", "YYYY"), + ("2016-05blah", "YYYY-MM"), + ("2016-05-16blah", "YYYY-MM-DD"), + ("2016-05-16T04:05:06.789120blah", "YYYY-MM-DDThh:mm:ss.S"), + ("2016-05-16T04:05:06.789120ZblahZ", "YYYY-MM-DDThh:mm:ss.SZ"), + ("2016-05-16T04:05:06.789120Zblah", "YYYY-MM-DDThh:mm:ss.SZ"), + ("2016-05-16T04:05:06.789120blahZ", "YYYY-MM-DDThh:mm:ss.SZ"), + ] + + for pair in input_format_pairs: + with pytest.raises(ParserError): + self.parser.parse(pair[0], pair[1]) + + def test_parse_with_extra_words_at_start_and_end_valid(self): + # Spaces surrounding the parsable date are ok because we + # allow the parsing of natural language input. Additionally, a single + # character of specific punctuation before or after the date is okay. + # See docs for full list of valid punctuation. + + assert self.parser.parse("blah 2016 blah", "YYYY") == datetime(2016, 1, 1) + + assert self.parser.parse("blah 2016", "YYYY") == datetime(2016, 1, 1) + + assert self.parser.parse("2016 blah", "YYYY") == datetime(2016, 1, 1) + + # test one additional space along with space divider + assert self.parser.parse( + "blah 2016-05-16 04:05:06.789120", "YYYY-MM-DD hh:mm:ss.S" + ) == datetime(2016, 5, 16, 4, 5, 6, 789120) + + assert self.parser.parse( + "2016-05-16 04:05:06.789120 blah", "YYYY-MM-DD hh:mm:ss.S" + ) == datetime(2016, 5, 16, 4, 5, 6, 789120) + + # test one additional space along with T divider + assert self.parser.parse( + "blah 2016-05-16T04:05:06.789120", "YYYY-MM-DDThh:mm:ss.S" + ) == datetime(2016, 5, 16, 4, 5, 6, 789120) + + assert self.parser.parse( + "2016-05-16T04:05:06.789120 blah", "YYYY-MM-DDThh:mm:ss.S" + ) == datetime(2016, 5, 16, 4, 5, 6, 789120) + + assert ( + self.parser.parse( + "Meet me at 2016-05-16T04:05:06.789120 at the restaurant.", + "YYYY-MM-DDThh:mm:ss.S", + ) + == datetime(2016, 5, 16, 4, 5, 6, 789120) + ) + + assert ( + self.parser.parse( + "Meet me at 2016-05-16 04:05:06.789120 at the restaurant.", + "YYYY-MM-DD hh:mm:ss.S", + ) + == datetime(2016, 5, 16, 4, 5, 6, 789120) + ) + + # regression test for issue #701 + # tests cases of a partial match surrounded by punctuation + # for the list of valid punctuation, see documentation + def test_parse_with_punctuation_fences(self): + assert self.parser.parse( + "Meet me at my house on Halloween (2019-31-10)", "YYYY-DD-MM" + ) == datetime(2019, 10, 31) + + assert self.parser.parse( + "Monday, 9. September 2019, 16:15-20:00", "dddd, D. MMMM YYYY" + ) == datetime(2019, 9, 9) + + assert self.parser.parse("A date is 11.11.2011.", "DD.MM.YYYY") == datetime( + 2011, 11, 11 + ) + + with pytest.raises(ParserMatchError): + self.parser.parse("11.11.2011.1 is not a valid date.", "DD.MM.YYYY") + + with pytest.raises(ParserMatchError): + self.parser.parse( + "This date has too many punctuation marks following it (11.11.2011).", + "DD.MM.YYYY", + ) + + def test_parse_with_leading_and_trailing_whitespace(self): + assert self.parser.parse(" 2016", "YYYY") == datetime(2016, 1, 1) + + assert self.parser.parse("2016 ", "YYYY") == datetime(2016, 1, 1) + + assert self.parser.parse(" 2016 ", "YYYY") == datetime(2016, 1, 1) + + assert self.parser.parse( + " 2016-05-16 04:05:06.789120 ", "YYYY-MM-DD hh:mm:ss.S" + ) == datetime(2016, 5, 16, 4, 5, 6, 789120) + + assert self.parser.parse( + " 2016-05-16T04:05:06.789120 ", "YYYY-MM-DDThh:mm:ss.S" + ) == datetime(2016, 5, 16, 4, 5, 6, 789120) + + def test_parse_YYYY_DDDD(self): + assert self.parser.parse("1998-136", "YYYY-DDDD") == datetime(1998, 5, 16) + + assert self.parser.parse("1998-006", "YYYY-DDDD") == datetime(1998, 1, 6) + + with pytest.raises(ParserError): + self.parser.parse("1998-456", "YYYY-DDDD") + + def test_parse_YYYY_DDD(self): + assert self.parser.parse("1998-6", "YYYY-DDD") == datetime(1998, 1, 6) + + assert self.parser.parse("1998-136", "YYYY-DDD") == datetime(1998, 5, 16) + + with pytest.raises(ParserError): + self.parser.parse("1998-756", "YYYY-DDD") + + # month cannot be passed with DDD and DDDD tokens + def test_parse_YYYY_MM_DDDD(self): + with pytest.raises(ParserError): + self.parser.parse("2015-01-009", "YYYY-MM-DDDD") + + # year is required with the DDD and DDDD tokens + def test_parse_DDD_only(self): + with pytest.raises(ParserError): + self.parser.parse("5", "DDD") + + def test_parse_DDDD_only(self): + with pytest.raises(ParserError): + self.parser.parse("145", "DDDD") + + def test_parse_ddd_and_dddd(self): + fr_parser = parser.DateTimeParser("fr") + + # Day of week should be ignored when a day is passed + # 2019-10-17 is a Thursday, so we know day of week + # is ignored if the same date is outputted + expected = datetime(2019, 10, 17) + assert self.parser.parse("Tue 2019-10-17", "ddd YYYY-MM-DD") == expected + assert fr_parser.parse("mar 2019-10-17", "ddd YYYY-MM-DD") == expected + assert self.parser.parse("Tuesday 2019-10-17", "dddd YYYY-MM-DD") == expected + assert fr_parser.parse("mardi 2019-10-17", "dddd YYYY-MM-DD") == expected + + # Get first Tuesday after epoch + expected = datetime(1970, 1, 6) + assert self.parser.parse("Tue", "ddd") == expected + assert fr_parser.parse("mar", "ddd") == expected + assert self.parser.parse("Tuesday", "dddd") == expected + assert fr_parser.parse("mardi", "dddd") == expected + + # Get first Tuesday in 2020 + expected = datetime(2020, 1, 7) + assert self.parser.parse("Tue 2020", "ddd YYYY") == expected + assert fr_parser.parse("mar 2020", "ddd YYYY") == expected + assert self.parser.parse("Tuesday 2020", "dddd YYYY") == expected + assert fr_parser.parse("mardi 2020", "dddd YYYY") == expected + + # Get first Tuesday in February 2020 + expected = datetime(2020, 2, 4) + assert self.parser.parse("Tue 02 2020", "ddd MM YYYY") == expected + assert fr_parser.parse("mar 02 2020", "ddd MM YYYY") == expected + assert self.parser.parse("Tuesday 02 2020", "dddd MM YYYY") == expected + assert fr_parser.parse("mardi 02 2020", "dddd MM YYYY") == expected + + # Get first Tuesday in February after epoch + expected = datetime(1970, 2, 3) + assert self.parser.parse("Tue 02", "ddd MM") == expected + assert fr_parser.parse("mar 02", "ddd MM") == expected + assert self.parser.parse("Tuesday 02", "dddd MM") == expected + assert fr_parser.parse("mardi 02", "dddd MM") == expected + + # Times remain intact + expected = datetime(2020, 2, 4, 10, 25, 54, 123456, tz.tzoffset(None, -3600)) + assert ( + self.parser.parse( + "Tue 02 2020 10:25:54.123456-01:00", "ddd MM YYYY HH:mm:ss.SZZ" + ) + == expected + ) + assert ( + fr_parser.parse( + "mar 02 2020 10:25:54.123456-01:00", "ddd MM YYYY HH:mm:ss.SZZ" + ) + == expected + ) + assert ( + self.parser.parse( + "Tuesday 02 2020 10:25:54.123456-01:00", "dddd MM YYYY HH:mm:ss.SZZ" + ) + == expected + ) + assert ( + fr_parser.parse( + "mardi 02 2020 10:25:54.123456-01:00", "dddd MM YYYY HH:mm:ss.SZZ" + ) + == expected + ) + + def test_parse_ddd_and_dddd_ignore_case(self): + # Regression test for issue #851 + expected = datetime(2019, 6, 24) + assert ( + self.parser.parse("MONDAY, June 24, 2019", "dddd, MMMM DD, YYYY") + == expected + ) + + def test_parse_ddd_and_dddd_then_format(self): + # Regression test for issue #446 + arw_formatter = formatter.DateTimeFormatter() + assert arw_formatter.format(self.parser.parse("Mon", "ddd"), "ddd") == "Mon" + assert ( + arw_formatter.format(self.parser.parse("Monday", "dddd"), "dddd") + == "Monday" + ) + assert arw_formatter.format(self.parser.parse("Tue", "ddd"), "ddd") == "Tue" + assert ( + arw_formatter.format(self.parser.parse("Tuesday", "dddd"), "dddd") + == "Tuesday" + ) + assert arw_formatter.format(self.parser.parse("Wed", "ddd"), "ddd") == "Wed" + assert ( + arw_formatter.format(self.parser.parse("Wednesday", "dddd"), "dddd") + == "Wednesday" + ) + assert arw_formatter.format(self.parser.parse("Thu", "ddd"), "ddd") == "Thu" + assert ( + arw_formatter.format(self.parser.parse("Thursday", "dddd"), "dddd") + == "Thursday" + ) + assert arw_formatter.format(self.parser.parse("Fri", "ddd"), "ddd") == "Fri" + assert ( + arw_formatter.format(self.parser.parse("Friday", "dddd"), "dddd") + == "Friday" + ) + assert arw_formatter.format(self.parser.parse("Sat", "ddd"), "ddd") == "Sat" + assert ( + arw_formatter.format(self.parser.parse("Saturday", "dddd"), "dddd") + == "Saturday" + ) + assert arw_formatter.format(self.parser.parse("Sun", "ddd"), "ddd") == "Sun" + assert ( + arw_formatter.format(self.parser.parse("Sunday", "dddd"), "dddd") + == "Sunday" + ) + + def test_parse_HH_24(self): + assert self.parser.parse( + "2019-10-30T24:00:00", "YYYY-MM-DDTHH:mm:ss" + ) == datetime(2019, 10, 31, 0, 0, 0, 0) + assert self.parser.parse("2019-10-30T24:00", "YYYY-MM-DDTHH:mm") == datetime( + 2019, 10, 31, 0, 0, 0, 0 + ) + assert self.parser.parse("2019-10-30T24", "YYYY-MM-DDTHH") == datetime( + 2019, 10, 31, 0, 0, 0, 0 + ) + assert self.parser.parse( + "2019-10-30T24:00:00.0", "YYYY-MM-DDTHH:mm:ss.S" + ) == datetime(2019, 10, 31, 0, 0, 0, 0) + assert self.parser.parse( + "2019-10-31T24:00:00", "YYYY-MM-DDTHH:mm:ss" + ) == datetime(2019, 11, 1, 0, 0, 0, 0) + assert self.parser.parse( + "2019-12-31T24:00:00", "YYYY-MM-DDTHH:mm:ss" + ) == datetime(2020, 1, 1, 0, 0, 0, 0) + assert self.parser.parse( + "2019-12-31T23:59:59.9999999", "YYYY-MM-DDTHH:mm:ss.S" + ) == datetime(2020, 1, 1, 0, 0, 0, 0) + + with pytest.raises(ParserError): + self.parser.parse("2019-12-31T24:01:00", "YYYY-MM-DDTHH:mm:ss") + + with pytest.raises(ParserError): + self.parser.parse("2019-12-31T24:00:01", "YYYY-MM-DDTHH:mm:ss") + + with pytest.raises(ParserError): + self.parser.parse("2019-12-31T24:00:00.1", "YYYY-MM-DDTHH:mm:ss.S") + + with pytest.raises(ParserError): + self.parser.parse("2019-12-31T24:00:00.999999", "YYYY-MM-DDTHH:mm:ss.S") + + def test_parse_W(self): + + assert self.parser.parse("2011-W05-4", "W") == datetime(2011, 2, 3) + assert self.parser.parse("2011W054", "W") == datetime(2011, 2, 3) + assert self.parser.parse("2011-W05", "W") == datetime(2011, 1, 31) + assert self.parser.parse("2011W05", "W") == datetime(2011, 1, 31) + assert self.parser.parse("2011-W05-4T14:17:01", "WTHH:mm:ss") == datetime( + 2011, 2, 3, 14, 17, 1 + ) + assert self.parser.parse("2011W054T14:17:01", "WTHH:mm:ss") == datetime( + 2011, 2, 3, 14, 17, 1 + ) + assert self.parser.parse("2011-W05T14:17:01", "WTHH:mm:ss") == datetime( + 2011, 1, 31, 14, 17, 1 + ) + assert self.parser.parse("2011W05T141701", "WTHHmmss") == datetime( + 2011, 1, 31, 14, 17, 1 + ) + assert self.parser.parse("2011W054T141701", "WTHHmmss") == datetime( + 2011, 2, 3, 14, 17, 1 + ) + + bad_formats = [ + "201W22", + "1995-W1-4", + "2001-W34-90", + "2001--W34", + "2011-W03--3", + "thstrdjtrsrd676776r65", + "2002-W66-1T14:17:01", + "2002-W23-03T14:17:01", + ] + + for fmt in bad_formats: + with pytest.raises(ParserError): + self.parser.parse(fmt, "W") + + def test_parse_normalize_whitespace(self): + assert self.parser.parse( + "Jun 1 2005 1:33PM", "MMM D YYYY H:mmA", normalize_whitespace=True + ) == datetime(2005, 6, 1, 13, 33) + + with pytest.raises(ParserError): + self.parser.parse("Jun 1 2005 1:33PM", "MMM D YYYY H:mmA") + + assert ( + self.parser.parse( + "\t 2013-05-05 T \n 12:30:45\t123456 \t \n", + "YYYY-MM-DD T HH:mm:ss S", + normalize_whitespace=True, + ) + == datetime(2013, 5, 5, 12, 30, 45, 123456) + ) + + with pytest.raises(ParserError): + self.parser.parse( + "\t 2013-05-05 T \n 12:30:45\t123456 \t \n", + "YYYY-MM-DD T HH:mm:ss S", + ) + + assert self.parser.parse( + " \n Jun 1\t 2005\n ", "MMM D YYYY", normalize_whitespace=True + ) == datetime(2005, 6, 1) + + with pytest.raises(ParserError): + self.parser.parse(" \n Jun 1\t 2005\n ", "MMM D YYYY") + + +@pytest.mark.usefixtures("dt_parser_regex") +class TestDateTimeParserRegex: + def test_format_year(self): + + assert self.format_regex.findall("YYYY-YY") == ["YYYY", "YY"] + + def test_format_month(self): + + assert self.format_regex.findall("MMMM-MMM-MM-M") == ["MMMM", "MMM", "MM", "M"] + + def test_format_day(self): + + assert self.format_regex.findall("DDDD-DDD-DD-D") == ["DDDD", "DDD", "DD", "D"] + + def test_format_hour(self): + + assert self.format_regex.findall("HH-H-hh-h") == ["HH", "H", "hh", "h"] + + def test_format_minute(self): + + assert self.format_regex.findall("mm-m") == ["mm", "m"] + + def test_format_second(self): + + assert self.format_regex.findall("ss-s") == ["ss", "s"] + + def test_format_subsecond(self): + + assert self.format_regex.findall("SSSSSS-SSSSS-SSSS-SSS-SS-S") == [ + "SSSSSS", + "SSSSS", + "SSSS", + "SSS", + "SS", + "S", + ] + + def test_format_tz(self): + + assert self.format_regex.findall("ZZZ-ZZ-Z") == ["ZZZ", "ZZ", "Z"] + + def test_format_am_pm(self): + + assert self.format_regex.findall("A-a") == ["A", "a"] + + def test_format_timestamp(self): + + assert self.format_regex.findall("X") == ["X"] + + def test_format_timestamp_milli(self): + + assert self.format_regex.findall("x") == ["x"] + + def test_escape(self): + + escape_regex = parser.DateTimeParser._ESCAPE_RE + + assert escape_regex.findall("2018-03-09 8 [h] 40 [hello]") == ["[h]", "[hello]"] + + def test_month_names(self): + p = parser.DateTimeParser("en_us") + + text = "_".join(calendar.month_name[1:]) + + result = p._input_re_map["MMMM"].findall(text) + + assert result == calendar.month_name[1:] + + def test_month_abbreviations(self): + p = parser.DateTimeParser("en_us") + + text = "_".join(calendar.month_abbr[1:]) + + result = p._input_re_map["MMM"].findall(text) + + assert result == calendar.month_abbr[1:] + + def test_digits(self): + + assert parser.DateTimeParser._ONE_OR_TWO_DIGIT_RE.findall("4-56") == ["4", "56"] + assert parser.DateTimeParser._ONE_OR_TWO_OR_THREE_DIGIT_RE.findall( + "4-56-789" + ) == ["4", "56", "789"] + assert parser.DateTimeParser._ONE_OR_MORE_DIGIT_RE.findall( + "4-56-789-1234-12345" + ) == ["4", "56", "789", "1234", "12345"] + assert parser.DateTimeParser._TWO_DIGIT_RE.findall("12-3-45") == ["12", "45"] + assert parser.DateTimeParser._THREE_DIGIT_RE.findall("123-4-56") == ["123"] + assert parser.DateTimeParser._FOUR_DIGIT_RE.findall("1234-56") == ["1234"] + + def test_tz(self): + tz_z_re = parser.DateTimeParser._TZ_Z_RE + assert tz_z_re.findall("-0700") == [("-", "07", "00")] + assert tz_z_re.findall("+07") == [("+", "07", "")] + assert tz_z_re.search("15/01/2019T04:05:06.789120Z") is not None + assert tz_z_re.search("15/01/2019T04:05:06.789120") is None + + tz_zz_re = parser.DateTimeParser._TZ_ZZ_RE + assert tz_zz_re.findall("-07:00") == [("-", "07", "00")] + assert tz_zz_re.findall("+07") == [("+", "07", "")] + assert tz_zz_re.search("15/01/2019T04:05:06.789120Z") is not None + assert tz_zz_re.search("15/01/2019T04:05:06.789120") is None + + tz_name_re = parser.DateTimeParser._TZ_NAME_RE + assert tz_name_re.findall("Europe/Warsaw") == ["Europe/Warsaw"] + assert tz_name_re.findall("GMT") == ["GMT"] + + def test_timestamp(self): + timestamp_re = parser.DateTimeParser._TIMESTAMP_RE + assert timestamp_re.findall("1565707550.452729") == ["1565707550.452729"] + assert timestamp_re.findall("-1565707550.452729") == ["-1565707550.452729"] + assert timestamp_re.findall("-1565707550") == ["-1565707550"] + assert timestamp_re.findall("1565707550") == ["1565707550"] + assert timestamp_re.findall("1565707550.") == [] + assert timestamp_re.findall(".1565707550") == [] + + def test_timestamp_milli(self): + timestamp_expanded_re = parser.DateTimeParser._TIMESTAMP_EXPANDED_RE + assert timestamp_expanded_re.findall("-1565707550") == ["-1565707550"] + assert timestamp_expanded_re.findall("1565707550") == ["1565707550"] + assert timestamp_expanded_re.findall("1565707550.452729") == [] + assert timestamp_expanded_re.findall("1565707550.") == [] + assert timestamp_expanded_re.findall(".1565707550") == [] + + def test_time(self): + time_re = parser.DateTimeParser._TIME_RE + time_seperators = [":", ""] + + for sep in time_seperators: + assert time_re.findall("12") == [("12", "", "", "", "")] + assert time_re.findall("12{sep}35".format(sep=sep)) == [ + ("12", "35", "", "", "") + ] + assert time_re.findall("12{sep}35{sep}46".format(sep=sep)) == [ + ("12", "35", "46", "", "") + ] + assert time_re.findall("12{sep}35{sep}46.952313".format(sep=sep)) == [ + ("12", "35", "46", ".", "952313") + ] + assert time_re.findall("12{sep}35{sep}46,952313".format(sep=sep)) == [ + ("12", "35", "46", ",", "952313") + ] + + assert time_re.findall("12:") == [] + assert time_re.findall("12:35:46.") == [] + assert time_re.findall("12:35:46,") == [] + + +@pytest.mark.usefixtures("dt_parser") +class TestDateTimeParserISO: + def test_YYYY(self): + + assert self.parser.parse_iso("2013") == datetime(2013, 1, 1) + + def test_YYYY_DDDD(self): + assert self.parser.parse_iso("1998-136") == datetime(1998, 5, 16) + + assert self.parser.parse_iso("1998-006") == datetime(1998, 1, 6) + + with pytest.raises(ParserError): + self.parser.parse_iso("1998-456") + + # 2016 is a leap year, so Feb 29 exists (leap day) + assert self.parser.parse_iso("2016-059") == datetime(2016, 2, 28) + assert self.parser.parse_iso("2016-060") == datetime(2016, 2, 29) + assert self.parser.parse_iso("2016-061") == datetime(2016, 3, 1) + + # 2017 is not a leap year, so Feb 29 does not exist + assert self.parser.parse_iso("2017-059") == datetime(2017, 2, 28) + assert self.parser.parse_iso("2017-060") == datetime(2017, 3, 1) + assert self.parser.parse_iso("2017-061") == datetime(2017, 3, 2) + + # Since 2016 is a leap year, the 366th day falls in the same year + assert self.parser.parse_iso("2016-366") == datetime(2016, 12, 31) + + # Since 2017 is not a leap year, the 366th day falls in the next year + assert self.parser.parse_iso("2017-366") == datetime(2018, 1, 1) + + def test_YYYY_DDDD_HH_mm_ssZ(self): + + assert self.parser.parse_iso("2013-036 04:05:06+01:00") == datetime( + 2013, 2, 5, 4, 5, 6, tzinfo=tz.tzoffset(None, 3600) + ) + + assert self.parser.parse_iso("2013-036 04:05:06Z") == datetime( + 2013, 2, 5, 4, 5, 6, tzinfo=tz.tzutc() + ) + + def test_YYYY_MM_DDDD(self): + with pytest.raises(ParserError): + self.parser.parse_iso("2014-05-125") + + def test_YYYY_MM(self): + + for separator in DateTimeParser.SEPARATORS: + assert self.parser.parse_iso(separator.join(("2013", "02"))) == datetime( + 2013, 2, 1 + ) + + def test_YYYY_MM_DD(self): + + for separator in DateTimeParser.SEPARATORS: + assert self.parser.parse_iso( + separator.join(("2013", "02", "03")) + ) == datetime(2013, 2, 3) + + def test_YYYY_MM_DDTHH_mmZ(self): + + assert self.parser.parse_iso("2013-02-03T04:05+01:00") == datetime( + 2013, 2, 3, 4, 5, tzinfo=tz.tzoffset(None, 3600) + ) + + def test_YYYY_MM_DDTHH_mm(self): + + assert self.parser.parse_iso("2013-02-03T04:05") == datetime(2013, 2, 3, 4, 5) + + def test_YYYY_MM_DDTHH(self): + + assert self.parser.parse_iso("2013-02-03T04") == datetime(2013, 2, 3, 4) + + def test_YYYY_MM_DDTHHZ(self): + + assert self.parser.parse_iso("2013-02-03T04+01:00") == datetime( + 2013, 2, 3, 4, tzinfo=tz.tzoffset(None, 3600) + ) + + def test_YYYY_MM_DDTHH_mm_ssZ(self): + + assert self.parser.parse_iso("2013-02-03T04:05:06+01:00") == datetime( + 2013, 2, 3, 4, 5, 6, tzinfo=tz.tzoffset(None, 3600) + ) + + def test_YYYY_MM_DDTHH_mm_ss(self): + + assert self.parser.parse_iso("2013-02-03T04:05:06") == datetime( + 2013, 2, 3, 4, 5, 6 + ) + + def test_YYYY_MM_DD_HH_mmZ(self): + + assert self.parser.parse_iso("2013-02-03 04:05+01:00") == datetime( + 2013, 2, 3, 4, 5, tzinfo=tz.tzoffset(None, 3600) + ) + + def test_YYYY_MM_DD_HH_mm(self): + + assert self.parser.parse_iso("2013-02-03 04:05") == datetime(2013, 2, 3, 4, 5) + + def test_YYYY_MM_DD_HH(self): + + assert self.parser.parse_iso("2013-02-03 04") == datetime(2013, 2, 3, 4) + + def test_invalid_time(self): + + with pytest.raises(ParserError): + self.parser.parse_iso("2013-02-03T") + + with pytest.raises(ParserError): + self.parser.parse_iso("2013-02-03 044") + + with pytest.raises(ParserError): + self.parser.parse_iso("2013-02-03 04:05:06.") + + def test_YYYY_MM_DD_HH_mm_ssZ(self): + + assert self.parser.parse_iso("2013-02-03 04:05:06+01:00") == datetime( + 2013, 2, 3, 4, 5, 6, tzinfo=tz.tzoffset(None, 3600) + ) + + def test_YYYY_MM_DD_HH_mm_ss(self): + + assert self.parser.parse_iso("2013-02-03 04:05:06") == datetime( + 2013, 2, 3, 4, 5, 6 + ) + + def test_YYYY_MM_DDTHH_mm_ss_S(self): + + assert self.parser.parse_iso("2013-02-03T04:05:06.7") == datetime( + 2013, 2, 3, 4, 5, 6, 700000 + ) + + assert self.parser.parse_iso("2013-02-03T04:05:06.78") == datetime( + 2013, 2, 3, 4, 5, 6, 780000 + ) + + assert self.parser.parse_iso("2013-02-03T04:05:06.789") == datetime( + 2013, 2, 3, 4, 5, 6, 789000 + ) + + assert self.parser.parse_iso("2013-02-03T04:05:06.7891") == datetime( + 2013, 2, 3, 4, 5, 6, 789100 + ) + + assert self.parser.parse_iso("2013-02-03T04:05:06.78912") == datetime( + 2013, 2, 3, 4, 5, 6, 789120 + ) + + # ISO 8601:2004(E), ISO, 2004-12-01, 4.2.2.4 ... the decimal fraction + # shall be divided from the integer part by the decimal sign specified + # in ISO 31-0, i.e. the comma [,] or full stop [.]. Of these, the comma + # is the preferred sign. + assert self.parser.parse_iso("2013-02-03T04:05:06,789123678") == datetime( + 2013, 2, 3, 4, 5, 6, 789124 + ) + + # there is no limit on the number of decimal places + assert self.parser.parse_iso("2013-02-03T04:05:06.789123678") == datetime( + 2013, 2, 3, 4, 5, 6, 789124 + ) + + def test_YYYY_MM_DDTHH_mm_ss_SZ(self): + + assert self.parser.parse_iso("2013-02-03T04:05:06.7+01:00") == datetime( + 2013, 2, 3, 4, 5, 6, 700000, tzinfo=tz.tzoffset(None, 3600) + ) + + assert self.parser.parse_iso("2013-02-03T04:05:06.78+01:00") == datetime( + 2013, 2, 3, 4, 5, 6, 780000, tzinfo=tz.tzoffset(None, 3600) + ) + + assert self.parser.parse_iso("2013-02-03T04:05:06.789+01:00") == datetime( + 2013, 2, 3, 4, 5, 6, 789000, tzinfo=tz.tzoffset(None, 3600) + ) + + assert self.parser.parse_iso("2013-02-03T04:05:06.7891+01:00") == datetime( + 2013, 2, 3, 4, 5, 6, 789100, tzinfo=tz.tzoffset(None, 3600) + ) + + assert self.parser.parse_iso("2013-02-03T04:05:06.78912+01:00") == datetime( + 2013, 2, 3, 4, 5, 6, 789120, tzinfo=tz.tzoffset(None, 3600) + ) + + assert self.parser.parse_iso("2013-02-03 04:05:06.78912Z") == datetime( + 2013, 2, 3, 4, 5, 6, 789120, tzinfo=tz.tzutc() + ) + + def test_W(self): + + assert self.parser.parse_iso("2011-W05-4") == datetime(2011, 2, 3) + + assert self.parser.parse_iso("2011-W05-4T14:17:01") == datetime( + 2011, 2, 3, 14, 17, 1 + ) + + assert self.parser.parse_iso("2011W054") == datetime(2011, 2, 3) + + assert self.parser.parse_iso("2011W054T141701") == datetime( + 2011, 2, 3, 14, 17, 1 + ) + + def test_invalid_Z(self): + + with pytest.raises(ParserError): + self.parser.parse_iso("2013-02-03T04:05:06.78912z") + + with pytest.raises(ParserError): + self.parser.parse_iso("2013-02-03T04:05:06.78912zz") + + with pytest.raises(ParserError): + self.parser.parse_iso("2013-02-03T04:05:06.78912Zz") + + with pytest.raises(ParserError): + self.parser.parse_iso("2013-02-03T04:05:06.78912ZZ") + + with pytest.raises(ParserError): + self.parser.parse_iso("2013-02-03T04:05:06.78912+Z") + + with pytest.raises(ParserError): + self.parser.parse_iso("2013-02-03T04:05:06.78912-Z") + + with pytest.raises(ParserError): + self.parser.parse_iso("2013-02-03T04:05:06.78912 Z") + + def test_parse_subsecond(self): + self.expected = datetime(2013, 1, 1, 12, 30, 45, 900000) + assert self.parser.parse_iso("2013-01-01 12:30:45.9") == self.expected + + self.expected = datetime(2013, 1, 1, 12, 30, 45, 980000) + assert self.parser.parse_iso("2013-01-01 12:30:45.98") == self.expected + + self.expected = datetime(2013, 1, 1, 12, 30, 45, 987000) + assert self.parser.parse_iso("2013-01-01 12:30:45.987") == self.expected + + self.expected = datetime(2013, 1, 1, 12, 30, 45, 987600) + assert self.parser.parse_iso("2013-01-01 12:30:45.9876") == self.expected + + self.expected = datetime(2013, 1, 1, 12, 30, 45, 987650) + assert self.parser.parse_iso("2013-01-01 12:30:45.98765") == self.expected + + self.expected = datetime(2013, 1, 1, 12, 30, 45, 987654) + assert self.parser.parse_iso("2013-01-01 12:30:45.987654") == self.expected + + # use comma as subsecond separator + self.expected = datetime(2013, 1, 1, 12, 30, 45, 987654) + assert self.parser.parse_iso("2013-01-01 12:30:45,987654") == self.expected + + def test_gnu_date(self): + """Regression tests for parsing output from GNU date.""" + # date -Ins + assert self.parser.parse_iso("2016-11-16T09:46:30,895636557-0800") == datetime( + 2016, 11, 16, 9, 46, 30, 895636, tzinfo=tz.tzoffset(None, -3600 * 8) + ) + + # date --rfc-3339=ns + assert self.parser.parse_iso("2016-11-16 09:51:14.682141526-08:00") == datetime( + 2016, 11, 16, 9, 51, 14, 682142, tzinfo=tz.tzoffset(None, -3600 * 8) + ) + + def test_isoformat(self): + + dt = datetime.utcnow() + + assert self.parser.parse_iso(dt.isoformat()) == dt + + def test_parse_iso_normalize_whitespace(self): + assert self.parser.parse_iso( + "2013-036 \t 04:05:06Z", normalize_whitespace=True + ) == datetime(2013, 2, 5, 4, 5, 6, tzinfo=tz.tzutc()) + + with pytest.raises(ParserError): + self.parser.parse_iso("2013-036 \t 04:05:06Z") + + assert self.parser.parse_iso( + "\t 2013-05-05T12:30:45.123456 \t \n", normalize_whitespace=True + ) == datetime(2013, 5, 5, 12, 30, 45, 123456) + + with pytest.raises(ParserError): + self.parser.parse_iso("\t 2013-05-05T12:30:45.123456 \t \n") + + def test_parse_iso_with_leading_and_trailing_whitespace(self): + datetime_string = " 2016-11-15T06:37:19.123456" + with pytest.raises(ParserError): + self.parser.parse_iso(datetime_string) + + datetime_string = " 2016-11-15T06:37:19.123456 " + with pytest.raises(ParserError): + self.parser.parse_iso(datetime_string) + + datetime_string = "2016-11-15T06:37:19.123456 " + with pytest.raises(ParserError): + self.parser.parse_iso(datetime_string) + + datetime_string = "2016-11-15T 06:37:19.123456" + with pytest.raises(ParserError): + self.parser.parse_iso(datetime_string) + + # leading whitespace + datetime_string = " 2016-11-15 06:37:19.123456" + with pytest.raises(ParserError): + self.parser.parse_iso(datetime_string) + + # trailing whitespace + datetime_string = "2016-11-15 06:37:19.123456 " + with pytest.raises(ParserError): + self.parser.parse_iso(datetime_string) + + datetime_string = " 2016-11-15 06:37:19.123456 " + with pytest.raises(ParserError): + self.parser.parse_iso(datetime_string) + + # two dividing spaces + datetime_string = "2016-11-15 06:37:19.123456" + with pytest.raises(ParserError): + self.parser.parse_iso(datetime_string) + + def test_parse_iso_with_extra_words_at_start_and_end_invalid(self): + test_inputs = [ + "blah2016", + "blah2016blah", + "blah 2016 blah", + "blah 2016", + "2016 blah", + "blah 2016-05-16 04:05:06.789120", + "2016-05-16 04:05:06.789120 blah", + "blah 2016-05-16T04:05:06.789120", + "2016-05-16T04:05:06.789120 blah", + "2016blah", + "2016-05blah", + "2016-05-16blah", + "2016-05-16T04:05:06.789120blah", + "2016-05-16T04:05:06.789120ZblahZ", + "2016-05-16T04:05:06.789120Zblah", + "2016-05-16T04:05:06.789120blahZ", + "Meet me at 2016-05-16T04:05:06.789120 at the restaurant.", + "Meet me at 2016-05-16 04:05:06.789120 at the restaurant.", + ] + + for ti in test_inputs: + with pytest.raises(ParserError): + self.parser.parse_iso(ti) + + def test_iso8601_basic_format(self): + assert self.parser.parse_iso("20180517") == datetime(2018, 5, 17) + + assert self.parser.parse_iso("20180517T10") == datetime(2018, 5, 17, 10) + + assert self.parser.parse_iso("20180517T105513.843456") == datetime( + 2018, 5, 17, 10, 55, 13, 843456 + ) + + assert self.parser.parse_iso("20180517T105513Z") == datetime( + 2018, 5, 17, 10, 55, 13, tzinfo=tz.tzutc() + ) + + assert self.parser.parse_iso("20180517T105513.843456-0700") == datetime( + 2018, 5, 17, 10, 55, 13, 843456, tzinfo=tz.tzoffset(None, -25200) + ) + + assert self.parser.parse_iso("20180517T105513-0700") == datetime( + 2018, 5, 17, 10, 55, 13, tzinfo=tz.tzoffset(None, -25200) + ) + + assert self.parser.parse_iso("20180517T105513-07") == datetime( + 2018, 5, 17, 10, 55, 13, tzinfo=tz.tzoffset(None, -25200) + ) + + # ordinal in basic format: YYYYDDDD + assert self.parser.parse_iso("1998136") == datetime(1998, 5, 16) + + # timezone requires +- seperator + with pytest.raises(ParserError): + self.parser.parse_iso("20180517T1055130700") + + with pytest.raises(ParserError): + self.parser.parse_iso("20180517T10551307") + + # too many digits in date + with pytest.raises(ParserError): + self.parser.parse_iso("201860517T105513Z") + + # too many digits in time + with pytest.raises(ParserError): + self.parser.parse_iso("20180517T1055213Z") + + def test_midnight_end_day(self): + assert self.parser.parse_iso("2019-10-30T24:00:00") == datetime( + 2019, 10, 31, 0, 0, 0, 0 + ) + assert self.parser.parse_iso("2019-10-30T24:00") == datetime( + 2019, 10, 31, 0, 0, 0, 0 + ) + assert self.parser.parse_iso("2019-10-30T24:00:00.0") == datetime( + 2019, 10, 31, 0, 0, 0, 0 + ) + assert self.parser.parse_iso("2019-10-31T24:00:00") == datetime( + 2019, 11, 1, 0, 0, 0, 0 + ) + assert self.parser.parse_iso("2019-12-31T24:00:00") == datetime( + 2020, 1, 1, 0, 0, 0, 0 + ) + assert self.parser.parse_iso("2019-12-31T23:59:59.9999999") == datetime( + 2020, 1, 1, 0, 0, 0, 0 + ) + + with pytest.raises(ParserError): + self.parser.parse_iso("2019-12-31T24:01:00") + + with pytest.raises(ParserError): + self.parser.parse_iso("2019-12-31T24:00:01") + + with pytest.raises(ParserError): + self.parser.parse_iso("2019-12-31T24:00:00.1") + + with pytest.raises(ParserError): + self.parser.parse_iso("2019-12-31T24:00:00.999999") + + +@pytest.mark.usefixtures("tzinfo_parser") +class TestTzinfoParser: + def test_parse_local(self): + + assert self.parser.parse("local") == tz.tzlocal() + + def test_parse_utc(self): + + assert self.parser.parse("utc") == tz.tzutc() + assert self.parser.parse("UTC") == tz.tzutc() + + def test_parse_iso(self): + + assert self.parser.parse("01:00") == tz.tzoffset(None, 3600) + assert self.parser.parse("11:35") == tz.tzoffset(None, 11 * 3600 + 2100) + assert self.parser.parse("+01:00") == tz.tzoffset(None, 3600) + assert self.parser.parse("-01:00") == tz.tzoffset(None, -3600) + + assert self.parser.parse("0100") == tz.tzoffset(None, 3600) + assert self.parser.parse("+0100") == tz.tzoffset(None, 3600) + assert self.parser.parse("-0100") == tz.tzoffset(None, -3600) + + assert self.parser.parse("01") == tz.tzoffset(None, 3600) + assert self.parser.parse("+01") == tz.tzoffset(None, 3600) + assert self.parser.parse("-01") == tz.tzoffset(None, -3600) + + def test_parse_str(self): + + assert self.parser.parse("US/Pacific") == tz.gettz("US/Pacific") + + def test_parse_fails(self): + + with pytest.raises(parser.ParserError): + self.parser.parse("fail") + + +@pytest.mark.usefixtures("dt_parser") +class TestDateTimeParserMonthName: + def test_shortmonth_capitalized(self): + + assert self.parser.parse("2013-Jan-01", "YYYY-MMM-DD") == datetime(2013, 1, 1) + + def test_shortmonth_allupper(self): + + assert self.parser.parse("2013-JAN-01", "YYYY-MMM-DD") == datetime(2013, 1, 1) + + def test_shortmonth_alllower(self): + + assert self.parser.parse("2013-jan-01", "YYYY-MMM-DD") == datetime(2013, 1, 1) + + def test_month_capitalized(self): + + assert self.parser.parse("2013-January-01", "YYYY-MMMM-DD") == datetime( + 2013, 1, 1 + ) + + def test_month_allupper(self): + + assert self.parser.parse("2013-JANUARY-01", "YYYY-MMMM-DD") == datetime( + 2013, 1, 1 + ) + + def test_month_alllower(self): + + assert self.parser.parse("2013-january-01", "YYYY-MMMM-DD") == datetime( + 2013, 1, 1 + ) + + def test_localized_month_name(self): + parser_ = parser.DateTimeParser("fr_fr") + + assert parser_.parse("2013-Janvier-01", "YYYY-MMMM-DD") == datetime(2013, 1, 1) + + def test_localized_month_abbreviation(self): + parser_ = parser.DateTimeParser("it_it") + + assert parser_.parse("2013-Gen-01", "YYYY-MMM-DD") == datetime(2013, 1, 1) + + +@pytest.mark.usefixtures("dt_parser") +class TestDateTimeParserMeridians: + def test_meridians_lowercase(self): + assert self.parser.parse("2013-01-01 5am", "YYYY-MM-DD ha") == datetime( + 2013, 1, 1, 5 + ) + + assert self.parser.parse("2013-01-01 5pm", "YYYY-MM-DD ha") == datetime( + 2013, 1, 1, 17 + ) + + def test_meridians_capitalized(self): + assert self.parser.parse("2013-01-01 5AM", "YYYY-MM-DD hA") == datetime( + 2013, 1, 1, 5 + ) + + assert self.parser.parse("2013-01-01 5PM", "YYYY-MM-DD hA") == datetime( + 2013, 1, 1, 17 + ) + + def test_localized_meridians_lowercase(self): + parser_ = parser.DateTimeParser("hu_hu") + assert parser_.parse("2013-01-01 5 de", "YYYY-MM-DD h a") == datetime( + 2013, 1, 1, 5 + ) + + assert parser_.parse("2013-01-01 5 du", "YYYY-MM-DD h a") == datetime( + 2013, 1, 1, 17 + ) + + def test_localized_meridians_capitalized(self): + parser_ = parser.DateTimeParser("hu_hu") + assert parser_.parse("2013-01-01 5 DE", "YYYY-MM-DD h A") == datetime( + 2013, 1, 1, 5 + ) + + assert parser_.parse("2013-01-01 5 DU", "YYYY-MM-DD h A") == datetime( + 2013, 1, 1, 17 + ) + + # regression test for issue #607 + def test_es_meridians(self): + parser_ = parser.DateTimeParser("es") + + assert parser_.parse( + "Junio 30, 2019 - 08:00 pm", "MMMM DD, YYYY - hh:mm a" + ) == datetime(2019, 6, 30, 20, 0) + + with pytest.raises(ParserError): + parser_.parse( + "Junio 30, 2019 - 08:00 pasdfasdfm", "MMMM DD, YYYY - hh:mm a" + ) + + def test_fr_meridians(self): + parser_ = parser.DateTimeParser("fr") + + # the French locale always uses a 24 hour clock, so it does not support meridians + with pytest.raises(ParserError): + parser_.parse("Janvier 30, 2019 - 08:00 pm", "MMMM DD, YYYY - hh:mm a") + + +@pytest.mark.usefixtures("dt_parser") +class TestDateTimeParserMonthOrdinalDay: + def test_english(self): + parser_ = parser.DateTimeParser("en_us") + + assert parser_.parse("January 1st, 2013", "MMMM Do, YYYY") == datetime( + 2013, 1, 1 + ) + assert parser_.parse("January 2nd, 2013", "MMMM Do, YYYY") == datetime( + 2013, 1, 2 + ) + assert parser_.parse("January 3rd, 2013", "MMMM Do, YYYY") == datetime( + 2013, 1, 3 + ) + assert parser_.parse("January 4th, 2013", "MMMM Do, YYYY") == datetime( + 2013, 1, 4 + ) + assert parser_.parse("January 11th, 2013", "MMMM Do, YYYY") == datetime( + 2013, 1, 11 + ) + assert parser_.parse("January 12th, 2013", "MMMM Do, YYYY") == datetime( + 2013, 1, 12 + ) + assert parser_.parse("January 13th, 2013", "MMMM Do, YYYY") == datetime( + 2013, 1, 13 + ) + assert parser_.parse("January 21st, 2013", "MMMM Do, YYYY") == datetime( + 2013, 1, 21 + ) + assert parser_.parse("January 31st, 2013", "MMMM Do, YYYY") == datetime( + 2013, 1, 31 + ) + + with pytest.raises(ParserError): + parser_.parse("January 1th, 2013", "MMMM Do, YYYY") + + with pytest.raises(ParserError): + parser_.parse("January 11st, 2013", "MMMM Do, YYYY") + + def test_italian(self): + parser_ = parser.DateTimeParser("it_it") + + assert parser_.parse("Gennaio 1º, 2013", "MMMM Do, YYYY") == datetime( + 2013, 1, 1 + ) + + def test_spanish(self): + parser_ = parser.DateTimeParser("es_es") + + assert parser_.parse("Enero 1º, 2013", "MMMM Do, YYYY") == datetime(2013, 1, 1) + + def test_french(self): + parser_ = parser.DateTimeParser("fr_fr") + + assert parser_.parse("Janvier 1er, 2013", "MMMM Do, YYYY") == datetime( + 2013, 1, 1 + ) + + assert parser_.parse("Janvier 2e, 2013", "MMMM Do, YYYY") == datetime( + 2013, 1, 2 + ) + + assert parser_.parse("Janvier 11e, 2013", "MMMM Do, YYYY") == datetime( + 2013, 1, 11 + ) + + +@pytest.mark.usefixtures("dt_parser") +class TestDateTimeParserSearchDate: + def test_parse_search(self): + + assert self.parser.parse( + "Today is 25 of September of 2003", "DD of MMMM of YYYY" + ) == datetime(2003, 9, 25) + + def test_parse_search_with_numbers(self): + + assert self.parser.parse( + "2000 people met the 2012-01-01 12:05:10", "YYYY-MM-DD HH:mm:ss" + ) == datetime(2012, 1, 1, 12, 5, 10) + + assert self.parser.parse( + "Call 01-02-03 on 79-01-01 12:05:10", "YY-MM-DD HH:mm:ss" + ) == datetime(1979, 1, 1, 12, 5, 10) + + def test_parse_search_with_names(self): + + assert self.parser.parse("June was born in May 1980", "MMMM YYYY") == datetime( + 1980, 5, 1 + ) + + def test_parse_search_locale_with_names(self): + p = parser.DateTimeParser("sv_se") + + assert p.parse("Jan föddes den 31 Dec 1980", "DD MMM YYYY") == datetime( + 1980, 12, 31 + ) + + assert p.parse("Jag föddes den 25 Augusti 1975", "DD MMMM YYYY") == datetime( + 1975, 8, 25 + ) + + def test_parse_search_fails(self): + + with pytest.raises(parser.ParserError): + self.parser.parse("Jag föddes den 25 Augusti 1975", "DD MMMM YYYY") + + def test_escape(self): + + format = "MMMM D, YYYY [at] h:mma" + assert self.parser.parse( + "Thursday, December 10, 2015 at 5:09pm", format + ) == datetime(2015, 12, 10, 17, 9) + + format = "[MMMM] M D, YYYY [at] h:mma" + assert self.parser.parse("MMMM 12 10, 2015 at 5:09pm", format) == datetime( + 2015, 12, 10, 17, 9 + ) + + format = "[It happened on] MMMM Do [in the year] YYYY [a long time ago]" + assert self.parser.parse( + "It happened on November 25th in the year 1990 a long time ago", format + ) == datetime(1990, 11, 25) + + format = "[It happened on] MMMM Do [in the][ year] YYYY [a long time ago]" + assert self.parser.parse( + "It happened on November 25th in the year 1990 a long time ago", format + ) == datetime(1990, 11, 25) + + format = "[I'm][ entirely][ escaped,][ weee!]" + assert self.parser.parse("I'm entirely escaped, weee!", format) == datetime( + 1, 1, 1 + ) + + # Special RegEx characters + format = "MMM DD, YYYY |^${}().*+?<>-& h:mm A" + assert self.parser.parse( + "Dec 31, 2017 |^${}().*+?<>-& 2:00 AM", format + ) == datetime(2017, 12, 31, 2, 0) diff --git a/openpype/modules/ftrack/python2_vendor/arrow/tests/test_util.py b/openpype/modules/ftrack/python2_vendor/arrow/tests/test_util.py new file mode 100644 index 0000000000..e48b4de066 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/tests/test_util.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +import time +from datetime import datetime + +import pytest + +from arrow import util + + +class TestUtil: + def test_next_weekday(self): + # Get first Monday after epoch + assert util.next_weekday(datetime(1970, 1, 1), 0) == datetime(1970, 1, 5) + + # Get first Tuesday after epoch + assert util.next_weekday(datetime(1970, 1, 1), 1) == datetime(1970, 1, 6) + + # Get first Wednesday after epoch + assert util.next_weekday(datetime(1970, 1, 1), 2) == datetime(1970, 1, 7) + + # Get first Thursday after epoch + assert util.next_weekday(datetime(1970, 1, 1), 3) == datetime(1970, 1, 1) + + # Get first Friday after epoch + assert util.next_weekday(datetime(1970, 1, 1), 4) == datetime(1970, 1, 2) + + # Get first Saturday after epoch + assert util.next_weekday(datetime(1970, 1, 1), 5) == datetime(1970, 1, 3) + + # Get first Sunday after epoch + assert util.next_weekday(datetime(1970, 1, 1), 6) == datetime(1970, 1, 4) + + # Weekdays are 0-indexed + with pytest.raises(ValueError): + util.next_weekday(datetime(1970, 1, 1), 7) + + with pytest.raises(ValueError): + util.next_weekday(datetime(1970, 1, 1), -1) + + def test_total_seconds(self): + td = datetime(2019, 1, 1) - datetime(2018, 1, 1) + assert util.total_seconds(td) == td.total_seconds() + + def test_is_timestamp(self): + timestamp_float = time.time() + timestamp_int = int(timestamp_float) + + assert util.is_timestamp(timestamp_int) + assert util.is_timestamp(timestamp_float) + assert util.is_timestamp(str(timestamp_int)) + assert util.is_timestamp(str(timestamp_float)) + + assert not util.is_timestamp(True) + assert not util.is_timestamp(False) + + class InvalidTimestamp: + pass + + assert not util.is_timestamp(InvalidTimestamp()) + + full_datetime = "2019-06-23T13:12:42" + assert not util.is_timestamp(full_datetime) + + def test_normalize_timestamp(self): + timestamp = 1591161115.194556 + millisecond_timestamp = 1591161115194 + microsecond_timestamp = 1591161115194556 + + assert util.normalize_timestamp(timestamp) == timestamp + assert util.normalize_timestamp(millisecond_timestamp) == 1591161115.194 + assert util.normalize_timestamp(microsecond_timestamp) == 1591161115.194556 + + with pytest.raises(ValueError): + util.normalize_timestamp(3e17) + + def test_iso_gregorian(self): + with pytest.raises(ValueError): + util.iso_to_gregorian(2013, 0, 5) + + with pytest.raises(ValueError): + util.iso_to_gregorian(2013, 8, 0) diff --git a/openpype/modules/ftrack/python2_vendor/arrow/tests/utils.py b/openpype/modules/ftrack/python2_vendor/arrow/tests/utils.py new file mode 100644 index 0000000000..2a048feb3f --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/tests/utils.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +import pytz +from dateutil.zoneinfo import get_zonefile_instance + +from arrow import util + + +def make_full_tz_list(): + dateutil_zones = set(get_zonefile_instance().zones) + pytz_zones = set(pytz.all_timezones) + return dateutil_zones.union(pytz_zones) + + +def assert_datetime_equality(dt1, dt2, within=10): + assert dt1.tzinfo == dt2.tzinfo + assert abs(util.total_seconds(dt1 - dt2)) < within diff --git a/openpype/modules/ftrack/python2_vendor/arrow/tox.ini b/openpype/modules/ftrack/python2_vendor/arrow/tox.ini new file mode 100644 index 0000000000..46576b12e3 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow/tox.ini @@ -0,0 +1,53 @@ +[tox] +minversion = 3.18.0 +envlist = py{py3,27,35,36,37,38,39},lint,docs +skip_missing_interpreters = true + +[gh-actions] +python = + pypy3: pypy3 + 2.7: py27 + 3.5: py35 + 3.6: py36 + 3.7: py37 + 3.8: py38 + 3.9: py39 + +[testenv] +deps = -rrequirements.txt +allowlist_externals = pytest +commands = pytest + +[testenv:lint] +basepython = python3 +skip_install = true +deps = pre-commit +commands = + pre-commit install + pre-commit run --all-files --show-diff-on-failure + +[testenv:docs] +basepython = python3 +skip_install = true +changedir = docs +deps = + doc8 + sphinx + python-dateutil +allowlist_externals = make +commands = + doc8 index.rst ../README.rst --extension .rst --ignore D001 + make html SPHINXOPTS="-W --keep-going" + +[pytest] +addopts = -v --cov-branch --cov=arrow --cov-fail-under=100 --cov-report=term-missing --cov-report=xml +testpaths = tests + +[isort] +line_length = 88 +multi_line_output = 3 +include_trailing_comma = true + +[flake8] +per-file-ignores = arrow/__init__.py:F401 +ignore = E203,E501,W503 diff --git a/openpype/modules/default_modules/ftrack/python2_vendor/backports.functools_lru_cache/backports/__init__.py b/openpype/modules/ftrack/python2_vendor/backports.functools_lru_cache/backports/__init__.py similarity index 100% rename from openpype/modules/default_modules/ftrack/python2_vendor/backports.functools_lru_cache/backports/__init__.py rename to openpype/modules/ftrack/python2_vendor/backports.functools_lru_cache/backports/__init__.py diff --git a/openpype/modules/default_modules/ftrack/python2_vendor/backports.functools_lru_cache/backports/configparser/__init__.py b/openpype/modules/ftrack/python2_vendor/backports.functools_lru_cache/backports/configparser/__init__.py similarity index 100% rename from openpype/modules/default_modules/ftrack/python2_vendor/backports.functools_lru_cache/backports/configparser/__init__.py rename to openpype/modules/ftrack/python2_vendor/backports.functools_lru_cache/backports/configparser/__init__.py diff --git a/openpype/modules/default_modules/ftrack/python2_vendor/backports.functools_lru_cache/backports/configparser/helpers.py b/openpype/modules/ftrack/python2_vendor/backports.functools_lru_cache/backports/configparser/helpers.py similarity index 100% rename from openpype/modules/default_modules/ftrack/python2_vendor/backports.functools_lru_cache/backports/configparser/helpers.py rename to openpype/modules/ftrack/python2_vendor/backports.functools_lru_cache/backports/configparser/helpers.py diff --git a/openpype/modules/default_modules/ftrack/python2_vendor/backports.functools_lru_cache/backports/functools_lru_cache.py b/openpype/modules/ftrack/python2_vendor/backports.functools_lru_cache/backports/functools_lru_cache.py similarity index 100% rename from openpype/modules/default_modules/ftrack/python2_vendor/backports.functools_lru_cache/backports/functools_lru_cache.py rename to openpype/modules/ftrack/python2_vendor/backports.functools_lru_cache/backports/functools_lru_cache.py diff --git a/openpype/modules/default_modules/ftrack/python2_vendor/builtins/builtins/__init__.py b/openpype/modules/ftrack/python2_vendor/builtins/builtins/__init__.py similarity index 100% rename from openpype/modules/default_modules/ftrack/python2_vendor/builtins/builtins/__init__.py rename to openpype/modules/ftrack/python2_vendor/builtins/builtins/__init__.py diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/.gitignore b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/.gitignore new file mode 100644 index 0000000000..be621609ab --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/.gitignore @@ -0,0 +1,42 @@ +# General +*.py[cod] + +# Packages +*.egg +*.egg-info +dist +build +.eggs/ +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 +__pycache__ + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox + +# Caches +Thumbs.db + +# Development +.project +.pydevproject +.settings +.idea/ +.history/ +.vscode/ + +# Testing +.cache +test-reports/* +.pytest_cache/* \ No newline at end of file diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/LICENSE.python b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/LICENSE.python new file mode 100644 index 0000000000..9dc010d803 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/LICENSE.python @@ -0,0 +1,254 @@ +A. HISTORY OF THE SOFTWARE +========================== + +Python was created in the early 1990s by Guido van Rossum at Stichting +Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands +as a successor of a language called ABC. Guido remains Python's +principal author, although it includes many contributions from others. + +In 1995, Guido continued his work on Python at the Corporation for +National Research Initiatives (CNRI, see http://www.cnri.reston.va.us) +in Reston, Virginia where he released several versions of the +software. + +In May 2000, Guido and the Python core development team moved to +BeOpen.com to form the BeOpen PythonLabs team. In October of the same +year, the PythonLabs team moved to Digital Creations, which became +Zope Corporation. In 2001, the Python Software Foundation (PSF, see +https://www.python.org/psf/) was formed, a non-profit organization +created specifically to own Python-related Intellectual Property. +Zope Corporation was a sponsoring member of the PSF. + +All Python releases are Open Source (see http://www.opensource.org for +the Open Source Definition). Historically, most, but not all, Python +releases have also been GPL-compatible; the table below summarizes +the various releases. + + Release Derived Year Owner GPL- + from compatible? (1) + + 0.9.0 thru 1.2 1991-1995 CWI yes + 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes + 1.6 1.5.2 2000 CNRI no + 2.0 1.6 2000 BeOpen.com no + 1.6.1 1.6 2001 CNRI yes (2) + 2.1 2.0+1.6.1 2001 PSF no + 2.0.1 2.0+1.6.1 2001 PSF yes + 2.1.1 2.1+2.0.1 2001 PSF yes + 2.1.2 2.1.1 2002 PSF yes + 2.1.3 2.1.2 2002 PSF yes + 2.2 and above 2.1.1 2001-now PSF yes + +Footnotes: + +(1) GPL-compatible doesn't mean that we're distributing Python under + the GPL. All Python licenses, unlike the GPL, let you distribute + a modified version without making your changes open source. The + GPL-compatible licenses make it possible to combine Python with + other software that is released under the GPL; the others don't. + +(2) According to Richard Stallman, 1.6.1 is not GPL-compatible, + because its license has a choice of law clause. According to + CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 + is "not incompatible" with the GPL. + +Thanks to the many outside volunteers who have worked under Guido's +direction to make these releases possible. + + +B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON +=============================================================== + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019 Python Software Foundation; +All Rights Reserved" are retained in Python alone or in any derivative version +prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 +------------------------------------------- + +BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 + +1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an +office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the +Individual or Organization ("Licensee") accessing and otherwise using +this software in source or binary form and its associated +documentation ("the Software"). + +2. Subject to the terms and conditions of this BeOpen Python License +Agreement, BeOpen hereby grants Licensee a non-exclusive, +royalty-free, world-wide license to reproduce, analyze, test, perform +and/or display publicly, prepare derivative works, distribute, and +otherwise use the Software alone or in any derivative version, +provided, however, that the BeOpen Python License is retained in the +Software, alone or in any derivative version prepared by Licensee. + +3. BeOpen is making the Software available to Licensee on an "AS IS" +basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE +SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS +AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY +DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +5. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +6. This License Agreement shall be governed by and interpreted in all +respects by the law of the State of California, excluding conflict of +law provisions. Nothing in this License Agreement shall be deemed to +create any relationship of agency, partnership, or joint venture +between BeOpen and Licensee. This License Agreement does not grant +permission to use BeOpen trademarks or trade names in a trademark +sense to endorse or promote products or services of Licensee, or any +third party. As an exception, the "BeOpen Python" logos available at +http://www.pythonlabs.com/logos.html may be used according to the +permissions granted on that web page. + +7. By copying, installing or otherwise using the software, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 +--------------------------------------- + +1. This LICENSE AGREEMENT is between the Corporation for National +Research Initiatives, having an office at 1895 Preston White Drive, +Reston, VA 20191 ("CNRI"), and the Individual or Organization +("Licensee") accessing and otherwise using Python 1.6.1 software in +source or binary form and its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, CNRI +hereby grants Licensee a nonexclusive, royalty-free, world-wide +license to reproduce, analyze, test, perform and/or display publicly, +prepare derivative works, distribute, and otherwise use Python 1.6.1 +alone or in any derivative version, provided, however, that CNRI's +License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) +1995-2001 Corporation for National Research Initiatives; All Rights +Reserved" are retained in Python 1.6.1 alone or in any derivative +version prepared by Licensee. Alternately, in lieu of CNRI's License +Agreement, Licensee may substitute the following text (omitting the +quotes): "Python 1.6.1 is made available subject to the terms and +conditions in CNRI's License Agreement. This Agreement together with +Python 1.6.1 may be located on the Internet using the following +unique, persistent identifier (known as a handle): 1895.22/1013. This +Agreement may also be obtained from a proxy server on the Internet +using the following URL: http://hdl.handle.net/1895.22/1013". + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python 1.6.1 or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python 1.6.1. + +4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" +basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. This License Agreement shall be governed by the federal +intellectual property law of the United States, including without +limitation the federal copyright law, and, to the extent such +U.S. federal law does not apply, by the law of the Commonwealth of +Virginia, excluding Virginia's conflict of law provisions. +Notwithstanding the foregoing, with regard to derivative works based +on Python 1.6.1 that incorporate non-separable material that was +previously distributed under the GNU General Public License (GPL), the +law of the Commonwealth of Virginia shall govern this License +Agreement only as to issues arising under or with respect to +Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this +License Agreement shall be deemed to create any relationship of +agency, partnership, or joint venture between CNRI and Licensee. This +License Agreement does not grant permission to use CNRI trademarks or +trade name in a trademark sense to endorse or promote products or +services of Licensee, or any third party. + +8. By clicking on the "ACCEPT" button where indicated, or by copying, +installing or otherwise using Python 1.6.1, Licensee agrees to be +bound by the terms and conditions of this License Agreement. + + ACCEPT + + +CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 +-------------------------------------------------- + +Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, +The Netherlands. All rights reserved. + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Stichting Mathematisch +Centrum or CWI not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior +permission. + +STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/LICENSE.txt b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/LICENSE.txt new file mode 100644 index 0000000000..d9a10c0d8e --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/LICENSE.txt @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/MANIFEST.in b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/MANIFEST.in new file mode 100644 index 0000000000..3216ee548c --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/MANIFEST.in @@ -0,0 +1,4 @@ +include LICENSE.txt +include README.rst +recursive-include resource *.py +recursive-include doc *.rst *.conf *.py *.png *.css diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/README.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/README.rst new file mode 100644 index 0000000000..074a35f97c --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/README.rst @@ -0,0 +1,34 @@ +################# +ftrack Python API +################# + +Python API for ftrack. + +.. important:: + + This is the new Python client for the ftrack API. If you are migrating from + the old client then please read the dedicated `migration guide `_. + +************* +Documentation +************* + +Full documentation, including installation and setup guides, can be found at +http://ftrack-python-api.rtd.ftrack.com/en/stable/ + +********************* +Copyright and license +********************* + +Copyright (c) 2014 ftrack + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this work except in compliance with the License. You may obtain a copy of the +License in the LICENSE.txt file, or at: + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. \ No newline at end of file diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/bitbucket-pipelines.yml b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/bitbucket-pipelines.yml new file mode 100644 index 0000000000..355f00f752 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/bitbucket-pipelines.yml @@ -0,0 +1,24 @@ +# Test configuration for bitbucket pipelines. +options: + max-time: 20 +definitions: + services: + ftrack: + image: + name: ftrackdocker/test-server:latest + username: $DOCKER_HUB_USERNAME + password: $DOCKER_HUB_PASSWORD + email: $DOCKER_HUB_EMAIL +pipelines: + default: + - parallel: + - step: + name: run tests against python 2.7.x + image: python:2.7 + caches: + - pip + services: + - ftrack + script: + - bash -c 'while [[ "$(curl -s -o /dev/null -w ''%{http_code}'' $FTRACK_SERVER)" != "200" ]]; do sleep 1; done' + - python setup.py test \ No newline at end of file diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/_static/ftrack.css b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/_static/ftrack.css new file mode 100644 index 0000000000..3456b0c3c5 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/_static/ftrack.css @@ -0,0 +1,16 @@ +@import "css/theme.css"; + +.domain-summary li { + float: left; + min-width: 12em; +} + +.domain-summary ul:before, ul:after { + content: ''; + clear: both; + display:block; +} + +.rst-content table.docutils td:last-child { + white-space: normal; +} diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/accessor/base.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/accessor/base.rst new file mode 100644 index 0000000000..4e165b0122 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/accessor/base.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +************************ +ftrack_api.accessor.base +************************ + +.. automodule:: ftrack_api.accessor.base diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/accessor/disk.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/accessor/disk.rst new file mode 100644 index 0000000000..f7d9dddf37 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/accessor/disk.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +************************ +ftrack_api.accessor.disk +************************ + +.. automodule:: ftrack_api.accessor.disk diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/accessor/index.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/accessor/index.rst new file mode 100644 index 0000000000..0adc23fe2d --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/accessor/index.rst @@ -0,0 +1,14 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +******************* +ftrack_api.accessor +******************* + +.. automodule:: ftrack_api.accessor + +.. toctree:: + :maxdepth: 1 + :glob: + + * diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/accessor/server.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/accessor/server.rst new file mode 100644 index 0000000000..62bd7f4165 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/accessor/server.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +************************ +ftrack_api.accessor.server +************************ + +.. automodule:: ftrack_api.accessor.server diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/attribute.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/attribute.rst new file mode 100644 index 0000000000..9fd8994eb1 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/attribute.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +******************** +ftrack_api.attribute +******************** + +.. automodule:: ftrack_api.attribute diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/cache.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/cache.rst new file mode 100644 index 0000000000..cbf9128a5a --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/cache.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +**************** +ftrack_api.cache +**************** + +.. automodule:: ftrack_api.cache diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/collection.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/collection.rst new file mode 100644 index 0000000000..607d574cb5 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/collection.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +********************* +ftrack_api.collection +********************* + +.. automodule:: ftrack_api.collection diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/asset_version.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/asset_version.rst new file mode 100644 index 0000000000..0bc4ce35f1 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/asset_version.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +******************************* +ftrack_api.entity.asset_version +******************************* + +.. automodule:: ftrack_api.entity.asset_version diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/base.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/base.rst new file mode 100644 index 0000000000..f4beedc9a4 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/base.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +********************** +ftrack_api.entity.base +********************** + +.. automodule:: ftrack_api.entity.base diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/component.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/component.rst new file mode 100644 index 0000000000..c9ce0a0cf1 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/component.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +*************************** +ftrack_api.entity.component +*************************** + +.. automodule:: ftrack_api.entity.component diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/factory.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/factory.rst new file mode 100644 index 0000000000..483c16641b --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/factory.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +************************* +ftrack_api.entity.factory +************************* + +.. automodule:: ftrack_api.entity.factory diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/index.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/index.rst new file mode 100644 index 0000000000..fce68c0e94 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/index.rst @@ -0,0 +1,14 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +***************** +ftrack_api.entity +***************** + +.. automodule:: ftrack_api.entity + +.. toctree:: + :maxdepth: 1 + :glob: + + * diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/job.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/job.rst new file mode 100644 index 0000000000..9d22a7c378 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/job.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +********************* +ftrack_api.entity.job +********************* + +.. automodule:: ftrack_api.entity.job diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/location.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/location.rst new file mode 100644 index 0000000000..60e006a10c --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/location.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +************************** +ftrack_api.entity.location +************************** + +.. automodule:: ftrack_api.entity.location diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/note.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/note.rst new file mode 100644 index 0000000000..3588e48e5b --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/note.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +********************** +ftrack_api.entity.note +********************** + +.. automodule:: ftrack_api.entity.note diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/project_schema.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/project_schema.rst new file mode 100644 index 0000000000..5777ab0b40 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/project_schema.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +******************************** +ftrack_api.entity.project_schema +******************************** + +.. automodule:: ftrack_api.entity.project_schema diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/user.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/user.rst new file mode 100644 index 0000000000..0014498b9c --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/entity/user.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +********************** +ftrack_api.entity.user +********************** + +.. automodule:: ftrack_api.entity.user diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/base.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/base.rst new file mode 100644 index 0000000000..2b0ca8d3ed --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/base.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +********************* +ftrack_api.event.base +********************* + +.. automodule:: ftrack_api.event.base diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/expression.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/expression.rst new file mode 100644 index 0000000000..f582717060 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/expression.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +*************************** +ftrack_api.event.expression +*************************** + +.. automodule:: ftrack_api.event.expression diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/hub.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/hub.rst new file mode 100644 index 0000000000..36d7a33163 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/hub.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +******************** +ftrack_api.event.hub +******************** + +.. automodule:: ftrack_api.event.hub diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/index.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/index.rst new file mode 100644 index 0000000000..0986e8e2f4 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/index.rst @@ -0,0 +1,14 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +**************** +ftrack_api.event +**************** + +.. automodule:: ftrack_api.event + +.. toctree:: + :maxdepth: 1 + :glob: + + * diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/subscriber.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/subscriber.rst new file mode 100644 index 0000000000..974f375817 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/subscriber.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +*************************** +ftrack_api.event.subscriber +*************************** + +.. automodule:: ftrack_api.event.subscriber diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/subscription.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/subscription.rst new file mode 100644 index 0000000000..94a20e3611 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/event/subscription.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +***************************** +ftrack_api.event.subscription +***************************** + +.. automodule:: ftrack_api.event.subscription diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/exception.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/exception.rst new file mode 100644 index 0000000000..64c3a699d7 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/exception.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +******************** +ftrack_api.exception +******************** + +.. automodule:: ftrack_api.exception diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/formatter.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/formatter.rst new file mode 100644 index 0000000000..9b8154bdc3 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/formatter.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +******************** +ftrack_api.formatter +******************** + +.. automodule:: ftrack_api.formatter diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/index.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/index.rst new file mode 100644 index 0000000000..ea3517ca68 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/index.rst @@ -0,0 +1,20 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _api_reference: + +************* +API Reference +************* + +ftrack_api +========== + +.. automodule:: ftrack_api + +.. toctree:: + :maxdepth: 1 + :glob: + + */index + * diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/inspection.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/inspection.rst new file mode 100644 index 0000000000..8223ee72f2 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/inspection.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +********************* +ftrack_api.inspection +********************* + +.. automodule:: ftrack_api.inspection diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/logging.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/logging.rst new file mode 100644 index 0000000000..ecb883d385 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/logging.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2016 ftrack + +****************** +ftrack_api.logging +****************** + +.. automodule:: ftrack_api.logging diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/operation.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/operation.rst new file mode 100644 index 0000000000..b2dff9933d --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/operation.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +******************** +ftrack_api.operation +******************** + +.. automodule:: ftrack_api.operation diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/plugin.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/plugin.rst new file mode 100644 index 0000000000..a4993d94cf --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/plugin.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +***************** +ftrack_api.plugin +***************** + +.. automodule:: ftrack_api.plugin diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/query.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/query.rst new file mode 100644 index 0000000000..acbd8d237a --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/query.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +**************** +ftrack_api.query +**************** + +.. automodule:: ftrack_api.query diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/resource_identifier_transformer/base.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/resource_identifier_transformer/base.rst new file mode 100644 index 0000000000..09cdad8627 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/resource_identifier_transformer/base.rst @@ -0,0 +1,10 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +.. _api_reference/resource_identifier_transformer.base: + +*********************************************** +ftrack_api.resource_identifier_transformer.base +*********************************************** + +.. automodule:: ftrack_api.resource_identifier_transformer.base diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/resource_identifier_transformer/index.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/resource_identifier_transformer/index.rst new file mode 100644 index 0000000000..755f052c9d --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/resource_identifier_transformer/index.rst @@ -0,0 +1,16 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +.. _api_reference/resource_identifier_transformer: + +****************************************** +ftrack_api.resource_identifier_transformer +****************************************** + +.. automodule:: ftrack_api.resource_identifier_transformer + +.. toctree:: + :maxdepth: 1 + :glob: + + * diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/session.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/session.rst new file mode 100644 index 0000000000..dcce173d1f --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/session.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +****************** +ftrack_api.session +****************** + +.. automodule:: ftrack_api.session diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/base.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/base.rst new file mode 100644 index 0000000000..55a1cc75d2 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/base.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +************************* +ftrack_api.structure.base +************************* + +.. automodule:: ftrack_api.structure.base diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/id.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/id.rst new file mode 100644 index 0000000000..ade2c7ae88 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/id.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +*********************** +ftrack_api.structure.id +*********************** + +.. automodule:: ftrack_api.structure.id diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/index.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/index.rst new file mode 100644 index 0000000000..cbd4545cf7 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/index.rst @@ -0,0 +1,14 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +******************** +ftrack_api.structure +******************** + +.. automodule:: ftrack_api.structure + +.. toctree:: + :maxdepth: 1 + :glob: + + * diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/origin.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/origin.rst new file mode 100644 index 0000000000..403173e257 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/origin.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +*************************** +ftrack_api.structure.origin +*************************** + +.. automodule:: ftrack_api.structure.origin diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/standard.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/standard.rst new file mode 100644 index 0000000000..5c0d88026b --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/structure/standard.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +***************************** +ftrack_api.structure.standard +***************************** + +.. automodule:: ftrack_api.structure.standard diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/symbol.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/symbol.rst new file mode 100644 index 0000000000..55dc0125a8 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/api_reference/symbol.rst @@ -0,0 +1,8 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +***************** +ftrack_api.symbol +***************** + +.. automodule:: ftrack_api.symbol diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/caching.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/caching.rst new file mode 100644 index 0000000000..bfc5cef401 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/caching.rst @@ -0,0 +1,175 @@ +.. + :copyright: Copyright (c) 2015 ftrack + + +.. _caching: + +******* +Caching +******* + +The API makes use of caching in order to provide more efficient retrieval of +data by reducing the number of calls to the remote server:: + + # First call to retrieve user performs a request to the server. + user = session.get('User', 'some-user-id') + + # A later call in the same session to retrieve the same user just gets + # the existing instance from the cache without a request to the server. + user = session.get('User', 'some-user-id') + +It also seamlessly merges related data together regardless of how it was +retrieved:: + + >>> timelog = user['timelogs'][0] + >>> with session.auto_populating(False): + >>> print timelog['comment'] + NOT_SET + >>> session.query( + ... 'select comment from Timelog where id is "{0}"' + ... .format(timelog['id']) + ... ).all() + >>> with session.auto_populating(False): + >>> print timelog['comment'] + 'Some comment' + +By default, each :class:`~ftrack_api.session.Session` is configured with a +simple :class:`~ftrack_api.cache.MemoryCache()` and the cache is lost as soon as +the session expires. + +Configuring a session cache +=========================== + +It is possible to configure the cache that a session uses. An example would be a +persistent auto-populated cache that survives between sessions:: + + import os + import ftrack_api.cache + + # Specify where the file based cache should be stored. + cache_path = os.path.join(tempfile.gettempdir(), 'ftrack_session_cache.dbm') + + + # Define a cache maker that returns a file based cache. Note that a + # function is used because the file based cache should use the session's + # encode and decode methods to serialise the entity data to a format that + # can be written to disk (JSON). + def cache_maker(session): + '''Return cache to use for *session*.''' + return ftrack_api.cache.SerialisedCache( + ftrack_api.cache.FileCache(cache_path), + encode=session.encode, + decode=session.decode + ) + + # Create the session using the cache maker. + session = ftrack_api.Session(cache=cache_maker) + +.. note:: + + There can be a performance penalty when using a more complex cache setup. + For example, serialising data and also writing and reading from disk can be + relatively slow operations. + +Regardless of the cache specified, the session will always construct a +:class:`~ftrack_api.cache.LayeredCache` with a +:class:`~ftrack_api.cache.MemoryCache` at the top level and then your cache at +the second level. This is to ensure consistency of instances returned by the +session. + +You can check (or even modify) at any time what cache configuration a session is +using by accessing the `cache` attribute on a +:class:`~ftrack_api.session.Session`:: + + >>> print session.cache + + +Writing a new cache interface +============================= + +If you have a custom cache backend you should be able to integrate it into the +system by writing a cache interface that matches the one defined by +:class:`ftrack_api.cache.Cache`. This typically involves a subclass and +overriding the :meth:`~ftrack_api.cache.Cache.get`, +:meth:`~ftrack_api.cache.Cache.set` and :meth:`~ftrack_api.cache.Cache.remove` +methods. + + +Managing what gets cached +========================= + +The cache system is quite flexible when it comes to controlling what should be +cached. + +Consider you have a layered cache where the bottom layer cache should be +persisted between sessions. In this setup you probably don't want the persisted +cache to hold non-persisted values, such as modified entity values or newly +created entities not yet committed to the server. However, you might want the +top level memory cache to hold onto these values. + +Here is one way to set this up. First define a new proxy cache that is selective +about what it sets:: + + import ftrack_api.inspection + + + class SelectiveCache(ftrack_api.cache.ProxyCache): + '''Proxy cache that won't cache newly created entities.''' + + def set(self, key, value): + '''Set *value* for *key*.''' + if isinstance(value, ftrack_api.entity.base.Entity): + if ( + ftrack_api.inspection.state(value) + is ftrack_api.symbol.CREATED + ): + return + + super(SelectiveCache, self).set(key, value) + +Now use this custom cache to wrap the serialised cache in the setup above: + +.. code-block:: python + :emphasize-lines: 3, 9 + + def cache_maker(session): + '''Return cache to use for *session*.''' + return SelectiveCache( + ftrack_api.cache.SerialisedCache( + ftrack_api.cache.FileCache(cache_path), + encode=session.encode, + decode=session.decode + ) + ) + +Now to prevent modified attributes also being persisted, tweak the encode +settings for the file cache: + +.. code-block:: python + :emphasize-lines: 1, 9-12 + + import functools + + + def cache_maker(session): + '''Return cache to use for *session*.''' + return SelectiveCache( + ftrack_api.cache.SerialisedCache( + ftrack_api.cache.FileCache(cache_path), + encode=functools.partial( + session.encode, + entity_attribute_strategy='persisted_only' + ), + decode=session.decode + ) + ) + +And use the updated cache maker for your session:: + + session = ftrack_api.Session(cache=cache_maker) + +.. note:: + + For some type of attributes that are computed, long term caching is not + recommended and such values will not be encoded with the `persisted_only` + strategy. diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/conf.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/conf.py new file mode 100644 index 0000000000..1154472155 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/conf.py @@ -0,0 +1,102 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +'''ftrack Python API documentation build configuration file.''' + +import os +import re + +# -- General ------------------------------------------------------------------ + +# Extensions. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.extlinks', + 'sphinx.ext.intersphinx', + 'sphinx.ext.todo', + 'sphinx.ext.viewcode', + 'lowdown' +] + + +# The suffix of source filenames. +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'ftrack Python API' +copyright = u'2014, ftrack' + +# Version +with open( + os.path.join( + os.path.dirname(__file__), '..', 'source', + 'ftrack_api', '_version.py' + ) +) as _version_file: + _version = re.match( + r'.*__version__ = \'(.*?)\'', _version_file.read(), re.DOTALL + ).group(1) + +version = _version +release = _version + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_template'] + +# A list of prefixes to ignore for module listings. +modindex_common_prefix = [ + 'ftrack_api.' +] + +# -- HTML output -------------------------------------------------------------- + +if not os.environ.get('READTHEDOCS', None) == 'True': + # Only import and set the theme if building locally. + import sphinx_rtd_theme + html_theme = 'sphinx_rtd_theme' + html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + +html_static_path = ['_static'] +html_style = 'ftrack.css' + +# If True, copy source rst files to output for reference. +html_copy_source = True + + +# -- Autodoc ------------------------------------------------------------------ + +autodoc_default_flags = ['members', 'undoc-members', 'inherited-members'] +autodoc_member_order = 'bysource' + + +def autodoc_skip(app, what, name, obj, skip, options): + '''Don't skip __init__ method for autodoc.''' + if name == '__init__': + return False + + return skip + + +# -- Intersphinx -------------------------------------------------------------- + +intersphinx_mapping = { + 'python': ('http://docs.python.org/', None), + 'ftrack': ( + 'http://rtd.ftrack.com/docs/ftrack/en/stable/', None + ) +} + + +# -- Todos --------------------------------------------------------------------- + +todo_include_todos = os.environ.get('FTRACK_DOC_INCLUDE_TODOS', False) == 'True' + + +# -- Setup -------------------------------------------------------------------- + +def setup(app): + app.connect('autodoc-skip-member', autodoc_skip) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/docutils.conf b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/docutils.conf new file mode 100644 index 0000000000..3c927cc1ee --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/docutils.conf @@ -0,0 +1,2 @@ +[html4css1 writer] +field-name-limit:0 \ No newline at end of file diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/environment_variables.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/environment_variables.rst new file mode 100644 index 0000000000..99019ee44f --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/environment_variables.rst @@ -0,0 +1,56 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _environment_variables: + +********************* +Environment variables +********************* + +The following is a consolidated list of environment variables that this API +can reference: + +.. envvar:: FTRACK_SERVER + + The full url of the ftrack server to connect to. For example + "https://mycompany.ftrackapp.com" + +.. envvar:: FTRACK_API_USER + + The username of the ftrack user to act on behalf of when performing actions + in the system. + + .. note:: + + When this environment variable is not set, the API will typically also + check other standard operating system variables that hold the username + of the current logged in user. To do this it uses + :func:`getpass.getuser`. + +.. envvar:: FTRACK_API_KEY + + The API key to use when performing actions in the system. The API key is + used to determine the permissions that a script has in the system. + +.. envvar:: FTRACK_APIKEY + + For backwards compatibility. See :envvar:`FTRACK_API_KEY`. + +.. envvar:: FTRACK_EVENT_PLUGIN_PATH + + Paths to search recursively for plugins to load and use in a session. + Multiple paths can be specified by separating with the value of + :attr:`os.pathsep` (e.g. ':' or ';'). + +.. envvar:: FTRACK_API_SCHEMA_CACHE_PATH + + Path to a directory that will be used for storing and retrieving a cache of + the entity schemas fetched from the server. + +.. envvar:: http_proxy / https_proxy + + If you need to use a proxy to connect to ftrack you can use the + "standard" :envvar:`http_proxy` and :envvar:`https_proxy`. Please note that they + are lowercase. + + For example "export https_proxy=http://proxy.mycompany.com:8080" \ No newline at end of file diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/event_list.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/event_list.rst new file mode 100644 index 0000000000..0c44a1b68c --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/event_list.rst @@ -0,0 +1,137 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _event_list: + +********** +Event list +********** + +The following is a consolidated list of events published directly by this API. + +For some events, a template plugin file is also listed for download +(:guilabel:`Download template plugin`) to help get you started with writing your +own plugin for a particular event. + +.. seealso:: + + * :ref:`handling_events` + * :ref:`ftrack server event list ` + +.. _event_list/ftrack.api.session.construct-entity-type: + +ftrack.api.session.construct-entity-type +======================================== + +:download:`Download template plugin +` + +:ref:`Synchronous `. Published by +the session to retrieve constructed class for specified schema:: + + Event( + topic='ftrack.api.session.construct-entity-type', + data=dict( + schema=schema, + schemas=schemas + ) + ) + +Expects returned data to be:: + + A Python class. + +.. seealso:: :ref:`working_with_entities/entity_types`. + +.. _event_list/ftrack.api.session.configure-location: + +ftrack.api.session.configure-location +===================================== + +:download:`Download template plugin +` + +:ref:`Synchronous `. Published by +the session to allow configuring of location instances:: + + Event( + topic='ftrack.api.session.configure-location', + data=dict( + session=self + ) + ) + +.. seealso:: :ref:`Configuring locations `. + +.. _event_list/ftrack.location.component-added: + +ftrack.location.component-added +=============================== + +Published whenever a component is added to a location:: + + Event( + topic='ftrack.location.component-added', + data=dict( + component_id='e2dc0524-b576-11d3-9612-080027331d74', + location_id='07b82a97-8cf9-11e3-9383-20c9d081909b' + ) + ) + +.. _event_list/ftrack.location.component-removed: + +ftrack.location.component-removed +================================= + +Published whenever a component is removed from a location:: + + Event( + topic='ftrack.location.component-removed', + data=dict( + component_id='e2dc0524-b576-11d3-9612-080027331d74', + location_id='07b82a97-8cf9-11e3-9383-20c9d081909b' + ) + ) + +.. _event_list/ftrack.api.session.ready: + +ftrack.api.session.ready +======================== + +:ref:`Synchronous `. Published after +a :class:`~ftrack_api.session.Session` has been initialized and +is ready to be used:: + + Event( + topic='ftrack.api.session.ready', + data=dict( + session=, + ) + ) + +.. warning:: + + Since the event is synchronous and blocking, avoid doing any unnecessary + work as it will slow down session initialization. + +.. seealso:: + + Also see example usage in :download:`example_plugin_using_session.py + `. + + +.. _event_list/ftrack.api.session.reset: + +ftrack.api.session.reset +======================== + +:ref:`Synchronous `. Published after +a :class:`~ftrack_api.session.Session` has been reset and is ready to be used +again:: + + Event( + topic='ftrack.api.session.reset', + data=dict( + session=, + ) + ) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/assignments_and_allocations.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/assignments_and_allocations.rst new file mode 100644 index 0000000000..985eb9bb44 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/assignments_and_allocations.rst @@ -0,0 +1,82 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +.. _example/assignments_and_allocations: + +**************************************** +Working with assignments and allocations +**************************************** + +.. currentmodule:: ftrack_api.session + +The API exposes `assignments` and `allocations` relationships on objects in +the project hierarchy. You can use these to retrieve the allocated or assigned +resources, which can be either groups or users. + +Allocations can be used to allocate users or groups to a project team, while +assignments are more explicit and is used to assign users to tasks. Both +assignment and allocations are modelled as `Appointment` objects, with a +`type` attribute indicating the type of the appoinment. + +The following example retrieves all users part of the project team:: + + # Retrieve a project + project = session.query('Project').first() + + # Set to hold all users part of the project team + project_team = set() + + # Add all allocated groups and users + for allocation in project['allocations']: + + # Resource may be either a group or a user + resource = allocation['resource'] + + # If the resource is a group, add its members + if isinstance(resource, session.types['Group']): + for membership in resource['memberships']: + user = membership['user'] + project_team.add(user) + + # The resource is a user, add it. + else: + user = resource + project_team.add(user) + +The next example shows how to assign the current user to a task:: + + # Retrieve a task and the current user + task = session.query('Task').first() + current_user = session.query( + u'User where username is {0}'.format(session.api_user) + ).one() + + # Create a new Appointment of type assignment. + session.create('Appointment', { + 'context': task, + 'resource': current_user, + 'type': 'assignment' + }) + + # Finally, persist the new assignment + session.commit() + +To list all users assigned to a task, see the following example:: + + task = session.query('Task').first() + users = session.query( + 'select first_name, last_name from User ' + 'where assignments any (context_id = "{0}")'.format(task['id']) + ) + for user in users: + print user['first_name'], user['last_name'] + +To list the current user's assigned tasks, see the example below:: + + assigned_tasks = session.query( + 'select link from Task ' + 'where assignments any (resource.username = "{0}")'.format(session.api_user) + ) + for task in assigned_tasks: + print u' / '.join(item['name'] for item in task['link']) + diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/component.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/component.rst new file mode 100644 index 0000000000..6a39bb20d1 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/component.rst @@ -0,0 +1,23 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _example/component: + +*********************** +Working with components +*********************** + +.. currentmodule:: ftrack_api.session + +Components can be created manually or using the provide helper methods on a +:meth:`session ` or existing +:meth:`asset version +`:: + + component = version.create_component('/path/to/file_or_sequence.jpg') + session.commit() + +When a component is created using the helpers it is automatically added to a +location. + +.. seealso:: :ref:`Locations tutorial ` diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/custom_attribute.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/custom_attribute.rst new file mode 100644 index 0000000000..033942b442 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/custom_attribute.rst @@ -0,0 +1,94 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +.. _example/custom_attribute: + +*********************** +Using custom attributes +*********************** + +.. currentmodule:: ftrack_api.session + +Custom attributes can be written and read from entities using the +``custom_attributes`` property. + +The ``custom_attributes`` property provides a similar interface to a dictionary. + +Keys can be printed using the keys method:: + + >>> task['custom_attributes'].keys() + [u'my_text_field'] + +or access keys and values as items:: + + >>> print task['custom_attributes'].items() + [(u'my_text_field', u'some text')] + +Read existing custom attribute values:: + + >>> print task['custom_attributes']['my_text_field'] + 'some text' + +Updating a custom attributes can also be done similar to a dictionary:: + + task['custom_attributes']['my_text_field'] = 'foo' + +To query for tasks with a custom attribute, ``my_text_field``, you can use the +key from the configuration:: + + for task in session.query( + 'Task where custom_attributes any ' + '(key is "my_text_field" and value is "bar")' + ): + print task['name'] + +Limitations +=========== + +Expression attributes +--------------------- + +Expression attributes are not yet supported and the reported value will +always be the non-evaluated expression. + +Hierarchical attributes +----------------------- + +Hierarchical attributes are not yet fully supported in the API. Hierarchical +attributes support both read and write, but when read they are not calculated +and instead the `raw` value is returned:: + + # The hierarchical attribute `my_attribute` is set on Shot but this will not + # be reflected on the children. Instead the raw value is returned. + print shot['custom_attributes']['my_attribute'] + 'foo' + print task['custom_attributes']['my_attribute'] + None + +To work around this limitation it is possible to use the legacy api for +hierarchical attributes or to manually query the parents for values and use the +first value that is set. + +Validation +========== + +Custom attributes are validated on the ftrack server before persisted. The +validation will check that the type of the data is correct for the custom +attribute. + + * number - :py:class:`int` or :py:class:`float` + * text - :py:class:`str` or :py:class:`unicode` + * enumerator - :py:class:`list` + * boolean - :py:class:`bool` + * date - :py:class:`datetime.datetime` or :py:class:`datetime.date` + +If the value set is not valid a :py:exc:`ftrack_api.exception.ServerError` is +raised with debug information:: + + shot['custom_attributes']['fstart'] = 'test' + + Traceback (most recent call last): + ... + ftrack_api.exception.ServerError: Server reported error: + ValidationError(Custom attribute value for "fstart" must be of type number. + Got "test" of type ) \ No newline at end of file diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/encode_media.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/encode_media.rst new file mode 100644 index 0000000000..2be01ffe47 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/encode_media.rst @@ -0,0 +1,53 @@ +.. + :copyright: Copyright (c) 2016 ftrack + +.. currentmodule:: ftrack_api.session + +.. _example/encode_media: + +************** +Encoding media +************** + +Media such as images and video can be encoded by the ftrack server to allow +playing it in the ftrack web interface. Media can be encoded using +:meth:`ftrack_api.session.Session.encode_media` which accepts a path to a file +or an existing component in the ftrack.server location. + +Here is an example of how to encode a video and read the output:: + + job = session.encode_media('/PATH/TO/MEDIA') + job_data = json.loads(job['data']) + + print 'Source component id', job_data['source_component_id'] + print 'Keeping original component', job_data['keep_original'] + for output in job_data['output']: + print u'Output component - id: {0}, format: {1}'.format( + output['component_id'], output['format'] + ) + +You can also call the corresponding helper method on an :meth:`asset version +`, to have the +encoded components automatically associated with the version:: + + job = asset_version.encode_media('/PATH/TO/MEDIA') + +It is also possible to get the URL to an encoded component once the job has +finished:: + + job = session.encode_media('/PATH/TO/MEDIA') + + # Wait for job to finish. + + location = session.query('Location where name is "ftrack.server"').one() + for component in job['job_components']: + print location.get_url(component) + +Media can also be an existing component in another location. Before encoding it, +the component needs to be added to the ftrack.server location:: + + location = session.query('Location where name is "ftrack.server"').one() + location.add_component(component) + session.commit() + + job = session.encode_media(component) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/entity_links.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/entity_links.rst new file mode 100644 index 0000000000..43e31484f4 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/entity_links.rst @@ -0,0 +1,56 @@ +.. + :copyright: Copyright (c) 2016 ftrack + +.. _example/entity_links: + +****************** +Using entity links +****************** + +A link can be used to represent a dependency or another relation between +two entities in ftrack. + +There are two types of entities that can be linked: + +* Versions can be linked to other asset versions, where the link entity type + is `AssetVersionLink`. +* Objects like Task, Shot or Folder, where the link entity type is + `TypedContextLink`. + +Both `AssetVersion` and `TypedContext` objects have the same relations +`incoming_links` and `outgoing_links`. To list the incoming links to a Shot we +can use the relationship `incoming_links`:: + + for link in shot['incoming_links']: + print link['from'], link['to'] + +In the above example `link['to']` is the shot and `link['from']` could be an +asset build or something else that is linked to the shot. There is an equivalent +`outgoing_links` that can be used to access outgoing links on an object. + +To create a new link between objects or asset versions create a new +`TypedContextLink` or `AssetVersionLink` entity with the from and to properties +set. In this example we will link two asset versions:: + + session.create('AssetVersionLink', { + 'from': from_asset_version, + 'to': to_asset_version + }) + session.commit() + +Using asset version link shortcut +================================= + +Links on asset version can also be created by the use of the `uses_versions` and +`used_in_versions` relations:: + + rig_version['uses_versions'].append(model_version) + session.commit() + +This has the same result as creating the `AssetVersionLink` entity as in the +previous section. + +Which versions are using the model can be listed with:: + + for version in model_version['used_in_versions']: + print '{0} is using {1}'.format(version, model_version) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/index.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/index.rst new file mode 100644 index 0000000000..4fca37d754 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/index.rst @@ -0,0 +1,52 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +.. currentmodule:: ftrack_api.session + +.. _example: + +************** +Usage examples +************** + +The following examples show how to use the API to accomplish specific tasks +using the default configuration. + +.. note:: + + If you are using a server with a customised configuration you may need to + alter the examples slightly to make them work correctly. + +Most of the examples assume you have the *ftrack_api* package imported and have +already constructed a :class:`Session`:: + + import ftrack_api + + session = ftrack_api.Session() + + +.. toctree:: + + project + component + review_session + metadata + custom_attribute + manage_custom_attribute_configuration + link_attribute + scope + job + note + list + timer + assignments_and_allocations + thumbnail + encode_media + entity_links + web_review + publishing + security_roles + task_template + sync_ldap_users + invite_user + diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/invite_user.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/invite_user.rst new file mode 100644 index 0000000000..342f0ef602 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/invite_user.rst @@ -0,0 +1,31 @@ +.. + :copyright: Copyright (c) 2017 ftrack + +.. _example/invite_user: + +********************* +Invite user +********************* + +Here we create a new user and send them a invitation through mail + + +Create a new user:: + + user_email = 'artist@mail.vfx-company.com' + + new_user = session.create( + 'User', { + 'username':user_email, + 'email':user_email, + 'is_active':True + } + ) + + session.commit() + + +Invite our new user:: + + new_user.send_invite() + diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/job.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/job.rst new file mode 100644 index 0000000000..296a0f5e17 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/job.rst @@ -0,0 +1,97 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _example/job: + +************* +Managing jobs +************* + +.. currentmodule:: ftrack_api.session + +Jobs can be used to display feedback to users in the ftrack web interface when +performing long running tasks in the API. + +To create a job use :meth:`Session.create`:: + + user = # Get a user from ftrack. + + job = session.create('Job', { + 'user': user, + 'status': 'running' + }) + +The created job will appear as running in the :guilabel:`jobs` menu for the +specified user. To set a description on the job, add a dictionary containing +description as the `data` key: + +.. note:: + + In the current version of the API the dictionary needs to be JSON + serialised. + +.. code-block:: python + + import json + + job = session.create('Job', { + 'user': user, + 'status': 'running', + 'data': json.dumps({ + 'description': 'My custom job description.' + }) + }) + +When the long running task has finished simply set the job as completed and +continue with the next task. + +.. code-block:: python + + job['status'] = 'done' + session.commit() + +Attachments +=========== + +Job attachments are files that are attached to a job. In the ftrack web +interface these attachments can be downloaded by clicking on a job in the `Jobs` +menu. + +To get a job's attachments through the API you can use the `job_components` +relation and then use the ftrack server location to get the download URL:: + + server_location = session.query( + 'Location where name is "ftrack.server"' + ).one() + + for job_component in job['job_components']: + print 'Download URL: {0}'.format( + server_location.get_url(job_component['component']) + ) + +To add an attachment to a job you have to add it to the ftrack server location +and create a `jobComponent`:: + + server_location = session.query( + 'Location where name is "ftrack.server"' + ).one() + + # Create component and name it "My file". + component = session.create_component( + '/path/to/file', + data={'name': 'My file'}, + location=server_location + ) + + # Attach the component to the job. + session.create( + 'JobComponent', + {'component_id': component['id'], 'job_id': job['id']} + ) + + session.commit() + +.. note:: + + The ftrack web interface does only support downloading one attachment so + attaching more than one will have limited support in the web interface. diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/link_attribute.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/link_attribute.rst new file mode 100644 index 0000000000..1dcea842cd --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/link_attribute.rst @@ -0,0 +1,55 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +.. _example/link_attribute: + +********************* +Using link attributes +********************* + +The `link` attribute can be used to retreive the ids and names of the parents of +an object. It is particularly useful in cases where the path of an object must +be presented in a UI, but can also be used to speedup certain query patterns. + +You can use the `link` attribute on any entity inheriting from a +`Context` or `AssetVersion`. Here we use it on the `Task` entity:: + + task = session.query( + 'select link from Task where name is "myTask"' + ).first() + print task['link'] + +It can also be used create a list of parent entities, including the task +itself:: + + entities = [] + for item in task['link']: + entities.append(session.get(item['type'], item['id'])) + +The `link` attribute is an ordered list of dictionaries containting data +of the parents and the item itself. Each dictionary contains the following +entries: + + id + The id of the object and can be used to do a :meth:`Session.get`. + name + The name of the object. + type + The schema id of the object. + +A more advanced use-case is to get the parent names and ids of all timelogs for +a user:: + + for timelog in session.query( + 'select context.link, start, duration from Timelog ' + 'where user.username is "john.doe"' + ): + print timelog['context']['link'], timelog['start'], timelog['duration'] + +The attribute is also available from the `AssetVersion` asset relation:: + + for asset_version in session.query( + 'select link from AssetVersion ' + 'where user.username is "john.doe"' + ): + print asset_version['link'] diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/list.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/list.rst new file mode 100644 index 0000000000..155b25f9af --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/list.rst @@ -0,0 +1,46 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +.. _example/list: + +*********** +Using lists +*********** + +.. currentmodule:: ftrack_api.session + +Lists can be used to create a collection of asset versions or objects such as +tasks. It could be a list of items that should be sent to client, be included in +todays review session or items that belong together in way that is different +from the project hierarchy. + +There are two types of lists, one for asset versions and one for other objects +such as tasks. + +To create a list use :meth:`Session.create`:: + + user = # Get a user from ftrack. + project = # Get a project from ftrack. + list_category = # Get a list category from ftrack. + + asset_version_list = session.create('AssetVersionList', { + 'owner': user, + 'project': project, + 'category': list_category + }) + + task_list = session.create('TypedContextList', { + 'owner': user, + 'project': project, + 'category': list_category + }) + +Then add items to the list like this:: + + asset_version_list['items'].append(asset_version) + task_list['items'].append(task) + +And remove items from the list like this:: + + asset_version_list['items'].remove(asset_version) + task_list['items'].remove(task) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/manage_custom_attribute_configuration.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/manage_custom_attribute_configuration.rst new file mode 100644 index 0000000000..e3d7c4062c --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/manage_custom_attribute_configuration.rst @@ -0,0 +1,320 @@ +.. + :copyright: Copyright (c) 2017 ftrack + +.. _example/manage_custom_attribute_configuration: + +**************************************** +Managing custom attribute configurations +**************************************** + +From the API it is not only possible to +:ref:`read and update custom attributes for entities `, +but also managing custom attribute configurations. + +Existing custom attribute configurations can be queried as :: + + # Print all existing custom attribute configurations. + print session.query('CustomAttributeConfiguration').all() + +Use :meth:`Session.create` to create a new custom attribute configuration:: + + # Get the custom attribute type. + custom_attribute_type = session.query( + 'CustomAttributeType where name is "text"' + ).one() + + # Create a custom attribute configuration. + session.create('CustomAttributeConfiguration', { + 'entity_type': 'assetversion', + 'type': custom_attribute_type, + 'label': 'Asset version text attribute', + 'key': 'asset_version_text_attribute', + 'default': 'bar', + 'config': json.dumps({'markdown': False}) + }) + + # Persist it to the ftrack instance. + session.commit() + +.. tip:: + + The example above does not add security roles. This can be done either + from System Settings in the ftrack web application, or by following the + :ref:`example/manage_custom_attribute_configuration/security_roles` example. + +Global or project specific +========================== + +A custom attribute can be global or project specific depending on the +`project_id` attribute:: + + # Create a custom attribute configuration. + session.create('CustomAttributeConfiguration', { + # Set the `project_id` and the custom attribute will only be available + # on `my_project`. + 'project_id': my_project['id'], + 'entity_type': 'assetversion', + 'type': custom_attribute_type, + 'label': 'Asset version text attribute', + 'key': 'asset_version_text_attribute', + 'default': 'bar', + 'config': json.dumps({'markdown': False}) + }) + session.commit() + +A project specific custom attribute can be changed to a global:: + + custom_attribute_configuration['project_id'] = None + session.commit() + +Changing a global custom attribute configuration to a project specific is not +allowed. + +Entity types +============ + +Custom attribute configuration entity types are using a legacy notation. A +configuration can have one of the following as `entity_type`: + +:task: + Represents TypedContext (Folder, Shot, Sequence, Task, etc.) custom + attribute configurations. When setting this as entity_type the + object_type_id must be set as well. + + Creating a text custom attribute for Folder:: + + custom_attribute_type = session.query( + 'CustomAttributeType where name is "text"' + ).one() + object_type = session.query('ObjectType where name is "Folder"').one() + session.create('CustomAttributeConfiguration', { + 'entity_type': 'task', + 'object_type_id': object_type['id'], + 'type': custom_attribute_type, + 'label': 'Foo', + 'key': 'foo', + 'default': 'bar', + }) + session.commit() + + Can be associated with a `project_id`. + +:show: + Represents Projects custom attribute configurations. + + Can be associated with a `project_id`. + +:assetversion: + Represents AssetVersion custom attribute configurations. + + Can be associated with a `project_id`. + +:user: + Represents User custom attribute configurations. + + Must be `global` and cannot be associated with a `project_id`. + +:list: + Represents List custom attribute configurations. + + Can be associated with a `project_id`. + +:asset: + Represents Asset custom attribute configurations. + + .. note:: + + Asset custom attributes have limited support in the ftrack web + interface. + + Can be associated with a `project_id`. + +It is not possible to change type after a custom attribute configuration has +been created. + +Custom attribute configuration types +==================================== + +Custom attributes can be of different data types depending on what type is set +in the configuration. Some types requires an extra json encoded config to be +set: + +:text: + A sting type custom attribute. + + The `default` value must be either :py:class:`str` or :py:class:`unicode`. + + Can be either presented as raw text or markdown formatted in applicaitons + which support it. This is configured through a markwdown key:: + + # Get the custom attribute type. + custom_attribute_type = session.query( + 'CustomAttributeType where name is "text"' + ).one() + + # Create a custom attribute configuration. + session.create('CustomAttributeConfiguration', { + 'entity_type': 'assetversion', + 'type': custom_attribute_type, + 'label': 'Asset version text attribute', + 'key': 'asset_version_text_attribute', + 'default': 'bar', + 'config': json.dumps({'markdown': False}) + }) + + # Persist it to the ftrack instance. + session.commit() + +:boolean: + + A boolean type custom attribute. + + The `default` value must be a :py:class:`bool`. + + No config is required. + +:date: + A date type custom attribute. + + The `default` value must be an :term:`arrow` date - e.g. + arrow.Arrow(2017, 2, 8). + + No config is required. + +:enumerator: + An enumerator type custom attribute. + + The `default` value must be a list with either :py:class:`str` or + :py:class:`unicode`. + + The enumerator can either be single or multi select. The config must a json + dump of a dictionary containing `multiSelect` and `data`. Where + `multiSelect` is True or False and data is a list of options. Each option + should be a dictionary containing `value` and `menu`, where `menu` is meant + to be used as label in a user interface. + + Create a custom attribute enumerator:: + + custom_attribute_type = session.query( + 'CustomAttributeType where name is "enumerator"' + ).first() + session.create('CustomAttributeConfiguration', { + 'entity_type': 'assetversion', + 'type': custom_attribute_type, + 'label': 'Enumerator attribute', + 'key': 'enumerator_attribute', + 'default': ['bar'], + 'config': json.dumps({ + 'multiSelect': True, + 'data': json.dumps([ + {'menu': 'Foo', 'value': 'foo'}, + {'menu': 'Bar', 'value': 'bar'} + ]) + }) + }) + session.commit() + +:dynamic enumerator: + + An enumerator type where available options are fetched from remote. Created + in the same way as enumerator but without `data`. + +:number: + + A number custom attribute can be either decimal or integer for presentation. + + This can be configured through the `isdecimal` config option:: + + custom_attribute_type = session.query( + 'CustomAttributeType where name is "number"' + ).first() + session.create('CustomAttributeConfiguration', { + 'entity_type': 'assetversion', + 'type': custom_attribute_type, + 'label': 'Number attribute', + 'key': 'number_attribute', + 'default': 42, + 'config': json.dumps({ + 'isdecimal': True + }) + }) + session.commit() + +Changing default +================ + +It is possible to update the `default` value of a custom attribute +configuration. This will not change the value of any existing custom +attributes:: + + # Change the default value of custom attributes. This will only affect + # newly created entities. + custom_attribute_configuration['default'] = 43 + session.commit() + +.. _example/manage_custom_attribute_configuration/security_roles: + +Security roles +============== + +By default new custom attribute configurations and the entity values are not +readable or writable by any security role. + +This can be configured through the `read_security_roles` and `write_security_roles` +attributes:: + + # Pick random security role. + security_role = session.query('SecurityRole').first() + custom_attribute_type = session.query( + 'CustomAttributeType where name is "date"' + ).first() + session.create('CustomAttributeConfiguration', { + 'entity_type': 'assetversion', + 'type': custom_attribute_type, + 'label': 'Date attribute', + 'key': 'date_attribute', + 'default': arrow.Arrow(2017, 2, 8), + 'write_security_roles': [security_role], + 'read_security_roles': [security_role] + }) + session.commit() + +.. note:: + + Setting the correct security role is important and must be changed to + whatever security role is appropriate for your configuration and intended + purpose. + +Custom attribute groups +======================= + +A custom attribute configuration can be categorized using a +`CustomAttributeGroup`:: + + group = session.query('CustomAttributeGroup').first() + security_role = session.query('SecurityRole').first() + custom_attribute_type = session.query( + 'CustomAttributeType where name is "enumerator"' + ).first() + session.create('CustomAttributeConfiguration', { + 'entity_type': 'assetversion', + 'type': custom_attribute_type, + 'label': 'Enumerator attribute', + 'key': 'enumerator_attribute', + 'default': ['bar'], + 'config': json.dumps({ + 'multiSelect': True, + 'data': json.dumps([ + {'menu': 'Foo', 'value': 'foo'}, + {'menu': 'Bar', 'value': 'bar'} + ]) + }), + 'group': group, + 'write_security_roles': [security_role], + 'read_security_roles': [security_role] + }) + session.commit() + +.. seealso:: + + :ref:`example/custom_attribute` diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/metadata.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/metadata.rst new file mode 100644 index 0000000000..7b16881017 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/metadata.rst @@ -0,0 +1,43 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _example/metadata: + +************** +Using metadata +************** + +.. currentmodule:: ftrack_api.session + +Key/value metadata can be written to entities using the metadata property +and also used to query entities. + +The metadata property has a similar interface as a dictionary and keys can be +printed using the keys method:: + + >>> print new_sequence['metadata'].keys() + ['frame_padding', 'focal_length'] + +or items:: + + >>> print new_sequence['metadata'].items() + [('frame_padding': '4'), ('focal_length': '70')] + +Read existing metadata:: + + >>> print new_sequence['metadata']['frame_padding'] + '4' + +Setting metadata can be done in a few ways where that later one will replace +any existing metadata:: + + new_sequence['metadata']['frame_padding'] = '5' + new_sequence['metadata'] = { + 'frame_padding': '4' + } + +Entities can also be queried using metadata:: + + session.query( + 'Sequence where metadata any (key is "frame_padding" and value is "4")' + ) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/note.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/note.rst new file mode 100644 index 0000000000..8f8f1bb57d --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/note.rst @@ -0,0 +1,169 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +.. currentmodule:: ftrack_api.session + +.. _example/note: + +*********** +Using notes +*********** + +Notes can be written on almost all levels in ftrack. To retrieve notes on an +entity you can either query them or use the relation called `notes`:: + + task = session.query('Task').first() + + # Retrieve notes using notes property. + notes_on_task = task['notes'] + + # Or query them. + notes_on_task = session.query('Note where parent_id is "{}"'.format( + task['id'] + )) + +.. note:: + + It's currently not possible to use the `parent` property when querying + notes or to use the `parent` property on notes:: + + task = session.query('Task').first() + + # This won't work in the current version of the API. + session.query('Note where parent.id is "{}"'.format( + task['id'] + )) + + # Neither will this. + parent_of_note = note['parent'] + +To create new notes you can either use the helper method called +:meth:`~ftrack_api.entity.note.CreateNoteMixin.create_note` on any entity that +can have notes or use :meth:`Session.create` to create them manually:: + + user = session.query('User').first() + + # Create note using the helper method. + note = task.create_note('My new note', author=user) + + # Manually create a note + note = session.create('Note', { + 'content': 'My new note', + 'author': user + }) + + task['notes'].append(note) + +Replying to an existing note can also be done with a helper method or by +using :meth:`Session.create`:: + + # Create using helper method. + first_note_on_task = task['notes'][0] + first_note_on_task.create_reply('My new reply on note', author=user) + + # Create manually + reply = session.create('Note', { + 'content': 'My new note', + 'author': user + }) + + first_note_on_task.replies.append(reply) + +Notes can have labels. Use the label argument to set labels on the +note using the helper method:: + + label = session.query( + 'NoteLabel where name is "External Note"' + ).first() + + note = task.create_note( + 'New note with external category', author=user, labels=[label] + ) + +Or add labels to notes when creating a note manually:: + + label = session.query( + 'NoteLabel where name is "External Note"' + ).first() + + note = session.create('Note', { + 'content': 'New note with external category', + 'author': user + }) + + session.create('NoteLabelLink', { + 'note_id': note['id], + 'label_id': label['id'] + }) + + task['notes'].append(note) + +.. note:: + + Support for labels on notes was added in ftrack server version 4.3. For + older versions of the server, NoteCategory can be used instead. + +To specify a category when creating a note simply pass a `NoteCategory` instance +to the helper method:: + + category = session.query( + 'NoteCategory where name is "External Note"' + ).first() + + note = task.create_note( + 'New note with external category', author=user, category=category + ) + +When writing notes you might want to direct the note to someone. This is done +by adding users as recipients. If a user is added as a recipient the user will +receive notifications and the note will be displayed in their inbox. + +To add recipients pass a list of user or group instances to the helper method:: + + john = session.query('User where username is "john"').one() + animation_group = session.query('Group where name is "Animation"').first() + + note = task.create_note( + 'Note with recipients', author=user, recipients=[john, animation_group] + ) + +Attachments +=========== + +Note attachments are files that are attached to a note. In the ftrack web +interface these attachments appears next to the note and can be downloaded by +the user. + +To get a note's attachments through the API you can use the `note_components` +relation and then use the ftrack server location to get the download URL:: + + server_location = session.query( + 'Location where name is "ftrack.server"' + ).one() + + for note_component in note['note_components']: + print 'Download URL: {0}'.format( + server_location.get_url(note_component['component']) + ) + +To add an attachment to a note you have to add it to the ftrack server location +and create a `NoteComponent`:: + + server_location = session.query( + 'Location where name is "ftrack.server"' + ).one() + + # Create component and name it "My file". + component = session.create_component( + '/path/to/file', + data={'name': 'My file'}, + location=server_location + ) + + # Attach the component to the note. + session.create( + 'NoteComponent', + {'component_id': component['id'], 'note_id': note['id']} + ) + + session.commit() diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/project.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/project.rst new file mode 100644 index 0000000000..0b4c0879d6 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/project.rst @@ -0,0 +1,65 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +.. _example/project: + +********************* +Working with projects +********************* + +.. currentmodule:: ftrack_api.session + +Creating a project +================== + +A project with sequences, shots and tasks can be created in one single +transaction. Tasks need to have a type and status set on creation based on the +project schema:: + + import uuid + + # Create a unique name for the project. + name = 'projectname_{0}'.format(uuid.uuid1().hex) + + # Naively pick the first project schema. For this example to work the + # schema must contain `Shot` and `Sequence` object types. + project_schema = session.query('ProjectSchema').first() + + # Create the project with the chosen schema. + project = session.create('Project', { + 'name': name, + 'full_name': name + '_full', + 'project_schema': project_schema + }) + + # Retrieve default types. + default_shot_status = project_schema.get_statuses('Shot')[0] + default_task_type = project_schema.get_types('Task')[0] + default_task_status = project_schema.get_statuses( + 'Task', default_task_type['id'] + )[0] + + # Create sequences, shots and tasks. + for sequence_number in range(1, 5): + sequence = session.create('Sequence', { + 'name': 'seq_{0}'.format(sequence_number), + 'parent': project + }) + + for shot_number in range(1, 5): + shot = session.create('Shot', { + 'name': '{0}0'.format(shot_number).zfill(3), + 'parent': sequence, + 'status': default_shot_status + }) + + for task_number in range(1, 5): + session.create('Task', { + 'name': 'task_{0}'.format(task_number), + 'parent': shot, + 'status': default_task_status, + 'type': default_task_type + }) + + # Commit all changes to the server. + session.commit() diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/publishing.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/publishing.rst new file mode 100644 index 0000000000..bf1da18ab9 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/publishing.rst @@ -0,0 +1,73 @@ +.. + :copyright: Copyright (c) 2016 ftrack + +.. currentmodule:: ftrack_api.session + +.. _example/publishing: + +******************* +Publishing versions +******************* + +To know more about publishing and the concepts around publishing, read the +`ftrack article `_ +about publishing. + +To publish an asset you first need to get the context where the asset should be +published:: + + # Get a task from a given id. + task = session.get('Task', '423ac382-e61d-4802-8914-dce20c92b740') + +And the parent of the task which will be used to publish the asset on:: + + asset_parent = task['parent'] + +Then we create an asset and a version on the asset:: + + asset_type = session.query('AssetType where name is "Geometry"').one() + asset = session.create('Asset', { + 'name': 'My asset', + 'type': asset_type, + 'parent': asset_parent + }) + asset_version = session.create('AssetVersion', { + 'asset': asset, + 'task': task + }) + +.. note:: + + The task is not used as the parent of the asset, instead the task is linked + directly to the AssetVersion. + +Then when we have a version where we can create the components:: + + asset_version.create_component( + '/path/to/a/file.mov', location='auto' + ) + asset_version.create_component( + '/path/to/a/another-file.mov', location='auto' + ) + + session.commit() + +This will automatically create a new component and add it to the location which +has been configured as the first in priority. + +Components can also be named and added to a custom location like this:: + + location = session.query('Location where name is "my-location"') + asset_version.create_component( + '/path/to/a/file.mov', + data={ + 'name': 'foobar' + }, + location=location + ) + +.. seealso:: + + * :ref:`example/component` + * :ref:`example/web_review` + * :ref:`example/thumbnail` diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/review_session.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/review_session.rst new file mode 100644 index 0000000000..68f7870d1c --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/review_session.rst @@ -0,0 +1,87 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +.. _example/review_session: + +********************* +Using review sessions +********************* + +.. currentmodule:: ftrack_api.session + +Client review sessions can either be queried manually or by using a project +instance. + +.. code-block:: python + + review_sessions = session.query( + 'ReviewSession where name is "Weekly review"' + ) + + project_review_sessions = project['review_sessions'] + +To create a new review session on a specific project use :meth:`Session.create`. + +.. code-block:: python + + review_session = session.create('ReviewSession', { + 'name': 'Weekly review', + 'description': 'See updates from last week.', + 'project': project + }) + +To add objects to a review session create them using +:meth:`Session.create` and reference a review session and an asset version. + +.. code-block:: python + + review_session = session.create('ReviewSessionObject', { + 'name': 'Compositing', + 'description': 'Fixed shadows.', + 'version': 'Version 3', + 'review_session': review_session, + 'asset_version': asset_version + }) + +To list all objects in a review session. + +.. code-block:: python + + review_session_objects = review_session['review_session_objects'] + +Listing and adding collaborators to review session can be done using +:meth:`Session.create` and the `review_session_invitees` relation on a +review session. + +.. code-block:: python + + invitee = session.create('ReviewSessionInvitee', { + 'name': 'John Doe', + 'email': 'john.doe@example.com', + 'review_session': review_session + }) + + session.commit() + + invitees = review_session['review_session_invitees'] + +To remove a collaborator simply delete the object using +:meth:`Session.delete`. + +.. code-block:: python + + session.delete(invitee) + +To send out an invite email to a signle collaborator use +:meth:`Session.send_review_session_invite`. + +.. code-block:: python + + session.send_review_session_invite(invitee) + +Multiple invitees can have emails sent to them in one batch using +:meth:`Session.send_review_session_invites`. + +.. code-block:: python + + session.send_review_session_invites(a_list_of_invitees) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/scope.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/scope.rst new file mode 100644 index 0000000000..3be42322ce --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/scope.rst @@ -0,0 +1,27 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _example/scope: + +************ +Using scopes +************ + +.. currentmodule:: ftrack_api.session + +Entities can be queried based on their scopes:: + + >>> tasks = session.query( + ... 'Task where scopes.name is "London"' + ... ) + +Scopes can be read and modified for entities:: + + >>> scope = session.query( + ... 'Scope where name is "London"' + ... )[0] + ... + ... if scope in task['scopes']: + ... task['scopes'].remove(scope) + ... else: + ... task['scopes'].append(scope) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/security_roles.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/security_roles.rst new file mode 100644 index 0000000000..4219e3d126 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/security_roles.rst @@ -0,0 +1,73 @@ +.. + :copyright: Copyright (c) 2017 ftrack + +.. _example/security_roles: + +********************************* +Working with user security roles +********************************* + +.. currentmodule:: ftrack_api.session + +The API exposes `SecurityRole` and `UserSecurityRole` that can be used to +specify who should have access to certain data on different projects. + +List all available security roles like this:: + + security_roles = session.query( + 'select name from SecurityRole where type is "PROJECT"' + ) + +.. note:: + + We only query for project roles since those are the ones we can add to a + user for certain projects. Other types include API and ASSIGNED. Type API + can only be added to global API keys, which is currently not supported via + the api and type ASSIGNED only applies to assigned tasks. + +To get all security roles from a user we can either use relations like this:: + + for user_security_role in user['user_security_roles']: + if user_security_role['is_all_projects']: + result_string = 'all projects' + else: + result_string = ', '.join( + [project['full_name'] for project in user_security_role['projects']] + ) + + print 'User has security role "{0}" which is valid on {1}.'.format( + user_security_role['security_role']['name'], + result_string + ) + +or query them directly like this:: + + user_security_roles = session.query( + 'UserSecurityRole where user.username is "{0}"'.format(session.api_user) + ).all() + +User security roles can also be added to a user for all projects like this:: + + project_manager_role = session.query( + 'SecurityRole where name is "Project Manager"' + ).one() + + session.create('UserSecurityRole', { + 'is_all_projects': True, + 'user': user, + 'security_role': project_manager_role + }) + session.commit() + +or for certain projects only like this:: + + projects = session.query( + 'Project where full_name is "project1" or full_name is "project2"' + ).all()[:] + + session.create('UserSecurityRole', { + 'user': user, + 'security_role': project_manager_role, + 'projects': projects + }) + session.commit() diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/sync_ldap_users.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/sync_ldap_users.rst new file mode 100644 index 0000000000..5ea0e47dc6 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/sync_ldap_users.rst @@ -0,0 +1,30 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _example/sync_with_ldap: + +******************** +Sync users with LDAP +******************** + +.. currentmodule:: ftrack_api.session + + +If ftrack is configured to connect to LDAP you may trigger a +synchronization through the api using the +:meth:`ftrack_api.session.Session.call`:: + + result = session.call([ + dict( + action='delayed_job', + job_type='SYNC_USERS_LDAP' + ) + ]) + job = result[0]['data] + +You will get a `ftrack_api.entity.job.Job` instance back which can be used +to check the success of the job:: + + if job.get('status') == 'failed': + # The job failed get the error. + logging.error(job.get('data')) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/task_template.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/task_template.rst new file mode 100644 index 0000000000..c6161e834a --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/task_template.rst @@ -0,0 +1,56 @@ +.. + :copyright: Copyright (c) 2017 ftrack + +.. _example/task_template: + +*************************** +Working with Task Templates +*************************** + +Task templates can help you organize your workflows by building a collection +of tasks to be applied for specific contexts. They can be applied to all `Context` +objects for example Project, Sequences, Shots, etc... + +Query task templates +======================= + +Retrive all task templates and there tasks for a project:: + + project = session.query('Project').first() + + for task_template in project['project_schema']['task_templates']: + print('\ntask template: {0}'.format( + task_template['name'] + )) + + for task_type in [t['task_type'] for t in task_template['items']]: + print('\ttask type: {0}'.format( + task_type['name'] + )) + + + +"Apply" a task template +======================= +Create all tasks in a random task template directly under the project:: + + + project = session.query('Project').first() + + task_template = random.choice( + project['project_schema']['task_templates'] + ) + + for task_type in [t['task_type'] for t in task_template['items']]: + session.create( + 'Task', { + 'name': task_type['name'], + 'type': task_type, + 'parent': project + } + ) + + session.commit() + + + diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/thumbnail.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/thumbnail.rst new file mode 100644 index 0000000000..64199869a5 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/thumbnail.rst @@ -0,0 +1,71 @@ +.. + :copyright: Copyright (c) 2016 ftrack + +.. _example/thumbnail: + +*********************** +Working with thumbnails +*********************** + +Components can be used as thumbnails on various entities, including +`Project`, `Task`, `AssetVersion` and `User`. To create and set a thumbnail +you can use the helper method +:meth:`~ftrack_api.entity.component.CreateThumbnailMixin.create_thumbnail` on +any entity that can have a thumbnail:: + + task = session.get('Task', my_task_id) + thumbnail_component = task.create_thumbnail('/path/to/image.jpg') + +It is also possible to set an entity thumbnail by setting its `thumbnail` +relation or `thumbnail_id` attribute to a component you would +like to use as a thumbnail. For a component to be usable as a thumbnail, +it should + + 1. Be a FileComponent. + 2. Exist in the *ftrack.server* :term:`location`. + 3. Be of an appropriate resolution and valid file type. + +The following example creates a new component in the server location, and +uses that as a thumbnail for a task:: + + task = session.get('Task', my_task_id) + server_location = session.query( + 'Location where name is "ftrack.server"' + ).one() + + thumbnail_component = session.create_component( + '/path/to/image.jpg', + dict(name='thumbnail'), + location=server_location + ) + task['thumbnail'] = thumbnail_component + session.commit() + +The next example reuses a version's thumbnail for the asset parent thumbnail:: + + asset_version = session.get('AssetVersion', my_asset_version_id) + asset_parent = asset_version['asset']['parent'] + asset_parent['thumbnail_id'] = asset_version['thumbnail_id'] + session.commit() + +.. _example/thumbnail/url: + +Retrieving thumbnail URL +======================== + +To get an URL to a thumbnail, `thumbnail_component`, which can be used used +to download or display the image in an interface, use the following:: + + import ftrack_api.symbol + server_location = session.get('Location', ftrack_api.symbol.SERVER_LOCATION_ID) + thumbnail_url = server_location.get_thumbnail_url(thumbnail_component) + thumbnail_url_tiny = server_location.get_thumbnail_url( + thumbnail_component, size=100 + ) + thumbnail_url_large = server_location.get_thumbnail_url( + thumbnail_component, size=500 + ) + +.. seealso:: + + :ref:`example/component` diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/timer.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/timer.rst new file mode 100644 index 0000000000..eb86e2f897 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/timer.rst @@ -0,0 +1,37 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +.. _example/timer: + +************ +Using timers +************ + +.. currentmodule:: ftrack_api.session + +Timers can be used to track how much time has been spend working on something. + +To start a timer for a user:: + + user = # Get a user from ftrack. + task = # Get a task from ftrack. + + user.start_timer(task) + +A timer has now been created for that user and should show up in the ftrack web +UI. + +To stop the currently running timer for a user and create a timelog from it:: + + user = # Get a user from ftrack. + + timelog = user.stop_timer() + +.. note:: + + Starting a timer when a timer is already running will raise in an exception. + Use the force parameter to automatically stop the running timer first. + + .. code-block:: python + + user.start_timer(task, force=True) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/web_review.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/web_review.rst new file mode 100644 index 0000000000..f1dede570f --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/example/web_review.rst @@ -0,0 +1,78 @@ +.. + :copyright: Copyright (c) 2016 ftrack + +.. currentmodule:: ftrack_api.session + +.. _example/web_review: + +************************* +Publishing for web review +************************* + +Follow the :ref:`example/encode_media` example if you want to +upload and encode media using ftrack. + +If you already have a file encoded in the correct format and want to bypass +the built-in encoding in ftrack, you can create the component manually +and add it to the `ftrack.server` location:: + + # Retrieve or create version. + version = session.query('AssetVersion', 'SOME-ID') + + server_location = session.query('Location where name is "ftrack.server"').one() + filepath = '/path/to/local/file.mp4' + + component = version.create_component( + path=filepath, + data={ + 'name': 'ftrackreview-mp4' + }, + location=server_location + ) + + # Meta data needs to contain *frameIn*, *frameOut* and *frameRate*. + component['metadata']['ftr_meta'] = json.dumps({ + 'frameIn': 0, + 'frameOut': 150, + 'frameRate': 25 + }) + + component.session.commit() + +To publish an image for review the steps are similar:: + + # Retrieve or create version. + version = session.query('AssetVersion', 'SOME-ID') + + server_location = session.query('Location where name is "ftrack.server"').one() + filepath = '/path/to/image.jpg' + + component = version.create_component( + path=filepath, + data={ + 'name': 'ftrackreview-image' + }, + location=server_location + ) + + # Meta data needs to contain *format*. + component['metadata']['ftr_meta'] = json.dumps({ + 'format': 'image' + }) + + component.session.commit() + +Here is a list of components names and how they should be used: + +================== ===================================== +Component name Use +================== ===================================== +ftrackreview-image Images reviewable in the browser +ftrackreview-mp4 H.264/mp4 video reviewable in browser +ftrackreview-webm WebM video reviewable in browser +================== ===================================== + +.. note:: + + Make sure to use the pre-defined component names and set the `ftr_meta` on + the components or review will not work. diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/glossary.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/glossary.rst new file mode 100644 index 0000000000..aa5cc77976 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/glossary.rst @@ -0,0 +1,76 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +******** +Glossary +******** + +.. glossary:: + + accessor + An implementation (typically a :term:`Python` plugin) for accessing + a particular type of storage using a specific protocol. + + .. seealso:: :ref:`locations/overview/accessors` + + action + Actions in ftrack provide a standardised way to integrate other tools, + either off-the-shelf or custom built, directly into your ftrack + workflow. + + .. seealso:: :ref:`ftrack:using/actions` + + api + Application programming interface. + + arrow + A Python library that offers a sensible, human-friendly approach to + creating, manipulating, formatting and converting dates, times, and + timestamps. Read more at http://crsmithdev.com/arrow/ + + asset + A container for :term:`asset versions `, typically + representing the output from an artist. For example, 'geometry' + from a modeling artist. Has an :term:`asset type` that categorises the + asset. + + asset type + Category for a particular asset. + + asset version + A specific version of data for an :term:`asset`. Can contain multiple + :term:`components `. + + component + A container to hold any type of data (such as a file or file sequence). + An :term:`asset version` can have any number of components, each with + a specific name. For example, a published version of geometry might + have two components containing the high and low resolution files, with + the component names as 'hires' and 'lowres' respectively. + + PEP-8 + Style guide for :term:`Python` code. Read the guide at + https://www.python.org/dev/peps/pep-0008/ + + plugin + :term:`Python` plugins are used by the API to extend it with new + functionality, such as :term:`locations ` or :term:`actions `. + + .. seealso:: :ref:`understanding_sessions/plugins` + + python + A programming language that lets you work more quickly and integrate + your systems more effectively. Often used in creative industries. Visit + the language website at http://www.python.org + + PyPi + :term:`Python` package index. The Python Package Index or PyPI is the + official third-party software repository for the Python programming + language. Visit the website at https://pypi.python.org/pypi + + resource identifier + A string that is stored in ftrack as a reference to a resource (such as + a file) in a specific location. Used by :term:`accessors ` to + determine how to access data. + + .. seealso:: :ref:`locations/overview/resource_identifiers` diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/handling_events.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/handling_events.rst new file mode 100644 index 0000000000..1d378473fa --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/handling_events.rst @@ -0,0 +1,315 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _handling_events: + +*************** +Handling events +*************** + +.. currentmodule:: ftrack_api.event + +Events are generated in ftrack when things happen such as a task being updated +or a new version being published. Each :class:`~ftrack_api.session.Session` +automatically connects to the event server and can be used to subscribe to +specific events and perform an action as a result. That action could be updating +another related entity based on a status change or generating folders when a new +shot is created for example. + +The :class:`~hub.EventHub` for each :class:`~ftrack_api.session.Session` is +accessible via :attr:`Session.event_hub +<~ftrack_api.session.Session.event_hub>`. + +.. _handling_events/subscribing: + +Subscribing to events +===================== + +To listen to events, you register a function against a subscription using +:meth:`Session.event_hub.subscribe `. The subscription +uses the :ref:`expression ` syntax and will filter +against each :class:`~base.Event` instance to determine if the registered +function should receive that event. If the subscription matches, the registered +function will be called with the :class:`~base.Event` instance as its sole +argument. The :class:`~base.Event` instance is a mapping like structure and can +be used like a normal dictionary. + +The following example subscribes a function to receive all 'ftrack.update' +events and then print out the entities that were updated:: + + import ftrack_api + + + def my_callback(event): + '''Event callback printing all new or updated entities.''' + for entity in event['data'].get('entities', []): + + # Print data for the entity. + print(entity) + + + # Subscribe to events with the update topic. + session = ftrack_api.Session() + session.event_hub.subscribe('topic=ftrack.update', my_callback) + +At this point, if you run this, your code would exit almost immediately. This +is because the event hub listens for events in a background thread. Typically, +you only want to stay connected whilst using the session, but in some cases you +will want to block and listen for events solely - a dedicated event processor. +To do this, use the :meth:`EventHub.wait ` method:: + + # Wait for events to be received and handled. + session.event_hub.wait() + +You cancel waiting for events by using a system interrupt (:kbd:`Ctrl-C`). +Alternatively, you can specify a *duration* to process events for:: + + # Only wait and process events for 5 seconds. + session.event_hub.wait(duration=5) + +.. note:: + + Events are continually received and queued for processing in the background + as soon as the connection to the server is established. As a result you may + see a flurry of activity as soon as you call + :meth:`~hub.EventHub.wait` for the first time. + +.. _handling_events/subscribing/subscriber_information: + +Subscriber information +---------------------- + +When subscribing, you can also specify additional information about your +subscriber. This contextual information can be useful when routing events, +particularly when :ref:`targeting events +`. By default, the +:class:`~hub.EventHub` will set some default information, but it can be +useful to enhance this. To do so, simply pass in *subscriber* as a dictionary of +data to the :meth:`~hub.EventHub.subscribe` method:: + + session.event_hub.subscribe( + 'topic=ftrack.update', + my_callback, + subscriber={ + 'id': 'my-unique-subscriber-id', + 'applicationId': 'maya' + } + ) + +.. _handling_events/subscribing/sending_replies: + +Sending replies +--------------- + +When handling an event it is sometimes useful to be able to send information +back to the source of the event. For example, +:ref:`ftrack:developing/events/list/ftrack.location.request-resolve` would +expect a resolved path to be sent back. + +You can craft a custom reply event if you want, but an easier way is just to +return the appropriate data from your handler. Any non *None* value will be +automatically sent as a reply:: + + def on_event(event): + # Send following data in automatic reply. + return {'success': True, 'message': 'Cool!'} + + session.event_hub.subscribe('topic=test-reply', on_event) + +.. seealso:: + + :ref:`handling_events/publishing/handling_replies` + +.. note:: + + Some events are published :ref:`synchronously + `. In this case, any returned data + is passed back to the publisher directly. + +.. _handling_events/subscribing/stopping_events: + +Stopping events +--------------- + +The *event* instance passed to each event handler also provides a method for +stopping the event, :meth:`Event.stop `. + +Once an event has been stopped, no further handlers for that specific event +will be called **locally**. Other handlers in other processes may still be +called. + +Combining this with setting appropriate priorities when subscribing to a topic +allows handlers to prevent lower priority handlers running when desired. + + >>> import ftrack_api + >>> import ftrack_api.event.base + >>> + >>> def callback_a(event): + ... '''Stop the event!''' + ... print('Callback A') + ... event.stop() + >>> + >>> def callback_b(event): + ... '''Never run.''' + ... print('Callback B') + >>> + >>> session = ftrack_api.Session() + >>> session.event_hub.subscribe( + ... 'topic=test-stop-event', callback_a, priority=10 + ... ) + >>> session.event_hub.subscribe( + ... 'topic=test-stop-event', callback_b, priority=20 + ... ) + >>> session.event_hub.publish( + ... ftrack_api.event.base.Event(topic='test-stop-event') + ... ) + >>> session.event_hub.wait(duration=5) + Callback A called. + +.. _handling_events/publishing: + +Publishing events +================= + +So far we have looked at listening to events coming from ftrack. However, you +are also free to publish your own events (or even publish relevant ftrack +events). + +To do this, simply construct an instance of :class:`ftrack_api.event.base.Event` +and pass it to :meth:`EventHub.publish ` via the session:: + + import ftrack_api.event.base + + event = ftrack_api.event.base.Event( + topic='my-company.some-topic', + data={'key': 'value'} + ) + session.event_hub.publish(event) + +The event hub will automatically add some information to your event before it +gets published, including the *source* of the event. By default the event source +is just the event hub, but you can customise this to provide more relevant +information if you want. For example, if you were publishing from within Maya:: + + session.event_hub.publish(ftrack_api.event.base.Event( + topic='my-company.some-topic', + data={'key': 'value'}, + source={ + 'applicationId': 'maya' + } + )) + +Remember that all supplied information can be used by subscribers to filter +events so the more accurate the information the better. + +.. _handling_events/publishing/synchronously: + +Publish synchronously +--------------------- + +It is also possible to call :meth:`~hub.EventHub.publish` synchronously by +passing `synchronous=True`. In synchronous mode, only local handlers will be +called. The result from each called handler is collected and all the results +returned together in a list:: + + >>> import ftrack_api + >>> import ftrack_api.event.base + >>> + >>> def callback_a(event): + ... return 'A' + >>> + >>> def callback_b(event): + ... return 'B' + >>> + >>> session = ftrack_api.Session() + >>> session.event_hub.subscribe( + ... 'topic=test-synchronous', callback_a, priority=10 + ... ) + >>> session.event_hub.subscribe( + ... 'topic=test-synchronous', callback_b, priority=20 + ... ) + >>> results = session.event_hub.publish( + ... ftrack_api.event.base.Event(topic='test-synchronous'), + ... synchronous=True + ... ) + >>> print results + ['A', 'B'] + +.. _handling_events/publishing/handling_replies: + +Handling replies +---------------- + +When publishing an event it is also possible to pass a callable that will be +called with any :ref:`reply event ` +received in response to the published event. + +To do so, simply pass in a callable as the *on_reply* parameter:: + + def handle_reply(event): + print 'Got reply', event + + session.event_hub.publish( + ftrack_api.event.base.Event(topic='test-reply'), + on_reply=handle_reply + ) + +.. _handling_events/publishing/targeting: + +Targeting events +---------------- + +In addition to subscribers filtering events to receive, it is also possible to +give an event a specific target to help route it to the right subscriber. + +To do this, set the *target* value on the event to an :ref:`expression +`. The expression will filter against registered +:ref:`subscriber information +`. + +For example, if you have many subscribers listening for a event, but only want +one of those subscribers to get the event, you can target the event to the +subscriber using its registered subscriber id:: + + session.event_hub.publish( + ftrack_api.event.base.Event( + topic='my-company.topic', + data={'key': 'value'}, + target='id=my-custom-subscriber-id' + ) + ) + +.. _handling_events/expressions: + +Expressions +=========== + +An expression is used to filter against a data structure, returning whether the +structure fulfils the expression requirements. Expressions are currently used +for subscriptions when :ref:`subscribing to events +` and for targets when :ref:`publishing targeted +events `. + +The form of the expression is loosely groupings of 'key=value' with conjunctions +to join them. + +For example, a common expression for subscriptions is to filter against an event +topic:: + + 'topic=ftrack.location.component-added' + +However, you can also perform more complex filtering, including accessing +nested parameters:: + + 'topic=ftrack.location.component-added and data.locationId=london' + +.. note:: + + If the structure being tested does not have any value for the specified + key reference then it is treated as *not* matching. + +You can also use a single wildcard '*' at the end of any value for matching +multiple values. For example, the following would match all events that have a +topic starting with 'ftrack.':: + + 'topic=ftrack.*' diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/image/configuring_plugins_directory.png b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/image/configuring_plugins_directory.png new file mode 100644 index 0000000000000000000000000000000000000000..7438cb52bebd5dd1c0c5814cd7e1d5f2fdf6a572 GIT binary patch literal 7313 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 rgJ69>%In+H&=0rLaU`. + +.. toctree:: + :maxdepth: 1 + + introduction + installing + tutorial + understanding_sessions + working_with_entities + querying + handling_events + caching + locations/index + example/index + api_reference/index + event_list + environment_variables + security_and_authentication + release/index + glossary + +****************** +Indices and tables +****************** + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/installing.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/installing.rst new file mode 100644 index 0000000000..5e42621bee --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/installing.rst @@ -0,0 +1,77 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _installing: + +********** +Installing +********** + +.. highlight:: bash + +Installation is simple with `pip `_:: + + pip install ftrack-python-api + +Building from source +==================== + +You can also build manually from the source for more control. First obtain a +copy of the source by either downloading the +`zipball `_ or +cloning the public repository:: + + git clone git@bitbucket.org:ftrack/ftrack-python-api.git + +Then you can build and install the package into your current Python +site-packages folder:: + + python setup.py install + +Alternatively, just build locally and manage yourself:: + + python setup.py build + +Building documentation from source +---------------------------------- + +To build the documentation from source:: + + python setup.py build_sphinx + +Then view in your browser:: + + file:///path/to/ftrack-python-api/build/doc/html/index.html + +Running tests against the source +-------------------------------- + +With a copy of the source it is also possible to run the unit tests:: + + python setup.py test + +Dependencies +============ + +* `ftrack server `_ >= 3.3.11 +* `Python `_ >= 2.7, < 3 +* `Requests `_ >= 2, <3, +* `Arrow `_ >= 0.4.4, < 1, +* `termcolor `_ >= 1.1.0, < 2, +* `pyparsing `_ >= 2.0, < 3, +* `Clique `_ >= 1.2.0, < 2, +* `websocket-client `_ >= 0.40.0, < 1 + +Additional For building +----------------------- + +* `Sphinx `_ >= 1.2.2, < 2 +* `sphinx_rtd_theme `_ >= 0.1.6, < 1 +* `Lowdown `_ >= 0.1.0, < 2 + +Additional For testing +---------------------- + +* `Pytest `_ >= 2.3.5, < 3 +* `pytest-mock `_ >= 0.4, < 1, +* `pytest-catchlog `_ >= 1, <=2 \ No newline at end of file diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/introduction.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/introduction.rst new file mode 100644 index 0000000000..63fe980749 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/introduction.rst @@ -0,0 +1,26 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _introduction: + +************ +Introduction +************ + +This API allows developers to write :term:`Python` scripts that talk directly +with an ftrack server. The scripts can perform operations against that server +depending on granted permissions. + +With any API it is important to find the right balance between flexibility and +usefulness. If an API is too low level then everyone ends up writing boilerplate +code for common problems and usually in an non-uniform way making it harder to +share scripts with others. It's also harder to get started with such an API. +Conversely, an API that attempts to be too smart can often become restrictive +when trying to do more advanced functionality or optimise for performance. + +With this API we have tried to strike the right balance between these two, +providing an API that should be simple to use out-of-the-box, but also expose +more flexibility and power when needed. + +Nothing is perfect though, so please do provide feedback on ways that we can +continue to improve this API for your specific needs. diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/locations/configuring.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/locations/configuring.rst new file mode 100644 index 0000000000..97483221aa --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/locations/configuring.rst @@ -0,0 +1,87 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _locations/configuring: + +********************* +Configuring locations +********************* + +To allow management of data by a location or retrieval of filesystem paths where +supported, a location instance needs to be configured in a session with an +:term:`accessor` and :term:`structure`. + +.. note:: + + The standard builtin locations require no further setup or configuration + and it is not necessary to read the rest of this section to use them. + +Before continuing, make sure that you are familiar with the general concepts +of locations by reading the :ref:`locations/overview`. + +.. _locations/configuring/manually: + +Configuring manually +==================== + +Locations can be configured manually when using a session by retrieving the +location and setting the appropriate attributes:: + + location = session.query('Location where name is "my.location"').one() + location.structure = ftrack_api.structure.id.IdStructure() + location.priority = 50 + +.. _locations/configuring/automatically: + +Configuring automatically +========================= + +Often the configuration of locations should be determined by developers +looking after the core pipeline and so ftrack provides a way for a plugin to +be registered to configure the necessary locations for each session. This can +then be managed centrally if desired. + +The configuration is handled through the standard events system via a topic +*ftrack.api.session.configure-location*. Set up an :ref:`event listener plugin +` as normal with a register function that +accepts a :class:`~ftrack_api.session.Session` instance. Then register a +callback against the relevant topic to configure locations at the appropriate +time:: + + import ftrack_api + import ftrack_api.entity.location + import ftrack_api.accessor.disk + import ftrack_api.structure.id + + + def configure_locations(event): + '''Configure locations for session.''' + session = event['data']['session'] + + # Find location(s) and customise instances. + location = session.query('Location where name is "my.location"').one() + ftrack_api.mixin(location, ftrack_api.entity.location.UnmanagedLocationMixin) + location.accessor = ftrack_api.accessor.disk.DiskAccessor(prefix='') + location.structure = ftrack_api.structure.id.IdStructure() + location.priority = 50 + + + def register(session): + '''Register plugin with *session*.''' + session.event_hub.subscribe( + 'topic=ftrack.api.session.configure-location', + configure_locations + ) + +.. note:: + + If you expect the plugin to also be evaluated by the legacy API, remember + to :ref:`validate the arguments `. + +So long as the directory containing the plugin exists on your +:envvar:`FTRACK_EVENT_PLUGIN_PATH`, the plugin will run for each session +created and any configured locations will then remain configured for the +duration of that related session. + +Be aware that you can configure many locations in one plugin or have separate +plugins for different locations - the choice is entirely up to you! diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/locations/index.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/locations/index.rst new file mode 100644 index 0000000000..ac1eaba649 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/locations/index.rst @@ -0,0 +1,18 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _developing/locations: + +********* +Locations +********* + +Learn how to access locations using the API and configure your own location +plugins. + +.. toctree:: + :maxdepth: 1 + + overview + tutorial + configuring diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/locations/overview.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/locations/overview.rst new file mode 100644 index 0000000000..0a6ec171aa --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/locations/overview.rst @@ -0,0 +1,143 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _locations/overview: + +******** +Overview +******** + +Locations provides a way to easily track and manage data (files, image sequences +etc.) using ftrack. + +With locations it is possible to see where published data is in the world and +also to transfer data automatically between different locations, even different +storage mechanisms, by defining a few simple :term:`Python` plugins. By keeping +track of the size of the data it also helps manage storage capacity better. In +addition, the intrinsic links to production information makes assigning work to +others and transferring only the relevant data much simpler as well as greatly +reducing the burden on those responsible for archiving finished work. + +Concepts +======== + +The system is implemented in layers using a few key concepts in order to provide +a balance between out of the box functionality and custom configuration. + +.. _locations/overview/locations: + +Locations +--------- + +Data locations can be varied in scope and meaning - a facility, a laptop, a +specific drive. As such, rather than place a hard limit on what can be +considered a location, ftrack simply requires that a location be identifiable by +a string and that string be unique to that location. + +A global company with facilities in many different parts of the world might +follow a location naming convention similar to the following: + + * 'ftrack.london.server01' + * 'ftrack.london.server02' + * 'ftrack.nyc.server01' + * 'ftrack.amsterdam.server01' + * '..' + +Whereas, for a looser setup, the following might suit better: + + * 'bjorns-workstation' + * 'fredriks-mobile' + * 'martins-laptop' + * 'cloud-backup' + +Availability +------------ + +When tracking data across several locations it is important to be able to +quickly find out where data is available and where it is not. As such, ftrack +provides simple mechanisms for retrieving information on the availability of a +:term:`component` in each location. + +For a single file, the availability with be either 0% or 100%. For containers, +such as file sequences, each file is tracked separately and the availability of +the container calculated as an overall percentage (e.g. 47%). + +.. _locations/overview/accessors: + +Accessors +--------- + +Due to the flexibility of what can be considered a location, the system must be +able to cope with locations that represent different ways of storing data. For +example, data might be stored on a local hard drive, a cloud service or even in +a database. + +In addition, the method of accessing that storage can change depending on +perspective - local filesystem, FTP, S3 API etc. + +To handle this, ftrack introduces the idea of an :term:`accessor` that provides +access to the data in a standard way. An accessor is implemented in +:term:`Python` following a set interface and can be configured at runtime to +provide relevant access to a location. + +With an accessor configured for a location, it becomes possible to not only +track data, but also manage it through ftrack by using the accessor to add and +remove data from the location. + +At present, ftrack includes a :py:class:`disk accessor +` for local filesystem access. More will be +added over time and developers are encouraged to contribute their own. + +.. _locations/overview/structure: + +Structure +--------- + +Another important consideration for locations is how data should be structured +in the location (folder structure and naming conventions). For example, +different facilities may want to use different folder structures, or different +storage mechanisms may use different paths for the data. + +For this, ftrack supports the use of a :term:`Python` structure plugin. This +plugin is called when adding a :term:`component` to a location in order to +determine the correct structure to use. + +.. note:: + + A structure plugin accepts an ftrack entity as its input and so can be + reused for generating general structures as well. For example, an action + callback could be implemented to create the base folder structure for some + selected shots by reusing a structure plugin. + +.. _locations/overview/resource_identifiers: + +Resource identifiers +-------------------- + +When a :term:`component` can be linked to multiple locations it becomes +necessary to store information about the relationship on the link rather than +directly on the :term:`component` itself. The most important information is the +path to the data in that location. + +However, as seen above, not all locations may be filesystem based or accessed +using standard filesystem protocols. For this reason, and to help avoid +confusion, this *path* is referred to as a :term:`resource identifier` and no +limitations are placed on the format. Keep in mind though that accessors use +this information (retrieved from the database) in order to work out how to +access the data, so the format used must be compatible with all the accessors +used for any one location. For this reason, most +:term:`resource identifiers ` should ideally look like +relative filesystem paths. + +.. _locations/overview/resource_identifiers/transformer: + +Transformer +^^^^^^^^^^^ + +To further support custom formats for +:term:`resource identifiers `, it is also possible to +configure a resource identifier transformer plugin which will convert +the identifiers before they are stored centrally and after they are retrieved. + +A possible use case of this might be to store JSON encoded metadata about a path +in the database and convert this to an actual filesystem path on retrieval. diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/locations/tutorial.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/locations/tutorial.rst new file mode 100644 index 0000000000..4c5a6c0f13 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/locations/tutorial.rst @@ -0,0 +1,193 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _locations/tutorial: + +******** +Tutorial +******** + +This tutorial is a walkthrough on how you interact with Locations using the +ftrack :term:`API`. Before you read this tutorial, make sure you familiarize +yourself with the location concepts by reading the :ref:`locations/overview`. + +All examples assume you are using Python 2.x, have the :mod:`ftrack_api` +module imported and a :class:`session ` created. + +.. code-block:: python + + import ftrack_api + session = ftrack_api.Session() + +.. _locations/creating-locations: + +Creating locations +================== + +Locations can be created just like any other entity using +:meth:`Session.create `:: + + location = session.create('Location', dict(name='my.location')) + session.commit() + +.. note:: + Location names beginning with ``ftrack.`` are reserved for internal use. Do + not use this prefix for your location names. + +To create a location only if it doesn't already exist use the convenience +method :meth:`Session.ensure `. This will return +either an existing matching location or a newly created one. + +Retrieving locations +==================== + +You can retrieve existing locations using the standard session +:meth:`~ftrack_api.session.Session.get` and +:meth:`~ftrack_api.session.Session.query` methods:: + + # Retrieve location by unique id. + location_by_id = session.get('Location', 'unique-id') + + # Retrieve location by name. + location_by_name = session.query( + 'Location where name is "my.location"' + ).one() + +To retrieve all existing locations use a standard query:: + + all_locations = session.query('Location').all() + for existing_location in all_locations: + print existing_location['name'] + +Configuring locations +===================== + +At this point you have created a custom location "my.location" in the database +and have an instance to reflect that. However, the location cannot be used in +this session to manage data unless it has been configured. To configure a +location for the session, set the appropriate attributes for accessor and +structure:: + + import tempfile + import ftrack_api.accessor.disk + import ftrack_api.structure.id + + # Assign a disk accessor with *temporary* storage + location.accessor = ftrack_api.accessor.disk.DiskAccessor( + prefix=tempfile.mkdtemp() + ) + + # Assign using ID structure. + location.structure = ftrack_api.structure.id.IdStructure() + + # Set a priority which will be used when automatically picking locations. + # Lower number is higher priority. + location.priority = 30 + +To learn more about how to configure locations automatically in a session, see +:ref:`locations/configuring`. + +.. note:: + + If a location is not configured in a session it can still be used as a + standard entity and to find out availability of components + +Using components with locations +=============================== + +The Locations :term:`API` tries to use sane defaults to stay out of your way. +When creating :term:`components `, a location is automatically picked +using :meth:`Session.pick_location `:: + + (_, component_path) = tempfile.mkstemp(suffix='.txt') + component_a = session.create_component(path=component_path) + +To override, specify a location explicitly:: + + (_, component_path) = tempfile.mkstemp(suffix='.txt') + component_b = session.create_component( + path=component_path, location=location + ) + +If you set the location to ``None``, the component will only be present in the +special origin location for the duration of the session:: + + (_, component_path) = tempfile.mkstemp(suffix='.txt') + component_c = session.create_component(path=component_path, location=None) + +After creating a :term:`component` in a location, it can be added to another +location by calling :meth:`Location.add_component +` and passing the location to +use as the *source* location:: + + origin_location = session.query( + 'Location where name is "ftrack.origin"' + ).one() + location.add_component(component_c, origin_location) + +To remove a component from a location use :meth:`Location.remove_component +`:: + + location.remove_component(component_b) + +Each location specifies whether to automatically manage data when adding or +removing components. To ensure that a location does not manage data, mixin the +relevant location mixin class before use:: + + import ftrack_api + import ftrack_api.entity.location + + ftrack_api.mixin(location, ftrack_api.entity.location.UnmanagedLocationMixin) + +Accessing paths +=============== + +The locations system is designed to help avoid having to deal with filesystem +paths directly. This is particularly important when you consider that a number +of locations won't provide any direct filesystem access (such as cloud storage). + +However, it is useful to still be able to get a filesystem path from locations +that support them (typically those configured with a +:class:`~ftrack_api.accessor.disk.DiskAccessor`). For example, you might need to +pass a filesystem path to another application or perform a copy using a faster +protocol. + +To retrieve the path if available, use :meth:`Location.get_filesystem_path +`:: + + print location.get_filesystem_path(component_c) + +Obtaining component availability +================================ + +Components in locations have a notion of availability. For regular components, +consisting of a single file, the availability would be either 0 if the +component is unavailable or 100 percent if the component is available in the +location. Composite components, like image sequences, have an availability +which is proportional to the amount of child components that have been added to +the location. + +For example, an image sequence might currently be in a state of being +transferred to :data:`test.location`. If half of the images are transferred, it +might be possible to start working with the sequence. To check availability use +the helper :meth:`Session.get_component_availability +` method:: + + print session.get_component_availability(component_c) + +There are also convenience methods on both :meth:`components +` and :meth:`locations +` for +retrieving availability as well:: + + print component_c.get_availability() + print location.get_component_availability(component_c) + +Location events +=============== + +If you want to receive event notifications when components are added to or +removed from locations, you can subscribe to the topics published, +:data:`ftrack_api.symbol.COMPONENT_ADDED_TO_LOCATION_TOPIC` or +:data:`ftrack_api.symbol.COMPONENT_REMOVED_FROM_LOCATION_TOPIC` and the callback +you want to be run. diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/querying.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/querying.rst new file mode 100644 index 0000000000..7a200529ab --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/querying.rst @@ -0,0 +1,263 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _querying: + +******** +Querying +******** + +.. currentmodule:: ftrack_api.session + +The API provides a simple, but powerful query language in addition to iterating +directly over entity attributes. Using queries can often substantially speed +up your code as well as reduce the amount of code written. + +A query is issued using :meth:`Session.query` and returns a list of matching +entities. The query always has a single *target* entity type that the query +is built against. This means that you cannot currently retrieve back a list of +different entity types in one query, though using :ref:`projections +` does allow retrieving related entities of a different +type in one go. + +The syntax for a query is: + +.. code-block:: none + + select from where + +However, both the selection of projections and criteria are optional. This means +the most basic query is just to fetch all entities of a particular type, such as +all projects in the system:: + + projects = session.query('Project') + +A query always returns a :class:`~ftrack_api.query.QueryResult` instance that +acts like a list with some special behaviour. The main special behaviour is that +the actual query to the server is not issued until you iterate or index into the +query results:: + + for project in projects: + print project['name'] + +You can also explicitly call :meth:`~ftrack_api.query.QueryResult.all` on the +result set:: + + projects = session.query('Project').all() + +.. note:: + + This behaviour exists in order to make way for efficient *paging* and other + optimisations in future. + +.. _querying/criteria: + +Using criteria to narrow results +================================ + +Often you will have some idea of the entities you want to retrieve. In this +case you can optimise your code by not fetching more data than you need. To do +this, add criteria to your query:: + + projects = session.query('Project where status is active') + +Each criteria follows the form: + +.. code-block:: none + + + +You can inspect the entity type or instance to find out which :ref:`attributes +` are available to filter on for a particular +entity type. The list of :ref:`operators ` that can +be applied and the types of values they expect is listed later on. + +.. _querying/criteria/combining: + +Combining criteria +------------------ + +Multiple criteria can be applied in a single expression by joining them with +either ``and`` or ``or``:: + + projects = session.query( + 'Project where status is active and name like "%thrones"' + ) + +You can use parenthesis to control the precedence when compound criteria are +used (by default ``and`` takes precedence):: + + projects = session.query( + 'Project where status is active and ' + '(name like "%thrones" or full_name like "%thrones")' + ) + +.. _querying/criteria/relationships: + +Filtering on relationships +-------------------------- + +Filtering on relationships is also intuitively supported. Simply follow the +relationship using a dotted notation:: + + tasks_in_project = session.query( + 'Task where project.id is "{0}"'.format(project['id']) + ) + +This works even for multiple strides across relationships (though do note that +excessive strides can affect performance):: + + tasks_completed_in_project = session.query( + 'Task where project.id is "{0}" and ' + 'status.type.name is "Done"' + .format(project['id']) + ) + +The same works for collections (where each entity in the collection is compared +against the subsequent condition):: + + import arrow + + tasks_with_time_logged_today = session.query( + 'Task where timelogs.start >= "{0}"'.format(arrow.now().floor('day')) + ) + +In the above query, each *Task* that has at least one *Timelog* with a *start* +time greater than the start of today is returned. + +When filtering on relationships, the conjunctions ``has`` and ``any`` can be +used to specify how the criteria should be applied. This becomes important when +querying using multiple conditions on collection relationships. The relationship +condition can be written against the following form:: + + () + +For optimal performance ``has`` should be used for scalar relationships when +multiple conditions are involved. For example, to find notes by a specific +author when only name is known:: + + notes_written_by_jane_doe = session.query( + 'Note where author has (first_name is "Jane" and last_name is "Doe")' + ) + +This query could be written without ``has``, giving the same results:: + + notes_written_by_jane_doe = session.query( + 'Note where author.first_name is "Jane" and author.last_name is "Doe"' + ) + +``any`` should be used for collection relationships. For example, to find all +projects that have at least one metadata instance that has `key=some_key` +and `value=some_value` the query would be:: + + projects_where_some_key_is_some_value = session.query( + 'Project where metadata any (key=some_key and value=some_value)' + ) + +If the query was written without ``any``, projects with one metadata matching +*key* and another matching the *value* would be returned. + +``any`` can also be used to query for empty relationship collections:: + + users_without_timelogs = session.query( + 'User where not timelogs any ()' + ) + +.. _querying/criteria/operators: + +Supported operators +------------------- + +This is the list of currently supported operators: + ++--------------+----------------+----------------------------------------------+ +| Operators | Description | Example | ++==============+================+==============================================+ +| = | Exactly equal. | name is "martin" | +| is | | | ++--------------+----------------+----------------------------------------------+ +| != | Not exactly | name is_not "martin" | +| is_not | equal. | | ++--------------+----------------+----------------------------------------------+ +| > | Greater than | start after "2015-06-01" | +| after | exclusive. | | +| greater_than | | | ++--------------+----------------+----------------------------------------------+ +| < | Less than | end before "2015-06-01" | +| before | exclusive. | | +| less_than | | | ++--------------+----------------+----------------------------------------------+ +| >= | Greater than | bid >= 10 | +| | inclusive. | | ++--------------+----------------+----------------------------------------------+ +| <= | Less than | bid <= 10 | +| | inclusive. | | ++--------------+----------------+----------------------------------------------+ +| in | One of. | status.type.name in ("In Progress", "Done") | ++--------------+----------------+----------------------------------------------+ +| not_in | Not one of. | status.name not_in ("Omitted", "On Hold") | ++--------------+----------------+----------------------------------------------+ +| like | Matches | name like "%thrones" | +| | pattern. | | ++--------------+----------------+----------------------------------------------+ +| not_like | Does not match | name not_like "%thrones" | +| | pattern. | | ++--------------+----------------+----------------------------------------------+ +| has | Test scalar | author has (first_name is "Jane" and | +| | relationship. | last_name is "Doe") | ++--------------+----------------+----------------------------------------------+ +| any | Test collection| metadata any (key=some_key and | +| | relationship. | value=some_value) | ++--------------+----------------+----------------------------------------------+ + +.. _querying/projections: + +Optimising using projections +============================ + +In :ref:`understanding_sessions` we mentioned :ref:`auto-population +` of attribute values on access. This +meant that when iterating over a lot of entities and attributes a large number +of queries were being sent to the server. Ultimately, this can cause your code +to run slowly:: + + >>> projects = session.query('Project') + >>> for project in projects: + ... print( + ... # Multiple queries issued here for each attribute accessed for + ... # each project in the loop! + ... '{project[full_name]} - {project[status][name]})' + ... .format(project=project) + ... ) + + +Fortunately, there is an easy way to optimise. If you know what attributes you +are interested in ahead of time you can include them in your query string as +*projections* in order to fetch them in one go:: + + >>> projects = session.query( + ... 'select full_name, status.name from Project' + ... ) + >>> for project in projects: + ... print( + ... # No additional queries issued here as the values were already + ... # loaded by the above query! + ... '{project[full_name]} - {project[status][name]})' + ... .format(project=project) + ... ) + +Notice how this works for related entities as well. In the example above, we +also fetched the name of each *Status* entity attached to a project in the same +query, which meant that no further queries had to be issued when accessing those +nested attributes. + +.. note:: + + There are no arbitrary limits to the number (or depth) of projections, but + do be aware that excessive projections can ultimately result in poor + performance also. As always, it is about choosing the right tool for the + job. + +You can also customise the +:ref:`working_with_entities/entity_types/default_projections` to use for each +entity type when none are specified in the query string. diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/release/index.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/release/index.rst new file mode 100644 index 0000000000..0eef0b7407 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/release/index.rst @@ -0,0 +1,18 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _release: + +*************************** +Release and migration notes +*************************** + +Find out information about what has changed between versions and any important +migration notes to be aware of when switching to a new version. + +.. toctree:: + :maxdepth: 1 + + release_notes + migration + migrating_from_old_api diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/release/migrating_from_old_api.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/release/migrating_from_old_api.rst new file mode 100644 index 0000000000..699ccf224a --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/release/migrating_from_old_api.rst @@ -0,0 +1,613 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +.. _release/migrating_from_old_api: + +********************** +Migrating from old API +********************** + +.. currentmodule:: ftrack_api.session + +Why a new API? +============== + +With the introduction of Workflows, ftrack is capable of supporting a greater +diversity of industries. We're enabling teams to closely align the system with +their existing practices and naming conventions, resulting in a tool that feels +more natural and intuitive. The old API was locked to specific workflows, making +it impractical to support this new feature naturally. + +We also wanted this new flexibility to extend to developers, so we set about +redesigning the API to fully leverage the power in the system. And while we had +the wrenches out, we figured why not go that extra mile and build in some of the +features that we see developers having to continually implement in-house across +different companies - features such as caching and support for custom pipeline +extensions. In essence, we decided to build the API that, as pipeline +developers, we had always wanted from our production tracking and asset +management systems. We think we succeeded, and we hope you agree. + +Installing +========== + +Before, you used to download the API package from your ftrack instance. With +each release of the new API we make it available on :term:`PyPi`, and +installing is super simple: + +.. code-block:: none + + pip install ftrack-python-api + +Before installing, it is always good to check the latest +:ref:`release/release_notes` to see which version of the ftrack server is +required. + +.. seealso:: :ref:`installing` + +Overview +======== + +An API needs to be approachable, so we built the new API to feel +intuitive and familiar. We bundle all the core functionality into one place – a +session – with consistent methods for interacting with entities in the system:: + + import ftrack_api + session = ftrack_api.Session() + +The session is responsible for loading plugins and communicating with the ftrack +server and allows you to use multiple simultaneous sessions. You will no longer +need to explicitly call :meth:`ftrack.setup` to load plugins. + +The core methods are straightforward: + +Session.create + create a new entity, like a new version. +Session.query + fetch entities from the server using a powerful query language. +Session.delete + delete existing entities. +Session.commit + commit all changes in one efficient call. + +.. note:: + + The new API batches create, update and delete operations by default for + efficiency. To synchronise local changes with the server you need to call + :meth:`Session.commit`. + +In addition all entities in the API now act like simple Python dictionaries, +with some additional helper methods where appropriate. If you know a little +Python (or even if you don't) getting up to speed should be a breeze:: + + >>> print user.keys() + ['first_name', 'last_name', 'email', ...] + >>> print user['email'] + 'old@example.com' + >>> user['email'] = 'new@example.com' + +And of course, relationships between entities are reflected in a natural way as +well:: + + new_timelog = session.create('Timelog', {...}) + task['timelogs'].append(new_timelog) + +.. seealso :: :ref:`tutorial` + +The new API also makes use of caching in order to provide more efficient +retrieval of data by reducing the number of calls to the remote server. + +.. seealso:: :ref:`caching` + +Open source and standard code style +=================================== + +The new API is open source software and developed in public at +`Bitbucket `_. We welcome you +to join us in the development and create pull requests there. + +In the new API, we also follow the standard code style for Python, +:term:`PEP-8`. This means that you will now find that methods and variables are +written using ``snake_case`` instead of ``camelCase``, amongst other things. + +Package name +============ + +The new package is named :mod:`ftrack_api`. By using a new package name, we +enable you to use the old API and the new side-by-side in the same process. + +Old API:: + + import ftrack + +New API:: + + import ftrack_api + +Specifying your credentials +=========================== + +The old API used three environment variables to authenticate with your ftrack +instance. While these continue to work as before, you now also have +the option to specify them when initializing the session:: + + >>> import ftrack_api + >>> session = ftrack_api.Session( + ... server_url='https://mycompany.ftrackapp.com', + ... api_key='7545384e-a653-11e1-a82c-f22c11dd25eq', + ... api_user='martin' + ... ) + +In the examples below, will assume that you have imported the package and +created a session. + +.. seealso:: + + * :ref:`environment_variables` + * :ref:`tutorial` + + +Querying objects +================ + +The old API relied on predefined methods for querying objects and constructors +which enabled you to get an entity by it's id or name. + +Old API:: + + project = ftrack.getProject('dev_tutorial') + task = ftrack.Task('8923b7b3-4bf0-11e5-8811-3c0754289fd3') + user = ftrack.User('jane') + +New API:: + + project = session.query('Project where name is "dev_tutorial"').one() + task = session.get('Task', '8923b7b3-4bf0-11e5-8811-3c0754289fd3') + user = session.query('User where username is "jane"').one() + +While the new API can be a bit more verbose for simple queries, it is much more +powerful and allows you to filter on any field and preload related data:: + + tasks = session.query( + 'select name, parent.name from Task ' + 'where project.full_name is "My Project" ' + 'and status.type.short is "DONE" ' + 'and not timelogs any ()' + ).all() + +The above fetches all tasks for “My Project” that are done but have no timelogs. +It also pre-fetches related information about the tasks parent – all in one +efficient query. + +.. seealso:: :ref:`querying` + +Creating objects +================ + +In the old API, you create objects using specialized methods, such as +:meth:`ftrack.createProject`, :meth:`Project.createSequence` and +:meth:`Task.createShot`. + +In the new API, you can create any object using :meth:`Session.create`. In +addition, there are a few helper methods to reduce the amount of boilerplate +necessary to create certain objects. Don't forget to call :meth:`Session.commit` +once you have issued your create statements to commit your changes. + +As an example, let's look at populating a project with a few entities. + +Old API:: + + project = ftrack.getProject('migration_test') + + # Get default task type and status from project schema + taskType = project.getTaskTypes()[0] + taskStatus = project.getTaskStatuses(taskType)[0] + + sequence = project.createSequence('001') + + # Create five shots with one task each + for shot_number in xrange(10, 60, 10): + shot = sequence.createShot( + '{0:03d}'.format(shot_number) + ) + shot.createTask( + 'Task name', + taskType, + taskStatus + ) + + +New API:: + + project = session.query('Project where name is "migration_test"').one() + + # Get default task type and status from project schema + project_schema = project['project_schema'] + default_shot_status = project_schema.get_statuses('Shot')[0] + default_task_type = project_schema.get_types('Task')[0] + default_task_status = project_schema.get_statuses( + 'Task', default_task_type['id'] + )[0] + + # Create sequence + sequence = session.create('Sequence', { + 'name': '001', + 'parent': project + }) + + # Create five shots with one task each + for shot_number in xrange(10, 60, 10): + shot = session.create('Shot', { + 'name': '{0:03d}'.format(shot_number), + 'parent': sequence, + 'status': default_shot_status + }) + session.create('Task', { + 'name': 'Task name', + 'parent': shot, + 'status': default_task_status, + 'type': default_task_type + }) + + # Commit all changes to the server. + session.commit() + +If you test the example above, one thing you might notice is that the new API +is much more efficient. Thanks to the transaction-based architecture in the new +API only a single call to the server is required to create all the objects. + +.. seealso:: :ref:`working_with_entities/creating` + +Updating objects +================ + +Updating objects in the new API works in a similar way to the old API. Instead +of using the :meth:`set` method on objects, you simply set the key of the +entity to the new value, and call :meth:`Session.commit` to persist the +changes to the database. + +The following example adjusts the duration and comment of a timelog for a +user using the old and new API, respectively. + +Old API:: + + import ftrack + + user = ftrack.User('john') + user.set('email', 'john@example.com') + +New API:: + + import ftrack_api + session = ftrack_api.Session() + + user = session.query('User where username is "john"').one() + user['email'] = 'john@example.com' + session.commit() + +.. seealso:: :ref:`working_with_entities/updating` + + +Date and datetime attributes +============================ + +In the old API, date and datetime attributes where represented using a standard +:mod:`datetime` object. In the new API we have opted to use the :term:`arrow` +library instead. Datetime attributes are represented in the server timezone, +but with the timezone information stripped. + +Old API:: + + >>> import datetime + + >>> task_old_api = ftrack.Task(task_id) + >>> task_old_api.get('startdate') + datetime.datetime(2015, 9, 2, 0, 0) + + >>> # Updating a datetime attribute + >>> task_old_api.set('startdate', datetime.date.today()) + +New API:: + + >>> import arrow + + >>> task_new_api = session.get('Task', task_id) + >>> task_new_api['start_date'] + + + >>> # In the new API, utilize the arrow library when updating a datetime. + >>> task_new_api['start_date'] = arrow.utcnow().floor('day') + >>> session.commit() + +Custom attributes +================= + +In the old API, custom attributes could be retrieved from an entity by using +the methods :meth:`get` and :meth:`set`, like standard attributes. In the new +API, custom attributes can be written and read from entities using the +``custom_attributes`` property, which provides a dictionary-like interface. + +Old API:: + + >>> task_old_api = ftrack.Task(task_id) + >>> task_old_api.get('my_custom_attribute') + + >>> task_old_api.set('my_custom_attribute', 'My new value') + + +New API:: + + >>> task_new_api = session.get('Task', task_id) + >>> task_new_api['custom_attributes']['my_custom_attribute'] + + + >>> task_new_api['custom_attributes']['my_custom_attribute'] = 'My new value' + +For more information on working with custom attributes and existing +limitations, please see: + +.. seealso:: + + :ref:`example/custom_attribute` + + +Using both APIs side-by-side +============================ + +With so many powerful new features and the necessary support for more flexible +workflows, we chose early on to not limit the new API design by necessitating +backwards compatibility. However, we also didn't want to force teams using the +existing API to make a costly all-or-nothing switchover. As such, we have made +the new API capable of coexisting in the same process as the old API:: + + import ftrack + import ftrack_api + +In addition, the old API will continue to be supported for some time, but do +note that it will not support the new `Workflows +`_ and will not have new features back ported +to it. + +In the first example, we obtain a task reference using the old API and +then use the new API to assign a user to it:: + + import ftrack + import ftrack_api + + # Create session for new API, authenticating using envvars. + session = ftrack_api.Session() + + # Obtain task id using old API + shot = ftrack.getShot(['migration_test', '001', '010']) + task = shot.getTasks()[0] + task_id = task.getId() + + user = session.query( + 'User where username is "{0}"'.format(session.api_user) + ).one() + session.create('Appointment', { + 'resource': user, + 'context_id': task_id, + 'type': 'assignment' + }) + +The second example fetches a version using the new API and uploads and sets a +thumbnail using the old API:: + + import arrow + import ftrack + + # fetch a version published today + version = session.query( + 'AssetVersion where date >= "{0}"'.format( + arrow.now().floor('day') + ) + ).first() + + # Create a thumbnail using the old api. + thumbnail_path = '/path/to/thumbnail.jpg' + version_old_api = ftrack.AssetVersion(version['id']) + thumbnail = version_old_api.createThumbnail(thumbnail_path) + + # Also set the same thumbnail on the task linked to the version. + task_old_api = ftrack.Task(version['task_id']) + task_old_api.setThumbnail(thumbnail) + +.. note:: + + It is now possible to set thumbnails using the new API as well, for more + info see :ref:`example/thumbnail`. + +Plugin registration +------------------- + +To make event and location plugin register functions work with both old and new +API the function should be updated to validate the input arguments. For old +plugins the register method should validate that the first input is of type +``ftrack.Registry``, and for the new API it should be of type +:class:`ftrack_api.session.Session`. + +If the input parameter is not validated, a plugin might be mistakenly +registered twice, since both the new and old API will look for plugins the +same directories. + +.. seealso:: + + :ref:`ftrack:release/migration/3.0.29/developer_notes/register_function` + + +Example: publishing a new version +================================= + +In the following example, we look at migrating a script which publishes a new +version with two components. + +Old API:: + + # Query a shot and a task to create the asset against. + shot = ftrack.getShot(['dev_tutorial', '001', '010']) + task = shot.getTasks()[0] + + # Create new asset. + asset = shot.createAsset(name='forest', assetType='geo') + + # Create a new version for the asset. + version = asset.createVersion( + comment='Added more leaves.', + taskid=task.getId() + ) + + # Get the calculated version number. + print version.getVersion() + + # Add some components. + previewPath = '/path/to/forest_preview.mov' + previewComponent = version.createComponent(path=previewPath) + + modelPath = '/path/to/forest_mode.ma' + modelComponent = version.createComponent(name='model', path=modelPath) + + # Publish. + asset.publish() + + # Add thumbnail to version. + thumbnail = version.createThumbnail('/path/to/forest_thumbnail.jpg') + + # Set thumbnail on other objects without duplicating it. + task.setThumbnail(thumbnail) + +New API:: + + # Query a shot and a task to create the asset against. + shot = session.query( + 'Shot where project.name is "dev_tutorial" ' + 'and parent.name is "001" and name is "010"' + ).one() + task = shot['children'][0] + + # Create new asset. + asset_type = session.query('AssetType where short is "geo"').first() + asset = session.create('Asset', { + 'parent': shot, + 'name': 'forest', + 'type': asset_type + }) + + # Create a new version for the asset. + status = session.query('Status where name is "Pending"').one() + version = session.create('AssetVersion', { + 'asset': asset, + 'status': status, + 'comment': 'Added more leaves.', + 'task': task + }) + + # In the new API, the version number is not set until we persist the changes + print 'Version number before commit: {0}'.format(version['version']) + session.commit() + print 'Version number after commit: {0}'.format(version['version']) + + # Add some components. + preview_path = '/path/to/forest_preview.mov' + preview_component = version.create_component(preview_path, location='auto') + + model_path = '/path/to/forest_mode.ma' + model_component = version.create_component(model_path, { + 'name': 'model' + }, location='auto') + + # Publish. Newly created version defaults to being published in the new api, + # but if set to false you can update it by setting the key on the version. + version['is_published'] = True + + # Persist the changes + session.commit() + + # Add thumbnail to version. + thumbnail = version.create_thumbnail( + '/path/to/forest_thumbnail.jpg' + ) + + # Set thumbnail on other objects without duplicating it. + task['thumbnail'] = thumbnail + session.commit() + + +Workarounds for missing convenience methods +=========================================== + +Query object by path +-------------------- + +In the old API, there existed a convenience methods to get an object by +referencing the path (i.e object and parent names). + +Old API:: + + shot = ftrack.getShot(['dev_tutorial', '001', '010']) + +New API:: + + shot = session.query( + 'Shot where project.name is "dev_tutorial" ' + 'and parent.name is "001" and name is "010"' + ) + + +Retrieving an object's parents +------------------------------ + +To retrieve a list of an object's parents, you could call the method +:meth:`getParents` in the old API. Currently, it is not possible to fetch this +in a single call using the new API, so you will have to traverse the ancestors +one-by-one and fetch each object's parent. + +Old API:: + + parents = task.getParents() + +New API:: + + parents = [] + for item in task['link'][:-1]: + parents.append(session.get(item['type'], item['id'])) + +Note that link includes the task itself so `[:-1]` is used to only retreive the +parents. To learn more about the `link` attribute, see +:ref:`Using link attributes example`. + +Limitations in the current version of the API +============================================= + +The new API is still quite young and in active development and there are a few +limitations currently to keep in mind when using it. + +Missing schemas +--------------- + +The following entities are as of the time of writing not currently available +in the new API. Let us know if you depend on any of them. + + * Booking + * Calendar and Calendar Type + * Dependency + * Manager and Manager Type + * Phase + * Role + * Task template + * Temp data + +Action base class +----------------- +There is currently no helper class for creating actions using the new API. We +will add one in the near future. + +In the meantime, it is still possible to create actions without the base class +by listening and responding to the +:ref:`ftrack:developing/events/list/ftrack.action.discover` and +:ref:`ftrack:developing/events/list/ftrack.action.launch` events. + +Legacy location +--------------- + +The ftrack legacy disk locations utilizing the +:class:`InternalResourceIdentifierTransformer` has been deprecated. diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/release/migration.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/release/migration.rst new file mode 100644 index 0000000000..1df2211f96 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/release/migration.rst @@ -0,0 +1,98 @@ +.. + :copyright: Copyright (c) 2015 ftrack + +.. _release/migration: + +*************** +Migration notes +*************** + +.. note:: + + Migrating from the old ftrack API? Read the dedicated :ref:`guide + `. + +Migrate to upcoming 2.0.0 +========================= + +.. _release/migration/2.0.0/event_hub: + +Default behavior for connecting to event hub +-------------------------------------------- + +The default behavior for the `ftrack_api.Session` class will change +for the argument `auto_connect_event_hub`, the default value will +switch from True to False. In order for code relying on the event hub +to continue functioning as expected you must modify your code +to explicitly set the argument to True or that you manually call +`session.event_hub.connect()`. + +.. note:: + If you rely on the `ftrack.location.component-added` or + `ftrack.location.component-removed` events to further process created + or deleted components remember that your session must be connected + to the event hub for the events to be published. + + +Migrate to 1.0.3 +================ + +.. _release/migration/1.0.3/mutating_dictionary: + +Mutating custom attribute dictionary +------------------------------------ + +Custom attributes can no longer be set by mutating entire dictionary:: + + # This will result in an error. + task['custom_attributes'] = dict(foo='baz', bar=2) + session.commit() + +Instead the individual values should be changed:: + + # This works better. + task['custom_attributes']['foo'] = 'baz' + task['custom_attributes']['bar'] = 2 + session.commit() + +Migrate to 1.0.0 +================ + +.. _release/migration/1.0.0/chunked_transfer: + +Chunked accessor transfers +-------------------------- + +Data transfers between accessors is now buffered using smaller chunks instead of +all data at the same time. Included accessor file representations such as +:class:`ftrack_api.data.File` and :class:`ftrack_api.accessor.server.ServerFile` +are built to handle that. If you have written your own accessor and file +representation you may have to update it to support multiple reads using the +limit parameter and multiple writes. + +Migrate to 0.2.0 +================ + +.. _release/migration/0.2.0/new_api_name: + +New API name +------------ + +In this release the API has been renamed from `ftrack` to `ftrack_api`. This is +to allow both the old and new API to co-exist in the same environment without +confusion. + +As such, any scripts using this new API need to be updated to import +`ftrack_api` instead of `ftrack`. For example: + +**Previously**:: + + import ftrack + import ftrack.formatter + ... + +**Now**:: + + import ftrack_api + import ftrack_api.formatter + ... diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/release/release_notes.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/release/release_notes.rst new file mode 100644 index 0000000000..d7978ac0b8 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/release/release_notes.rst @@ -0,0 +1,1478 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _release/release_notes: + +************* +Release Notes +************* + +.. currentmodule:: ftrack_api.session + +.. release:: 1.8.2 + :date: 2020-01-14 + + .. change:: fixed + :tag: Test + + test_ensure_entity_with_non_string_data_types test fails due to missing parents. + + .. change:: changed + :tags: session + + Use WeakMethod when registering atexit handler to prevent memory leak. + +.. release:: 1.8.1 + :date: 2019-10-30 + + .. change:: changed + :tags: Location + + Increase chunk size for file operations to 1 Megabyte. + This value can now also be set from the environment variable: + + :envvar:`FTRACK_API_FILE_CHUNK_SIZE` + + .. change:: new + :tag: setup + + Add check for correct python version when installing with pip. + + .. change:: new + :tags: Notes + + Add support for note labels in create_note helper method. + + .. change:: changed + :tags: session + + Ensure errors from server are fully reported with stack trace. + +.. release:: 1.8.0 + :date: 2019-02-21 + + .. change:: fixed + :tags: documentation + + Event description component-removed report component-added event signature. + + .. change:: new + :tags: session, attribute + + Add new scalar type `object` to factory. + + .. change:: new + :tags: session, attribute + + Add support for list of `computed` attributes as part of schema + definition. A computed attribute is derived on the server side, and can + be time dependentant and differ between users. As such a computed + attribute is not suitable for long term encoding and will not be encoded + with the `persisted_only` stragey. + + .. change:: changed + + The `delayed_job` method has been deprecated in favour of a direct + `Session.call`. See :ref:`example/sync_with_ldap` for example + usage. + + .. change:: changed + + Private method :meth:`Session._call` has been converted to + a public method, :meth:`Session.call`. + + The private method will continue to work, but a pending deprecation + warning will be issued when used. The private method will be removed + entirely in version 2.0. + + .. change:: changed + :tags: session, events + + Event server connection error is too generic, + the actual error is now reported to users. + +.. release:: 1.7.1 + :date: 2018-11-13 + + .. change:: fixed + :tags: session, events + + Meta events for event hub connect and disconnect does not include + source. + + .. change:: fixed + :tags: session, location + + Missing context argument to + :meth:`ResourceIdentifierTransformer.decode` + in :meth:`Location.get_resource_identifier`. + +.. release:: 1.7.0 + :date: 2018-07-27 + + .. change:: new + :tags: session, events + + Added new events :ref:`event_list/ftrack.api.session.ready` and + :ref:`event_list/ftrack.api.session.reset` which can be used to perform + operations after the session is ready or has been reset, respectively. + + .. change:: changed + + Private method :meth:`Session._entity_reference` has been converted to + a public method, :meth:`Session.entity_reference`. + + The private method will continue to work, but a pending deprecation + warning will be issued when used. The private method will be removed + entirely in version 2.0. + + .. change:: fixed + :tags: session, events + + :meth:`Session.close` raises an exception if event hub was explicitly + connected after session initialization. + +.. release:: 1.6.0 + :date: 2018-05-17 + + .. change:: new + :tags: depreciation, events + + In version 2.0.0 of the `ftrack-python-api` the default behavior for + the :class:`Session` class will change for the argument + *auto_connect_event_hub*, the default value will switch from *True* to + *False*. + + A warning will now be emitted if async events are published or + subscribed to without *auto_connect_event_hub* has not explicitly been + set to *True*. + + .. seealso:: :ref:`release/migration/2.0.0/event_hub`. + + .. change:: fixed + :tags: documentation + + Event payload not same as what is being emitted for + :ref:`event_list/ftrack.location.component-added` and + :ref:`event_list/ftrack.location.component-removed`. + + .. change:: fixed + :tags: events + + Pyparsing is causing random errors in a threaded environment. + +.. release:: 1.5.0 + :date: 2018-04-19 + + .. change:: fixed + :tags: session, cache + + Cached entities not updated correctly when fetched in a nested + query. + +.. release:: 1.4.0 + :date: 2018-02-05 + + .. change:: fixed + :tags: session, cache + + Collection attributes not merged correctly when fetched from + server. + + .. change:: new + :tags: session, user, api key + + New function :meth:`ftrack_api.session.Session.reset_remote` allows + resetting of attributes to their default value. A convenience method + for resetting a users api key utalizing this was also added + :meth:`ftrack_api.entity.user.User.reset_api_key`. + + .. seealso:: :ref:`working_with_entities/resetting` + + .. change:: new + + Add support for sending out invitation emails to users. + See :ref:`example/invite_user` for example usage. + + .. change:: changed + :tags: cache, performance + + Entities fetched from cache are now lazily merged. Improved + performance when dealing with highly populated caches. + +.. release:: 1.3.3 + :date: 2017-11-16 + + + .. change:: new + :tags: users, ldap + + Add support for triggering a synchronization of + users between ldap and ftrack. See :ref:`example/sync_with_ldap` + for example usage. + + .. note:: + + This requires that you run ftrack 3.5.10 or later. + + .. change:: fixed + :tags: metadata + + Not possible to set metadata on creation. + +.. release:: 1.3.2 + :date: 2017-09-18 + + + .. change:: new + :tags: task template + + Added example for managing task templates through the API. See + :ref:`example/task_template` for example usage. + + .. change:: fixed + :tags: custom attributes + + Not possible to set hierarchical custom attributes on an entity that + has not been committed. + + .. change:: fixed + :tags: custom attributes + + Not possible to set custom attributes on an `Asset` that has not been + committed. + + .. change:: fixed + :tags: metadata + + Not possible to set metadata on creation. + +.. release:: 1.3.1 + :date: 2017-07-21 + + .. change:: fixed + :tags: session, events + + Calling disconnect on the event hub is slow. + +.. release:: 1.3.0 + :date: 2017-07-17 + + .. change:: new + :tags: session + + Support using a :class:`Session` as a context manager to aid closing of + session after use:: + + with ftrack_api.Session() as session: + # Perform operations with session. + + .. change:: new + :tags: session + + :meth:`Session.close` automatically called on Python exit if session not + already closed. + + .. change:: new + :tags: session + + Added :meth:`Session.close` to properly close a session's connections to + the server(s) as well as ensure event listeners are properly + unsubscribed. + + .. change:: new + + Added :exc:`ftrack_api.exception.ConnectionClosedError` to represent + error caused when trying to access servers over closed connection. + +.. release:: 1.2.0 + :date: 2017-06-16 + + .. change:: changed + :tags: events + + Updated the websocket-client dependency to version >= 0.40.0 to allow + for http proxies. + + .. change:: fixed + :tags: documentation + + The :ref:`example/publishing` example incorrectly stated that a + location would be automatically picked if the *location* keyword + argument was omitted. + +.. release:: 1.1.1 + :date: 2017-04-27 + + .. change:: fixed + :tags: custom attributes + + Cannot use custom attributes for `Asset` in ftrack versions prior to + `3.5.0`. + + .. change:: fixed + :tags: documentation + + The :ref:`example ` + section for managing `text` custom attributes is not correct. + +.. release:: 1.1.0 + :date: 2017-03-08 + + .. change:: new + :tags: server location, thumbnail + + Added method :meth:`get_thumbnail_url() ` + to server location, which can be used to retrieve a thumbnail URL. + See :ref:`example/thumbnail/url` for example usage. + + .. change:: new + :tags: documentation + + Added :ref:`example ` on how to manage entity + links from the API. + + .. change:: new + :tags: documentation + + Added :ref:`example ` on + how to manage custom attribute configurations from the API. + + .. change:: new + :tags: documentation + + Added :ref:`example ` on how to use + `SecurityRole` and `UserSecurityRole` to manage security roles for + users. + + .. change:: new + :tags: documentation + + Added :ref:`examples ` to show how + to list a user's assigned tasks and all users assigned to a task. + + .. change:: changed + :tags: session, plugins + + Added *plugin_arguments* to :class:`Session` to allow passing of + optional keyword arguments to discovered plugin register functions. Only + arguments defined in a plugin register function signature are passed so + existing plugin register functions do not need updating if the new + functionality is not desired. + + .. change:: fixed + :tags: documentation + + The :ref:`example/project` example can be confusing since the project + schema may not contain the necessary object types. + + .. change:: fixed + :tags: documentation + + Query tutorial article gives misleading information about the ``has`` + operator. + + .. change:: fixed + :tags: session + + Size is not set on sequence components when using + :meth:`Session.create_component`. + +.. release:: 1.0.4 + :date: 2017-01-13 + + .. change:: fixed + :tags: custom attributes + + Custom attribute values cannot be set on entities that are not + persisted. + + .. change:: fixed + :tags: events + + `username` in published event's source data is set to the operating + system user and not the API user. + +.. release:: 1.0.3 + :date: 2017-01-04 + + .. change:: changed + :tags: session, custom attributes + + Increased performance of custom attributes and better support for + filtering when using a version of ftrack that supports non-sparse + attribute values. + + .. change:: changed + :tags: session, custom attributes + + Custom attributes can no longer be set by mutating entire dictionary. + + .. seealso:: :ref:`release/migration/1.0.3/mutating_dictionary`. + +.. release:: 1.0.2 + :date: 2016-11-17 + + .. change:: changed + :tags: session + + Removed version restriction for higher server versions. + +.. release:: 1.0.1 + :date: 2016-11-11 + + .. change:: fixed + + :meth:`EventHub.publish ` + *on_reply* callback only called for first received reply. It should be + called for all relevant replies received. + +.. release:: 1.0.0 + :date: 2016-10-28 + + .. change:: new + :tags: session + + :meth:`Session.get_upload_metadata` has been added. + + .. change:: changed + :tags: locations, backwards-incompatible + + Data transfer between locations using accessors is now chunked to avoid + reading large files into memory. + + .. seealso:: :ref:`release/migration/1.0.0/chunked_transfer`. + + .. change:: changed + :tags: server accessor + + :class:`ftrack_api.accessor.server.ServerFile` has been refactored to + work with large files more efficiently. + + .. change:: changed + :tags: server accessor + + :class:`ftrack_api.accessor.server.ServerFile` has been updated to use + the get_upload_metadata API endpoint instead of + /component/getPutMetadata. + + .. change:: changed + :tags: locations + + :class:`ftrack_api.data.String` is now using a temporary file instead of + StringIO to avoid reading large files into memory. + + .. change:: fixed + :tags: session, locations + + `ftrack.centralized-storage` does not properly validate location + selection during user configuration. + +.. release:: 0.16.0 + :date: 2016-10-18 + + .. change:: new + :tags: session, encode media + + :meth:`Session.encode_media` can now automatically associate the output + with a version by specifying a *version_id* keyword argument. A new + helper method on versions, :meth:`AssetVersion.encode_media + `, can be + used to make versions playable in a browser. A server version of 3.3.32 + or higher is required for it to function properly. + + .. seealso:: :ref:`example/encode_media`. + + .. change:: changed + :tags: session, encode media + + You can now decide if :meth:`Session.encode_media` should keep or + delete the original component by specifying the *keep_original* + keyword argument. + + .. change:: changed + :tags: backwards-incompatible, collection + + Collection mutation now stores collection instance in operations rather + than underlying data structure. + + .. change:: changed + :tags: performance + + Improve performance of commit operations by optimising encoding and + reducing payload sent to server. + + .. change:: fixed + :tags: documentation + + Asset parent variable is declared but never used in + :ref:`example/publishing`. + + .. change:: fixed + :tags: documentation + + Documentation of hierarchical attributes and their limitations are + misleading. See :ref:`example/custom_attribute`. + +.. release:: 0.15.5 + :date: 2016-08-12 + + .. change:: new + :tags: documentation + + Added two new examples for :ref:`example/publishing` and + :ref:`example/web_review`. + + .. change:: fixed + :tags: session, availability + + :meth:`Session.get_component_availabilities` ignores passed locations + shortlist and includes all locations in returned availability mapping. + + .. change:: fixed + :tags: documentation + + Source distribution of ftrack-python-api does not include ftrack.css + in the documentation. + +.. release:: 0.15.4 + :date: 2016-07-12 + + .. change:: fixed + :tags: querying + + Custom offset not respected by + :meth:`QueryResult.first `. + + .. change:: changed + :tags: querying + + Using a custom offset with :meth:`QueryResult.one + ` helper method now raises an + exception as an offset is inappropriate when expecting to select a + single item. + + .. change:: fixed + :tags: caching + + :meth:`LayeredCache.remove ` + incorrectly raises :exc:`~exceptions.KeyError` if key only exists in + sub-layer cache. + +.. release:: 0.15.3 + :date: 2016-06-30 + + .. change:: fixed + :tags: session, caching + + A newly created entity now has the correct + :attr:`ftrack_api.symbol.CREATED` state when checked in caching layer. + Previously the state was :attr:`ftrack_api.symbol.NOT_SET`. Note that + this fix causes a change in logic and the stored + :class:`ftrack_api.operation.CreateEntityOperation` might hold data that + has not been fully :meth:`merged `. + + .. change:: fixed + :tags: documentation + + The second example in the assignments article is not working. + + .. change:: changed + :tags: session, caching + + A callable cache maker can now return ``None`` to indicate that it could + not create a suitable cache, but :class:`Session` instantiation can + continue safely. + +.. release:: 0.15.2 + :date: 2016-06-02 + + .. change:: new + :tags: documentation + + Added an example on how to work with assignments and allocations + :ref:`example/assignments_and_allocations`. + + .. change:: new + :tags: documentation + + Added :ref:`example/entity_links` article with + examples of how to manage asset version dependencies. + + .. change:: fixed + :tags: performance + + Improve performance of large collection management. + + .. change:: fixed + + Entities are not hashable because + :meth:`ftrack_api.entity.base.Entity.__hash__` raises `TypeError`. + +.. release:: 0.15.1 + :date: 2016-05-02 + + .. change:: fixed + :tags: collection, attribute, performance + + Custom attribute configurations does not cache necessary keys, leading + to performance issues. + + .. change:: fixed + :tags: locations, structure + + Standard structure does not work if version relation is not set on + the `Component`. + +.. release:: 0.15.0 + :date: 2016-04-04 + + .. change:: new + :tags: session, locations + + `ftrack.centralized-storage` not working properly on Windows. + +.. release:: 0.14.0 + :date: 2016-03-14 + + .. change:: changed + :tags: session, locations + + The `ftrack.centralized-storage` configurator now validates that name, + label and description for new locations are filled in. + + .. change:: new + :tags: session, client review + + Added :meth:`Session.send_review_session_invite` and + :meth:`Session.send_review_session_invites` that can be used to inform + review session invitees about a review session. + + .. seealso:: :ref:`Usage guide `. + + .. change:: new + :tags: session, locations + + Added `ftrack.centralized-storage` configurator as a private module. It + implements a wizard like interface used to configure a centralised + storage scenario. + + .. change:: new + :tags: session, locations + + `ftrack.centralized-storage` storage scenario is automatically + configured based on information passed from the server with the + `query_server_information` action. + + .. change:: new + :tags: structure + + Added :class:`ftrack_api.structure.standard.StandardStructure` with + hierarchy based resource identifier generation. + + .. change:: new + :tags: documentation + + Added more information to the :ref:`understanding_sessions/plugins` + article. + + .. change:: fixed + + :meth:`~ftrack_api.entity.user.User.start_timer` arguments *comment* + and *name* are ignored. + + .. change:: fixed + + :meth:`~ftrack_api.entity.user.User.stop_timer` calculates the wrong + duration when the server is not running in UTC. + + For the duration to be calculated correctly ftrack server version + >= 3.3.15 is required. + +.. release:: 0.13.0 + :date: 2016-02-10 + + .. change:: new + :tags: component, thumbnail + + Added improved support for handling thumbnails. + + .. seealso:: :ref:`example/thumbnail`. + + .. change:: new + :tags: session, encode media + + Added :meth:`Session.encode_media` that can be used to encode + media to make it playable in a browser. + + .. seealso:: :ref:`example/encode_media`. + + .. change:: fixed + + :meth:`Session.commit` fails when setting a custom attribute on an asset + version that has been created and committed in the same session. + + .. change:: new + :tags: locations + + Added :meth:`ftrack_api.entity.location.Location.get_url` to retrieve a + URL to a component in a location if supported by the + :class:`ftrack_api.accessor.base.Accessor`. + + .. change:: new + :tags: documentation + + Updated :ref:`example/note` and :ref:`example/job` articles with + examples of how to use note and job components. + + .. change:: changed + :tags: logging, performance + + Logged messages now evaluated lazily using + :class:`ftrack_api.logging.LazyLogMessage` as optimisation. + + .. change:: changed + :tags: session, events + + Auto connection of event hub for :class:`Session` now takes place in + background to improve session startup time. + + .. change:: changed + :tags: session, events + + Event hub connection timeout is now 60 seconds instead of 10. + + .. change:: changed + :tags: server version + + ftrack server version >= 3.3.11, < 3.4 required. + + .. change:: changed + :tags: querying, performance + + :class:`ftrack_api.query.QueryResult` now pages internally using a + specified page size in order to optimise record retrieval for large + query results. :meth:`Session.query` has also been updated to allow + passing a custom page size at runtime if desired. + + .. change:: changed + :tags: querying, performance + + Increased performance of :meth:`~ftrack_api.query.QueryResult.first` and + :meth:`~ftrack_api.query.QueryResult.one` by using new `limit` syntax. + +.. release:: 0.12.0 + :date: 2015-12-17 + + .. change:: new + :tags: session, widget url + + Added :meth:`ftrack_api.session.Session.get_widget_url` to retrieve an + authenticated URL to info or tasks widgets. + +.. release:: 0.11.0 + :date: 2015-12-04 + + .. change:: new + :tags: documentation + + Updated :ref:`release/migrating_from_old_api` with new link attribute + and added a :ref:`usage example `. + + .. change:: new + :tags: caching, schemas, performance + + Caching of schemas for increased performance. + :meth:`ftrack_api.session.Session` now accepts `schema_cache_path` + argument to specify location of schema cache. If not set it will use a + temporary folder. + +.. release:: 0.10.0 + :date: 2015-11-24 + + .. change:: changed + :tags: tests + + Updated session test to use mocked schemas for encoding tests. + + .. change:: fixed + + Documentation specifies Python 2.6 instead of Python 2.7 as minimum + interpreter version. + + .. change:: fixed + + Documentation does not reflect current dependencies. + + .. change:: changed + :tags: session, component, locations, performance + + Improved performance of + :meth:`ftrack_api.entity.location.Location.add_components` by batching + database operations. + + As a result it is no longer possible to determine progress of transfer + for container components in realtime as events will be emitted in batch + at end of operation. + + In addition, it is now the callers responsibility to clean up any + transferred data should an error occur during either data transfer or + database registration. + + .. change:: changed + :tags: exception, locations + + :exc:`ftrack_api.exception.ComponentInLocationError` now accepts either + a single component or multiple components and makes them available as + *components* in its *details* parameter. + + .. change:: changed + :tags: tests + + Updated session test to not fail on the new private link attribute. + + .. change:: changed + :tags: session + + Internal method :py:meth:`_fetch_schemas` has beed renamed to + :py:meth:`Session._load_schemas` and now requires a `schema_cache_path` + argument. + +.. release:: 0.9.0 + :date: 2015-10-30 + + .. change:: new + :tags: caching + + Added :meth:`ftrack_api.cache.Cache.values` as helper for retrieving + all values in cache. + + .. change:: fixed + :tags: session, caching + + :meth:`Session.merge` redundantly attempts to expand entity references + that have already been expanded causing performance degradation. + + .. change:: new + :tags: session + + :meth:`Session.rollback` has been added to support cleanly reverting + session state to last good state following a failed commit. + + .. change:: changed + :tags: events + + Event hub will no longer allow unverified SSL connections. + + .. seealso:: :ref:`security_and_authentication`. + + .. change:: changed + :tags: session + + :meth:`Session.reset` no longer resets the connection. It also clears + all local state and re-configures certain aspects that are cache + dependant, such as location plugins. + + .. change:: fixed + :tags: factory + + Debug logging messages using incorrect index for formatting leading to + misleading exception. + +.. release:: 0.8.4 + :date: 2015-10-08 + + .. change:: new + + Added initial support for custom attributes. + + .. seealso:: :ref:`example/custom_attribute`. + + .. change:: new + :tags: collection, attribute + + Added :class:`ftrack_api.collection.CustomAttributeCollectionProxy` and + :class:`ftrack_api.attribute.CustomAttributeCollectionAttribute` to + handle custom attributes. + + .. change:: changed + :tags: collection, attribute + + ``ftrack_api.attribute.MappedCollectionAttribute`` renamed to + :class:`ftrack_api.attribute.KeyValueMappedCollectionAttribute` to more + closely reflect purpose. + + .. change:: changed + :tags: collection + + :class:`ftrack_api.collection.MappedCollectionProxy` has been refactored + as a generic base class with key, value specialisation handled in new + dedicated class + :class:`ftrack_api.collection.KeyValueMappedCollectionProxy`. This is + done to avoid confusion following introduction of new + :class:`ftrack_api.collection.CustomAttributeCollectionProxy` class. + + .. change:: fixed + :tags: events + + The event hub does not always reconnect after computer has come back + from sleep. + +.. release:: 0.8.3 + :date: 2015-09-28 + + .. change:: changed + :tags: server version + + ftrack server version >= 3.2.1, < 3.4 required. + + .. change:: changed + + Updated *ftrack.server* location implementation. A server version of 3.3 + or higher is required for it to function properly. + + .. change:: fixed + + :meth:`ftrack_api.entity.factory.StandardFactory.create` not respecting + *bases* argument. + +.. release:: 0.8.2 + :date: 2015-09-16 + + .. change:: fixed + :tags: session + + Wrong file type set on component when publishing image sequence using + :meth:`Session.create_component`. + +.. release:: 0.8.1 + :date: 2015-09-08 + + .. change:: fixed + :tags: session + + :meth:`Session.ensure` not implemented. + +.. release:: 0.8.0 + :date: 2015-08-28 + + .. change:: changed + :tags: server version + + ftrack server version >= 3.2.1, < 3.3 required. + + .. change:: new + + Added lists example. + + .. seealso:: :ref:`example/list`. + + .. change:: new + + Added convenience methods for handling timers + :class:`~ftrack_api.entity.user.User.start_timer` and + :class:`~ftrack_api.entity.user.User.stop_timer`. + + .. change:: changed + + The dynamic API classes Type, Status, Priority and + StatusType have been renamed to Type, Status, Priority and State. + + .. change:: changed + + :meth:`Session.reset` now also clears the top most level cache (by + default a :class:`~ftrack_api.cache.MemoryCache`). + + .. change:: fixed + + Some invalid server url formats not detected. + + .. change:: fixed + + Reply events not encoded correctly causing them to be misinterpreted by + the server. + +.. release:: 0.7.0 + :date: 2015-08-24 + + .. change:: changed + :tags: server version + + ftrack server version >= 3.2, < 3.3 required. + + .. change:: changed + + Removed automatic set of default statusid, priorityid and typeid on + objects as that is now either not mandatory or handled on server. + + .. change:: changed + + Updated :meth:`~ftrack_api.entity.project_schema.ProjectSchema.get_statuses` + and :meth:`~ftrack_api.entity.project_schema.ProjectSchema.get_types` to + handle custom objects. + +.. release:: 0.6.0 + :date: 2015-08-19 + + .. change:: changed + :tags: server version + + ftrack server version >= 3.1.8, < 3.2 required. + + .. change:: changed + :tags: querying, documentation + + Updated documentation with details on new operators ``has`` and ``any`` + for querying relationships. + + .. seealso:: :ref:`querying/criteria/operators` + +.. release:: 0.5.2 + :date: 2015-07-29 + + .. change:: changed + :tags: server version + + ftrack server version 3.1.5 or greater required. + + .. change:: changed + + Server reported errors are now more readable and are no longer sometimes + presented as an HTML page. + +.. release:: 0.5.1 + :date: 2015-07-06 + + .. change:: changed + + Defaults computed by :class:`~ftrack_api.entity.factory.StandardFactory` + are now memoised per session to improve performance. + + .. change:: changed + + :class:`~ftrack_api.cache.Memoiser` now supports a *return_copies* + parameter to control whether deep copies should be returned when a value + was retrieved from the cache. + +.. release:: 0.5.0 + :date: 2015-07-02 + + .. change:: changed + + Now checks for server compatibility and requires an ftrack server + version of 3.1 or greater. + + .. change:: new + + Added convenience methods to :class:`~ftrack_api.query.QueryResult` to + fetch :meth:`~ftrack_api.query.QueryResult.first` or exactly + :meth:`~ftrack_api.query.QueryResult.one` result. + + .. change:: new + :tags: notes + + Added support for handling notes. + + .. seealso:: :ref:`example/note`. + + .. change:: changed + + Collection attributes generate empty collection on first access when no + remote value available. This allows interacting with a collection on a + newly created entity before committing. + + .. change:: fixed + :tags: session + + Ambiguous error raised when :class:`Session` is started with an invalid + user or key. + + .. change:: fixed + :tags: caching, session + + :meth:`Session.merge` fails against + :class:`~ftrack_api.cache.SerialisedCache` when circular reference + encountered due to entity identity not being prioritised in merge. + +.. release:: 0.4.3 + :date: 2015-06-29 + + .. change:: fixed + :tags: plugins, session, entity types + + Entity types not constructed following standard install. + + This is because the discovery of the default plugins is unreliable + across Python installation processes (pip, wheel etc). Instead, the + default plugins have been added as templates to the :ref:`event_list` + documentation and the + :class:`~ftrack_api.entity.factory.StandardFactory` used to create any + missing classes on :class:`Session` startup. + +.. release:: 0.4.2 + :date: 2015-06-26 + + .. change:: fixed + :tags: metadata + + Setting exact same metadata twice can cause + :exc:`~ftrack_api.exception.ImmutableAttributeError` to be incorrectly + raised. + + .. change:: fixed + :tags: session + + Calling :meth:`Session.commit` does not clear locally set attribute + values leading to immutability checks being bypassed in certain cases. + +.. release:: 0.4.1 + :date: 2015-06-25 + + .. change:: fixed + :tags: metadata + + Setting metadata twice in one session causes `KeyError`. + +.. release:: 0.4.0 + :date: 2015-06-22 + + .. change:: changed + :tags: documentation + + Documentation extensively updated. + + .. change:: new + :tags: Client review + + Added support for handling review sessions. + + .. seealso:: :ref:`Usage guide `. + + .. change:: fixed + + Metadata property not working in line with rest of system, particularly + the caching framework. + + .. change:: new + :tags: collection + + Added :class:`ftrack_api.collection.MappedCollectionProxy` class for + providing a dictionary interface to a standard + :class:`ftrack_api.collection.Collection`. + + .. change:: new + :tags: collection, attribute + + Added :class:`ftrack_api.attribute.MappedCollectionAttribute` class for + describing an attribute that should use the + :class:`ftrack_api.collection.MappedCollectionProxy`. + + .. change:: new + + Entities that use composite primary keys are now fully supported in the + session, including for :meth:`Session.get` and :meth:`Session.populate`. + + .. change:: change + + Base :class:`ftrack_api.entity.factory.Factory` refactored to separate + out attribute instantiation into dedicated methods to make extending + simpler. + + .. change:: change + :tags: collection, attribute + + :class:`ftrack_api.attribute.DictionaryAttribute` and + :class:`ftrack_api.attribute.DictionaryAttributeCollection` removed. + They have been replaced by the new + :class:`ftrack_api.attribute.MappedCollectionAttribute` and + :class:`ftrack_api.collection.MappedCollectionProxy` respectively. + + .. change:: new + :tags: events + + :class:`Session` now supports an *auto_connect_event_hub* argument to + control whether the built in event hub should connect to the server on + session initialisation. This is useful for when only local events should + be supported or when the connection should be manually controlled. + +.. release:: 0.3.0 + :date: 2015-06-14 + + .. change:: fixed + + Session operations may be applied server side in invalid order resulting + in unexpected error. + + .. change:: fixed + + Creating and deleting an entity in single commit causes error as create + operation never persisted to server. + + Now all operations for the entity are ignored on commit when this case + is detected. + + .. change:: changed + + Internally moved from differential state to operation tracking for + determining session changes when persisting. + + .. change:: new + + ``Session.recorded_operations`` attribute for examining current + pending operations on a :class:`Session`. + + .. change:: new + + :meth:`Session.operation_recording` context manager for suspending + recording operations temporarily. Can also manually control + ``Session.record_operations`` boolean. + + .. change:: new + + Operation classes to track individual operations occurring in session. + + .. change:: new + + Public :meth:`Session.merge` method for merging arbitrary values into + the session manually. + + .. change:: changed + + An entity's state is now computed from the operations performed on it + and is no longer manually settable. + + .. change:: changed + + ``Entity.state`` attribute removed. Instead use the new inspection + :func:`ftrack_api.inspection.state`. + + Previously:: + + print entity.state + + Now:: + + import ftrack_api.inspection + print ftrack_api.inspection.state(entity) + + There is also an optimised inspection, + :func:`ftrack_api.inspection.states`. for determining state of many + entities at once. + + .. change:: changed + + Shallow copying a :class:`ftrack_api.symbol.Symbol` instance now + returns same instance. + +.. release:: 0.2.0 + :date: 2015-06-04 + + .. change:: changed + + Changed name of API from `ftrack` to `ftrack_api`. + + .. seealso:: :ref:`release/migration/0.2.0/new_api_name`. + + .. change:: new + :tags: caching + + Configurable caching support in :class:`Session`, including the ability + to use an external persisted cache and new cache implementations. + + .. seealso:: :ref:`caching`. + + .. change:: new + :tags: caching + + :meth:`Session.get` now tries to retrieve matching entity from + configured cache first. + + .. change:: new + :tags: serialisation, caching + + :meth:`Session.encode` supports a new mode *persisted_only* that will + only encode persisted attribute values. + + .. change:: changed + + Session.merge method is now private (:meth:`Session._merge`) until it is + qualified for general usage. + + .. change:: changed + :tags: entity state + + :class:`~ftrack_api.entity.base.Entity` state now managed on the entity + directly rather than stored separately in the :class:`Session`. + + Previously:: + + session.set_state(entity, state) + print session.get_state(entity) + + Now:: + + entity.state = state + print entity.state + + .. change:: changed + :tags: entity state + + Entity states are now :class:`ftrack_api.symbol.Symbol` instances rather + than strings. + + Previously:: + + entity.state = 'created' + + Now:: + + entity.state = ftrack_api.symbol.CREATED + + .. change:: fixed + :tags: entity state + + It is now valid to transition from most entity states to an + :attr:`ftrack_api.symbol.NOT_SET` state. + + .. change:: changed + :tags: caching + + :class:`~ftrack_api.cache.EntityKeyMaker` removed and replaced by + :class:`~ftrack_api.cache.StringKeyMaker`. Entity identity now + computed separately and passed to key maker to allow key maker to work + with non entity instances. + + .. change:: fixed + :tags: entity + + Internal data keys ignored when re/constructing entities reducing + distracting and irrelevant warnings in logs. + + .. change:: fixed + :tags: entity + + :class:`~ftrack_api.entity.base.Entity` equality test raises error when + other is not an entity instance. + + .. change:: changed + :tags: entity, caching + + :meth:`~ftrack_api.entity.base.Entity.merge` now also merges state and + local attributes. In addition, it ensures values being merged have also + been merged into the session and outputs more log messages. + + .. change:: fixed + :tags: inspection + + :func:`ftrack_api.inspection.identity` returns different result for same + entity depending on whether entity type is unicode or string. + + .. change:: fixed + + :func:`ftrack_api.mixin` causes method resolution failure when same + class mixed in multiple times. + + .. change:: changed + + Representations of objects now show plain id rather than converting to + hex. + + .. change:: fixed + :tags: events + + Event hub raises TypeError when listening to ftrack.update events. + + .. change:: fixed + :tags: events + + :meth:`ftrack_api.event.hub.EventHub.subscribe` fails when subscription + argument contains special characters such as `@` or `+`. + + .. change:: fixed + :tags: collection + + :meth:`ftrack_api.collection.Collection` incorrectly modifies entity + state on initialisation. + +.. release:: 0.1.0 + :date: 2015-03-25 + + .. change:: changed + + Moved standardised construct entity type logic to core package (as part + of the :class:`~ftrack_api.entity.factory.StandardFactory`) for easier + reuse and extension. + +.. release:: 0.1.0-beta.2 + :date: 2015-03-17 + + .. change:: new + :tags: locations + + Support for ftrack.server location. The corresponding server build is + required for it to function properly. + + .. change:: new + :tags: locations + + Support for managing components in locations has been added. Check out + the :ref:`dedicated tutorial `. + + .. change:: new + + A new inspection API (:mod:`ftrack_api.inspection`) has been added for + extracting useful information from objects in the system, such as the + identity of an entity. + + .. change:: changed + + ``Entity.primary_key`` and ``Entity.identity`` have been removed. + Instead, use the new :func:`ftrack_api.inspection.primary_key` and + :func:`ftrack_api.inspection.identity` functions. This was done to make it + clearer the the extracted information is determined from the current + entity state and modifying the returned object will have no effect on + the entity instance itself. + + .. change:: changed + + :func:`ftrack_api.inspection.primary_key` now returns a mapping of the + attribute names and values that make up the primary key, rather than + the previous behaviour of returning a tuple of just the values. To + emulate previous behaviour do:: + + ftrack_api.inspection.primary_key(entity).values() + + .. change:: changed + + :meth:`Session.encode` now supports different strategies for encoding + entities via the entity_attribute_strategy* keyword argument. This makes + it possible to use this method for general serialisation of entity + instances. + + .. change:: changed + + Encoded referenced entities are now a mapping containing + *__entity_type__* and then each key, value pair that makes up the + entity's primary key. For example:: + + { + '__entity_type__': 'User', + 'id': '8b90a444-4e65-11e1-a500-f23c91df25eb' + } + + .. change:: changed + + :meth:`Session.decode` no longer automatically adds decoded entities to + the :class:`Session` cache making it possible to use decode + independently. + + .. change:: new + + Added :meth:`Session.merge` for merging entities recursively into the + session cache. + + .. change:: fixed + + Replacing an entity in a :class:`ftrack_api.collection.Collection` with an + identical entity no longer raises + :exc:`ftrack_api.exception.DuplicateItemInCollectionError`. diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/resource/example_plugin.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/resource/example_plugin.py new file mode 100644 index 0000000000..5fda0195a9 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/resource/example_plugin.py @@ -0,0 +1,24 @@ +# :coding: utf-8 +import logging + +import ftrack_api.session + + +def register(session, **kw): + '''Register plugin. Called when used as an plugin.''' + logger = logging.getLogger('com.example.example-plugin') + + # Validate that session is an instance of ftrack_api.Session. If not, + # assume that register is being called from an old or incompatible API and + # return without doing anything. + if not isinstance(session, ftrack_api.session.Session): + logger.debug( + 'Not subscribing plugin as passed argument {0!r} is not an ' + 'ftrack_api.Session instance.'.format(session) + ) + return + + # Perform your logic here, such as subscribe to an event. + pass + + logger.debug('Plugin registered') diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/resource/example_plugin_safe.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/resource/example_plugin_safe.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/resource/example_plugin_using_session.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/resource/example_plugin_using_session.py new file mode 100644 index 0000000000..dd11136d69 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/resource/example_plugin_using_session.py @@ -0,0 +1,37 @@ +# :coding: utf-8 +import logging + +import ftrack_api.session + + +def register_with_session_ready(event): + '''Called when session is ready to be used.''' + logger = logging.getLogger('com.example.example-plugin') + logger.debug('Session ready.') + session = event['data']['session'] + + # Session is now ready and can be used to e.g. query objects. + task = session.query('Task').first() + print task['name'] + + +def register(session, **kw): + '''Register plugin. Called when used as an plugin.''' + logger = logging.getLogger('com.example.example-plugin') + + # Validate that session is an instance of ftrack_api.Session. If not, + # assume that register is being called from an old or incompatible API and + # return without doing anything. + if not isinstance(session, ftrack_api.session.Session): + logger.debug( + 'Not subscribing plugin as passed argument {0!r} is not an ' + 'ftrack_api.Session instance.'.format(session) + ) + return + + session.event_hub.subscribe( + 'topic=ftrack.api.session.ready', + register_with_session_ready + ) + + logger.debug('Plugin registered') diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/security_and_authentication.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/security_and_authentication.rst new file mode 100644 index 0000000000..724afa81a6 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/security_and_authentication.rst @@ -0,0 +1,38 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _security_and_authentication: + +*************************** +Security and authentication +*************************** + +Self signed SSL certificate +=========================== + +When using a self signed SSL certificate the API may fail to connect if it +cannot verify the SSL certificate. Under the hood the +`requests `_ library is used and it +must be specified where the trusted certificate authority can be found using the +environment variable ``REQUESTS_CA_BUNDLE``. + +.. seealso:: `SSL Cert Verification `_ + +InsecurePlatformWarning +======================= + +When using this API you may sometimes see a warning:: + + InsecurePlatformWarning: A true SSLContext object is not available. This + prevents urllib3 from configuring SSL appropriately and may cause certain + SSL connections to fail. + +If you encounter this warning, its recommended you upgrade to Python 2.7.9, or +use pyOpenSSL. To use pyOpenSSL simply:: + + pip install pyopenssl ndg-httpsclient pyasn1 + +and the `requests `_ library used by +this API will use pyOpenSSL instead. + +.. seealso:: `InsecurePlatformWarning `_ diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/tutorial.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/tutorial.rst new file mode 100644 index 0000000000..73b352eb2f --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/tutorial.rst @@ -0,0 +1,156 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _tutorial: + +******** +Tutorial +******** + +.. currentmodule:: ftrack_api.session + +This tutorial provides a quick dive into using the API and the broad stroke +concepts involved. + +First make sure the ftrack Python API is :ref:`installed `. + +Then start a Python session and import the ftrack API:: + + >>> import ftrack_api + +The API uses :ref:`sessions ` to manage communication +with an ftrack server. Create a session that connects to your ftrack server +(changing the passed values as appropriate):: + + >>> session = ftrack_api.Session( + ... server_url='https://mycompany.ftrackapp.com', + ... api_key='7545384e-a653-11e1-a82c-f22c11dd25eq', + ... api_user='martin' + ... ) + +.. note:: + + A session can use :ref:`environment variables + ` to configure itself. + +Now print a list of the available entity types retrieved from the server:: + + >>> print session.types.keys() + [u'TypedContext', u'ObjectType', u'Priority', u'Project', u'Sequence', + u'Shot', u'Task', u'Status', u'Type', u'Timelog', u'User'] + +Now the list of possible entity types is known, :ref:`query ` the +server to retrieve entities of a particular type by using the +:meth:`Session.query` method:: + + >>> projects = session.query('Project') + +Each project retrieved will be an :ref:`entity ` instance +that behaves much like a standard Python dictionary. For example, to find out +the available keys for an entity, call the +:meth:`~ftrack_api.entity.Entity.keys` method:: + + >>> print projects[0].keys() + [u'status', u'is_global', u'name', u'end_date', u'context_type', + u'id', u'full_name', u'root', u'start_date'] + +Now, iterate over the retrieved entities and print each ones name:: + + >>> for project in projects: + ... print project['name'] + test + client_review + tdb + man_test + ftrack + bunny + +.. note:: + + Many attributes for retrieved entities are loaded on demand when the + attribute is first accessed. Doing this lots of times in a script can be + inefficient, so it is worth using :ref:`projections ` + in queries or :ref:`pre-populating ` + entities where appropriate. You can also :ref:`customise default projections + ` to help others + pre-load common attributes. + +To narrow a search, add :ref:`criteria ` to the query:: + + >>> active_projects = session.query('Project where status is active') + +Combine criteria for more powerful queries:: + + >>> import arrow + >>> + >>> active_projects_ending_before_next_week = session.query( + ... 'Project where status is active and end_date before "{0}"' + ... .format(arrow.now().replace(weeks=+1)) + ... ) + +Some attributes on an entity will refer to another entity or collection of +entities, such as *children* on a *Project* being a collection of *Context* +entities that have the project as their parent:: + + >>> project = session.query('Project').first() + >>> print project['children'] + + +And on each *Context* there is a corresponding *parent* attribute which is a +link back to the parent:: + + >>> child = project['children'][0] + >>> print child['parent'] is project + True + +These relationships can also be used in the criteria for a query:: + + >>> results = session.query( + ... 'Context where parent.name like "te%"' + ... ) + +To create new entities in the system use :meth:`Session.create`:: + + >>> new_sequence = session.create('Sequence', { + ... 'name': 'Starlord Reveal' + ... }) + +The created entity is not yet persisted to the server, but it is still possible +to modify it. + + >>> new_sequence['description'] = 'First hero character reveal.' + +The sequence also needs a parent. This can be done in one of two ways: + +* Set the parent attribute on the sequence:: + + >>> new_sequence['parent'] = project + +* Add the sequence to a parent's children attribute:: + + >>> project['children'].append(new_sequence) + +When ready, persist to the server using :meth:`Session.commit`:: + + >>> session.commit() + +When finished with a :class:`Session`, it is important to :meth:`~Session.close` +it in order to release resources and properly unsubscribe any registered event +listeners. It is also possible to use the session as a context manager in order +to have it closed automatically after use:: + + >>> with ftrack_api.Session() as session: + ... print session.query('User').first() + + >>> print session.closed + True + +Once a :class:`Session` is closed, any operations that attempt to use the closed +connection to the ftrack server will fail:: + + >>> session.query('Project').first() + ConnectionClosedError: Connection closed. + +Continue to the next section to start learning more about the API in greater +depth or jump over to the :ref:`usage examples ` if you prefer to learn +by example. diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/understanding_sessions.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/understanding_sessions.rst new file mode 100644 index 0000000000..e3602c4fa9 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/understanding_sessions.rst @@ -0,0 +1,281 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _understanding_sessions: + +********************** +Understanding sessions +********************** + +.. currentmodule:: ftrack_api.session + +All communication with an ftrack server takes place through a :class:`Session`. +This allows more opportunity for configuring the connection, plugins etc. and +also makes it possible to connect to multiple ftrack servers from within the +same Python process. + +.. _understanding_sessions/connection: + +Connection +========== + +A session can be manually configured at runtime to connect to a server with +certain credentials:: + + >>> session = ftrack_api.Session( + ... server_url='https://mycompany.ftrackapp.com', + ... api_key='7545384e-a653-11e1-a82c-f22c11dd25eq', + ... api_user='martin' + ... ) + +Alternatively, a session can use the following environment variables to +configure itself: + + * :envvar:`FTRACK_SERVER` + * :envvar:`FTRACK_API_USER` + * :envvar:`FTRACK_API_KEY` + +When using environment variables, no server connection arguments need to be +passed manually:: + + >>> session = ftrack_api.Session() + +.. _understanding_sessions/unit_of_work: + +Unit of work +============ + +Each session follows the unit of work pattern. This means that many of the +operations performed using a session will happen locally and only be persisted +to the server at certain times, notably when calling :meth:`Session.commit`. +This approach helps optimise calls to the server and also group related logic +together in a transaction:: + + user = session.create('User', {}) + user['username'] = 'martin' + other_user = session.create('User', {'username': 'bjorn'}) + other_user['email'] = 'bjorn@example.com' + +Behind the scenes a series of :class:`operations +` are recorded reflecting the changes made. You +can take a peek at these operations if desired by examining the +``Session.recorded_operations`` property:: + + >>> for operation in session.recorded_operations: + ... print operation + + + + + +Calling :meth:`Session.commit` persists all recorded operations to the server +and clears the operation log:: + + session.commit() + +.. note:: + + The commit call will optimise operations to be as efficient as possible + without breaking logical ordering. For example, a create followed by updates + on the same entity will be compressed into a single create. + +Queries are special and always issued on demand. As a result, a query may return +unexpected results if the relevant local changes have not yet been sent to the +server:: + + >>> user = session.create('User', {'username': 'some_unique_username'}) + >>> query = 'User where username is "{0}"'.format(user['username']) + >>> print len(session.query(query)) + 0 + >>> session.commit() + >>> print len(session.query(query)) + 1 + +Where possible, query results are merged in with existing data transparently +with any local changes preserved:: + + >>> user = session.query('User').first() + >>> user['email'] = 'me@example.com' # Not yet committed to server. + >>> retrieved = session.query( + ... 'User where id is "{0}"'.format(user['id']) + ... ).one() + >>> print retrieved['email'] # Displays locally set value. + 'me@example.com' + >>> print retrieved is user + True + +This is possible due to the smart :ref:`caching` layer in the session. + +.. _understanding_sessions/auto_population: + +Auto-population +=============== + +Another important concept in a session is that of auto-population. By default a +session is configured to auto-populate missing attribute values on access. This +means that the first time you access an attribute on an entity instance a query +will be sent to the server to fetch the value:: + + user = session.query('User').first() + # The next command will issue a request to the server to fetch the + # 'username' value on demand at this is the first time it is accessed. + print user['username'] + +Once a value has been retrieved it is :ref:`cached ` locally in the +session and accessing it again will not issue more server calls:: + + # On second access no server call is made. + print user['username'] + +You can control the auto population behaviour of a session by either changing +the ``Session.auto_populate`` attribute on a session or using the provided +context helper :meth:`Session.auto_populating` to temporarily change the +setting. When turned off you may see a special +:attr:`~ftrack_api.symbol.NOT_SET` symbol that represents a value has not yet +been fetched:: + + >>> with session.auto_populating(False): + ... print user['email'] + NOT_SET + +Whilst convenient for simple scripts, making many requests to the server for +each attribute can slow execution of a script. To support optimisation the API +includes methods for batch fetching attributes. Read about them in +:ref:`querying/projections` and :ref:`working_with_entities/populating`. + +.. _understanding_sessions/entity_types: + +Entity types +============ + +When a session has successfully connected to the server it will automatically +download schema information and :ref:`create appropriate classes +` for use. This is important as different +servers can support different entity types and configurations. + +This information is readily available and useful if you need to check that the +entity types you expect are present. Here's how to print a list of all entity +types registered for use in the current API session:: + + >>> print session.types.keys() + [u'Task', u'Shot', u'TypedContext', u'Sequence', u'Priority', + u'Status', u'Project', u'User', u'Type', u'ObjectType'] + +Each entity type is backed by a :ref:`customisable class +` that further describes the entity type and +the attributes that are available. + +.. hint:: + + If you need to use an :func:`isinstance` check, always go through the + session as the classes are built dynamically:: + + >>> isinstance(entity, session.types['Project']) + +.. _understanding_sessions/plugins: + +Configuring plugins +=================== + +Plugins are used by the API to extend it with new functionality, such as +:term:`locations ` or adding convenience methods to +:ref:`understanding_sessions/entity_types`. In addition to new API +functionality, event plugins may also be used for event processing by listening +to :ref:`ftrack update events ` or adding custom functionality to ftrack by registering +:term:`actions `. + + +When starting a new :class:`Session` either pass the *plugins_paths* to search +explicitly or rely on the environment variable +:envvar:`FTRACK_EVENT_PLUGIN_PATH`. As each session is independent of others, +you can configure plugins per session. + +The paths will be searched for :term:`plugins `, python files +which expose a `register` function. These functions will be evaluated and can +be used extend the API with new functionality, such as locations or actions. + +If you do not specify any override then the session will attempt to discover and +use the default plugins. + +Plugins are discovered using :func:`ftrack_api.plugin.discover` with the +session instance passed as the sole positional argument. Most plugins should +take the form of a mount function that then subscribes to specific :ref:`events +` on the session:: + + def configure_locations(event): + '''Configure locations for session.''' + session = event['data']['session'] + # Find location(s) and customise instances. + + def register(session): + '''Register plugin with *session*.''' + session.event_hub.subscribe( + 'topic=ftrack.api.session.configure-location', + configure_locations + ) + +Additional keyword arguments can be passed as *plugin_arguments* to the +:class:`Session` on instantiation. These are passed to the plugin register +function if its signature supports them:: + + # a_plugin.py + def register(session, reticulate_splines=False): + '''Register plugin with *session*.''' + ... + + # main.py + session = ftrack_api.Session( + plugin_arguments={ + 'reticulate_splines': True, + 'some_other_argument': 42 + } + ) + +.. seealso:: + + Lists of events which you can subscribe to in your plugins are available + both for :ref:`synchronous event published by the python API ` + and :ref:`asynchronous events published by the server ` + + +Quick setup +----------- + +1. Create a directory where plugins will be stored. Place any plugins you want +loaded automatically in an API *session* here. + +.. image:: /image/configuring_plugins_directory.png + +2. Configure the :envvar:`FTRACK_EVENT_PLUGIN_PATH` to point to the directory. + + +Detailed setup +-------------- + +Start out by creating a directory on your machine where you will store your +plugins. Download :download:`example_plugin.py ` +and place it in the directory. + +Open up a terminal window, and ensure that plugin is picked up when +instantiating the session and manually setting the *plugin_paths*:: + + >>> # Set up basic logging + >>> import logging + >>> logging.basicConfig() + >>> plugin_logger = logging.getLogger('com.example.example-plugin') + >>> plugin_logger.setLevel(logging.DEBUG) + >>> + >>> # Configure the API, loading plugins in the specified paths. + >>> import ftrack_api + >>> plugin_paths = ['/path/to/plugins'] + >>> session = ftrack_api.Session(plugin_paths=plugin_paths) + +If everything is working as expected, you should see the following in the +output:: + + DEBUG:com.example.example-plugin:Plugin registered + +Instead of specifying the plugin paths when instantiating the session, you can +also specify the :envvar:`FTRACK_EVENT_PLUGIN_PATH` to point to the directory. +To specify multiple directories, use the path separator for your operating +system. \ No newline at end of file diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/working_with_entities.rst b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/working_with_entities.rst new file mode 100644 index 0000000000..2d9d26f986 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/doc/working_with_entities.rst @@ -0,0 +1,434 @@ +.. + :copyright: Copyright (c) 2014 ftrack + +.. _working_with_entities: + +********************* +Working with entities +********************* + +.. currentmodule:: ftrack_api.session + +:class:`Entity ` instances are Python dict-like +objects whose keys correspond to attributes for that type in the system. They +may also provide helper methods to perform common operations such as replying to +a note:: + + note = session.query('Note').first() + print note.keys() + print note['content'] + note['content'] = 'A different message!' + reply = note.create_reply(...) + +.. _working_with_entities/attributes: + +Attributes +========== + +Each entity instance is typed according to its underlying entity type on the +server and configured with appropriate attributes. For example, a *task* will be +represented by a *Task* class and have corresponding attributes. You can +:ref:`customise entity classes ` to alter +attribute access or provide your own helper methods. + +To see the available attribute names on an entity use the +:meth:`~ftrack_api.entity.base.Entity.keys` method on the instance:: + + >>> task = session.query('Task').first() + >>> print task.keys() + ['id', 'name', ...] + +If you need more information about the type of attribute, examine the +``attributes`` property on the corresponding class:: + + >>> for attribute in type(task).attributes: + ... print attribute + + + + + + ... + +Notice that there are different types of attribute such as +:class:`~ftrack_api.attribute.ScalarAttribute` for plain values or +:class:`~ftrack_api.attribute.ReferenceAttribute` for relationships. These +different types are reflected in the behaviour on the entity instance when +accessing a particular attribute by key: + + >>> # Scalar + >>> print task['name'] + 'model' + >>> task['name'] = 'comp' + + >>> # Single reference + >>> print task['status'] + + >>> new_status = session.query('Status').first() + >>> task['status'] = new_status + + >>> # Collection + >>> print task['timelogs'] + + >>> print task['timelogs'][:] + [, ...] + >>> new_timelog = session.create('Timelog', {...}) + >>> task['timelogs'].append(new_timelog) + +.. _working_with_entities/attributes/bidirectional: + +Bi-directional relationships +---------------------------- + +Some attributes refer to different sides of a bi-directional relationship. In +the current version of the API bi-directional updates are not propagated +automatically to the other side of the relationship. For example, setting a +*parent* will not update the parent entity's *children* collection locally. +There are plans to support this behaviour better in the future. For now, after +commit, :ref:`populate ` the reverse side +attribute manually. + +.. _working_with_entities/creating: + +Creating entities +================= + +In order to create a new instance of an entity call :meth:`Session.create` +passing in the entity type to create and any initial attribute values:: + + new_user = session.create('User', {'username': 'martin'}) + +If there are any default values that can be set client side then they will be +applied at this point. Typically this will be the unique entity key:: + + >>> print new_user['id'] + 170f02a4-6656-4f15-a5cb-c4dd77ce0540 + +At this point no information has been sent to the server. However, you are free +to continue :ref:`updating ` this object +locally until you are ready to persist the changes by calling +:meth:`Session.commit`. + +If you are wondering about what would happen if you accessed an unset attribute +on a newly created entity, go ahead and give it a go:: + + >>> print new_user['first_name'] + NOT_SET + +The session knows that it is a newly created entity that has not yet been +persisted so it doesn't try to fetch any attributes on access even when +``session.auto_populate`` is turned on. + +.. _working_with_entities/updating: + +Updating entities +================= + +Updating an entity is as simple as modifying the values for specific keys on +the dict-like instance and calling :meth:`Session.commit` when ready. The entity +to update can either be a new entity or a retrieved entity:: + + task = session.query('Task').first() + task['bid'] = 8 + +Remember that, for existing entities, accessing an attribute will load it from +the server automatically. If you are interested in just setting values without +first fetching them from the server, turn :ref:`auto-population +` off temporarily:: + + >>> with session.auto_populating(False): + ... task = session.query('Task').first() + ... task['bid'] = 8 + + +.. _working_with_entities/resetting: + +Server side reset of entity attributes or settings. +=========================== + +Some entities support resetting of attributes, for example +to reset a users api key:: + + + session.reset_remote( + 'api_key', entity=session.query('User where username is "test_user"').one() + ) + +.. note:: + Currently the only attribute possible to reset is 'api_key' on + the user entity type. + + +.. _working_with_entities/deleting: + +Deleting entities +================= + +To delete an entity you need an instance of the entity in your session (either +from having created one or retrieving one). Then call :meth:`Session.delete` on +the entity and :meth:`Session.commit` when ready:: + + task_to_delete = session.query('Task').first() + session.delete(task_to_delete) + ... + session.commit() + +.. note:: + + Even though the entity is deleted, you will still have access to the local + instance and any local data stored on that instance whilst that instance + remains in memory. + +Keep in mind that some deletions, when propagated to the server, will cause +other entities to be deleted also, so you don't have to worry about deleting an +entire hierarchy manually. For example, deleting a *Task* will also delete all +*Notes* on that task. + +.. _working_with_entities/populating: + +Populating entities +=================== + +When an entity is retrieved via :meth:`Session.query` or :meth:`Session.get` it +will have some attributes prepopulated. The rest are dynamically loaded when +they are accessed. If you need to access many attributes it can be more +efficient to request all those attributes be loaded in one go. One way to do +this is to use a :ref:`projections ` in queries. + +However, if you have entities that have been passed to you from elsewhere you +don't have control over the query that was issued to get those entities. In this +case you can you can populate those entities in one go using +:meth:`Session.populate` which works exactly like :ref:`projections +` in queries do, but operating against known entities:: + + >>> users = session.query('User') + >>> session.populate(users, 'first_name, last_name') + >>> with session.auto_populating(False): # Turn off for example purpose. + ... for user in users: + ... print 'Name: {0}'.format(user['first_name']) + ... print 'Email: {0}'.format(user['email']) + Name: Martin + Email: NOT_SET + ... + +.. note:: + + You can populate a single or many entities in one call so long as they are + all the same entity type. + +.. _working_with_entities/entity_states: + +Entity states +============= + +Operations on entities are :ref:`recorded in the session +` as they happen. At any time you can +inspect an entity to determine its current state from those pending operations. + +To do this, use :func:`ftrack_api.inspection.state`:: + + >>> import ftrack_api.inspection + >>> new_user = session.create('User', {}) + >>> print ftrack_api.inspection.state(new_user) + CREATED + >>> existing_user = session.query('User').first() + >>> print ftrack_api.inspection.state(existing_user) + NOT_SET + >>> existing_user['email'] = 'martin@example.com' + >>> print ftrack_api.inspection.state(existing_user) + MODIFIED + >>> session.delete(new_user) + >>> print ftrack_api.inspection.state(new_user) + DELETED + +.. _working_with_entities/entity_types: + +Customising entity types +======================== + +Each type of entity in the system is represented in the Python client by a +dedicated class. However, because the types of entities can vary these classes +are built on demand using schema information retrieved from the server. + +Many of the default classes provide additional helper methods which are mixed +into the generated class at runtime when a session is started. + +In some cases it can be useful to tailor the custom classes to your own pipeline +workflows. Perhaps you want to add more helper functions, change attribute +access rules or even providing a layer of backwards compatibility for existing +code. The Python client was built with this in mind and makes such +customisations as easy as possible. + +When a :class:`Session` is constructed it fetches schema details from the +connected server and then calls an :class:`Entity factory +` to create classes from those schemas. It +does this by emitting a synchronous event, +*ftrack.api.session.construct-entity-type*, for each schema and expecting a +*class* object to be returned. + +In the default setup, a :download:`construct_entity_type.py +<../resource/plugin/construct_entity_type.py>` plugin is placed on the +:envvar:`FTRACK_EVENT_PLUGIN_PATH`. This plugin will register a trivial subclass +of :class:`ftrack_api.entity.factory.StandardFactory` to create the classes in +response to the construct event. The simplest way to get started is to edit this +default plugin as required. + +.. seealso:: :ref:`understanding_sessions/plugins` + +.. _working_with_entities/entity_types/default_projections: + +Default projections +------------------- + +When a :ref:`query ` is issued without any :ref:`projections +`, the session will automatically add default projections +according to the type of the entity. + +For example, the following shows that for a *User*, only *id* is fetched by +default when no projections added to the query:: + + >>> user = session.query('User').first() + >>> with session.auto_populating(False): # For demonstration purpose only. + ... print user.items() + [ + (u'id', u'59f0963a-15e2-11e1-a5f1-0019bb4983d8') + (u'username', Symbol(NOT_SET)), + (u'first_name', Symbol(NOT_SET)), + ... + ] + +.. note:: + + These default projections are also used when you access a relationship + attribute using the dictionary key syntax. + +If you want to default to fetching *username* for a *Task* as well then you can +change the default_projections* in your class factory plugin:: + + class Factory(ftrack_api.entity.factory.StandardFactory): + '''Entity class factory.''' + + def create(self, schema, bases=None): + '''Create and return entity class from *schema*.''' + cls = super(Factory, self).create(schema, bases=bases) + + # Further customise cls before returning. + if schema['id'] == 'User': + cls.default_projections = ['id', 'username'] + + return cls + +Now a projection-less query will also query *username* by default: + +.. note:: + + You will need to start a new session to pick up the change you made:: + + session = ftrack_api.Session() + +.. code-block:: python + + >>> user = session.query('User').first() + >>> with session.auto_populating(False): # For demonstration purpose only. + ... print user.items() + [ + (u'id', u'59f0963a-15e2-11e1-a5f1-0019bb4983d8') + (u'username', u'martin'), + (u'first_name', Symbol(NOT_SET)), + ... + ] + +Note that if any specific projections are applied in a query, those override +the default projections entirely. This allows you to also *reduce* the data +loaded on demand:: + + >>> session = ftrack_api.Session() # Start new session to avoid cache. + >>> user = session.query('select id from User').first() + >>> with session.auto_populating(False): # For demonstration purpose only. + ... print user.items() + [ + (u'id', u'59f0963a-15e2-11e1-a5f1-0019bb4983d8') + (u'username', Symbol(NOT_SET)), + (u'first_name', Symbol(NOT_SET)), + ... + ] + +.. _working_with_entities/entity_types/helper_methods: + +Helper methods +-------------- + +If you want to add additional helper methods to the constructed classes to +better support your pipeline logic, then you can simply patch the created +classes in your factory, much like with changing the default projections:: + + def get_full_name(self): + '''Return full name for user.''' + return '{0} {1}'.format(self['first_name'], self['last_name']).strip() + + class Factory(ftrack_api.entity.factory.StandardFactory): + '''Entity class factory.''' + + def create(self, schema, bases=None): + '''Create and return entity class from *schema*.''' + cls = super(Factory, self).create(schema, bases=bases) + + # Further customise cls before returning. + if schema['id'] == 'User': + cls.get_full_name = get_full_name + + return cls + +Now you have a new helper method *get_full_name* on your *User* entities:: + + >>> session = ftrack_api.Session() # New session to pick up changes. + >>> user = session.query('User').first() + >>> print user.get_full_name() + Martin Pengelly-Phillips + +If you'd rather not patch the existing classes, or perhaps have a lot of helpers +to mixin, you can instead inject your own class as the base class. The only +requirement is that it has the base :class:`~ftrack_api.entity.base.Entity` +class in its ancestor classes:: + + import ftrack_api.entity.base + + + class CustomUser(ftrack_api.entity.base.Entity): + '''Represent user.''' + + def get_full_name(self): + '''Return full name for user.''' + return '{0} {1}'.format(self['first_name'], self['last_name']).strip() + + + class Factory(ftrack_api.entity.factory.StandardFactory): + '''Entity class factory.''' + + def create(self, schema, bases=None): + '''Create and return entity class from *schema*.''' + # Alter base class for constructed class. + if bases is None: + bases = [ftrack_api.entity.base.Entity] + + if schema['id'] == 'User': + bases = [CustomUser] + + cls = super(Factory, self).create(schema, bases=bases) + return cls + +The resulting effect is the same:: + + >>> session = ftrack_api.Session() # New session to pick up changes. + >>> user = session.query('User').first() + >>> print user.get_full_name() + Martin Pengelly-Phillips + +.. note:: + + Your custom class is not the leaf class which will still be a dynamically + generated class. Instead your custom class becomes the base for the leaf + class:: + + >>> print type(user).__mro__ + (, , ...) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/pytest.ini b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/pytest.ini new file mode 100644 index 0000000000..b1f515ee18 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +minversion = 2.4.2 +addopts = -v -k-slow --junitxml=test-reports/junit.xml --cache-clear +norecursedirs = .* _* +python_files = test_*.py +python_functions = test_* +mock_use_standalone_module = true \ No newline at end of file diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/resource/plugin/configure_locations.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/resource/plugin/configure_locations.py new file mode 100644 index 0000000000..0682a5eeb0 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/resource/plugin/configure_locations.py @@ -0,0 +1,39 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +import logging + +import ftrack_api +import ftrack_api.entity.location +import ftrack_api.accessor.disk + + +def configure_locations(event): + '''Configure locations for session.''' + session = event['data']['session'] + + # Find location(s) and customise instances. + # + # location = session.query('Location where name is "my.location"').one() + # ftrack_api.mixin(location, ftrack_api.entity.location.UnmanagedLocationMixin) + # location.accessor = ftrack_api.accessor.disk.DiskAccessor(prefix='') + + +def register(session): + '''Register plugin with *session*.''' + logger = logging.getLogger('ftrack_plugin:configure_locations.register') + + # Validate that session is an instance of ftrack_api.Session. If not, assume + # that register is being called from an old or incompatible API and return + # without doing anything. + if not isinstance(session, ftrack_api.Session): + logger.debug( + 'Not subscribing plugin as passed argument {0} is not an ' + 'ftrack_api.Session instance.'.format(session) + ) + return + + session.event_hub.subscribe( + 'topic=ftrack.api.session.configure-location', + configure_locations + ) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/resource/plugin/construct_entity_type.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/resource/plugin/construct_entity_type.py new file mode 100644 index 0000000000..45f7841670 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/resource/plugin/construct_entity_type.py @@ -0,0 +1,46 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +import logging + +import ftrack_api.entity.factory + + +class Factory(ftrack_api.entity.factory.StandardFactory): + '''Entity class factory.''' + + def create(self, schema, bases=None): + '''Create and return entity class from *schema*.''' + # Optionally change bases for class to be generated. + cls = super(Factory, self).create(schema, bases=bases) + + # Further customise cls before returning. + + return cls + + +def register(session): + '''Register plugin with *session*.''' + logger = logging.getLogger('ftrack_plugin:construct_entity_type.register') + + # Validate that session is an instance of ftrack_api.Session. If not, assume + # that register is being called from an old or incompatible API and return + # without doing anything. + if not isinstance(session, ftrack_api.Session): + logger.debug( + 'Not subscribing plugin as passed argument {0!r} is not an ' + 'ftrack_api.Session instance.'.format(session) + ) + return + + factory = Factory() + + def construct_entity_type(event): + '''Return class to represent entity type specified by *event*.''' + schema = event['data']['schema'] + return factory.create(schema) + + session.event_hub.subscribe( + 'topic=ftrack.api.session.construct-entity-type', + construct_entity_type + ) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/setup.cfg b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/setup.cfg new file mode 100644 index 0000000000..b2ad8fd086 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/setup.cfg @@ -0,0 +1,6 @@ +[build_sphinx] +config-dir = doc +source-dir = doc +build-dir = build/doc +builder = html +all_files = 1 diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/setup.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/setup.py new file mode 100644 index 0000000000..da99a572b4 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/setup.py @@ -0,0 +1,81 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +import os +import re + +from setuptools import setup, find_packages +from setuptools.command.test import test as TestCommand + + +ROOT_PATH = os.path.dirname(os.path.realpath(__file__)) +RESOURCE_PATH = os.path.join(ROOT_PATH, 'resource') +SOURCE_PATH = os.path.join(ROOT_PATH, 'source') +README_PATH = os.path.join(ROOT_PATH, 'README.rst') + + +# Read version from source. +with open( + os.path.join(SOURCE_PATH, 'ftrack_api', '_version.py') +) as _version_file: + VERSION = re.match( + r'.*__version__ = \'(.*?)\'', _version_file.read(), re.DOTALL + ).group(1) + + +# Custom commands. +class PyTest(TestCommand): + '''Pytest command.''' + + def finalize_options(self): + '''Finalize options to be used.''' + TestCommand.finalize_options(self) + self.test_args = [] + self.test_suite = True + + def run_tests(self): + '''Import pytest and run.''' + import pytest + raise SystemExit(pytest.main(self.test_args)) + + +# Call main setup. +setup( + name='ftrack-python-api', + version=VERSION, + description='Python API for ftrack.', + long_description=open(README_PATH).read(), + keywords='ftrack, python, api', + url='https://bitbucket.org/ftrack/ftrack-python-api', + author='ftrack', + author_email='support@ftrack.com', + license='Apache License (2.0)', + packages=find_packages(SOURCE_PATH), + package_dir={ + '': 'source' + }, + setup_requires=[ + 'sphinx >= 1.2.2, < 2', + 'sphinx_rtd_theme >= 0.1.6, < 1', + 'lowdown >= 0.1.0, < 2' + ], + install_requires=[ + 'requests >= 2, <3', + 'arrow >= 0.4.4, < 1', + 'termcolor >= 1.1.0, < 2', + 'pyparsing >= 2.0, < 3', + 'clique >= 1.2.0, < 2', + 'websocket-client >= 0.40.0, < 1' + ], + tests_require=[ + 'pytest >= 2.7, < 3', + 'pytest-mock >= 0.4, < 1', + 'pytest-catchlog >= 1, <=2' + ], + cmdclass={ + 'test': PyTest + }, + zip_safe=False, + python_requires=">=2.7.9, <3.0" + +) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/__init__.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/__init__.py new file mode 100644 index 0000000000..34833aa0dd --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/__init__.py @@ -0,0 +1 @@ +from ftrack_api import * diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/__init__.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/__init__.py new file mode 100644 index 0000000000..d8ee30bd8f --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/__init__.py @@ -0,0 +1,32 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +from ._version import __version__ +from .session import Session + + +def mixin(instance, mixin_class, name=None): + '''Mixin *mixin_class* to *instance*. + + *name* can be used to specify new class name. If not specified then one will + be generated. + + ''' + if name is None: + name = '{0}{1}'.format( + instance.__class__.__name__, mixin_class.__name__ + ) + + # Check mixin class not already present in mro in order to avoid consistent + # method resolution failure. + if mixin_class in instance.__class__.mro(): + return + + instance.__class__ = type( + name, + ( + mixin_class, + instance.__class__ + ), + {} + ) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/_centralized_storage_scenario.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/_centralized_storage_scenario.py new file mode 100644 index 0000000000..fbe14f3277 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/_centralized_storage_scenario.py @@ -0,0 +1,656 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2016 ftrack + +from __future__ import absolute_import + +import logging +import json +import sys +import os + +import ftrack_api +import ftrack_api.structure.standard as _standard +from ftrack_api.logging import LazyLogMessage as L + + +scenario_name = 'ftrack.centralized-storage' + + +class ConfigureCentralizedStorageScenario(object): + '''Configure a centralized storage scenario.''' + + def __init__(self): + '''Instansiate centralized storage scenario.''' + self.logger = logging.getLogger( + __name__ + '.' + self.__class__.__name__ + ) + + @property + def storage_scenario(self): + '''Return storage scenario setting.''' + return self.session.query( + 'select value from Setting ' + 'where name is "storage_scenario" and group is "STORAGE"' + ).one() + + @property + def existing_centralized_storage_configuration(self): + '''Return existing centralized storage configuration.''' + storage_scenario = self.storage_scenario + + try: + configuration = json.loads(storage_scenario['value']) + except (ValueError, TypeError): + return None + + if not isinstance(configuration, dict): + return None + + if configuration.get('scenario') != scenario_name: + return None + + return configuration.get('data', {}) + + def _get_confirmation_text(self, configuration): + '''Return confirmation text from *configuration*.''' + configure_location = configuration.get('configure_location') + select_location = configuration.get('select_location') + select_mount_point = configuration.get('select_mount_point') + + if configure_location: + location_text = unicode( + 'A new location will be created:\n\n' + '* Label: {location_label}\n' + '* Name: {location_name}\n' + '* Description: {location_description}\n' + ).format(**configure_location) + else: + location = self.session.get( + 'Location', select_location['location_id'] + ) + location_text = ( + u'You have choosen to use an existing location: {0}'.format( + location['label'] + ) + ) + + mount_points_text = unicode( + '* Linux: {linux}\n' + '* OS X: {osx}\n' + '* Windows: {windows}\n\n' + ).format( + linux=select_mount_point.get('linux_mount_point') or '*Not set*', + osx=select_mount_point.get('osx_mount_point') or '*Not set*', + windows=select_mount_point.get('windows_mount_point') or '*Not set*' + ) + + mount_points_not_set = [] + + if not select_mount_point.get('linux_mount_point'): + mount_points_not_set.append('Linux') + + if not select_mount_point.get('osx_mount_point'): + mount_points_not_set.append('OS X') + + if not select_mount_point.get('windows_mount_point'): + mount_points_not_set.append('Windows') + + if mount_points_not_set: + mount_points_text += unicode( + 'Please be aware that this location will not be working on ' + '{missing} because the mount points are not set up.' + ).format( + missing=' and '.join(mount_points_not_set) + ) + + text = unicode( + '#Confirm storage setup#\n\n' + 'Almost there! Please take a moment to verify the settings you ' + 'are about to save. You can always come back later and update the ' + 'configuration.\n' + '##Location##\n\n' + '{location}\n' + '##Mount points##\n\n' + '{mount_points}' + ).format( + location=location_text, + mount_points=mount_points_text + ) + + return text + + def configure_scenario(self, event): + '''Configure scenario based on *event* and return form items.''' + steps = ( + 'select_scenario', + 'select_location', + 'configure_location', + 'select_structure', + 'select_mount_point', + 'confirm_summary', + 'save_configuration' + ) + + warning_message = '' + values = event['data'].get('values', {}) + + # Calculate previous step and the next. + previous_step = values.get('step', 'select_scenario') + next_step = steps[steps.index(previous_step) + 1] + state = 'configuring' + + self.logger.info(L( + u'Configuring scenario, previous step: {0}, next step: {1}. ' + u'Values {2!r}.', + previous_step, next_step, values + )) + + if 'configuration' in values: + configuration = values.pop('configuration') + else: + configuration = {} + + if values: + # Update configuration with values from the previous step. + configuration[previous_step] = values + + if previous_step == 'select_location': + values = configuration['select_location'] + if values.get('location_id') != 'create_new_location': + location_exists = self.session.query( + 'Location where id is "{0}"'.format( + values.get('location_id') + ) + ).first() + if not location_exists: + next_step = 'select_location' + warning_message = ( + '**The selected location does not exist. Please choose ' + 'one from the dropdown or create a new one.**' + ) + + if next_step == 'select_location': + try: + location_id = ( + self.existing_centralized_storage_configuration['location_id'] + ) + except (KeyError, TypeError): + location_id = None + + options = [{ + 'label': 'Create new location', + 'value': 'create_new_location' + }] + for location in self.session.query( + 'select name, label, description from Location' + ): + if location['name'] not in ( + 'ftrack.origin', 'ftrack.unmanaged', 'ftrack.connect', + 'ftrack.server', 'ftrack.review' + ): + options.append({ + 'label': u'{label} ({name})'.format( + label=location['label'], name=location['name'] + ), + 'description': location['description'], + 'value': location['id'] + }) + + warning = '' + if location_id is not None: + # If there is already a location configured we must make the + # user aware that changing the location may be problematic. + warning = ( + '\n\n**Be careful if you switch to another location ' + 'for an existing storage scenario. Components that have ' + 'already been published to the previous location will be ' + 'made unavailable for common use.**' + ) + default_value = location_id + elif location_id is None and len(options) == 1: + # No location configured and no existing locations to use. + default_value = 'create_new_location' + else: + # There are existing locations to choose from but non of them + # are currently active in the centralized storage scenario. + default_value = None + + items = [{ + 'type': 'label', + 'value': ( + '#Select location#\n' + 'Choose an already existing location or create a new one ' + 'to represent your centralized storage. {0}'.format( + warning + ) + ) + }, { + 'type': 'enumerator', + 'label': 'Location', + 'name': 'location_id', + 'value': default_value, + 'data': options + }] + + default_location_name = 'studio.central-storage-location' + default_location_label = 'Studio location' + default_location_description = ( + 'The studio central location where all components are ' + 'stored.' + ) + + if previous_step == 'configure_location': + configure_location = configuration.get( + 'configure_location' + ) + + if configure_location: + try: + existing_location = self.session.query( + u'Location where name is "{0}"'.format( + configure_location.get('location_name') + ) + ).first() + except UnicodeEncodeError: + next_step = 'configure_location' + warning_message += ( + '**The location name contains non-ascii characters. ' + 'Please change the name and try again.**' + ) + values = configuration['select_location'] + else: + if existing_location: + next_step = 'configure_location' + warning_message += ( + u'**There is already a location named {0}. ' + u'Please change the name and try again.**'.format( + configure_location.get('location_name') + ) + ) + values = configuration['select_location'] + + if ( + not configure_location.get('location_name') or + not configure_location.get('location_label') or + not configure_location.get('location_description') + ): + next_step = 'configure_location' + warning_message += ( + '**Location name, label and description cannot ' + 'be empty.**' + ) + values = configuration['select_location'] + + if next_step == 'configure_location': + # Populate form with previous configuration. + default_location_label = configure_location['location_label'] + default_location_name = configure_location['location_name'] + default_location_description = ( + configure_location['location_description'] + ) + + if next_step == 'configure_location': + + if values.get('location_id') == 'create_new_location': + # Add options to create a new location. + items = [{ + 'type': 'label', + 'value': ( + '#Create location#\n' + 'Here you will create a new location to be used ' + 'with your new Storage scenario. For your ' + 'convenience we have already filled in some default ' + 'values. If this is the first time you are configuring ' + 'a storage scenario in ftrack we recommend that you ' + 'stick with these settings.' + ) + }, { + 'label': 'Label', + 'name': 'location_label', + 'value': default_location_label, + 'type': 'text' + }, { + 'label': 'Name', + 'name': 'location_name', + 'value': default_location_name, + 'type': 'text' + }, { + 'label': 'Description', + 'name': 'location_description', + 'value': default_location_description, + 'type': 'text' + }] + + else: + # The user selected an existing location. Move on to next + # step. + next_step = 'select_mount_point' + + if next_step == 'select_structure': + # There is only one structure to choose from, go to next step. + next_step = 'select_mount_point' + # items = [ + # { + # 'type': 'label', + # 'value': ( + # '#Select structure#\n' + # 'Select which structure to use with your location. ' + # 'The structure is used to generate the filesystem ' + # 'path for components that are added to this location.' + # ) + # }, + # { + # 'type': 'enumerator', + # 'label': 'Structure', + # 'name': 'structure_id', + # 'value': 'standard', + # 'data': [{ + # 'label': 'Standard', + # 'value': 'standard', + # 'description': ( + # 'The Standard structure uses the names in your ' + # 'project structure to determine the path.' + # ) + # }] + # } + # ] + + if next_step == 'select_mount_point': + try: + mount_points = ( + self.existing_centralized_storage_configuration['accessor']['mount_points'] + ) + except (KeyError, TypeError): + mount_points = dict() + + items = [ + { + 'value': ( + '#Mount points#\n' + 'Set mount points for your centralized storage ' + 'location. For the location to work as expected each ' + 'platform that you intend to use must have the ' + 'corresponding mount point set and the storage must ' + 'be accessible. If not set correctly files will not be ' + 'saved or read.' + ), + 'type': 'label' + }, { + 'type': 'text', + 'label': 'Linux', + 'name': 'linux_mount_point', + 'empty_text': 'E.g. /usr/mnt/MyStorage ...', + 'value': mount_points.get('linux', '') + }, { + 'type': 'text', + 'label': 'OS X', + 'name': 'osx_mount_point', + 'empty_text': 'E.g. /Volumes/MyStorage ...', + 'value': mount_points.get('osx', '') + }, { + 'type': 'text', + 'label': 'Windows', + 'name': 'windows_mount_point', + 'empty_text': 'E.g. \\\\MyStorage ...', + 'value': mount_points.get('windows', '') + } + ] + + if next_step == 'confirm_summary': + items = [{ + 'type': 'label', + 'value': self._get_confirmation_text(configuration) + }] + state = 'confirm' + + if next_step == 'save_configuration': + mount_points = configuration['select_mount_point'] + select_location = configuration['select_location'] + + if select_location['location_id'] == 'create_new_location': + configure_location = configuration['configure_location'] + location = self.session.create( + 'Location', + { + 'name': configure_location['location_name'], + 'label': configure_location['location_label'], + 'description': ( + configure_location['location_description'] + ) + } + ) + + else: + location = self.session.query( + 'Location where id is "{0}"'.format( + select_location['location_id'] + ) + ).one() + + setting_value = json.dumps({ + 'scenario': scenario_name, + 'data': { + 'location_id': location['id'], + 'location_name': location['name'], + 'accessor': { + 'mount_points': { + 'linux': mount_points['linux_mount_point'], + 'osx': mount_points['osx_mount_point'], + 'windows': mount_points['windows_mount_point'] + } + } + } + }) + + self.storage_scenario['value'] = setting_value + self.session.commit() + + # Broadcast an event that storage scenario has been configured. + event = ftrack_api.event.base.Event( + topic='ftrack.storage-scenario.configure-done' + ) + self.session.event_hub.publish(event) + + items = [{ + 'type': 'label', + 'value': ( + '#Done!#\n' + 'Your storage scenario is now configured and ready ' + 'to use. **Note that you may have to restart Connect and ' + 'other applications to start using it.**' + ) + }] + state = 'done' + + if warning_message: + items.insert(0, { + 'type': 'label', + 'value': warning_message + }) + + items.append({ + 'type': 'hidden', + 'value': configuration, + 'name': 'configuration' + }) + items.append({ + 'type': 'hidden', + 'value': next_step, + 'name': 'step' + }) + + return { + 'items': items, + 'state': state + } + + def discover_centralized_scenario(self, event): + '''Return action discover dictionary for *event*.''' + return { + 'id': scenario_name, + 'name': 'Centralized storage scenario', + 'description': ( + '(Recommended) centralized storage scenario where all files ' + 'are kept on a storage that is mounted and available to ' + 'everyone in the studio.' + ) + } + + def register(self, session): + '''Subscribe to events on *session*.''' + self.session = session + + #: TODO: Move these to a separate function. + session.event_hub.subscribe( + unicode( + 'topic=ftrack.storage-scenario.discover ' + 'and source.user.username="{0}"' + ).format( + session.api_user + ), + self.discover_centralized_scenario + ) + session.event_hub.subscribe( + unicode( + 'topic=ftrack.storage-scenario.configure ' + 'and data.scenario_id="{0}" ' + 'and source.user.username="{1}"' + ).format( + scenario_name, + session.api_user + ), + self.configure_scenario + ) + + +class ActivateCentralizedStorageScenario(object): + '''Activate a centralized storage scenario.''' + + def __init__(self): + '''Instansiate centralized storage scenario.''' + self.logger = logging.getLogger( + __name__ + '.' + self.__class__.__name__ + ) + + def activate(self, event): + '''Activate scenario in *event*.''' + storage_scenario = event['data']['storage_scenario'] + + try: + location_data = storage_scenario['data'] + location_name = location_data['location_name'] + location_id = location_data['location_id'] + mount_points = location_data['accessor']['mount_points'] + + except KeyError: + error_message = ( + 'Unable to read storage scenario data.' + ) + self.logger.error(L(error_message)) + raise ftrack_api.exception.LocationError( + 'Unable to configure location based on scenario.' + ) + + else: + location = self.session.create( + 'Location', + data=dict( + name=location_name, + id=location_id + ), + reconstructing=True + ) + + if sys.platform == 'darwin': + prefix = mount_points['osx'] + elif sys.platform == 'linux2': + prefix = mount_points['linux'] + elif sys.platform == 'win32': + prefix = mount_points['windows'] + else: + raise ftrack_api.exception.LocationError( + ( + 'Unable to find accessor prefix for platform {0}.' + ).format(sys.platform) + ) + + location.accessor = ftrack_api.accessor.disk.DiskAccessor( + prefix=prefix + ) + location.structure = _standard.StandardStructure() + location.priority = 1 + self.logger.info(L( + u'Storage scenario activated. Configured {0!r} from ' + u'{1!r}', + location, storage_scenario + )) + + def _verify_startup(self, event): + '''Verify the storage scenario configuration.''' + storage_scenario = event['data']['storage_scenario'] + location_data = storage_scenario['data'] + mount_points = location_data['accessor']['mount_points'] + + prefix = None + if sys.platform == 'darwin': + prefix = mount_points['osx'] + elif sys.platform == 'linux2': + prefix = mount_points['linux'] + elif sys.platform == 'win32': + prefix = mount_points['windows'] + + if not prefix: + return ( + u'The storage scenario has not been configured for your ' + u'operating system. ftrack may not be able to ' + u'store and track files correctly.' + ) + + if not os.path.isdir(prefix): + return ( + unicode( + 'The path {0} does not exist. ftrack may not be able to ' + 'store and track files correctly. \n\nIf the storage is ' + 'newly setup you may want to create necessary folder ' + 'structures. If the storage is a network drive you should ' + 'make sure that it is mounted correctly.' + ).format(prefix) + ) + + def register(self, session): + '''Subscribe to events on *session*.''' + self.session = session + + session.event_hub.subscribe( + ( + 'topic=ftrack.storage-scenario.activate ' + 'and data.storage_scenario.scenario="{0}"'.format( + scenario_name + ) + ), + self.activate + ) + + # Listen to verify startup event from ftrack connect to allow responding + # with a message if something is not working correctly with this + # scenario that the user should be notified about. + self.session.event_hub.subscribe( + ( + 'topic=ftrack.connect.verify-startup ' + 'and data.storage_scenario.scenario="{0}"'.format( + scenario_name + ) + ), + self._verify_startup + ) + +def register(session): + '''Register storage scenario.''' + scenario = ActivateCentralizedStorageScenario() + scenario.register(session) + + +def register_configuration(session): + '''Register storage scenario.''' + scenario = ConfigureCentralizedStorageScenario() + scenario.register(session) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/_python_ntpath.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/_python_ntpath.py new file mode 100644 index 0000000000..9f79a1850c --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/_python_ntpath.py @@ -0,0 +1,534 @@ +# pragma: no cover +# Module 'ntpath' -- common operations on WinNT/Win95 pathnames +"""Common pathname manipulations, WindowsNT/95 version. + +Instead of importing this module directly, import os and refer to this +module as os.path. +""" + +import os +import sys +import stat +import genericpath +import warnings + +from genericpath import * + +__all__ = ["normcase","isabs","join","splitdrive","split","splitext", + "basename","dirname","commonprefix","getsize","getmtime", + "getatime","getctime", "islink","exists","lexists","isdir","isfile", + "ismount","walk","expanduser","expandvars","normpath","abspath", + "splitunc","curdir","pardir","sep","pathsep","defpath","altsep", + "extsep","devnull","realpath","supports_unicode_filenames","relpath"] + +# strings representing various path-related bits and pieces +curdir = '.' +pardir = '..' +extsep = '.' +sep = '\\' +pathsep = ';' +altsep = '/' +defpath = '.;C:\\bin' +if 'ce' in sys.builtin_module_names: + defpath = '\\Windows' +elif 'os2' in sys.builtin_module_names: + # OS/2 w/ VACPP + altsep = '/' +devnull = 'nul' + +# Normalize the case of a pathname and map slashes to backslashes. +# Other normalizations (such as optimizing '../' away) are not done +# (this is done by normpath). + +def normcase(s): + """Normalize case of pathname. + + Makes all characters lowercase and all slashes into backslashes.""" + return s.replace("/", "\\").lower() + + +# Return whether a path is absolute. +# Trivial in Posix, harder on the Mac or MS-DOS. +# For DOS it is absolute if it starts with a slash or backslash (current +# volume), or if a pathname after the volume letter and colon / UNC resource +# starts with a slash or backslash. + +def isabs(s): + """Test whether a path is absolute""" + s = splitdrive(s)[1] + return s != '' and s[:1] in '/\\' + + +# Join two (or more) paths. + +def join(a, *p): + """Join two or more pathname components, inserting "\\" as needed. + If any component is an absolute path, all previous path components + will be discarded.""" + path = a + for b in p: + b_wins = 0 # set to 1 iff b makes path irrelevant + if path == "": + b_wins = 1 + + elif isabs(b): + # This probably wipes out path so far. However, it's more + # complicated if path begins with a drive letter: + # 1. join('c:', '/a') == 'c:/a' + # 2. join('c:/', '/a') == 'c:/a' + # But + # 3. join('c:/a', '/b') == '/b' + # 4. join('c:', 'd:/') = 'd:/' + # 5. join('c:/', 'd:/') = 'd:/' + if path[1:2] != ":" or b[1:2] == ":": + # Path doesn't start with a drive letter, or cases 4 and 5. + b_wins = 1 + + # Else path has a drive letter, and b doesn't but is absolute. + elif len(path) > 3 or (len(path) == 3 and + path[-1] not in "/\\"): + # case 3 + b_wins = 1 + + if b_wins: + path = b + else: + # Join, and ensure there's a separator. + assert len(path) > 0 + if path[-1] in "/\\": + if b and b[0] in "/\\": + path += b[1:] + else: + path += b + elif path[-1] == ":": + path += b + elif b: + if b[0] in "/\\": + path += b + else: + path += "\\" + b + else: + # path is not empty and does not end with a backslash, + # but b is empty; since, e.g., split('a/') produces + # ('a', ''), it's best if join() adds a backslash in + # this case. + path += '\\' + + return path + + +# Split a path in a drive specification (a drive letter followed by a +# colon) and the path specification. +# It is always true that drivespec + pathspec == p +def splitdrive(p): + """Split a pathname into drive and path specifiers. Returns a 2-tuple +"(drive,path)"; either part may be empty""" + if p[1:2] == ':': + return p[0:2], p[2:] + return '', p + + +# Parse UNC paths +def splitunc(p): + """Split a pathname into UNC mount point and relative path specifiers. + + Return a 2-tuple (unc, rest); either part may be empty. + If unc is not empty, it has the form '//host/mount' (or similar + using backslashes). unc+rest is always the input path. + Paths containing drive letters never have an UNC part. + """ + if p[1:2] == ':': + return '', p # Drive letter present + firstTwo = p[0:2] + if firstTwo == '//' or firstTwo == '\\\\': + # is a UNC path: + # vvvvvvvvvvvvvvvvvvvv equivalent to drive letter + # \\machine\mountpoint\directories... + # directory ^^^^^^^^^^^^^^^ + normp = normcase(p) + index = normp.find('\\', 2) + if index == -1: + ##raise RuntimeError, 'illegal UNC path: "' + p + '"' + return ("", p) + index = normp.find('\\', index + 1) + if index == -1: + index = len(p) + return p[:index], p[index:] + return '', p + + +# Split a path in head (everything up to the last '/') and tail (the +# rest). After the trailing '/' is stripped, the invariant +# join(head, tail) == p holds. +# The resulting head won't end in '/' unless it is the root. + +def split(p): + """Split a pathname. + + Return tuple (head, tail) where tail is everything after the final slash. + Either part may be empty.""" + + d, p = splitdrive(p) + # set i to index beyond p's last slash + i = len(p) + while i and p[i-1] not in '/\\': + i = i - 1 + head, tail = p[:i], p[i:] # now tail has no slashes + # remove trailing slashes from head, unless it's all slashes + head2 = head + while head2 and head2[-1] in '/\\': + head2 = head2[:-1] + head = head2 or head + return d + head, tail + + +# Split a path in root and extension. +# The extension is everything starting at the last dot in the last +# pathname component; the root is everything before that. +# It is always true that root + ext == p. + +def splitext(p): + return genericpath._splitext(p, sep, altsep, extsep) +splitext.__doc__ = genericpath._splitext.__doc__ + + +# Return the tail (basename) part of a path. + +def basename(p): + """Returns the final component of a pathname""" + return split(p)[1] + + +# Return the head (dirname) part of a path. + +def dirname(p): + """Returns the directory component of a pathname""" + return split(p)[0] + +# Is a path a symbolic link? +# This will always return false on systems where posix.lstat doesn't exist. + +def islink(path): + """Test for symbolic link. + On WindowsNT/95 and OS/2 always returns false + """ + return False + +# alias exists to lexists +lexists = exists + +# Is a path a mount point? Either a root (with or without drive letter) +# or an UNC path with at most a / or \ after the mount point. + +def ismount(path): + """Test whether a path is a mount point (defined as root of drive)""" + unc, rest = splitunc(path) + if unc: + return rest in ("", "/", "\\") + p = splitdrive(path)[1] + return len(p) == 1 and p[0] in '/\\' + + +# Directory tree walk. +# For each directory under top (including top itself, but excluding +# '.' and '..'), func(arg, dirname, filenames) is called, where +# dirname is the name of the directory and filenames is the list +# of files (and subdirectories etc.) in the directory. +# The func may modify the filenames list, to implement a filter, +# or to impose a different order of visiting. + +def walk(top, func, arg): + """Directory tree walk with callback function. + + For each directory in the directory tree rooted at top (including top + itself, but excluding '.' and '..'), call func(arg, dirname, fnames). + dirname is the name of the directory, and fnames a list of the names of + the files and subdirectories in dirname (excluding '.' and '..'). func + may modify the fnames list in-place (e.g. via del or slice assignment), + and walk will only recurse into the subdirectories whose names remain in + fnames; this can be used to implement a filter, or to impose a specific + order of visiting. No semantics are defined for, or required of, arg, + beyond that arg is always passed to func. It can be used, e.g., to pass + a filename pattern, or a mutable object designed to accumulate + statistics. Passing None for arg is common.""" + warnings.warnpy3k("In 3.x, os.path.walk is removed in favor of os.walk.", + stacklevel=2) + try: + names = os.listdir(top) + except os.error: + return + func(arg, top, names) + for name in names: + name = join(top, name) + if isdir(name): + walk(name, func, arg) + + +# Expand paths beginning with '~' or '~user'. +# '~' means $HOME; '~user' means that user's home directory. +# If the path doesn't begin with '~', or if the user or $HOME is unknown, +# the path is returned unchanged (leaving error reporting to whatever +# function is called with the expanded path as argument). +# See also module 'glob' for expansion of *, ? and [...] in pathnames. +# (A function should also be defined to do full *sh-style environment +# variable expansion.) + +def expanduser(path): + """Expand ~ and ~user constructs. + + If user or $HOME is unknown, do nothing.""" + if path[:1] != '~': + return path + i, n = 1, len(path) + while i < n and path[i] not in '/\\': + i = i + 1 + + if 'HOME' in os.environ: + userhome = os.environ['HOME'] + elif 'USERPROFILE' in os.environ: + userhome = os.environ['USERPROFILE'] + elif not 'HOMEPATH' in os.environ: + return path + else: + try: + drive = os.environ['HOMEDRIVE'] + except KeyError: + drive = '' + userhome = join(drive, os.environ['HOMEPATH']) + + if i != 1: #~user + userhome = join(dirname(userhome), path[1:i]) + + return userhome + path[i:] + + +# Expand paths containing shell variable substitutions. +# The following rules apply: +# - no expansion within single quotes +# - '$$' is translated into '$' +# - '%%' is translated into '%' if '%%' are not seen in %var1%%var2% +# - ${varname} is accepted. +# - $varname is accepted. +# - %varname% is accepted. +# - varnames can be made out of letters, digits and the characters '_-' +# (though is not verified in the ${varname} and %varname% cases) +# XXX With COMMAND.COM you can use any characters in a variable name, +# XXX except '^|<>='. + +def expandvars(path): + """Expand shell variables of the forms $var, ${var} and %var%. + + Unknown variables are left unchanged.""" + if '$' not in path and '%' not in path: + return path + import string + varchars = string.ascii_letters + string.digits + '_-' + res = '' + index = 0 + pathlen = len(path) + while index < pathlen: + c = path[index] + if c == '\'': # no expansion within single quotes + path = path[index + 1:] + pathlen = len(path) + try: + index = path.index('\'') + res = res + '\'' + path[:index + 1] + except ValueError: + res = res + path + index = pathlen - 1 + elif c == '%': # variable or '%' + if path[index + 1:index + 2] == '%': + res = res + c + index = index + 1 + else: + path = path[index+1:] + pathlen = len(path) + try: + index = path.index('%') + except ValueError: + res = res + '%' + path + index = pathlen - 1 + else: + var = path[:index] + if var in os.environ: + res = res + os.environ[var] + else: + res = res + '%' + var + '%' + elif c == '$': # variable or '$$' + if path[index + 1:index + 2] == '$': + res = res + c + index = index + 1 + elif path[index + 1:index + 2] == '{': + path = path[index+2:] + pathlen = len(path) + try: + index = path.index('}') + var = path[:index] + if var in os.environ: + res = res + os.environ[var] + else: + res = res + '${' + var + '}' + except ValueError: + res = res + '${' + path + index = pathlen - 1 + else: + var = '' + index = index + 1 + c = path[index:index + 1] + while c != '' and c in varchars: + var = var + c + index = index + 1 + c = path[index:index + 1] + if var in os.environ: + res = res + os.environ[var] + else: + res = res + '$' + var + if c != '': + index = index - 1 + else: + res = res + c + index = index + 1 + return res + + +# Normalize a path, e.g. A//B, A/./B and A/foo/../B all become A\B. +# Previously, this function also truncated pathnames to 8+3 format, +# but as this module is called "ntpath", that's obviously wrong! + +def normpath(path): + """Normalize path, eliminating double slashes, etc.""" + # Preserve unicode (if path is unicode) + backslash, dot = (u'\\', u'.') if isinstance(path, unicode) else ('\\', '.') + if path.startswith(('\\\\.\\', '\\\\?\\')): + # in the case of paths with these prefixes: + # \\.\ -> device names + # \\?\ -> literal paths + # do not do any normalization, but return the path unchanged + return path + path = path.replace("/", "\\") + prefix, path = splitdrive(path) + # We need to be careful here. If the prefix is empty, and the path starts + # with a backslash, it could either be an absolute path on the current + # drive (\dir1\dir2\file) or a UNC filename (\\server\mount\dir1\file). It + # is therefore imperative NOT to collapse multiple backslashes blindly in + # that case. + # The code below preserves multiple backslashes when there is no drive + # letter. This means that the invalid filename \\\a\b is preserved + # unchanged, where a\\\b is normalised to a\b. It's not clear that there + # is any better behaviour for such edge cases. + if prefix == '': + # No drive letter - preserve initial backslashes + while path[:1] == "\\": + prefix = prefix + backslash + path = path[1:] + else: + # We have a drive letter - collapse initial backslashes + if path.startswith("\\"): + prefix = prefix + backslash + path = path.lstrip("\\") + comps = path.split("\\") + i = 0 + while i < len(comps): + if comps[i] in ('.', ''): + del comps[i] + elif comps[i] == '..': + if i > 0 and comps[i-1] != '..': + del comps[i-1:i+1] + i -= 1 + elif i == 0 and prefix.endswith("\\"): + del comps[i] + else: + i += 1 + else: + i += 1 + # If the path is now empty, substitute '.' + if not prefix and not comps: + comps.append(dot) + return prefix + backslash.join(comps) + + +# Return an absolute path. +try: + from nt import _getfullpathname + +except ImportError: # not running on Windows - mock up something sensible + def abspath(path): + """Return the absolute version of a path.""" + if not isabs(path): + if isinstance(path, unicode): + cwd = os.getcwdu() + else: + cwd = os.getcwd() + path = join(cwd, path) + return normpath(path) + +else: # use native Windows method on Windows + def abspath(path): + """Return the absolute version of a path.""" + + if path: # Empty path must return current working directory. + try: + path = _getfullpathname(path) + except WindowsError: + pass # Bad path - return unchanged. + elif isinstance(path, unicode): + path = os.getcwdu() + else: + path = os.getcwd() + return normpath(path) + +# realpath is a no-op on systems without islink support +realpath = abspath +# Win9x family and earlier have no Unicode filename support. +supports_unicode_filenames = (hasattr(sys, "getwindowsversion") and + sys.getwindowsversion()[3] >= 2) + +def _abspath_split(path): + abs = abspath(normpath(path)) + prefix, rest = splitunc(abs) + is_unc = bool(prefix) + if not is_unc: + prefix, rest = splitdrive(abs) + return is_unc, prefix, [x for x in rest.split(sep) if x] + +def relpath(path, start=curdir): + """Return a relative version of a path""" + + if not path: + raise ValueError("no path specified") + + start_is_unc, start_prefix, start_list = _abspath_split(start) + path_is_unc, path_prefix, path_list = _abspath_split(path) + + if path_is_unc ^ start_is_unc: + raise ValueError("Cannot mix UNC and non-UNC paths (%s and %s)" + % (path, start)) + if path_prefix.lower() != start_prefix.lower(): + if path_is_unc: + raise ValueError("path is on UNC root %s, start on UNC root %s" + % (path_prefix, start_prefix)) + else: + raise ValueError("path is on drive %s, start on drive %s" + % (path_prefix, start_prefix)) + # Work out how much of the filepath is shared by start and path. + i = 0 + for e1, e2 in zip(start_list, path_list): + if e1.lower() != e2.lower(): + break + i += 1 + + rel_list = [pardir] * (len(start_list)-i) + path_list[i:] + if not rel_list: + return curdir + return join(*rel_list) + +try: + # The genericpath.isdir implementation uses os.stat and checks the mode + # attribute to tell whether or not the path is a directory. + # This is overkill on Windows - just pass the path to GetFileAttributes + # and check the attribute from there. + from nt import _isdir as isdir +except ImportError: + # Use genericpath.isdir as imported above. + pass diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/_version.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/_version.py new file mode 100644 index 0000000000..aa1a8c4aba --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/_version.py @@ -0,0 +1 @@ +__version__ = '1.8.2' diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/_weakref.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/_weakref.py new file mode 100644 index 0000000000..69cc6f4b4f --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/_weakref.py @@ -0,0 +1,66 @@ +""" +Yet another backport of WeakMethod for Python 2.7. +Changes include removing exception chaining and adding args to super() calls. + +Copyright (c) 2001-2019 Python Software Foundation.All rights reserved. + +Full license available in LICENSE.python. +""" +from weakref import ref + + +class WeakMethod(ref): + """ + A custom `weakref.ref` subclass which simulates a weak reference to + a bound method, working around the lifetime problem of bound methods. + """ + + __slots__ = "_func_ref", "_meth_type", "_alive", "__weakref__" + + def __new__(cls, meth, callback=None): + try: + obj = meth.__self__ + func = meth.__func__ + except AttributeError: + raise TypeError( + "argument should be a bound method, not {}".format(type(meth)) + ) + + def _cb(arg): + # The self-weakref trick is needed to avoid creating a reference + # cycle. + self = self_wr() + if self._alive: + self._alive = False + if callback is not None: + callback(self) + + self = ref.__new__(cls, obj, _cb) + self._func_ref = ref(func, _cb) + self._meth_type = type(meth) + self._alive = True + self_wr = ref(self) + return self + + def __call__(self): + obj = super(WeakMethod, self).__call__() + func = self._func_ref() + if obj is None or func is None: + return None + return self._meth_type(func, obj) + + def __eq__(self, other): + if isinstance(other, WeakMethod): + if not self._alive or not other._alive: + return self is other + return ref.__eq__(self, other) and self._func_ref == other._func_ref + return NotImplemented + + def __ne__(self, other): + if isinstance(other, WeakMethod): + if not self._alive or not other._alive: + return self is not other + return ref.__ne__(self, other) or self._func_ref != other._func_ref + return NotImplemented + + __hash__ = ref.__hash__ diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/accessor/__init__.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/accessor/__init__.py new file mode 100644 index 0000000000..1aab07ed77 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/accessor/__init__.py @@ -0,0 +1,2 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/accessor/base.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/accessor/base.py new file mode 100644 index 0000000000..6aa9cf0281 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/accessor/base.py @@ -0,0 +1,124 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2013 ftrack + +import abc + +import ftrack_api.exception + + +class Accessor(object): + '''Provide data access to a location. + + A location represents a specific storage, but access to that storage may + vary. For example, both local filesystem and FTP access may be possible for + the same storage. An accessor implements these different ways of accessing + the same data location. + + As different accessors may access the same location, only part of a data + path that is commonly understood may be stored in the database. The format + of this path should be a contract between the accessors that require access + to the same location and is left as an implementation detail. As such, this + system provides no guarantee that two different accessors can provide access + to the same location, though this is a clear goal. The path stored centrally + is referred to as the **resource identifier** and should be used when + calling any of the accessor methods that accept a *resource_identifier* + argument. + + ''' + + __metaclass__ = abc.ABCMeta + + def __init__(self): + '''Initialise location accessor.''' + super(Accessor, self).__init__() + + @abc.abstractmethod + def list(self, resource_identifier): + '''Return list of entries in *resource_identifier* container. + + Each entry in the returned list should be a valid resource identifier. + + Raise :exc:`~ftrack_api.exception.AccessorResourceNotFoundError` if + *resource_identifier* does not exist or + :exc:`~ftrack_api.exception.AccessorResourceInvalidError` if + *resource_identifier* is not a container. + + ''' + + @abc.abstractmethod + def exists(self, resource_identifier): + '''Return if *resource_identifier* is valid and exists in location.''' + + @abc.abstractmethod + def is_file(self, resource_identifier): + '''Return whether *resource_identifier* refers to a file.''' + + @abc.abstractmethod + def is_container(self, resource_identifier): + '''Return whether *resource_identifier* refers to a container.''' + + @abc.abstractmethod + def is_sequence(self, resource_identifier): + '''Return whether *resource_identifier* refers to a file sequence.''' + + @abc.abstractmethod + def open(self, resource_identifier, mode='rb'): + '''Return :class:`~ftrack_api.data.Data` for *resource_identifier*.''' + + @abc.abstractmethod + def remove(self, resource_identifier): + '''Remove *resource_identifier*. + + Raise :exc:`~ftrack_api.exception.AccessorResourceNotFoundError` if + *resource_identifier* does not exist. + + ''' + + @abc.abstractmethod + def make_container(self, resource_identifier, recursive=True): + '''Make a container at *resource_identifier*. + + If *recursive* is True, also make any intermediate containers. + + Should silently ignore existing containers and not recreate them. + + ''' + + @abc.abstractmethod + def get_container(self, resource_identifier): + '''Return resource_identifier of container for *resource_identifier*. + + Raise :exc:`~ftrack_api.exception.AccessorParentResourceNotFoundError` + if container of *resource_identifier* could not be determined. + + ''' + + def remove_container(self, resource_identifier): # pragma: no cover + '''Remove container at *resource_identifier*.''' + return self.remove(resource_identifier) + + def get_filesystem_path(self, resource_identifier): # pragma: no cover + '''Return filesystem path for *resource_identifier*. + + Raise :exc:`~ftrack_api.exception.AccessorFilesystemPathError` if + filesystem path could not be determined from *resource_identifier* or + :exc:`~ftrack_api.exception.AccessorUnsupportedOperationError` if + retrieving filesystem paths is not supported by this accessor. + + ''' + raise ftrack_api.exception.AccessorUnsupportedOperationError( + 'get_filesystem_path', resource_identifier=resource_identifier + ) + + def get_url(self, resource_identifier): + '''Return URL for *resource_identifier*. + + Raise :exc:`~ftrack_api.exception.AccessorFilesystemPathError` if + URL could not be determined from *resource_identifier* or + :exc:`~ftrack_api.exception.AccessorUnsupportedOperationError` if + retrieving URL is not supported by this accessor. + + ''' + raise ftrack_api.exception.AccessorUnsupportedOperationError( + 'get_url', resource_identifier=resource_identifier + ) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/accessor/disk.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/accessor/disk.py new file mode 100644 index 0000000000..65769603f6 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/accessor/disk.py @@ -0,0 +1,250 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2013 ftrack + +import os +import sys +import errno +import contextlib + +import ftrack_api._python_ntpath as ntpath +import ftrack_api.accessor.base +import ftrack_api.data +from ftrack_api.exception import ( + AccessorFilesystemPathError, + AccessorUnsupportedOperationError, + AccessorResourceNotFoundError, + AccessorOperationFailedError, + AccessorPermissionDeniedError, + AccessorResourceInvalidError, + AccessorContainerNotEmptyError, + AccessorParentResourceNotFoundError +) + + +class DiskAccessor(ftrack_api.accessor.base.Accessor): + '''Provide disk access to a location. + + Expect resource identifiers to refer to relative filesystem paths. + + ''' + + def __init__(self, prefix, **kw): + '''Initialise location accessor. + + *prefix* specifies the base folder for the disk based structure and + will be prepended to any path. It should be specified in the syntax of + the current OS. + + ''' + if prefix: + prefix = os.path.expanduser(os.path.expandvars(prefix)) + prefix = os.path.abspath(prefix) + self.prefix = prefix + + super(DiskAccessor, self).__init__(**kw) + + def list(self, resource_identifier): + '''Return list of entries in *resource_identifier* container. + + Each entry in the returned list should be a valid resource identifier. + + Raise :exc:`~ftrack_api.exception.AccessorResourceNotFoundError` if + *resource_identifier* does not exist or + :exc:`~ftrack_api.exception.AccessorResourceInvalidError` if + *resource_identifier* is not a container. + + ''' + filesystem_path = self.get_filesystem_path(resource_identifier) + + with error_handler( + operation='list', resource_identifier=resource_identifier + ): + listing = [] + for entry in os.listdir(filesystem_path): + listing.append(os.path.join(resource_identifier, entry)) + + return listing + + def exists(self, resource_identifier): + '''Return if *resource_identifier* is valid and exists in location.''' + filesystem_path = self.get_filesystem_path(resource_identifier) + return os.path.exists(filesystem_path) + + def is_file(self, resource_identifier): + '''Return whether *resource_identifier* refers to a file.''' + filesystem_path = self.get_filesystem_path(resource_identifier) + return os.path.isfile(filesystem_path) + + def is_container(self, resource_identifier): + '''Return whether *resource_identifier* refers to a container.''' + filesystem_path = self.get_filesystem_path(resource_identifier) + return os.path.isdir(filesystem_path) + + def is_sequence(self, resource_identifier): + '''Return whether *resource_identifier* refers to a file sequence.''' + raise AccessorUnsupportedOperationError(operation='is_sequence') + + def open(self, resource_identifier, mode='rb'): + '''Return :class:`~ftrack_api.Data` for *resource_identifier*.''' + filesystem_path = self.get_filesystem_path(resource_identifier) + + with error_handler( + operation='open', resource_identifier=resource_identifier + ): + data = ftrack_api.data.File(filesystem_path, mode) + + return data + + def remove(self, resource_identifier): + '''Remove *resource_identifier*. + + Raise :exc:`~ftrack_api.exception.AccessorResourceNotFoundError` if + *resource_identifier* does not exist. + + ''' + filesystem_path = self.get_filesystem_path(resource_identifier) + + if self.is_file(resource_identifier): + with error_handler( + operation='remove', resource_identifier=resource_identifier + ): + os.remove(filesystem_path) + + elif self.is_container(resource_identifier): + with error_handler( + operation='remove', resource_identifier=resource_identifier + ): + os.rmdir(filesystem_path) + + else: + raise AccessorResourceNotFoundError( + resource_identifier=resource_identifier + ) + + def make_container(self, resource_identifier, recursive=True): + '''Make a container at *resource_identifier*. + + If *recursive* is True, also make any intermediate containers. + + ''' + filesystem_path = self.get_filesystem_path(resource_identifier) + + with error_handler( + operation='makeContainer', resource_identifier=resource_identifier + ): + try: + if recursive: + os.makedirs(filesystem_path) + else: + try: + os.mkdir(filesystem_path) + except OSError as error: + if error.errno == errno.ENOENT: + raise AccessorParentResourceNotFoundError( + resource_identifier=resource_identifier + ) + else: + raise + + except OSError, error: + if error.errno != errno.EEXIST: + raise + + def get_container(self, resource_identifier): + '''Return resource_identifier of container for *resource_identifier*. + + Raise :exc:`~ftrack_api.exception.AccessorParentResourceNotFoundError` if + container of *resource_identifier* could not be determined. + + ''' + filesystem_path = self.get_filesystem_path(resource_identifier) + + container = os.path.dirname(filesystem_path) + + if self.prefix: + if not container.startswith(self.prefix): + raise AccessorParentResourceNotFoundError( + resource_identifier=resource_identifier, + message='Could not determine container for ' + '{resource_identifier} as container falls outside ' + 'of configured prefix.' + ) + + # Convert container filesystem path into resource identifier. + container = container[len(self.prefix):] + if ntpath.isabs(container): + # Ensure that resulting path is relative by stripping any + # leftover prefixed slashes from string. + # E.g. If prefix was '/tmp' and path was '/tmp/foo/bar' the + # result will be 'foo/bar'. + container = container.lstrip('\\/') + + return container + + def get_filesystem_path(self, resource_identifier): + '''Return filesystem path for *resource_identifier*. + + For example:: + + >>> accessor = DiskAccessor('my.location', '/mountpoint') + >>> print accessor.get_filesystem_path('test.txt') + /mountpoint/test.txt + >>> print accessor.get_filesystem_path('/mountpoint/test.txt') + /mountpoint/test.txt + + Raise :exc:`ftrack_api.exception.AccessorFilesystemPathError` if filesystem + path could not be determined from *resource_identifier*. + + ''' + filesystem_path = resource_identifier + if filesystem_path: + filesystem_path = os.path.normpath(filesystem_path) + + if self.prefix: + if not os.path.isabs(filesystem_path): + filesystem_path = os.path.normpath( + os.path.join(self.prefix, filesystem_path) + ) + + if not filesystem_path.startswith(self.prefix): + raise AccessorFilesystemPathError( + resource_identifier=resource_identifier, + message='Could not determine access path for ' + 'resource_identifier outside of configured prefix: ' + '{resource_identifier}.' + ) + + return filesystem_path + + +@contextlib.contextmanager +def error_handler(**kw): + '''Conform raised OSError/IOError exception to appropriate FTrack error.''' + try: + yield + + except (OSError, IOError) as error: + (exception_type, exception_value, traceback) = sys.exc_info() + kw.setdefault('error', error) + + error_code = getattr(error, 'errno') + if not error_code: + raise AccessorOperationFailedError(**kw), None, traceback + + if error_code == errno.ENOENT: + raise AccessorResourceNotFoundError(**kw), None, traceback + + elif error_code == errno.EPERM: + raise AccessorPermissionDeniedError(**kw), None, traceback + + elif error_code == errno.ENOTEMPTY: + raise AccessorContainerNotEmptyError(**kw), None, traceback + + elif error_code in (errno.ENOTDIR, errno.EISDIR, errno.EINVAL): + raise AccessorResourceInvalidError(**kw), None, traceback + + else: + raise AccessorOperationFailedError(**kw), None, traceback + + except Exception: + raise diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/accessor/server.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/accessor/server.py new file mode 100644 index 0000000000..9c735084d5 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/accessor/server.py @@ -0,0 +1,240 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import os +import hashlib +import base64 +import json + +import requests + +from .base import Accessor +from ..data import String +import ftrack_api.exception +import ftrack_api.symbol + + +class ServerFile(String): + '''Representation of a server file.''' + + def __init__(self, resource_identifier, session, mode='rb'): + '''Initialise file.''' + self.mode = mode + self.resource_identifier = resource_identifier + self._session = session + self._has_read = False + + super(ServerFile, self).__init__() + + def flush(self): + '''Flush all changes.''' + super(ServerFile, self).flush() + + if self.mode == 'wb': + self._write() + + def read(self, limit=None): + '''Read file.''' + if not self._has_read: + self._read() + self._has_read = True + + return super(ServerFile, self).read(limit) + + def _read(self): + '''Read all remote content from key into wrapped_file.''' + position = self.tell() + self.seek(0) + + response = requests.get( + '{0}/component/get'.format(self._session.server_url), + params={ + 'id': self.resource_identifier, + 'username': self._session.api_user, + 'apiKey': self._session.api_key + }, + stream=True + ) + + try: + response.raise_for_status() + except requests.exceptions.HTTPError as error: + raise ftrack_api.exception.AccessorOperationFailedError( + 'Failed to read data: {0}.'.format(error) + ) + + for block in response.iter_content(ftrack_api.symbol.CHUNK_SIZE): + self.wrapped_file.write(block) + + self.flush() + self.seek(position) + + def _write(self): + '''Write current data to remote key.''' + position = self.tell() + self.seek(0) + + # Retrieve component from cache to construct a filename. + component = self._session.get('FileComponent', self.resource_identifier) + if not component: + raise ftrack_api.exception.AccessorOperationFailedError( + 'Unable to retrieve component with id: {0}.'.format( + self.resource_identifier + ) + ) + + # Construct a name from component name and file_type. + name = component['name'] + if component['file_type']: + name = u'{0}.{1}'.format( + name, + component['file_type'].lstrip('.') + ) + + try: + metadata = self._session.get_upload_metadata( + component_id=self.resource_identifier, + file_name=name, + file_size=self._get_size(), + checksum=self._compute_checksum() + ) + except Exception as error: + raise ftrack_api.exception.AccessorOperationFailedError( + 'Failed to get put metadata: {0}.'.format(error) + ) + + # Ensure at beginning of file before put. + self.seek(0) + + # Put the file based on the metadata. + response = requests.put( + metadata['url'], + data=self.wrapped_file, + headers=metadata['headers'] + ) + + try: + response.raise_for_status() + except requests.exceptions.HTTPError as error: + raise ftrack_api.exception.AccessorOperationFailedError( + 'Failed to put file to server: {0}.'.format(error) + ) + + self.seek(position) + + def _get_size(self): + '''Return size of file in bytes.''' + position = self.tell() + self.seek(0, os.SEEK_END) + length = self.tell() + self.seek(position) + return length + + def _compute_checksum(self): + '''Return checksum for file.''' + fp = self.wrapped_file + buf_size = ftrack_api.symbol.CHUNK_SIZE + hash_obj = hashlib.md5() + spos = fp.tell() + + s = fp.read(buf_size) + while s: + hash_obj.update(s) + s = fp.read(buf_size) + + base64_digest = base64.encodestring(hash_obj.digest()) + if base64_digest[-1] == '\n': + base64_digest = base64_digest[0:-1] + + fp.seek(spos) + return base64_digest + + +class _ServerAccessor(Accessor): + '''Provide server location access.''' + + def __init__(self, session, **kw): + '''Initialise location accessor.''' + super(_ServerAccessor, self).__init__(**kw) + + self._session = session + + def open(self, resource_identifier, mode='rb'): + '''Return :py:class:`~ftrack_api.Data` for *resource_identifier*.''' + return ServerFile(resource_identifier, session=self._session, mode=mode) + + def remove(self, resourceIdentifier): + '''Remove *resourceIdentifier*.''' + response = requests.get( + '{0}/component/remove'.format(self._session.server_url), + params={ + 'id': resourceIdentifier, + 'username': self._session.api_user, + 'apiKey': self._session.api_key + } + ) + if response.status_code != 200: + raise ftrack_api.exception.AccessorOperationFailedError( + 'Failed to remove file.' + ) + + def get_container(self, resource_identifier): + '''Return resource_identifier of container for *resource_identifier*.''' + return None + + def make_container(self, resource_identifier, recursive=True): + '''Make a container at *resource_identifier*.''' + + def list(self, resource_identifier): + '''Return list of entries in *resource_identifier* container.''' + raise NotImplementedError() + + def exists(self, resource_identifier): + '''Return if *resource_identifier* is valid and exists in location.''' + return False + + def is_file(self, resource_identifier): + '''Return whether *resource_identifier* refers to a file.''' + raise NotImplementedError() + + def is_container(self, resource_identifier): + '''Return whether *resource_identifier* refers to a container.''' + raise NotImplementedError() + + def is_sequence(self, resource_identifier): + '''Return whether *resource_identifier* refers to a file sequence.''' + raise NotImplementedError() + + def get_url(self, resource_identifier): + '''Return url for *resource_identifier*.''' + url_string = ( + u'{url}/component/get?id={id}&username={username}' + u'&apiKey={apiKey}' + ) + return url_string.format( + url=self._session.server_url, + id=resource_identifier, + username=self._session.api_user, + apiKey=self._session.api_key + ) + + def get_thumbnail_url(self, resource_identifier, size=None): + '''Return thumbnail url for *resource_identifier*. + + Optionally, specify *size* to constrain the downscaled image to size + x size pixels. + ''' + url_string = ( + u'{url}/component/thumbnail?id={id}&username={username}' + u'&apiKey={apiKey}' + ) + url = url_string.format( + url=self._session.server_url, + id=resource_identifier, + username=self._session.api_user, + apiKey=self._session.api_key + ) + if size: + url += u'&size={0}'.format(size) + + return url diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/attribute.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/attribute.py new file mode 100644 index 0000000000..719b612f39 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/attribute.py @@ -0,0 +1,707 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +from __future__ import absolute_import + +import collections +import copy +import logging +import functools + +import ftrack_api.symbol +import ftrack_api.exception +import ftrack_api.collection +import ftrack_api.inspection +import ftrack_api.operation + +logger = logging.getLogger( + __name__ +) + + +def merge_references(function): + '''Decorator to handle merging of references / collections.''' + + @functools.wraps(function) + def get_value(attribute, entity): + '''Merge the attribute with the local cache.''' + + if attribute.name not in entity._inflated: + # Only merge on first access to avoid + # inflating them multiple times. + + logger.debug( + 'Merging potential new data into attached ' + 'entity for attribute {0}.'.format( + attribute.name + ) + ) + + # Local attributes. + local_value = attribute.get_local_value(entity) + if isinstance( + local_value, + ( + ftrack_api.entity.base.Entity, + ftrack_api.collection.Collection, + ftrack_api.collection.MappedCollectionProxy + ) + ): + logger.debug( + 'Merging local value for attribute {0}.'.format(attribute) + ) + + merged_local_value = entity.session._merge( + local_value, merged=dict() + ) + + if merged_local_value is not local_value: + with entity.session.operation_recording(False): + attribute.set_local_value(entity, merged_local_value) + + # Remote attributes. + remote_value = attribute.get_remote_value(entity) + if isinstance( + remote_value, + ( + ftrack_api.entity.base.Entity, + ftrack_api.collection.Collection, + ftrack_api.collection.MappedCollectionProxy + ) + ): + logger.debug( + 'Merging remote value for attribute {0}.'.format(attribute) + ) + + merged_remote_value = entity.session._merge( + remote_value, merged=dict() + ) + + if merged_remote_value is not remote_value: + attribute.set_remote_value(entity, merged_remote_value) + + entity._inflated.add( + attribute.name + ) + + return function( + attribute, entity + ) + + return get_value + + +class Attributes(object): + '''Collection of properties accessible by name.''' + + def __init__(self, attributes=None): + super(Attributes, self).__init__() + self._data = dict() + if attributes is not None: + for attribute in attributes: + self.add(attribute) + + def add(self, attribute): + '''Add *attribute*.''' + existing = self._data.get(attribute.name, None) + if existing: + raise ftrack_api.exception.NotUniqueError( + 'Attribute with name {0} already added as {1}' + .format(attribute.name, existing) + ) + + self._data[attribute.name] = attribute + + def remove(self, attribute): + '''Remove attribute.''' + self._data.pop(attribute.name) + + def get(self, name): + '''Return attribute by *name*. + + If no attribute matches *name* then return None. + + ''' + return self._data.get(name, None) + + def keys(self): + '''Return list of attribute names.''' + return self._data.keys() + + def __contains__(self, item): + '''Return whether *item* present.''' + if not isinstance(item, Attribute): + return False + + return item.name in self._data + + def __iter__(self): + '''Return iterator over attributes.''' + return self._data.itervalues() + + def __len__(self): + '''Return count of attributes.''' + return len(self._data) + + +class Attribute(object): + '''A name and value pair persisted remotely.''' + + def __init__( + self, name, default_value=ftrack_api.symbol.NOT_SET, mutable=True, + computed=False + ): + '''Initialise attribute with *name*. + + *default_value* represents the default value for the attribute. It may + be a callable. It is not used within the attribute when providing + values, but instead exists for other parts of the system to reference. + + If *mutable* is set to False then the local value of the attribute on an + entity can only be set when both the existing local and remote values + are :attr:`ftrack_api.symbol.NOT_SET`. The exception to this is when the + target value is also :attr:`ftrack_api.symbol.NOT_SET`. + + If *computed* is set to True the value is a remote side computed value + and should not be long-term cached. + + ''' + super(Attribute, self).__init__() + self._name = name + self._mutable = mutable + self._computed = computed + self.default_value = default_value + + self._local_key = 'local' + self._remote_key = 'remote' + + def __repr__(self): + '''Return representation of entity.''' + return '<{0}.{1}({2}) object at {3}>'.format( + self.__module__, + self.__class__.__name__, + self.name, + id(self) + ) + + def get_entity_storage(self, entity): + '''Return attribute storage on *entity* creating if missing.''' + storage_key = '_ftrack_attribute_storage' + storage = getattr(entity, storage_key, None) + if storage is None: + storage = collections.defaultdict( + lambda: + { + self._local_key: ftrack_api.symbol.NOT_SET, + self._remote_key: ftrack_api.symbol.NOT_SET + } + ) + setattr(entity, storage_key, storage) + + return storage + + @property + def name(self): + '''Return name.''' + return self._name + + @property + def mutable(self): + '''Return whether attribute is mutable.''' + return self._mutable + + @property + def computed(self): + '''Return whether attribute is computed.''' + return self._computed + + def get_value(self, entity): + '''Return current value for *entity*. + + If a value was set locally then return it, otherwise return last known + remote value. If no remote value yet retrieved, make a request for it + via the session and block until available. + + ''' + value = self.get_local_value(entity) + if value is not ftrack_api.symbol.NOT_SET: + return value + + value = self.get_remote_value(entity) + if value is not ftrack_api.symbol.NOT_SET: + return value + + if not entity.session.auto_populate: + return value + + self.populate_remote_value(entity) + return self.get_remote_value(entity) + + def get_local_value(self, entity): + '''Return locally set value for *entity*.''' + storage = self.get_entity_storage(entity) + return storage[self.name][self._local_key] + + def get_remote_value(self, entity): + '''Return remote value for *entity*. + + .. note:: + + Only return locally stored remote value, do not fetch from remote. + + ''' + storage = self.get_entity_storage(entity) + return storage[self.name][self._remote_key] + + def set_local_value(self, entity, value): + '''Set local *value* for *entity*.''' + if ( + not self.mutable + and self.is_set(entity) + and value is not ftrack_api.symbol.NOT_SET + ): + raise ftrack_api.exception.ImmutableAttributeError(self) + + old_value = self.get_local_value(entity) + + storage = self.get_entity_storage(entity) + storage[self.name][self._local_key] = value + + # Record operation. + if entity.session.record_operations: + entity.session.recorded_operations.push( + ftrack_api.operation.UpdateEntityOperation( + entity.entity_type, + ftrack_api.inspection.primary_key(entity), + self.name, + old_value, + value + ) + ) + + def set_remote_value(self, entity, value): + '''Set remote *value*. + + .. note:: + + Only set locally stored remote value, do not persist to remote. + + ''' + storage = self.get_entity_storage(entity) + storage[self.name][self._remote_key] = value + + def populate_remote_value(self, entity): + '''Populate remote value for *entity*.''' + entity.session.populate([entity], self.name) + + def is_modified(self, entity): + '''Return whether local value set and differs from remote. + + .. note:: + + Will not fetch remote value so may report True even when values + are the same on the remote. + + ''' + local_value = self.get_local_value(entity) + remote_value = self.get_remote_value(entity) + return ( + local_value is not ftrack_api.symbol.NOT_SET + and local_value != remote_value + ) + + def is_set(self, entity): + '''Return whether a value is set for *entity*.''' + return any([ + self.get_local_value(entity) is not ftrack_api.symbol.NOT_SET, + self.get_remote_value(entity) is not ftrack_api.symbol.NOT_SET + ]) + + +class ScalarAttribute(Attribute): + '''Represent a scalar value.''' + + def __init__(self, name, data_type, **kw): + '''Initialise property.''' + super(ScalarAttribute, self).__init__(name, **kw) + self.data_type = data_type + + +class ReferenceAttribute(Attribute): + '''Reference another entity.''' + + def __init__(self, name, entity_type, **kw): + '''Initialise property.''' + super(ReferenceAttribute, self).__init__(name, **kw) + self.entity_type = entity_type + + def populate_remote_value(self, entity): + '''Populate remote value for *entity*. + + As attribute references another entity, use that entity's configured + default projections to auto populate useful attributes when loading. + + ''' + reference_entity_type = entity.session.types[self.entity_type] + default_projections = reference_entity_type.default_projections + + projections = [] + if default_projections: + for projection in default_projections: + projections.append('{0}.{1}'.format(self.name, projection)) + else: + projections.append(self.name) + + entity.session.populate([entity], ', '.join(projections)) + + def is_modified(self, entity): + '''Return whether a local value has been set and differs from remote. + + .. note:: + + Will not fetch remote value so may report True even when values + are the same on the remote. + + ''' + local_value = self.get_local_value(entity) + remote_value = self.get_remote_value(entity) + + if local_value is ftrack_api.symbol.NOT_SET: + return False + + if remote_value is ftrack_api.symbol.NOT_SET: + return True + + if ( + ftrack_api.inspection.identity(local_value) + != ftrack_api.inspection.identity(remote_value) + ): + return True + + return False + + + @merge_references + def get_value(self, entity): + return super(ReferenceAttribute, self).get_value( + entity + ) + +class AbstractCollectionAttribute(Attribute): + '''Base class for collection attributes.''' + + #: Collection class used by attribute. + collection_class = None + + @merge_references + def get_value(self, entity): + '''Return current value for *entity*. + + If a value was set locally then return it, otherwise return last known + remote value. If no remote value yet retrieved, make a request for it + via the session and block until available. + + .. note:: + + As value is a collection that is mutable, will transfer a remote + value into the local value on access if no local value currently + set. + + ''' + super(AbstractCollectionAttribute, self).get_value(entity) + + # Conditionally, copy remote value into local value so that it can be + # mutated without side effects. + local_value = self.get_local_value(entity) + remote_value = self.get_remote_value(entity) + if ( + local_value is ftrack_api.symbol.NOT_SET + and isinstance(remote_value, self.collection_class) + ): + try: + with entity.session.operation_recording(False): + self.set_local_value(entity, copy.copy(remote_value)) + except ftrack_api.exception.ImmutableAttributeError: + pass + + value = self.get_local_value(entity) + + # If the local value is still not set then attempt to set it with a + # suitable placeholder collection so that the caller can interact with + # the collection using its normal interface. This is required for a + # newly created entity for example. It *could* be done as a simple + # default value, but that would incur cost for every collection even + # when they are not modified before commit. + if value is ftrack_api.symbol.NOT_SET: + try: + with entity.session.operation_recording(False): + self.set_local_value( + entity, + # None should be treated as empty collection. + None + ) + except ftrack_api.exception.ImmutableAttributeError: + pass + + return self.get_local_value(entity) + + def set_local_value(self, entity, value): + '''Set local *value* for *entity*.''' + if value is not ftrack_api.symbol.NOT_SET: + value = self._adapt_to_collection(entity, value) + value.mutable = self.mutable + + super(AbstractCollectionAttribute, self).set_local_value(entity, value) + + def set_remote_value(self, entity, value): + '''Set remote *value*. + + .. note:: + + Only set locally stored remote value, do not persist to remote. + + ''' + if value is not ftrack_api.symbol.NOT_SET: + value = self._adapt_to_collection(entity, value) + value.mutable = False + + super(AbstractCollectionAttribute, self).set_remote_value(entity, value) + + def _adapt_to_collection(self, entity, value): + '''Adapt *value* to appropriate collection instance for *entity*. + + .. note:: + + If *value* is None then return a suitable empty collection. + + ''' + raise NotImplementedError() + + +class CollectionAttribute(AbstractCollectionAttribute): + '''Represent a collection of other entities.''' + + #: Collection class used by attribute. + collection_class = ftrack_api.collection.Collection + + def _adapt_to_collection(self, entity, value): + '''Adapt *value* to a Collection instance on *entity*.''' + + if not isinstance(value, ftrack_api.collection.Collection): + + if value is None: + value = ftrack_api.collection.Collection(entity, self) + + elif isinstance(value, list): + value = ftrack_api.collection.Collection( + entity, self, data=value + ) + + else: + raise NotImplementedError( + 'Cannot convert {0!r} to collection.'.format(value) + ) + + else: + if value.attribute is not self: + raise ftrack_api.exception.AttributeError( + 'Collection already bound to a different attribute' + ) + + return value + + +class KeyValueMappedCollectionAttribute(AbstractCollectionAttribute): + '''Represent a mapped key, value collection of entities.''' + + #: Collection class used by attribute. + collection_class = ftrack_api.collection.KeyValueMappedCollectionProxy + + def __init__( + self, name, creator, key_attribute, value_attribute, **kw + ): + '''Initialise attribute with *name*. + + *creator* should be a function that accepts a dictionary of data and + is used by the referenced collection to create new entities in the + collection. + + *key_attribute* should be the name of the attribute on an entity in + the collection that represents the value for 'key' of the dictionary. + + *value_attribute* should be the name of the attribute on an entity in + the collection that represents the value for 'value' of the dictionary. + + ''' + self.creator = creator + self.key_attribute = key_attribute + self.value_attribute = value_attribute + + super(KeyValueMappedCollectionAttribute, self).__init__(name, **kw) + + def _adapt_to_collection(self, entity, value): + '''Adapt *value* to an *entity*.''' + if not isinstance( + value, ftrack_api.collection.KeyValueMappedCollectionProxy + ): + + if value is None: + value = ftrack_api.collection.KeyValueMappedCollectionProxy( + ftrack_api.collection.Collection(entity, self), + self.creator, self.key_attribute, + self.value_attribute + ) + + elif isinstance(value, (list, ftrack_api.collection.Collection)): + + if isinstance(value, list): + value = ftrack_api.collection.Collection( + entity, self, data=value + ) + + value = ftrack_api.collection.KeyValueMappedCollectionProxy( + value, self.creator, self.key_attribute, + self.value_attribute + ) + + elif isinstance(value, collections.Mapping): + # Convert mapping. + # TODO: When backend model improves, revisit this logic. + # First get existing value and delete all references. This is + # needed because otherwise they will not be automatically + # removed server side. + # The following should not cause recursion as the internal + # values should be mapped collections already. + current_value = self.get_value(entity) + if not isinstance( + current_value, + ftrack_api.collection.KeyValueMappedCollectionProxy + ): + raise NotImplementedError( + 'Cannot adapt mapping to collection as current value ' + 'type is not a KeyValueMappedCollectionProxy.' + ) + + # Create the new collection using the existing collection as + # basis. Then update through proxy interface to ensure all + # internal operations called consistently (such as entity + # deletion for key removal). + collection = ftrack_api.collection.Collection( + entity, self, data=current_value.collection[:] + ) + collection_proxy = ( + ftrack_api.collection.KeyValueMappedCollectionProxy( + collection, self.creator, + self.key_attribute, self.value_attribute + ) + ) + + # Remove expired keys from collection. + expired_keys = set(current_value.keys()) - set(value.keys()) + for key in expired_keys: + del collection_proxy[key] + + # Set new values for existing keys / add new keys. + for key, value in value.items(): + collection_proxy[key] = value + + value = collection_proxy + + else: + raise NotImplementedError( + 'Cannot convert {0!r} to collection.'.format(value) + ) + else: + if value.attribute is not self: + raise ftrack_api.exception.AttributeError( + 'Collection already bound to a different attribute.' + ) + + return value + + +class CustomAttributeCollectionAttribute(AbstractCollectionAttribute): + '''Represent a mapped custom attribute collection of entities.''' + + #: Collection class used by attribute. + collection_class = ( + ftrack_api.collection.CustomAttributeCollectionProxy + ) + + def _adapt_to_collection(self, entity, value): + '''Adapt *value* to an *entity*.''' + if not isinstance( + value, ftrack_api.collection.CustomAttributeCollectionProxy + ): + + if value is None: + value = ftrack_api.collection.CustomAttributeCollectionProxy( + ftrack_api.collection.Collection(entity, self) + ) + + elif isinstance(value, (list, ftrack_api.collection.Collection)): + + # Why are we creating a new if it is a list? This will cause + # any merge to create a new proxy and collection. + if isinstance(value, list): + value = ftrack_api.collection.Collection( + entity, self, data=value + ) + + value = ftrack_api.collection.CustomAttributeCollectionProxy( + value + ) + + elif isinstance(value, collections.Mapping): + # Convert mapping. + # TODO: When backend model improves, revisit this logic. + # First get existing value and delete all references. This is + # needed because otherwise they will not be automatically + # removed server side. + # The following should not cause recursion as the internal + # values should be mapped collections already. + current_value = self.get_value(entity) + if not isinstance( + current_value, + ftrack_api.collection.CustomAttributeCollectionProxy + ): + raise NotImplementedError( + 'Cannot adapt mapping to collection as current value ' + 'type is not a MappedCollectionProxy.' + ) + + # Create the new collection using the existing collection as + # basis. Then update through proxy interface to ensure all + # internal operations called consistently (such as entity + # deletion for key removal). + collection = ftrack_api.collection.Collection( + entity, self, data=current_value.collection[:] + ) + collection_proxy = ( + ftrack_api.collection.CustomAttributeCollectionProxy( + collection + ) + ) + + # Remove expired keys from collection. + expired_keys = set(current_value.keys()) - set(value.keys()) + for key in expired_keys: + del collection_proxy[key] + + # Set new values for existing keys / add new keys. + for key, value in value.items(): + collection_proxy[key] = value + + value = collection_proxy + + else: + raise NotImplementedError( + 'Cannot convert {0!r} to collection.'.format(value) + ) + else: + if value.attribute is not self: + raise ftrack_api.exception.AttributeError( + 'Collection already bound to a different attribute.' + ) + + return value diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/cache.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/cache.py new file mode 100644 index 0000000000..49456dc2d7 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/cache.py @@ -0,0 +1,579 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +'''Caching framework. + +Defines a standardised :class:`Cache` interface for storing data against +specific keys. Key generation is also standardised using a :class:`KeyMaker` +interface. + +Combining a Cache and KeyMaker allows for memoisation of function calls with +respect to the arguments used by using a :class:`Memoiser`. + +As a convenience a simple :func:`memoise` decorator is included for quick +memoisation of function using a global cache and standard key maker. + +''' + +import collections +import functools +import abc +import copy +import inspect +import re +import anydbm +import contextlib +try: + import cPickle as pickle +except ImportError: # pragma: no cover + import pickle + +import ftrack_api.inspection +import ftrack_api.symbol + + +class Cache(object): + '''Cache interface. + + Derive from this to define concrete cache implementations. A cache is + centered around the concept of key:value pairings where the key is unique + across the cache. + + ''' + + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def get(self, key): + '''Return value for *key*. + + Raise :exc:`KeyError` if *key* not found. + + ''' + + @abc.abstractmethod + def set(self, key, value): + '''Set *value* for *key*.''' + + @abc.abstractmethod + def remove(self, key): + '''Remove *key* and return stored value. + + Raise :exc:`KeyError` if *key* not found. + + ''' + + def keys(self): + '''Return list of keys at this current time. + + .. warning:: + + Actual keys may differ from those returned due to timing of access. + + ''' + raise NotImplementedError() # pragma: no cover + + def values(self): + '''Return values for current keys.''' + values = [] + for key in self.keys(): + try: + value = self.get(key) + except KeyError: + continue + else: + values.append(value) + + return values + + def clear(self, pattern=None): + '''Remove all keys matching *pattern*. + + *pattern* should be a regular expression string. + + If *pattern* is None then all keys will be removed. + + ''' + if pattern is not None: + pattern = re.compile(pattern) + + for key in self.keys(): + if pattern is not None: + if not pattern.search(key): + continue + + try: + self.remove(key) + except KeyError: + pass + + +class ProxyCache(Cache): + '''Proxy another cache.''' + + def __init__(self, proxied): + '''Initialise cache with *proxied* cache instance.''' + self.proxied = proxied + super(ProxyCache, self).__init__() + + def get(self, key): + '''Return value for *key*. + + Raise :exc:`KeyError` if *key* not found. + + ''' + return self.proxied.get(key) + + def set(self, key, value): + '''Set *value* for *key*.''' + return self.proxied.set(key, value) + + def remove(self, key): + '''Remove *key* and return stored value. + + Raise :exc:`KeyError` if *key* not found. + + ''' + return self.proxied.remove(key) + + def keys(self): + '''Return list of keys at this current time. + + .. warning:: + + Actual keys may differ from those returned due to timing of access. + + ''' + return self.proxied.keys() + + +class LayeredCache(Cache): + '''Layered cache.''' + + def __init__(self, caches): + '''Initialise cache with *caches*.''' + super(LayeredCache, self).__init__() + self.caches = caches + + def get(self, key): + '''Return value for *key*. + + Raise :exc:`KeyError` if *key* not found. + + Attempt to retrieve from cache layers in turn, starting with shallowest. + If value retrieved, then also set the value in each higher level cache + up from where retrieved. + + ''' + target_caches = [] + value = ftrack_api.symbol.NOT_SET + + for cache in self.caches: + try: + value = cache.get(key) + except KeyError: + target_caches.append(cache) + continue + else: + break + + if value is ftrack_api.symbol.NOT_SET: + raise KeyError(key) + + # Set value on all higher level caches. + for cache in target_caches: + cache.set(key, value) + + return value + + def set(self, key, value): + '''Set *value* for *key*.''' + for cache in self.caches: + cache.set(key, value) + + def remove(self, key): + '''Remove *key*. + + Raise :exc:`KeyError` if *key* not found in any layer. + + ''' + removed = False + for cache in self.caches: + try: + cache.remove(key) + except KeyError: + pass + else: + removed = True + + if not removed: + raise KeyError(key) + + def keys(self): + '''Return list of keys at this current time. + + .. warning:: + + Actual keys may differ from those returned due to timing of access. + + ''' + keys = [] + for cache in self.caches: + keys.extend(cache.keys()) + + return list(set(keys)) + + +class MemoryCache(Cache): + '''Memory based cache.''' + + def __init__(self): + '''Initialise cache.''' + self._cache = {} + super(MemoryCache, self).__init__() + + def get(self, key): + '''Return value for *key*. + + Raise :exc:`KeyError` if *key* not found. + + ''' + return self._cache[key] + + def set(self, key, value): + '''Set *value* for *key*.''' + self._cache[key] = value + + def remove(self, key): + '''Remove *key*. + + Raise :exc:`KeyError` if *key* not found. + + ''' + del self._cache[key] + + def keys(self): + '''Return list of keys at this current time. + + .. warning:: + + Actual keys may differ from those returned due to timing of access. + + ''' + return self._cache.keys() + + +class FileCache(Cache): + '''File based cache that uses :mod:`anydbm` module. + + .. note:: + + No locking of the underlying file is performed. + + ''' + + def __init__(self, path): + '''Initialise cache at *path*.''' + self.path = path + + # Initialise cache. + cache = anydbm.open(self.path, 'c') + cache.close() + + super(FileCache, self).__init__() + + @contextlib.contextmanager + def _database(self): + '''Yield opened database file.''' + cache = anydbm.open(self.path, 'w') + try: + yield cache + finally: + cache.close() + + def get(self, key): + '''Return value for *key*. + + Raise :exc:`KeyError` if *key* not found. + + ''' + with self._database() as cache: + return cache[key] + + def set(self, key, value): + '''Set *value* for *key*.''' + with self._database() as cache: + cache[key] = value + + def remove(self, key): + '''Remove *key*. + + Raise :exc:`KeyError` if *key* not found. + + ''' + with self._database() as cache: + del cache[key] + + def keys(self): + '''Return list of keys at this current time. + + .. warning:: + + Actual keys may differ from those returned due to timing of access. + + ''' + with self._database() as cache: + return cache.keys() + + +class SerialisedCache(ProxyCache): + '''Proxied cache that stores values as serialised data.''' + + def __init__(self, proxied, encode=None, decode=None): + '''Initialise cache with *encode* and *decode* callables. + + *proxied* is the underlying cache to use for storage. + + ''' + self.encode = encode + self.decode = decode + super(SerialisedCache, self).__init__(proxied) + + def get(self, key): + '''Return value for *key*. + + Raise :exc:`KeyError` if *key* not found. + + ''' + value = super(SerialisedCache, self).get(key) + if self.decode: + value = self.decode(value) + + return value + + def set(self, key, value): + '''Set *value* for *key*.''' + if self.encode: + value = self.encode(value) + + super(SerialisedCache, self).set(key, value) + + +class KeyMaker(object): + '''Generate unique keys.''' + + __metaclass__ = abc.ABCMeta + + def __init__(self): + '''Initialise key maker.''' + super(KeyMaker, self).__init__() + self.item_separator = '' + + def key(self, *items): + '''Return key for *items*.''' + keys = [] + for item in items: + keys.append(self._key(item)) + + return self.item_separator.join(keys) + + @abc.abstractmethod + def _key(self, obj): + '''Return key for *obj*.''' + + +class StringKeyMaker(KeyMaker): + '''Generate string key.''' + + def _key(self, obj): + '''Return key for *obj*.''' + return str(obj) + + +class ObjectKeyMaker(KeyMaker): + '''Generate unique keys for objects.''' + + def __init__(self): + '''Initialise key maker.''' + super(ObjectKeyMaker, self).__init__() + self.item_separator = '\0' + self.mapping_identifier = '\1' + self.mapping_pair_separator = '\2' + self.iterable_identifier = '\3' + self.name_identifier = '\4' + + def _key(self, item): + '''Return key for *item*. + + Returned key will be a pickle like string representing the *item*. This + allows for typically non-hashable objects to be used in key generation + (such as dictionaries). + + If *item* is iterable then each item in it shall also be passed to this + method to ensure correct key generation. + + Special markers are used to distinguish handling of specific cases in + order to ensure uniqueness of key corresponds directly to *item*. + + Example:: + + >>> key_maker = ObjectKeyMaker() + >>> def add(x, y): + ... "Return sum of *x* and *y*." + ... return x + y + ... + >>> key_maker.key(add, (1, 2)) + '\x04add\x00__main__\x00\x03\x80\x02K\x01.\x00\x80\x02K\x02.\x03' + >>> key_maker.key(add, (1, 3)) + '\x04add\x00__main__\x00\x03\x80\x02K\x01.\x00\x80\x02K\x03.\x03' + + ''' + # TODO: Consider using a more robust and comprehensive solution such as + # dill (https://github.com/uqfoundation/dill). + if isinstance(item, collections.Iterable): + if isinstance(item, basestring): + return pickle.dumps(item, pickle.HIGHEST_PROTOCOL) + + if isinstance(item, collections.Mapping): + contents = self.item_separator.join([ + ( + self._key(key) + + self.mapping_pair_separator + + self._key(value) + ) + for key, value in sorted(item.items()) + ]) + return ( + self.mapping_identifier + + contents + + self.mapping_identifier + ) + + else: + contents = self.item_separator.join([ + self._key(item) for item in item + ]) + return ( + self.iterable_identifier + + contents + + self.iterable_identifier + ) + + elif inspect.ismethod(item): + return ''.join(( + self.name_identifier, + item.__name__, + self.item_separator, + item.im_class.__name__, + self.item_separator, + item.__module__ + )) + + elif inspect.isfunction(item) or inspect.isclass(item): + return ''.join(( + self.name_identifier, + item.__name__, + self.item_separator, + item.__module__ + )) + + elif inspect.isbuiltin(item): + return self.name_identifier + item.__name__ + + else: + return pickle.dumps(item, pickle.HIGHEST_PROTOCOL) + + +class Memoiser(object): + '''Memoise function calls using a :class:`KeyMaker` and :class:`Cache`. + + Example:: + + >>> memoiser = Memoiser(MemoryCache(), ObjectKeyMaker()) + >>> def add(x, y): + ... "Return sum of *x* and *y*." + ... print 'Called' + ... return x + y + ... + >>> memoiser.call(add, (1, 2), {}) + Called + >>> memoiser.call(add, (1, 2), {}) + >>> memoiser.call(add, (1, 3), {}) + Called + + ''' + + def __init__(self, cache=None, key_maker=None, return_copies=True): + '''Initialise with *cache* and *key_maker* to use. + + If *cache* is not specified a default :class:`MemoryCache` will be + used. Similarly, if *key_maker* is not specified a default + :class:`ObjectKeyMaker` will be used. + + If *return_copies* is True then all results returned from the cache will + be deep copies to avoid indirect mutation of cached values. + + ''' + self.cache = cache + if self.cache is None: + self.cache = MemoryCache() + + self.key_maker = key_maker + if self.key_maker is None: + self.key_maker = ObjectKeyMaker() + + self.return_copies = return_copies + super(Memoiser, self).__init__() + + def call(self, function, args=None, kw=None): + '''Call *function* with *args* and *kw* and return result. + + If *function* was previously called with exactly the same arguments + then return cached result if available. + + Store result for call in cache. + + ''' + if args is None: + args = () + + if kw is None: + kw = {} + + # Support arguments being passed as positionals or keywords. + arguments = inspect.getcallargs(function, *args, **kw) + + key = self.key_maker.key(function, arguments) + try: + value = self.cache.get(key) + + except KeyError: + value = function(*args, **kw) + self.cache.set(key, value) + + # If requested, deep copy value to return in order to avoid cached value + # being inadvertently altered by the caller. + if self.return_copies: + value = copy.deepcopy(value) + + return value + + +def memoise_decorator(memoiser): + '''Decorator to memoise function calls using *memoiser*.''' + def outer(function): + + @functools.wraps(function) + def inner(*args, **kw): + return memoiser.call(function, args, kw) + + return inner + + return outer + + +#: Default memoiser. +memoiser = Memoiser() + +#: Default memoise decorator using standard cache and key maker. +memoise = memoise_decorator(memoiser) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/collection.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/collection.py new file mode 100644 index 0000000000..91655a7b02 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/collection.py @@ -0,0 +1,507 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +from __future__ import absolute_import + +import logging + +import collections +import copy + +import ftrack_api.exception +import ftrack_api.inspection +import ftrack_api.symbol +import ftrack_api.operation +import ftrack_api.cache +from ftrack_api.logging import LazyLogMessage as L + + +class Collection(collections.MutableSequence): + '''A collection of entities.''' + + def __init__(self, entity, attribute, mutable=True, data=None): + '''Initialise collection.''' + self.entity = entity + self.attribute = attribute + self._data = [] + self._identities = set() + + # Set initial dataset. + # Note: For initialisation, immutability is deferred till after initial + # population as otherwise there would be no public way to initialise an + # immutable collection. The reason self._data is not just set directly + # is to ensure other logic can be applied without special handling. + self.mutable = True + try: + if data is None: + data = [] + + with self.entity.session.operation_recording(False): + self.extend(data) + finally: + self.mutable = mutable + + def _identity_key(self, entity): + '''Return identity key for *entity*.''' + return str(ftrack_api.inspection.identity(entity)) + + def __copy__(self): + '''Return shallow copy. + + .. note:: + + To maintain expectations on usage, the shallow copy will include a + shallow copy of the underlying data store. + + ''' + cls = self.__class__ + copied_instance = cls.__new__(cls) + copied_instance.__dict__.update(self.__dict__) + copied_instance._data = copy.copy(self._data) + copied_instance._identities = copy.copy(self._identities) + + return copied_instance + + def _notify(self, old_value): + '''Notify about modification.''' + # Record operation. + if self.entity.session.record_operations: + self.entity.session.recorded_operations.push( + ftrack_api.operation.UpdateEntityOperation( + self.entity.entity_type, + ftrack_api.inspection.primary_key(self.entity), + self.attribute.name, + old_value, + self + ) + ) + + def insert(self, index, item): + '''Insert *item* at *index*.''' + if not self.mutable: + raise ftrack_api.exception.ImmutableCollectionError(self) + + if item in self: + raise ftrack_api.exception.DuplicateItemInCollectionError( + item, self + ) + + old_value = copy.copy(self) + self._data.insert(index, item) + self._identities.add(self._identity_key(item)) + self._notify(old_value) + + def __contains__(self, value): + '''Return whether *value* present in collection.''' + return self._identity_key(value) in self._identities + + def __getitem__(self, index): + '''Return item at *index*.''' + return self._data[index] + + def __setitem__(self, index, item): + '''Set *item* against *index*.''' + if not self.mutable: + raise ftrack_api.exception.ImmutableCollectionError(self) + + try: + existing_index = self.index(item) + except ValueError: + pass + else: + if index != existing_index: + raise ftrack_api.exception.DuplicateItemInCollectionError( + item, self + ) + + old_value = copy.copy(self) + try: + existing_item = self._data[index] + except IndexError: + pass + else: + self._identities.remove(self._identity_key(existing_item)) + + self._data[index] = item + self._identities.add(self._identity_key(item)) + self._notify(old_value) + + def __delitem__(self, index): + '''Remove item at *index*.''' + if not self.mutable: + raise ftrack_api.exception.ImmutableCollectionError(self) + + old_value = copy.copy(self) + item = self._data[index] + del self._data[index] + self._identities.remove(self._identity_key(item)) + self._notify(old_value) + + def __len__(self): + '''Return count of items.''' + return len(self._data) + + def __eq__(self, other): + '''Return whether this collection is equal to *other*.''' + if not isinstance(other, Collection): + return False + + return sorted(self._identities) == sorted(other._identities) + + def __ne__(self, other): + '''Return whether this collection is not equal to *other*.''' + return not self == other + + +class MappedCollectionProxy(collections.MutableMapping): + '''Common base class for mapped collection of entities.''' + + def __init__(self, collection): + '''Initialise proxy for *collection*.''' + self.logger = logging.getLogger( + __name__ + '.' + self.__class__.__name__ + ) + self.collection = collection + super(MappedCollectionProxy, self).__init__() + + def __copy__(self): + '''Return shallow copy. + + .. note:: + + To maintain expectations on usage, the shallow copy will include a + shallow copy of the underlying collection. + + ''' + cls = self.__class__ + copied_instance = cls.__new__(cls) + copied_instance.__dict__.update(self.__dict__) + copied_instance.collection = copy.copy(self.collection) + + return copied_instance + + @property + def mutable(self): + '''Return whether collection is mutable.''' + return self.collection.mutable + + @mutable.setter + def mutable(self, value): + '''Set whether collection is mutable to *value*.''' + self.collection.mutable = value + + @property + def attribute(self): + '''Return attribute bound to.''' + return self.collection.attribute + + @attribute.setter + def attribute(self, value): + '''Set bound attribute to *value*.''' + self.collection.attribute = value + + +class KeyValueMappedCollectionProxy(MappedCollectionProxy): + '''A mapped collection of key, value entities. + + Proxy a standard :class:`Collection` as a mapping where certain attributes + from the entities in the collection are mapped to key, value pairs. + + For example:: + + >>> collection = [Metadata(key='foo', value='bar'), ...] + >>> mapped = KeyValueMappedCollectionProxy( + ... collection, create_metadata, + ... key_attribute='key', value_attribute='value' + ... ) + >>> print mapped['foo'] + 'bar' + >>> mapped['bam'] = 'biz' + >>> print mapped.collection[-1] + Metadata(key='bam', value='biz') + + ''' + + def __init__( + self, collection, creator, key_attribute, value_attribute + ): + '''Initialise collection.''' + self.creator = creator + self.key_attribute = key_attribute + self.value_attribute = value_attribute + super(KeyValueMappedCollectionProxy, self).__init__(collection) + + def _get_entity_by_key(self, key): + '''Return entity instance with matching *key* from collection.''' + for entity in self.collection: + if entity[self.key_attribute] == key: + return entity + + raise KeyError(key) + + def __getitem__(self, key): + '''Return value for *key*.''' + entity = self._get_entity_by_key(key) + return entity[self.value_attribute] + + def __setitem__(self, key, value): + '''Set *value* for *key*.''' + try: + entity = self._get_entity_by_key(key) + except KeyError: + data = { + self.key_attribute: key, + self.value_attribute: value + } + entity = self.creator(self, data) + + if ( + ftrack_api.inspection.state(entity) is + ftrack_api.symbol.CREATED + ): + # Persisting this entity will be handled here, record the + # operation. + self.collection.append(entity) + + else: + # The entity is created and persisted separately by the + # creator. Do not record this operation. + with self.collection.entity.session.operation_recording(False): + # Do not record this operation since it will trigger + # redudant and potentially failing operations. + self.collection.append(entity) + + else: + entity[self.value_attribute] = value + + def __delitem__(self, key): + '''Remove and delete *key*. + + .. note:: + + The associated entity will be deleted as well. + + ''' + for index, entity in enumerate(self.collection): + if entity[self.key_attribute] == key: + break + else: + raise KeyError(key) + + del self.collection[index] + entity.session.delete(entity) + + def __iter__(self): + '''Iterate over all keys.''' + keys = set() + for entity in self.collection: + keys.add(entity[self.key_attribute]) + + return iter(keys) + + def __len__(self): + '''Return count of keys.''' + keys = set() + for entity in self.collection: + keys.add(entity[self.key_attribute]) + + return len(keys) + + +class PerSessionDefaultKeyMaker(ftrack_api.cache.KeyMaker): + '''Generate key for session.''' + + def _key(self, obj): + '''Return key for *obj*.''' + if isinstance(obj, dict): + session = obj.get('session') + if session is not None: + # Key by session only. + return str(id(session)) + + return str(obj) + + +#: Memoiser for use with callables that should be called once per session. +memoise_session = ftrack_api.cache.memoise_decorator( + ftrack_api.cache.Memoiser( + key_maker=PerSessionDefaultKeyMaker(), return_copies=False + ) +) + + +@memoise_session +def _get_custom_attribute_configurations(session): + '''Return list of custom attribute configurations. + + The configuration objects will have key, project_id, id and object_type_id + populated. + + ''' + return session.query( + 'select key, project_id, id, object_type_id, entity_type from ' + 'CustomAttributeConfiguration' + ).all() + + +class CustomAttributeCollectionProxy(MappedCollectionProxy): + '''A mapped collection of custom attribute value entities.''' + + def __init__( + self, collection + ): + '''Initialise collection.''' + self.key_attribute = 'configuration_id' + self.value_attribute = 'value' + super(CustomAttributeCollectionProxy, self).__init__(collection) + + def _get_entity_configurations(self): + '''Return all configurations for current collection entity.''' + entity = self.collection.entity + entity_type = None + project_id = None + object_type_id = None + + if 'object_type_id' in entity.keys(): + project_id = entity['project_id'] + entity_type = 'task' + object_type_id = entity['object_type_id'] + + if entity.entity_type == 'AssetVersion': + project_id = entity['asset']['parent']['project_id'] + entity_type = 'assetversion' + + if entity.entity_type == 'Asset': + project_id = entity['parent']['project_id'] + entity_type = 'asset' + + if entity.entity_type == 'Project': + project_id = entity['id'] + entity_type = 'show' + + if entity.entity_type == 'User': + entity_type = 'user' + + if entity_type is None: + raise ValueError( + 'Entity {!r} not supported.'.format(entity) + ) + + configurations = [] + for configuration in _get_custom_attribute_configurations( + entity.session + ): + if ( + configuration['entity_type'] == entity_type and + configuration['project_id'] in (project_id, None) and + configuration['object_type_id'] == object_type_id + ): + configurations.append(configuration) + + # Return with global configurations at the end of the list. This is done + # so that global conigurations are shadowed by project specific if the + # configurations list is looped when looking for a matching `key`. + return sorted( + configurations, key=lambda item: item['project_id'] is None + ) + + def _get_keys(self): + '''Return a list of all keys.''' + keys = [] + for configuration in self._get_entity_configurations(): + keys.append(configuration['key']) + + return keys + + def _get_entity_by_key(self, key): + '''Return entity instance with matching *key* from collection.''' + configuration_id = self.get_configuration_id_from_key(key) + for entity in self.collection: + if entity[self.key_attribute] == configuration_id: + return entity + + return None + + def get_configuration_id_from_key(self, key): + '''Return id of configuration with matching *key*. + + Raise :exc:`KeyError` if no configuration with matching *key* found. + + ''' + for configuration in self._get_entity_configurations(): + if key == configuration['key']: + return configuration['id'] + + raise KeyError(key) + + def __getitem__(self, key): + '''Return value for *key*.''' + entity = self._get_entity_by_key(key) + + if entity: + return entity[self.value_attribute] + + for configuration in self._get_entity_configurations(): + if configuration['key'] == key: + return configuration['default'] + + raise KeyError(key) + + def __setitem__(self, key, value): + '''Set *value* for *key*.''' + custom_attribute_value = self._get_entity_by_key(key) + + if custom_attribute_value: + custom_attribute_value[self.value_attribute] = value + else: + entity = self.collection.entity + session = entity.session + data = { + self.key_attribute: self.get_configuration_id_from_key(key), + self.value_attribute: value, + 'entity_id': entity['id'] + } + + # Make sure to use the currently active collection. This is + # necessary since a merge might have replaced the current one. + self.collection.entity['custom_attributes'].collection.append( + session.create('CustomAttributeValue', data) + ) + + def __delitem__(self, key): + '''Remove and delete *key*. + + .. note:: + + The associated entity will be deleted as well. + + ''' + custom_attribute_value = self._get_entity_by_key(key) + + if custom_attribute_value: + index = self.collection.index(custom_attribute_value) + del self.collection[index] + + custom_attribute_value.session.delete(custom_attribute_value) + else: + self.logger.warning(L( + 'Cannot delete {0!r} on {1!r}, no custom attribute value set.', + key, self.collection.entity + )) + + def __eq__(self, collection): + '''Return True if *collection* equals proxy collection.''' + if collection is ftrack_api.symbol.NOT_SET: + return False + + return collection.collection == self.collection + + def __iter__(self): + '''Iterate over all keys.''' + keys = self._get_keys() + return iter(keys) + + def __len__(self): + '''Return count of keys.''' + keys = self._get_keys() + return len(keys) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/data.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/data.py new file mode 100644 index 0000000000..1802e380c0 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/data.py @@ -0,0 +1,119 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2013 ftrack + +import os +from abc import ABCMeta, abstractmethod +import tempfile + + +class Data(object): + '''File-like object for manipulating data.''' + + __metaclass__ = ABCMeta + + def __init__(self): + '''Initialise data access.''' + self.closed = False + + @abstractmethod + def read(self, limit=None): + '''Return content from current position up to *limit*.''' + + @abstractmethod + def write(self, content): + '''Write content at current position.''' + + def flush(self): + '''Flush buffers ensuring data written.''' + + def seek(self, offset, whence=os.SEEK_SET): + '''Move internal pointer by *offset*. + + The *whence* argument is optional and defaults to os.SEEK_SET or 0 + (absolute file positioning); other values are os.SEEK_CUR or 1 + (seek relative to the current position) and os.SEEK_END or 2 + (seek relative to the file's end). + + ''' + raise NotImplementedError('Seek not supported.') + + def tell(self): + '''Return current position of internal pointer.''' + raise NotImplementedError('Tell not supported.') + + def close(self): + '''Flush buffers and prevent further access.''' + self.flush() + self.closed = True + + +class FileWrapper(Data): + '''Data wrapper for Python file objects.''' + + def __init__(self, wrapped_file): + '''Initialise access to *wrapped_file*.''' + self.wrapped_file = wrapped_file + self._read_since_last_write = False + super(FileWrapper, self).__init__() + + def read(self, limit=None): + '''Return content from current position up to *limit*.''' + self._read_since_last_write = True + + if limit is None: + limit = -1 + + return self.wrapped_file.read(limit) + + def write(self, content): + '''Write content at current position.''' + if self._read_since_last_write: + # Windows requires a seek before switching from read to write. + self.seek(self.tell()) + + self.wrapped_file.write(content) + self._read_since_last_write = False + + def flush(self): + '''Flush buffers ensuring data written.''' + super(FileWrapper, self).flush() + if hasattr(self.wrapped_file, 'flush'): + self.wrapped_file.flush() + + def seek(self, offset, whence=os.SEEK_SET): + '''Move internal pointer by *offset*.''' + self.wrapped_file.seek(offset, whence) + + def tell(self): + '''Return current position of internal pointer.''' + return self.wrapped_file.tell() + + def close(self): + '''Flush buffers and prevent further access.''' + if not self.closed: + super(FileWrapper, self).close() + if hasattr(self.wrapped_file, 'close'): + self.wrapped_file.close() + + +class File(FileWrapper): + '''Data wrapper accepting filepath.''' + + def __init__(self, path, mode='rb'): + '''Open file at *path* with *mode*.''' + file_object = open(path, mode) + super(File, self).__init__(file_object) + + +class String(FileWrapper): + '''Data wrapper using TemporaryFile instance.''' + + def __init__(self, content=None): + '''Initialise data with *content*.''' + super(String, self).__init__( + tempfile.TemporaryFile() + ) + + if content is not None: + self.wrapped_file.write(content) + self.wrapped_file.seek(0) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/__init__.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/__init__.py new file mode 100644 index 0000000000..1d452f2828 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/__init__.py @@ -0,0 +1,2 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack \ No newline at end of file diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/asset_version.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/asset_version.py new file mode 100644 index 0000000000..859d94e436 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/asset_version.py @@ -0,0 +1,91 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import ftrack_api.entity.base + + +class AssetVersion(ftrack_api.entity.base.Entity): + '''Represent asset version.''' + + def create_component( + self, path, data=None, location=None + ): + '''Create a new component from *path* with additional *data* + + .. note:: + + This is a helper method. To create components manually use the + standard :meth:`Session.create` method. + + *path* can be a string representing a filesystem path to the data to + use for the component. The *path* can also be specified as a sequence + string, in which case a sequence component with child components for + each item in the sequence will be created automatically. The accepted + format for a sequence is '{head}{padding}{tail} [{ranges}]'. For + example:: + + '/path/to/file.%04d.ext [1-5, 7, 8, 10-20]' + + .. seealso:: + + `Clique documentation `_ + + *data* should be a dictionary of any additional data to construct the + component with (as passed to :meth:`Session.create`). This version is + automatically set as the component's version. + + If *location* is specified then automatically add component to that + location. + + ''' + if data is None: + data = {} + + data.pop('version_id', None) + data['version'] = self + + return self.session.create_component(path, data=data, location=location) + + def encode_media(self, media, keep_original='auto'): + '''Return a new Job that encode *media* to make it playable in browsers. + + *media* can be a path to a file or a FileComponent in the ftrack.server + location. + + The job will encode *media* based on the file type and job data contains + information about encoding in the following format:: + + { + 'output': [{ + 'format': 'video/mp4', + 'component_id': 'e2dc0524-b576-11d3-9612-080027331d74' + }, { + 'format': 'image/jpeg', + 'component_id': '07b82a97-8cf9-11e3-9383-20c9d081909b' + }], + 'source_component_id': 'e3791a09-7e11-4792-a398-3d9d4eefc294', + 'keep_original': True + } + + The output components are associated with the job via the job_components + relation. + + An image component will always be generated if possible, and will be + set as the version's thumbnail. + + The new components will automatically be associated with the version. + A server version of 3.3.32 or higher is required for this to function + properly. + + If *media* is a file path, a new source component will be created and + added to the ftrack server location and a call to :meth:`commit` will be + issued. If *media* is a FileComponent, it will be assumed to be in + available in the ftrack.server location. + + If *keep_original* is not set, the original media will be kept if it + is a FileComponent, and deleted if it is a file path. You can specify + True or False to change this behavior. + ''' + return self.session.encode_media( + media, version_id=self['id'], keep_original=keep_original + ) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/base.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/base.py new file mode 100644 index 0000000000..f5a1a3cec3 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/base.py @@ -0,0 +1,402 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +from __future__ import absolute_import + +import abc +import collections +import logging + +import ftrack_api.symbol +import ftrack_api.attribute +import ftrack_api.inspection +import ftrack_api.exception +import ftrack_api.operation +from ftrack_api.logging import LazyLogMessage as L + + +class DynamicEntityTypeMetaclass(abc.ABCMeta): + '''Custom metaclass to customise representation of dynamic classes. + + .. note:: + + Derive from same metaclass as derived bases to avoid conflicts. + + ''' + def __repr__(self): + '''Return representation of class.''' + return ''.format(self.__name__) + + +class Entity(collections.MutableMapping): + '''Base class for all entities.''' + + __metaclass__ = DynamicEntityTypeMetaclass + + entity_type = 'Entity' + attributes = None + primary_key_attributes = None + default_projections = None + + def __init__(self, session, data=None, reconstructing=False): + '''Initialise entity. + + *session* is an instance of :class:`ftrack_api.session.Session` that + this entity instance is bound to. + + *data* is a mapping of key, value pairs to apply as initial attribute + values. + + *reconstructing* indicates whether this entity is being reconstructed, + such as from a query, and therefore should not have any special creation + logic applied, such as initialising defaults for missing data. + + ''' + super(Entity, self).__init__() + self.logger = logging.getLogger( + __name__ + '.' + self.__class__.__name__ + ) + self.session = session + self._inflated = set() + + if data is None: + data = {} + + self.logger.debug(L( + '{0} entity from {1!r}.', + ('Reconstructing' if reconstructing else 'Constructing'), data + )) + + self._ignore_data_keys = ['__entity_type__'] + if not reconstructing: + self._construct(data) + else: + self._reconstruct(data) + + def _construct(self, data): + '''Construct from *data*.''' + # Suspend operation recording so that all modifications can be applied + # in single create operation. In addition, recording a modification + # operation requires a primary key which may not be available yet. + + relational_attributes = dict() + + with self.session.operation_recording(False): + # Set defaults for any unset local attributes. + for attribute in self.__class__.attributes: + if attribute.name not in data: + default_value = attribute.default_value + if callable(default_value): + default_value = default_value(self) + + attribute.set_local_value(self, default_value) + + + # Data represents locally set values. + for key, value in data.items(): + if key in self._ignore_data_keys: + continue + + attribute = self.__class__.attributes.get(key) + if attribute is None: + self.logger.debug(L( + 'Cannot populate {0!r} attribute as no such ' + 'attribute found on entity {1!r}.', key, self + )) + continue + + if not isinstance(attribute, ftrack_api.attribute.ScalarAttribute): + relational_attributes.setdefault( + attribute, value + ) + + else: + attribute.set_local_value(self, value) + + # Record create operation. + # Note: As this operation is recorded *before* any Session.merge takes + # place there is the possibility that the operation will hold references + # to outdated data in entity_data. However, this would be unusual in + # that it would mean the same new entity was created twice and only one + # altered. Conversely, if this operation were recorded *after* + # Session.merge took place, any cache would not be able to determine + # the status of the entity, which could be important if the cache should + # not store newly created entities that have not yet been persisted. Out + # of these two 'evils' this approach is deemed the lesser at this time. + # A third, more involved, approach to satisfy both might be to record + # the operation with a PENDING entity_data value and then update with + # merged values post merge. + if self.session.record_operations: + entity_data = {} + + # Lower level API used here to avoid including any empty + # collections that are automatically generated on access. + for attribute in self.attributes: + value = attribute.get_local_value(self) + if value is not ftrack_api.symbol.NOT_SET: + entity_data[attribute.name] = value + + self.session.recorded_operations.push( + ftrack_api.operation.CreateEntityOperation( + self.entity_type, + ftrack_api.inspection.primary_key(self), + entity_data + ) + ) + + for attribute, value in relational_attributes.items(): + # Finally we set values for "relational" attributes, we need + # to do this at the end in order to get the create operations + # in the correct order as the newly created attributes might + # contain references to the newly created entity. + + attribute.set_local_value( + self, value + ) + + def _reconstruct(self, data): + '''Reconstruct from *data*.''' + # Data represents remote values. + for key, value in data.items(): + if key in self._ignore_data_keys: + continue + + attribute = self.__class__.attributes.get(key) + if attribute is None: + self.logger.debug(L( + 'Cannot populate {0!r} attribute as no such attribute ' + 'found on entity {1!r}.', key, self + )) + continue + + attribute.set_remote_value(self, value) + + def __repr__(self): + '''Return representation of instance.''' + return ''.format( + self.__class__.__name__, id(self) + ) + + def __str__(self): + '''Return string representation of instance.''' + with self.session.auto_populating(False): + primary_key = ['Unknown'] + try: + primary_key = ftrack_api.inspection.primary_key(self).values() + except KeyError: + pass + + return '<{0}({1})>'.format( + self.__class__.__name__, ', '.join(primary_key) + ) + + def __hash__(self): + '''Return hash representing instance.''' + return hash(str(ftrack_api.inspection.identity(self))) + + def __eq__(self, other): + '''Return whether *other* is equal to this instance. + + .. note:: + + Equality is determined by both instances having the same identity. + Values of attributes are not considered. + + ''' + try: + return ( + ftrack_api.inspection.identity(other) + == ftrack_api.inspection.identity(self) + ) + except (AttributeError, KeyError): + return False + + def __getitem__(self, key): + '''Return attribute value for *key*.''' + attribute = self.__class__.attributes.get(key) + if attribute is None: + raise KeyError(key) + + return attribute.get_value(self) + + def __setitem__(self, key, value): + '''Set attribute *value* for *key*.''' + attribute = self.__class__.attributes.get(key) + if attribute is None: + raise KeyError(key) + + attribute.set_local_value(self, value) + + def __delitem__(self, key): + '''Clear attribute value for *key*. + + .. note:: + + Will not remove the attribute, but instead clear any local value + and revert to the last known server value. + + ''' + attribute = self.__class__.attributes.get(key) + attribute.set_local_value(self, ftrack_api.symbol.NOT_SET) + + def __iter__(self): + '''Iterate over all attributes keys.''' + for attribute in self.__class__.attributes: + yield attribute.name + + def __len__(self): + '''Return count of attributes.''' + return len(self.__class__.attributes) + + def values(self): + '''Return list of values.''' + if self.session.auto_populate: + self._populate_unset_scalar_attributes() + + return super(Entity, self).values() + + def items(self): + '''Return list of tuples of (key, value) pairs. + + .. note:: + + Will fetch all values from the server if not already fetched or set + locally. + + ''' + if self.session.auto_populate: + self._populate_unset_scalar_attributes() + + return super(Entity, self).items() + + def clear(self): + '''Reset all locally modified attribute values.''' + for attribute in self: + del self[attribute] + + def merge(self, entity, merged=None): + '''Merge *entity* attribute values and other data into this entity. + + Only merge values from *entity* that are not + :attr:`ftrack_api.symbol.NOT_SET`. + + Return a list of changes made with each change being a mapping with + the keys: + + * type - Either 'remote_attribute', 'local_attribute' or 'property'. + * name - The name of the attribute / property modified. + * old_value - The previous value. + * new_value - The new merged value. + + ''' + log_debug = self.logger.isEnabledFor(logging.DEBUG) + + if merged is None: + merged = {} + + log_message = 'Merged {type} "{name}": {old_value!r} -> {new_value!r}' + changes = [] + + # Attributes. + + # Prioritise by type so that scalar values are set first. This should + # guarantee that the attributes making up the identity of the entity + # are merged before merging any collections that may have references to + # this entity. + attributes = collections.deque() + for attribute in entity.attributes: + if isinstance(attribute, ftrack_api.attribute.ScalarAttribute): + attributes.appendleft(attribute) + else: + attributes.append(attribute) + + for other_attribute in attributes: + attribute = self.attributes.get(other_attribute.name) + + # Local attributes. + other_local_value = other_attribute.get_local_value(entity) + if other_local_value is not ftrack_api.symbol.NOT_SET: + local_value = attribute.get_local_value(self) + if local_value != other_local_value: + merged_local_value = self.session.merge( + other_local_value, merged=merged + ) + + attribute.set_local_value(self, merged_local_value) + changes.append({ + 'type': 'local_attribute', + 'name': attribute.name, + 'old_value': local_value, + 'new_value': merged_local_value + }) + log_debug and self.logger.debug( + log_message.format(**changes[-1]) + ) + + # Remote attributes. + other_remote_value = other_attribute.get_remote_value(entity) + if other_remote_value is not ftrack_api.symbol.NOT_SET: + remote_value = attribute.get_remote_value(self) + if remote_value != other_remote_value: + merged_remote_value = self.session.merge( + other_remote_value, merged=merged + ) + + attribute.set_remote_value( + self, merged_remote_value + ) + + changes.append({ + 'type': 'remote_attribute', + 'name': attribute.name, + 'old_value': remote_value, + 'new_value': merged_remote_value + }) + + log_debug and self.logger.debug( + log_message.format(**changes[-1]) + ) + + # We need to handle collections separately since + # they may store a local copy of the remote attribute + # even though it may not be modified. + if not isinstance( + attribute, ftrack_api.attribute.AbstractCollectionAttribute + ): + continue + + local_value = attribute.get_local_value( + self + ) + + # Populated but not modified, update it. + if ( + local_value is not ftrack_api.symbol.NOT_SET and + local_value == remote_value + ): + attribute.set_local_value( + self, merged_remote_value + ) + changes.append({ + 'type': 'local_attribute', + 'name': attribute.name, + 'old_value': local_value, + 'new_value': merged_remote_value + }) + + log_debug and self.logger.debug( + log_message.format(**changes[-1]) + ) + + return changes + + def _populate_unset_scalar_attributes(self): + '''Populate all unset scalar attributes in one query.''' + projections = [] + for attribute in self.attributes: + if isinstance(attribute, ftrack_api.attribute.ScalarAttribute): + if attribute.get_remote_value(self) is ftrack_api.symbol.NOT_SET: + projections.append(attribute.name) + + if projections: + self.session.populate([self], ', '.join(projections)) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/component.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/component.py new file mode 100644 index 0000000000..9d59c4c051 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/component.py @@ -0,0 +1,74 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import ftrack_api.entity.base + + +class Component(ftrack_api.entity.base.Entity): + '''Represent a component.''' + + def get_availability(self, locations=None): + '''Return availability in *locations*. + + If *locations* is None, all known locations will be checked. + + Return a dictionary of {location_id:percentage_availability} + + ''' + return self.session.get_component_availability( + self, locations=locations + ) + + +class CreateThumbnailMixin(object): + '''Mixin to add create_thumbnail method on entity class.''' + + def create_thumbnail(self, path, data=None): + '''Set entity thumbnail from *path*. + + Creates a thumbnail component using in the ftrack.server location + :meth:`Session.create_component + ` The thumbnail component + will be created using *data* if specified. If no component name is + given, `thumbnail` will be used. + + The file is expected to be of an appropriate size and valid file + type. + + .. note:: + + A :meth:`Session.commit` will be + automatically issued. + + ''' + if data is None: + data = {} + if not data.get('name'): + data['name'] = 'thumbnail' + + thumbnail_component = self.session.create_component( + path, data, location=None + ) + + origin_location = self.session.get( + 'Location', ftrack_api.symbol.ORIGIN_LOCATION_ID + ) + server_location = self.session.get( + 'Location', ftrack_api.symbol.SERVER_LOCATION_ID + ) + server_location.add_component(thumbnail_component, [origin_location]) + + # TODO: This commit can be avoided by reordering the operations in + # this method so that the component is transferred to ftrack.server + # after the thumbnail has been set. + # + # There is currently a bug in the API backend, causing the operations + # to *some* times be ordered wrongly, where the update occurs before + # the component has been created, causing an integrity error. + # + # Once this issue has been resolved, this commit can be removed and + # and the update placed between component creation and registration. + self['thumbnail_id'] = thumbnail_component['id'] + self.session.commit() + + return thumbnail_component diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/factory.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/factory.py new file mode 100644 index 0000000000..e925b70f5a --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/factory.py @@ -0,0 +1,435 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +from __future__ import absolute_import + +import logging +import uuid +import functools + +import ftrack_api.attribute +import ftrack_api.entity.base +import ftrack_api.entity.location +import ftrack_api.entity.component +import ftrack_api.entity.asset_version +import ftrack_api.entity.project_schema +import ftrack_api.entity.note +import ftrack_api.entity.job +import ftrack_api.entity.user +import ftrack_api.symbol +import ftrack_api.cache +from ftrack_api.logging import LazyLogMessage as L + + +class Factory(object): + '''Entity class factory.''' + + def __init__(self): + '''Initialise factory.''' + super(Factory, self).__init__() + self.logger = logging.getLogger( + __name__ + '.' + self.__class__.__name__ + ) + + def create(self, schema, bases=None): + '''Create and return entity class from *schema*. + + *bases* should be a list of bases to give the constructed class. If not + specified, default to :class:`ftrack_api.entity.base.Entity`. + + ''' + entity_type = schema['id'] + class_name = entity_type + + class_bases = bases + if class_bases is None: + class_bases = [ftrack_api.entity.base.Entity] + + class_namespace = dict() + + # Build attributes for class. + attributes = ftrack_api.attribute.Attributes() + immutable_properties = schema.get('immutable', []) + computed_properties = schema.get('computed', []) + for name, fragment in schema.get('properties', {}).items(): + mutable = name not in immutable_properties + computed = name in computed_properties + + default = fragment.get('default', ftrack_api.symbol.NOT_SET) + if default == '{uid}': + default = lambda instance: str(uuid.uuid4()) + + data_type = fragment.get('type', ftrack_api.symbol.NOT_SET) + + if data_type is not ftrack_api.symbol.NOT_SET: + + if data_type in ( + 'string', 'boolean', 'integer', 'number', 'variable', + 'object' + ): + # Basic scalar attribute. + if data_type == 'number': + data_type = 'float' + + if data_type == 'string': + data_format = fragment.get('format') + if data_format == 'date-time': + data_type = 'datetime' + + attribute = self.create_scalar_attribute( + class_name, name, mutable, computed, default, data_type + ) + if attribute: + attributes.add(attribute) + + elif data_type == 'array': + attribute = self.create_collection_attribute( + class_name, name, mutable + ) + if attribute: + attributes.add(attribute) + + elif data_type == 'mapped_array': + reference = fragment.get('items', {}).get('$ref') + if not reference: + self.logger.debug(L( + 'Skipping {0}.{1} mapped_array attribute that does ' + 'not define a schema reference.', class_name, name + )) + continue + + attribute = self.create_mapped_collection_attribute( + class_name, name, mutable, reference + ) + if attribute: + attributes.add(attribute) + + else: + self.logger.debug(L( + 'Skipping {0}.{1} attribute with unrecognised data ' + 'type {2}', class_name, name, data_type + )) + else: + # Reference attribute. + reference = fragment.get('$ref', ftrack_api.symbol.NOT_SET) + if reference is ftrack_api.symbol.NOT_SET: + self.logger.debug(L( + 'Skipping {0}.{1} mapped_array attribute that does ' + 'not define a schema reference.', class_name, name + )) + continue + + attribute = self.create_reference_attribute( + class_name, name, mutable, reference + ) + if attribute: + attributes.add(attribute) + + default_projections = schema.get('default_projections', []) + + # Construct class. + class_namespace['entity_type'] = entity_type + class_namespace['attributes'] = attributes + class_namespace['primary_key_attributes'] = schema['primary_key'][:] + class_namespace['default_projections'] = default_projections + + cls = type( + str(class_name), # type doesn't accept unicode. + tuple(class_bases), + class_namespace + ) + + return cls + + def create_scalar_attribute( + self, class_name, name, mutable, computed, default, data_type + ): + '''Return appropriate scalar attribute instance.''' + return ftrack_api.attribute.ScalarAttribute( + name, data_type=data_type, default_value=default, mutable=mutable, + computed=computed + ) + + def create_reference_attribute(self, class_name, name, mutable, reference): + '''Return appropriate reference attribute instance.''' + return ftrack_api.attribute.ReferenceAttribute( + name, reference, mutable=mutable + ) + + def create_collection_attribute(self, class_name, name, mutable): + '''Return appropriate collection attribute instance.''' + return ftrack_api.attribute.CollectionAttribute( + name, mutable=mutable + ) + + def create_mapped_collection_attribute( + self, class_name, name, mutable, reference + ): + '''Return appropriate mapped collection attribute instance.''' + self.logger.debug(L( + 'Skipping {0}.{1} mapped_array attribute that has ' + 'no implementation defined for reference {2}.', + class_name, name, reference + )) + + +class PerSessionDefaultKeyMaker(ftrack_api.cache.KeyMaker): + '''Generate key for defaults.''' + + def _key(self, obj): + '''Return key for *obj*.''' + if isinstance(obj, dict): + entity = obj.get('entity') + if entity is not None: + # Key by session only. + return str(id(entity.session)) + + return str(obj) + + +#: Memoiser for use with default callables that should only be called once per +# session. +memoise_defaults = ftrack_api.cache.memoise_decorator( + ftrack_api.cache.Memoiser( + key_maker=PerSessionDefaultKeyMaker(), return_copies=False + ) +) + +#: Memoiser for use with callables that should be called once per session. +memoise_session = ftrack_api.cache.memoise_decorator( + ftrack_api.cache.Memoiser( + key_maker=PerSessionDefaultKeyMaker(), return_copies=False + ) +) + + +@memoise_session +def _get_custom_attribute_configurations(session): + '''Return list of custom attribute configurations. + + The configuration objects will have key, project_id, id and object_type_id + populated. + + ''' + return session.query( + 'select key, project_id, id, object_type_id, entity_type, ' + 'is_hierarchical from CustomAttributeConfiguration' + ).all() + + +def _get_entity_configurations(entity): + '''Return all configurations for current collection entity.''' + entity_type = None + project_id = None + object_type_id = None + + if 'object_type_id' in entity.keys(): + project_id = entity['project_id'] + entity_type = 'task' + object_type_id = entity['object_type_id'] + + if entity.entity_type == 'AssetVersion': + project_id = entity['asset']['parent']['project_id'] + entity_type = 'assetversion' + + if entity.entity_type == 'Project': + project_id = entity['id'] + entity_type = 'show' + + if entity.entity_type == 'User': + entity_type = 'user' + + if entity.entity_type == 'Asset': + entity_type = 'asset' + + if entity.entity_type in ('TypedContextList', 'AssetVersionList'): + entity_type = 'list' + + if entity_type is None: + raise ValueError( + 'Entity {!r} not supported.'.format(entity) + ) + + configurations = [] + for configuration in _get_custom_attribute_configurations( + entity.session + ): + if ( + configuration['entity_type'] == entity_type and + configuration['project_id'] in (project_id, None) and + configuration['object_type_id'] == object_type_id + ): + # The custom attribute configuration is for the target entity type. + configurations.append(configuration) + elif ( + entity_type in ('asset', 'assetversion', 'show', 'task') and + configuration['project_id'] in (project_id, None) and + configuration['is_hierarchical'] + ): + # The target entity type allows hierarchical attributes. + configurations.append(configuration) + + # Return with global configurations at the end of the list. This is done + # so that global conigurations are shadowed by project specific if the + # configurations list is looped when looking for a matching `key`. + return sorted( + configurations, key=lambda item: item['project_id'] is None + ) + + +class StandardFactory(Factory): + '''Standard entity class factory.''' + + def create(self, schema, bases=None): + '''Create and return entity class from *schema*.''' + if not bases: + bases = [] + + extra_bases = [] + # Customise classes. + if schema['id'] == 'ProjectSchema': + extra_bases = [ftrack_api.entity.project_schema.ProjectSchema] + + elif schema['id'] == 'Location': + extra_bases = [ftrack_api.entity.location.Location] + + elif schema['id'] == 'AssetVersion': + extra_bases = [ftrack_api.entity.asset_version.AssetVersion] + + elif schema['id'].endswith('Component'): + extra_bases = [ftrack_api.entity.component.Component] + + elif schema['id'] == 'Note': + extra_bases = [ftrack_api.entity.note.Note] + + elif schema['id'] == 'Job': + extra_bases = [ftrack_api.entity.job.Job] + + elif schema['id'] == 'User': + extra_bases = [ftrack_api.entity.user.User] + + bases = extra_bases + bases + + # If bases does not contain any items, add the base entity class. + if not bases: + bases = [ftrack_api.entity.base.Entity] + + # Add mixins. + if 'notes' in schema.get('properties', {}): + bases.append( + ftrack_api.entity.note.CreateNoteMixin + ) + + if 'thumbnail_id' in schema.get('properties', {}): + bases.append( + ftrack_api.entity.component.CreateThumbnailMixin + ) + + cls = super(StandardFactory, self).create(schema, bases=bases) + + return cls + + def create_mapped_collection_attribute( + self, class_name, name, mutable, reference + ): + '''Return appropriate mapped collection attribute instance.''' + if reference == 'Metadata': + + def create_metadata(proxy, data, reference): + '''Return metadata for *data*.''' + entity = proxy.collection.entity + session = entity.session + data.update({ + 'parent_id': entity['id'], + 'parent_type': entity.entity_type + }) + return session.create(reference, data) + + creator = functools.partial( + create_metadata, reference=reference + ) + key_attribute = 'key' + value_attribute = 'value' + + return ftrack_api.attribute.KeyValueMappedCollectionAttribute( + name, creator, key_attribute, value_attribute, mutable=mutable + ) + + elif reference == 'CustomAttributeValue': + return ( + ftrack_api.attribute.CustomAttributeCollectionAttribute( + name, mutable=mutable + ) + ) + + elif reference.endswith('CustomAttributeValue'): + def creator(proxy, data): + '''Create a custom attribute based on *proxy* and *data*. + + Raise :py:exc:`KeyError` if related entity is already presisted + to the server. The proxy represents dense custom attribute + values and should never create new custom attribute values + through the proxy if entity exists on the remote. + + If the entity is not persisted the ususal + CustomAttributeValue items cannot be updated as + the related entity does not exist on remote and values not in + the proxy. Instead a CustomAttributeValue will + be reconstructed and an update operation will be recorded. + + ''' + entity = proxy.collection.entity + if ( + ftrack_api.inspection.state(entity) is not + ftrack_api.symbol.CREATED + ): + raise KeyError( + 'Custom attributes must be created explicitly for the ' + 'given entity type before being set.' + ) + + configuration = None + for candidate in _get_entity_configurations(entity): + if candidate['key'] == data['key']: + configuration = candidate + break + + if configuration is None: + raise ValueError( + u'No valid custom attribute for data {0!r} was found.' + .format(data) + ) + + create_data = dict(data.items()) + create_data['configuration_id'] = configuration['id'] + create_data['entity_id'] = entity['id'] + + session = entity.session + + # Create custom attribute by reconstructing it and update the + # value. This will prevent a create operation to be sent to the + # remote, as create operations for this entity type is not + # allowed. Instead an update operation will be recorded. + value = create_data.pop('value') + item = session.create( + reference, + create_data, + reconstructing=True + ) + + # Record update operation. + item['value'] = value + + return item + + key_attribute = 'key' + value_attribute = 'value' + + return ftrack_api.attribute.KeyValueMappedCollectionAttribute( + name, creator, key_attribute, value_attribute, mutable=mutable + ) + + self.logger.debug(L( + 'Skipping {0}.{1} mapped_array attribute that has no configuration ' + 'for reference {2}.', class_name, name, reference + )) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/job.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/job.py new file mode 100644 index 0000000000..ae37922c51 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/job.py @@ -0,0 +1,48 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import ftrack_api.entity.base + + +class Job(ftrack_api.entity.base.Entity): + '''Represent job.''' + + def __init__(self, session, data=None, reconstructing=False): + '''Initialise entity. + + *session* is an instance of :class:`ftrack_api.session.Session` that + this entity instance is bound to. + + *data* is a mapping of key, value pairs to apply as initial attribute + values. + + To set a job `description` visible in the web interface, *data* can + contain a key called `data` which should be a JSON serialised + dictionary containing description:: + + data = { + 'status': 'running', + 'data': json.dumps(dict(description='My job description.')), + ... + } + + Will raise a :py:exc:`ValueError` if *data* contains `type` and `type` + is set to something not equal to "api_job". + + *reconstructing* indicates whether this entity is being reconstructed, + such as from a query, and therefore should not have any special creation + logic applied, such as initialising defaults for missing data. + + ''' + + if not reconstructing: + if data.get('type') not in ('api_job', None): + raise ValueError( + 'Invalid job type "{0}". Must be "api_job"'.format( + data.get('type') + ) + ) + + super(Job, self).__init__( + session, data=data, reconstructing=reconstructing + ) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/location.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/location.py new file mode 100644 index 0000000000..707f4fa652 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/location.py @@ -0,0 +1,733 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import collections +import functools + +import ftrack_api.entity.base +import ftrack_api.exception +import ftrack_api.event.base +import ftrack_api.symbol +import ftrack_api.inspection +from ftrack_api.logging import LazyLogMessage as L + + +class Location(ftrack_api.entity.base.Entity): + '''Represent storage for components.''' + + def __init__(self, session, data=None, reconstructing=False): + '''Initialise entity. + + *session* is an instance of :class:`ftrack_api.session.Session` that + this entity instance is bound to. + + *data* is a mapping of key, value pairs to apply as initial attribute + values. + + *reconstructing* indicates whether this entity is being reconstructed, + such as from a query, and therefore should not have any special creation + logic applied, such as initialising defaults for missing data. + + ''' + self.accessor = ftrack_api.symbol.NOT_SET + self.structure = ftrack_api.symbol.NOT_SET + self.resource_identifier_transformer = ftrack_api.symbol.NOT_SET + self.priority = 95 + super(Location, self).__init__( + session, data=data, reconstructing=reconstructing + ) + + def __str__(self): + '''Return string representation of instance.''' + representation = super(Location, self).__str__() + + with self.session.auto_populating(False): + name = self['name'] + if name is not ftrack_api.symbol.NOT_SET: + representation = representation.replace( + '(', '("{0}", '.format(name) + ) + + return representation + + def add_component(self, component, source, recursive=True): + '''Add *component* to location. + + *component* should be a single component instance. + + *source* should be an instance of another location that acts as the + source. + + Raise :exc:`ftrack_api.ComponentInLocationError` if the *component* + already exists in this location. + + Raise :exc:`ftrack_api.LocationError` if managing data and the generated + target structure for the component already exists according to the + accessor. This helps prevent potential data loss by avoiding overwriting + existing data. Note that there is a race condition between the check and + the write so if another process creates data at the same target during + that period it will be overwritten. + + .. note:: + + A :meth:`Session.commit` may be + automatically issued as part of the component registration. + + ''' + return self.add_components( + [component], sources=source, recursive=recursive + ) + + def add_components(self, components, sources, recursive=True, _depth=0): + '''Add *components* to location. + + *components* should be a list of component instances. + + *sources* may be either a single source or a list of sources. If a list + then each corresponding index in *sources* will be used for each + *component*. A source should be an instance of another location. + + Raise :exc:`ftrack_api.exception.ComponentInLocationError` if any + component in *components* already exists in this location. In this case, + no changes will be made and no data transferred. + + Raise :exc:`ftrack_api.exception.LocationError` if managing data and the + generated target structure for the component already exists according to + the accessor. This helps prevent potential data loss by avoiding + overwriting existing data. Note that there is a race condition between + the check and the write so if another process creates data at the same + target during that period it will be overwritten. + + .. note:: + + A :meth:`Session.commit` may be + automatically issued as part of the components registration. + + .. important:: + + If this location manages data then the *components* data is first + transferred to the target prescribed by the structure plugin, using + the configured accessor. If any component fails to transfer then + :exc:`ftrack_api.exception.LocationError` is raised and none of the + components are registered with the database. In this case it is left + up to the caller to decide and act on manually cleaning up any + transferred data using the 'transferred' detail in the raised error. + + Likewise, after transfer, all components are registered with the + database in a batch call. If any component causes an error then all + components will remain unregistered and + :exc:`ftrack_api.exception.LocationError` will be raised detailing + issues and any transferred data under the 'transferred' detail key. + + ''' + if ( + isinstance(sources, basestring) + or not isinstance(sources, collections.Sequence) + ): + sources = [sources] + + sources_count = len(sources) + if sources_count not in (1, len(components)): + raise ValueError( + 'sources must be either a single source or a sequence of ' + 'sources with indexes corresponding to passed components.' + ) + + if not self.structure: + raise ftrack_api.exception.LocationError( + 'No structure defined for location {location}.', + details=dict(location=self) + ) + + if not components: + # Optimisation: Return early when no components to process, such as + # when called recursively on an empty sequence component. + return + + indent = ' ' * (_depth + 1) + + # Check that components not already added to location. + existing_components = [] + try: + self.get_resource_identifiers(components) + + except ftrack_api.exception.ComponentNotInLocationError as error: + missing_component_ids = [ + missing_component['id'] + for missing_component in error.details['components'] + ] + for component in components: + if component['id'] not in missing_component_ids: + existing_components.append(component) + + else: + existing_components.extend(components) + + if existing_components: + # Some of the components already present in location. + raise ftrack_api.exception.ComponentInLocationError( + existing_components, self + ) + + # Attempt to transfer each component's data to this location. + transferred = [] + + for index, component in enumerate(components): + try: + # Determine appropriate source. + if sources_count == 1: + source = sources[0] + else: + source = sources[index] + + # Add members first for container components. + is_container = 'members' in component.keys() + if is_container and recursive: + self.add_components( + component['members'], source, recursive=recursive, + _depth=(_depth + 1) + ) + + # Add component to this location. + context = self._get_context(component, source) + resource_identifier = self.structure.get_resource_identifier( + component, context + ) + + # Manage data transfer. + self._add_data(component, resource_identifier, source) + + except Exception as error: + raise ftrack_api.exception.LocationError( + 'Failed to transfer component {component} data to location ' + '{location} due to error:\n{indent}{error}\n{indent}' + 'Transferred component data that may require cleanup: ' + '{transferred}', + details=dict( + indent=indent, + component=component, + location=self, + error=error, + transferred=transferred + ) + ) + + else: + transferred.append((component, resource_identifier)) + + # Register all successfully transferred components. + components_to_register = [] + component_resource_identifiers = [] + + try: + for component, resource_identifier in transferred: + if self.resource_identifier_transformer: + # Optionally encode resource identifier before storing. + resource_identifier = ( + self.resource_identifier_transformer.encode( + resource_identifier, + context={'component': component} + ) + ) + + components_to_register.append(component) + component_resource_identifiers.append(resource_identifier) + + # Store component in location information. + self._register_components_in_location( + components, component_resource_identifiers + ) + + except Exception as error: + raise ftrack_api.exception.LocationError( + 'Failed to register components with location {location} due to ' + 'error:\n{indent}{error}\n{indent}Transferred component data ' + 'that may require cleanup: {transferred}', + details=dict( + indent=indent, + location=self, + error=error, + transferred=transferred + ) + ) + + # Publish events. + for component in components_to_register: + + component_id = ftrack_api.inspection.primary_key( + component + ).values()[0] + location_id = ftrack_api.inspection.primary_key(self).values()[0] + + self.session.event_hub.publish( + ftrack_api.event.base.Event( + topic=ftrack_api.symbol.COMPONENT_ADDED_TO_LOCATION_TOPIC, + data=dict( + component_id=component_id, + location_id=location_id + ), + ), + on_error='ignore' + ) + + def _get_context(self, component, source): + '''Return context for *component* and *source*.''' + context = {} + if source: + try: + source_resource_identifier = source.get_resource_identifier( + component + ) + except ftrack_api.exception.ComponentNotInLocationError: + pass + else: + context.update(dict( + source_resource_identifier=source_resource_identifier + )) + + return context + + def _add_data(self, component, resource_identifier, source): + '''Manage transfer of *component* data from *source*. + + *resource_identifier* specifies the identifier to use with this + locations accessor. + + ''' + self.logger.debug(L( + 'Adding data for component {0!r} from source {1!r} to location ' + '{2!r} using resource identifier {3!r}.', + component, resource_identifier, source, self + )) + + # Read data from source and write to this location. + if not source.accessor: + raise ftrack_api.exception.LocationError( + 'No accessor defined for source location {location}.', + details=dict(location=source) + ) + + if not self.accessor: + raise ftrack_api.exception.LocationError( + 'No accessor defined for target location {location}.', + details=dict(location=self) + ) + + is_container = 'members' in component.keys() + if is_container: + # TODO: Improve this check. Possibly introduce an inspection + # such as ftrack_api.inspection.is_sequence_component. + if component.entity_type != 'SequenceComponent': + self.accessor.make_container(resource_identifier) + + else: + # Try to make container of component. + try: + container = self.accessor.get_container( + resource_identifier + ) + + except ftrack_api.exception.AccessorParentResourceNotFoundError: + # Container could not be retrieved from + # resource_identifier. Assume that there is no need to + # make the container. + pass + + else: + # No need for existence check as make_container does not + # recreate existing containers. + self.accessor.make_container(container) + + if self.accessor.exists(resource_identifier): + # Note: There is a race condition here in that the + # data may be added externally between the check for + # existence and the actual write which would still + # result in potential data loss. However, there is no + # good cross platform, cross accessor solution for this + # at present. + raise ftrack_api.exception.LocationError( + 'Cannot add component as data already exists and ' + 'overwriting could result in data loss. Computed ' + 'target resource identifier was: {0}' + .format(resource_identifier) + ) + + # Read and write data. + source_data = source.accessor.open( + source.get_resource_identifier(component), 'rb' + ) + target_data = self.accessor.open(resource_identifier, 'wb') + + # Read/write data in chunks to avoid reading all into memory at the + # same time. + chunked_read = functools.partial( + source_data.read, ftrack_api.symbol.CHUNK_SIZE + ) + for chunk in iter(chunked_read, ''): + target_data.write(chunk) + + target_data.close() + source_data.close() + + def _register_component_in_location(self, component, resource_identifier): + '''Register *component* in location against *resource_identifier*.''' + return self._register_components_in_location( + [component], [resource_identifier] + ) + + def _register_components_in_location( + self, components, resource_identifiers + ): + '''Register *components* in location against *resource_identifiers*. + + Indices of *components* and *resource_identifiers* should align. + + ''' + for component, resource_identifier in zip( + components, resource_identifiers + ): + self.session.create( + 'ComponentLocation', data=dict( + component=component, + location=self, + resource_identifier=resource_identifier + ) + ) + + self.session.commit() + + def remove_component(self, component, recursive=True): + '''Remove *component* from location. + + .. note:: + + A :meth:`Session.commit` may be + automatically issued as part of the component deregistration. + + ''' + return self.remove_components([component], recursive=recursive) + + def remove_components(self, components, recursive=True): + '''Remove *components* from location. + + .. note:: + + A :meth:`Session.commit` may be + automatically issued as part of the components deregistration. + + ''' + for component in components: + # Check component is in this location + self.get_resource_identifier(component) + + # Remove members first for container components. + is_container = 'members' in component.keys() + if is_container and recursive: + self.remove_components( + component['members'], recursive=recursive + ) + + # Remove data. + self._remove_data(component) + + # Remove metadata. + self._deregister_component_in_location(component) + + # Emit event. + component_id = ftrack_api.inspection.primary_key( + component + ).values()[0] + location_id = ftrack_api.inspection.primary_key(self).values()[0] + self.session.event_hub.publish( + ftrack_api.event.base.Event( + topic=ftrack_api.symbol.COMPONENT_REMOVED_FROM_LOCATION_TOPIC, + data=dict( + component_id=component_id, + location_id=location_id + ) + ), + on_error='ignore' + ) + + def _remove_data(self, component): + '''Remove data associated with *component*.''' + if not self.accessor: + raise ftrack_api.exception.LocationError( + 'No accessor defined for location {location}.', + details=dict(location=self) + ) + + try: + self.accessor.remove( + self.get_resource_identifier(component) + ) + except ftrack_api.exception.AccessorResourceNotFoundError: + # If accessor does not support detecting sequence paths then an + # AccessorResourceNotFoundError is raised. For now, if the + # component type is 'SequenceComponent' assume success. + if not component.entity_type == 'SequenceComponent': + raise + + def _deregister_component_in_location(self, component): + '''Deregister *component* from location.''' + component_id = ftrack_api.inspection.primary_key(component).values()[0] + location_id = ftrack_api.inspection.primary_key(self).values()[0] + + # TODO: Use session.get for optimisation. + component_location = self.session.query( + 'ComponentLocation where component_id is {0} and location_id is ' + '{1}'.format(component_id, location_id) + )[0] + + self.session.delete(component_location) + + # TODO: Should auto-commit here be optional? + self.session.commit() + + def get_component_availability(self, component): + '''Return availability of *component* in this location as a float.''' + return self.session.get_component_availability( + component, locations=[self] + )[self['id']] + + def get_component_availabilities(self, components): + '''Return availabilities of *components* in this location. + + Return list of float values corresponding to each component. + + ''' + return [ + availability[self['id']] for availability in + self.session.get_component_availabilities( + components, locations=[self] + ) + ] + + def get_resource_identifier(self, component): + '''Return resource identifier for *component*. + + Raise :exc:`ftrack_api.exception.ComponentNotInLocationError` if the + component is not present in this location. + + ''' + return self.get_resource_identifiers([component])[0] + + def get_resource_identifiers(self, components): + '''Return resource identifiers for *components*. + + Raise :exc:`ftrack_api.exception.ComponentNotInLocationError` if any + of the components are not present in this location. + + ''' + resource_identifiers = self._get_resource_identifiers(components) + + # Optionally decode resource identifier. + if self.resource_identifier_transformer: + for index, resource_identifier in enumerate(resource_identifiers): + resource_identifiers[index] = ( + self.resource_identifier_transformer.decode( + resource_identifier, + context={'component': components[index]} + ) + ) + + return resource_identifiers + + def _get_resource_identifiers(self, components): + '''Return resource identifiers for *components*. + + Raise :exc:`ftrack_api.exception.ComponentNotInLocationError` if any + of the components are not present in this location. + + ''' + component_ids_mapping = collections.OrderedDict() + for component in components: + component_id = ftrack_api.inspection.primary_key( + component + ).values()[0] + component_ids_mapping[component_id] = component + + component_locations = self.session.query( + 'select component_id, resource_identifier from ComponentLocation ' + 'where location_id is {0} and component_id in ({1})' + .format( + ftrack_api.inspection.primary_key(self).values()[0], + ', '.join(component_ids_mapping.keys()) + ) + ) + + resource_identifiers_map = {} + for component_location in component_locations: + resource_identifiers_map[component_location['component_id']] = ( + component_location['resource_identifier'] + ) + + resource_identifiers = [] + missing = [] + for component_id, component in component_ids_mapping.items(): + if component_id not in resource_identifiers_map: + missing.append(component) + else: + resource_identifiers.append( + resource_identifiers_map[component_id] + ) + + if missing: + raise ftrack_api.exception.ComponentNotInLocationError( + missing, self + ) + + return resource_identifiers + + def get_filesystem_path(self, component): + '''Return filesystem path for *component*.''' + return self.get_filesystem_paths([component])[0] + + def get_filesystem_paths(self, components): + '''Return filesystem paths for *components*.''' + resource_identifiers = self.get_resource_identifiers(components) + + filesystem_paths = [] + for resource_identifier in resource_identifiers: + filesystem_paths.append( + self.accessor.get_filesystem_path(resource_identifier) + ) + + return filesystem_paths + + def get_url(self, component): + '''Return url for *component*. + + Raise :exc:`~ftrack_api.exception.AccessorFilesystemPathError` if + URL could not be determined from *component* or + :exc:`~ftrack_api.exception.AccessorUnsupportedOperationError` if + retrieving URL is not supported by the location's accessor. + ''' + resource_identifier = self.get_resource_identifier(component) + + return self.accessor.get_url(resource_identifier) + + +class MemoryLocationMixin(object): + '''Represent storage for components. + + Unlike a standard location, only store metadata for components in this + location in memory rather than persisting to the database. + + ''' + + @property + def _cache(self): + '''Return cache.''' + try: + cache = self.__cache + except AttributeError: + cache = self.__cache = {} + + return cache + + def _register_component_in_location(self, component, resource_identifier): + '''Register *component* in location with *resource_identifier*.''' + component_id = ftrack_api.inspection.primary_key(component).values()[0] + self._cache[component_id] = resource_identifier + + def _register_components_in_location( + self, components, resource_identifiers + ): + '''Register *components* in location against *resource_identifiers*. + + Indices of *components* and *resource_identifiers* should align. + + ''' + for component, resource_identifier in zip( + components, resource_identifiers + ): + self._register_component_in_location(component, resource_identifier) + + def _deregister_component_in_location(self, component): + '''Deregister *component* in location.''' + component_id = ftrack_api.inspection.primary_key(component).values()[0] + self._cache.pop(component_id) + + def _get_resource_identifiers(self, components): + '''Return resource identifiers for *components*. + + Raise :exc:`ftrack_api.exception.ComponentNotInLocationError` if any + of the referenced components are not present in this location. + + ''' + resource_identifiers = [] + missing = [] + for component in components: + component_id = ftrack_api.inspection.primary_key( + component + ).values()[0] + resource_identifier = self._cache.get(component_id) + if resource_identifier is None: + missing.append(component) + else: + resource_identifiers.append(resource_identifier) + + if missing: + raise ftrack_api.exception.ComponentNotInLocationError( + missing, self + ) + + return resource_identifiers + + +class UnmanagedLocationMixin(object): + '''Location that does not manage data.''' + + def _add_data(self, component, resource_identifier, source): + '''Manage transfer of *component* data from *source*. + + *resource_identifier* specifies the identifier to use with this + locations accessor. + + Overridden to have no effect. + + ''' + return + + def _remove_data(self, component): + '''Remove data associated with *component*. + + Overridden to have no effect. + + ''' + return + + +class OriginLocationMixin(MemoryLocationMixin, UnmanagedLocationMixin): + '''Special origin location that expects sources as filepaths.''' + + def _get_context(self, component, source): + '''Return context for *component* and *source*.''' + context = {} + if source: + context.update(dict( + source_resource_identifier=source + )) + + return context + + +class ServerLocationMixin(object): + '''Location representing ftrack server. + + Adds convenience methods to location, specific to ftrack server. + ''' + def get_thumbnail_url(self, component, size=None): + '''Return thumbnail url for *component*. + + Optionally, specify *size* to constrain the downscaled image to size + x size pixels. + + Raise :exc:`~ftrack_api.exception.AccessorFilesystemPathError` if + URL could not be determined from *resource_identifier* or + :exc:`~ftrack_api.exception.AccessorUnsupportedOperationError` if + retrieving URL is not supported by the location's accessor. + ''' + resource_identifier = self.get_resource_identifier(component) + return self.accessor.get_thumbnail_url(resource_identifier, size) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/note.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/note.py new file mode 100644 index 0000000000..f5a9403728 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/note.py @@ -0,0 +1,105 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import warnings + +import ftrack_api.entity.base + + +class Note(ftrack_api.entity.base.Entity): + '''Represent a note.''' + + def create_reply( + self, content, author + ): + '''Create a reply with *content* and *author*. + + .. note:: + + This is a helper method. To create replies manually use the + standard :meth:`Session.create` method. + + ''' + reply = self.session.create( + 'Note', { + 'author': author, + 'content': content + } + ) + + self['replies'].append(reply) + + return reply + + +class CreateNoteMixin(object): + '''Mixin to add create_note method on entity class.''' + + def create_note( + self, content, author, recipients=None, category=None, labels=None + ): + '''Create note with *content*, *author*. + + NoteLabels can be set by including *labels*. + + Note category can be set by including *category*. + + *recipients* can be specified as a list of user or group instances. + + ''' + note_label_support = 'NoteLabel' in self.session.types + + if not labels: + labels = [] + + if labels and not note_label_support: + raise ValueError( + 'NoteLabel is not supported by the current server version.' + ) + + if category and labels: + raise ValueError( + 'Both category and labels cannot be set at the same time.' + ) + + if not recipients: + recipients = [] + + data = { + 'content': content, + 'author': author + } + + if category: + if note_label_support: + labels = [category] + warnings.warn( + 'category argument will be removed in an upcoming version, ' + 'please use labels instead.', + PendingDeprecationWarning + ) + else: + data['category_id'] = category['id'] + + note = self.session.create('Note', data) + + self['notes'].append(note) + + for resource in recipients: + recipient = self.session.create('Recipient', { + 'note_id': note['id'], + 'resource_id': resource['id'] + }) + + note['recipients'].append(recipient) + + for label in labels: + self.session.create( + 'NoteLabelLink', + { + 'label_id': label['id'], + 'note_id': note['id'] + } + ) + + return note diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/project_schema.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/project_schema.py new file mode 100644 index 0000000000..ec6db7c019 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/project_schema.py @@ -0,0 +1,94 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import ftrack_api.entity.base + + +class ProjectSchema(ftrack_api.entity.base.Entity): + '''Class representing ProjectSchema.''' + + def get_statuses(self, schema, type_id=None): + '''Return statuses for *schema* and optional *type_id*. + + *type_id* is the id of the Type for a TypedContext and can be used to + get statuses where the workflow has been overridden. + + ''' + # Task has overrides and need to be handled separately. + if schema == 'Task': + if type_id is not None: + overrides = self['_overrides'] + for override in overrides: + if override['type_id'] == type_id: + return override['workflow_schema']['statuses'][:] + + return self['_task_workflow']['statuses'][:] + + elif schema == 'AssetVersion': + return self['_version_workflow']['statuses'][:] + + else: + try: + EntityTypeClass = self.session.types[schema] + except KeyError: + raise ValueError('Schema {0} does not exist.'.format(schema)) + + object_type_id_attribute = EntityTypeClass.attributes.get( + 'object_type_id' + ) + + try: + object_type_id = object_type_id_attribute.default_value + except AttributeError: + raise ValueError( + 'Schema {0} does not have statuses.'.format(schema) + ) + + for _schema in self['_schemas']: + if _schema['type_id'] == object_type_id: + result = self.session.query( + 'select task_status from SchemaStatus ' + 'where schema_id is {0}'.format(_schema['id']) + ) + return [ + schema_type['task_status'] for schema_type in result + ] + + raise ValueError( + 'No valid statuses were found for schema {0}.'.format(schema) + ) + + def get_types(self, schema): + '''Return types for *schema*.''' + # Task need to be handled separately. + if schema == 'Task': + return self['_task_type_schema']['types'][:] + + else: + try: + EntityTypeClass = self.session.types[schema] + except KeyError: + raise ValueError('Schema {0} does not exist.'.format(schema)) + + object_type_id_attribute = EntityTypeClass.attributes.get( + 'object_type_id' + ) + + try: + object_type_id = object_type_id_attribute.default_value + except AttributeError: + raise ValueError( + 'Schema {0} does not have types.'.format(schema) + ) + + for _schema in self['_schemas']: + if _schema['type_id'] == object_type_id: + result = self.session.query( + 'select task_type from SchemaType ' + 'where schema_id is {0}'.format(_schema['id']) + ) + return [schema_type['task_type'] for schema_type in result] + + raise ValueError( + 'No valid types were found for schema {0}.'.format(schema) + ) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/user.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/user.py new file mode 100644 index 0000000000..511ad4ba99 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/entity/user.py @@ -0,0 +1,123 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import arrow + +import ftrack_api.entity.base +import ftrack_api.exception + + +class User(ftrack_api.entity.base.Entity): + '''Represent a user.''' + + def start_timer(self, context=None, comment='', name=None, force=False): + '''Start a timer for *context* and return it. + + *force* can be used to automatically stop an existing timer and create a + timelog for it. If you need to get access to the created timelog, use + :func:`stop_timer` instead. + + *comment* and *name* are optional but will be set on the timer. + + .. note:: + + This method will automatically commit the changes and if *force* is + False then it will fail with a + :class:`ftrack_api.exception.NotUniqueError` exception if a + timer is already running. + + ''' + if force: + try: + self.stop_timer() + except ftrack_api.exception.NoResultFoundError: + self.logger.debug('Failed to stop existing timer.') + + timer = self.session.create('Timer', { + 'user': self, + 'context': context, + 'name': name, + 'comment': comment + }) + + # Commit the new timer and try to catch any error that indicate another + # timelog already exists and inform the user about it. + try: + self.session.commit() + except ftrack_api.exception.ServerError as error: + if 'IntegrityError' in str(error): + raise ftrack_api.exception.NotUniqueError( + ('Failed to start a timelog for user with id: {0}, it is ' + 'likely that a timer is already running. Either use ' + 'force=True or stop the timer first.').format(self['id']) + ) + else: + # Reraise the error as it might be something unrelated. + raise + + return timer + + def stop_timer(self): + '''Stop the current timer and return a timelog created from it. + + If a timer is not running, a + :exc:`ftrack_api.exception.NoResultFoundError` exception will be + raised. + + .. note:: + + This method will automatically commit the changes. + + ''' + timer = self.session.query( + 'Timer where user_id = "{0}"'.format(self['id']) + ).one() + + # If the server is running in the same timezone as the local + # timezone, we remove the TZ offset to get the correct duration. + is_timezone_support_enabled = self.session.server_information.get( + 'is_timezone_support_enabled', None + ) + if is_timezone_support_enabled is None: + self.logger.warning( + 'Could not identify if server has timezone support enabled. ' + 'Will assume server is running in UTC.' + ) + is_timezone_support_enabled = True + + if is_timezone_support_enabled: + now = arrow.now() + else: + now = arrow.now().replace(tzinfo='utc') + + delta = now - timer['start'] + duration = delta.days * 24 * 60 * 60 + delta.seconds + + timelog = self.session.create('Timelog', { + 'user_id': timer['user_id'], + 'context_id': timer['context_id'], + 'comment': timer['comment'], + 'start': timer['start'], + 'duration': duration, + 'name': timer['name'] + }) + + self.session.delete(timer) + self.session.commit() + + return timelog + + def send_invite(self): + '''Send a invation email to the user''' + + self.session.send_user_invite( + self + ) + def reset_api_key(self): + '''Reset the users api key.''' + + response = self.session.reset_remote( + 'api_key', entity=self + ) + + return response['api_key'] diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/__init__.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/__init__.py new file mode 100644 index 0000000000..1aab07ed77 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/__init__.py @@ -0,0 +1,2 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/base.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/base.py new file mode 100644 index 0000000000..b5fd57da78 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/base.py @@ -0,0 +1,85 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +import uuid +import collections + + +class Event(collections.MutableMapping): + '''Represent a single event.''' + + def __init__(self, topic, id=None, data=None, sent=None, + source=None, target='', in_reply_to_event=None): + '''Initialise event. + + *topic* is the required topic for the event. It can use a dotted + notation to demarcate groupings. For example, 'ftrack.update'. + + *id* is the unique id for this event instance. It is primarily used when + replying to an event. If not supplied a default uuid based value will + be used. + + *data* refers to event specific data. It should be a mapping structure + and defaults to an empty dictionary if not supplied. + + *sent* is the timestamp the event is sent. It will be set automatically + as send time unless specified here. + + *source* is information about where the event originated. It should be + a mapping and include at least a unique id value under an 'id' key. If + not specified, senders usually populate the value automatically at + publish time. + + *target* can be an expression that targets this event. For example, + a reply event would target the event to the sender of the source event. + The expression will be tested against subscriber information only. + + *in_reply_to_event* is used when replying to an event and should contain + the unique id of the event being replied to. + + ''' + super(Event, self).__init__() + self._data = dict( + id=id or uuid.uuid4().hex, + data=data or {}, + topic=topic, + sent=sent, + source=source or {}, + target=target, + in_reply_to_event=in_reply_to_event + ) + self._stopped = False + + def stop(self): + '''Stop further processing of this event.''' + self._stopped = True + + def is_stopped(self): + '''Return whether event has been stopped.''' + return self._stopped + + def __str__(self): + '''Return string representation.''' + return '<{0} {1}>'.format( + self.__class__.__name__, str(self._data) + ) + + def __getitem__(self, key): + '''Return value for *key*.''' + return self._data[key] + + def __setitem__(self, key, value): + '''Set *value* for *key*.''' + self._data[key] = value + + def __delitem__(self, key): + '''Remove *key*.''' + del self._data[key] + + def __iter__(self): + '''Iterate over all keys.''' + return iter(self._data) + + def __len__(self): + '''Return count of keys.''' + return len(self._data) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/expression.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/expression.py new file mode 100644 index 0000000000..0535e4fd5f --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/expression.py @@ -0,0 +1,282 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +from operator import eq, ne, ge, le, gt, lt + +from pyparsing import (Group, Word, CaselessKeyword, Forward, + FollowedBy, Suppress, oneOf, OneOrMore, Optional, + alphanums, quotedString, removeQuotes) + +import ftrack_api.exception + +# Do not enable packrat since it is not thread-safe and will result in parsing +# exceptions in a multi threaded environment. +# ParserElement.enablePackrat() + + +class Parser(object): + '''Parse string based expression into :class:`Expression` instance.''' + + def __init__(self): + '''Initialise parser.''' + self._operators = { + '=': eq, + '!=': ne, + '>=': ge, + '<=': le, + '>': gt, + '<': lt + } + self._parser = self._construct_parser() + super(Parser, self).__init__() + + def _construct_parser(self): + '''Construct and return parser.''' + field = Word(alphanums + '_.') + operator = oneOf(self._operators.keys()) + value = Word(alphanums + '-_,./*@+') + quoted_value = quotedString('quoted_value').setParseAction(removeQuotes) + + condition = Group( + field + operator + (quoted_value | value) + )('condition') + + not_ = Optional(Suppress(CaselessKeyword('not')))('not') + and_ = Suppress(CaselessKeyword('and'))('and') + or_ = Suppress(CaselessKeyword('or'))('or') + + expression = Forward() + parenthesis = Suppress('(') + expression + Suppress(')') + previous = condition | parenthesis + + for conjunction in (not_, and_, or_): + current = Forward() + + if conjunction in (and_, or_): + conjunction_expression = ( + FollowedBy(previous + conjunction + previous) + + Group( + previous + OneOrMore(conjunction + previous) + )(conjunction.resultsName) + ) + + elif conjunction in (not_, ): + conjunction_expression = ( + FollowedBy(conjunction.expr + current) + + Group(conjunction + current)(conjunction.resultsName) + ) + + else: # pragma: no cover + raise ValueError('Unrecognised conjunction.') + + current <<= (conjunction_expression | previous) + previous = current + + expression <<= previous + return expression('expression') + + def parse(self, expression): + '''Parse string *expression* into :class:`Expression`. + + Raise :exc:`ftrack_api.exception.ParseError` if *expression* could + not be parsed. + + ''' + result = None + expression = expression.strip() + if expression: + try: + result = self._parser.parseString( + expression, parseAll=True + ) + except Exception as error: + raise ftrack_api.exception.ParseError( + 'Failed to parse: {0}. {1}'.format(expression, error) + ) + + return self._process(result) + + def _process(self, result): + '''Process *result* using appropriate method. + + Method called is determined by the name of the result. + + ''' + method_name = '_process_{0}'.format(result.getName()) + method = getattr(self, method_name) + return method(result) + + def _process_expression(self, result): + '''Process *result* as expression.''' + return self._process(result[0]) + + def _process_not(self, result): + '''Process *result* as NOT operation.''' + return Not(self._process(result[0])) + + def _process_and(self, result): + '''Process *result* as AND operation.''' + return All([self._process(entry) for entry in result]) + + def _process_or(self, result): + '''Process *result* as OR operation.''' + return Any([self._process(entry) for entry in result]) + + def _process_condition(self, result): + '''Process *result* as condition.''' + key, operator, value = result + return Condition(key, self._operators[operator], value) + + def _process_quoted_value(self, result): + '''Process *result* as quoted value.''' + return result + + +class Expression(object): + '''Represent a structured expression to test candidates against.''' + + def __str__(self): + '''Return string representation.''' + return '<{0}>'.format(self.__class__.__name__) + + def match(self, candidate): + '''Return whether *candidate* satisfies this expression.''' + return True + + +class All(Expression): + '''Match candidate that matches all of the specified expressions. + + .. note:: + + If no expressions are supplied then will always match. + + ''' + + def __init__(self, expressions=None): + '''Initialise with list of *expressions* to match against.''' + self._expressions = expressions or [] + super(All, self).__init__() + + def __str__(self): + '''Return string representation.''' + return '<{0} [{1}]>'.format( + self.__class__.__name__, + ' '.join(map(str, self._expressions)) + ) + + def match(self, candidate): + '''Return whether *candidate* satisfies this expression.''' + return all([ + expression.match(candidate) for expression in self._expressions + ]) + + +class Any(Expression): + '''Match candidate that matches any of the specified expressions. + + .. note:: + + If no expressions are supplied then will never match. + + ''' + + def __init__(self, expressions=None): + '''Initialise with list of *expressions* to match against.''' + self._expressions = expressions or [] + super(Any, self).__init__() + + def __str__(self): + '''Return string representation.''' + return '<{0} [{1}]>'.format( + self.__class__.__name__, + ' '.join(map(str, self._expressions)) + ) + + def match(self, candidate): + '''Return whether *candidate* satisfies this expression.''' + return any([ + expression.match(candidate) for expression in self._expressions + ]) + + +class Not(Expression): + '''Negate expression.''' + + def __init__(self, expression): + '''Initialise with *expression* to negate.''' + self._expression = expression + super(Not, self).__init__() + + def __str__(self): + '''Return string representation.''' + return '<{0} {1}>'.format( + self.__class__.__name__, + self._expression + ) + + def match(self, candidate): + '''Return whether *candidate* satisfies this expression.''' + return not self._expression.match(candidate) + + +class Condition(Expression): + '''Represent condition.''' + + def __init__(self, key, operator, value): + '''Initialise condition. + + *key* is the key to check on the data when matching. It can be a nested + key represented by dots. For example, 'data.eventType' would attempt to + match candidate['data']['eventType']. If the candidate is missing any + of the requested keys then the match fails immediately. + + *operator* is the operator function to use to perform the match between + the retrieved candidate value and the conditional *value*. + + If *value* is a string, it can use a wildcard '*' at the end to denote + that any values matching the substring portion are valid when matching + equality only. + + ''' + self._key = key + self._operator = operator + self._value = value + self._wildcard = '*' + self._operatorMapping = { + eq: '=', + ne: '!=', + ge: '>=', + le: '<=', + gt: '>', + lt: '<' + } + + def __str__(self): + '''Return string representation.''' + return '<{0} {1}{2}{3}>'.format( + self.__class__.__name__, + self._key, + self._operatorMapping.get(self._operator, self._operator), + self._value + ) + + def match(self, candidate): + '''Return whether *candidate* satisfies this expression.''' + key_parts = self._key.split('.') + + try: + value = candidate + for keyPart in key_parts: + value = value[keyPart] + except (KeyError, TypeError): + return False + + if ( + self._operator is eq + and isinstance(self._value, basestring) + and self._value[-1] == self._wildcard + ): + return self._value[:-1] in value + else: + return self._operator(value, self._value) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/hub.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/hub.py new file mode 100644 index 0000000000..9f4ba80c6e --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/hub.py @@ -0,0 +1,1091 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2013 ftrack + +from __future__ import absolute_import + +import collections +import urlparse +import threading +import Queue as queue +import logging +import time +import uuid +import operator +import functools +import json +import socket +import warnings + +import requests +import requests.exceptions +import websocket + +import ftrack_api.exception +import ftrack_api.event.base +import ftrack_api.event.subscriber +import ftrack_api.event.expression +from ftrack_api.logging import LazyLogMessage as L + + +SocketIoSession = collections.namedtuple('SocketIoSession', [ + 'id', + 'heartbeatTimeout', + 'supportedTransports', +]) + + +ServerDetails = collections.namedtuple('ServerDetails', [ + 'scheme', + 'hostname', + 'port', +]) + + + + +class EventHub(object): + '''Manage routing of events.''' + + _future_signature_warning = ( + 'When constructing your Session object you did not explicitly define ' + 'auto_connect_event_hub as True even though you appear to be publishing ' + 'and / or subscribing to asynchronous events. In version version 2.0 of ' + 'the ftrack-python-api the default behavior will change from True ' + 'to False. Please make sure to update your tools. You can read more at ' + 'http://ftrack-python-api.rtd.ftrack.com/en/stable/release/migration.html' + ) + + def __init__(self, server_url, api_user, api_key): + '''Initialise hub, connecting to ftrack *server_url*. + + *api_user* is the user to authenticate as and *api_key* is the API key + to authenticate with. + + ''' + super(EventHub, self).__init__() + self.logger = logging.getLogger( + __name__ + '.' + self.__class__.__name__ + ) + self.id = uuid.uuid4().hex + self._connection = None + + self._unique_packet_id = 0 + self._packet_callbacks = {} + self._lock = threading.RLock() + + self._wait_timeout = 4 + + self._subscribers = [] + self._reply_callbacks = {} + self._intentional_disconnect = False + + self._event_queue = queue.Queue() + self._event_namespace = 'ftrack.event' + self._expression_parser = ftrack_api.event.expression.Parser() + + # Default values for auto reconnection timeout on unintentional + # disconnection. Equates to 5 minutes. + self._auto_reconnect_attempts = 30 + self._auto_reconnect_delay = 10 + + self._deprecation_warning_auto_connect = False + + # Mapping of Socket.IO codes to meaning. + self._code_name_mapping = { + '0': 'disconnect', + '1': 'connect', + '2': 'heartbeat', + '3': 'message', + '4': 'json', + '5': 'event', + '6': 'acknowledge', + '7': 'error' + } + self._code_name_mapping.update( + dict((name, code) for code, name in self._code_name_mapping.items()) + ) + + self._server_url = server_url + self._api_user = api_user + self._api_key = api_key + + # Parse server URL and store server details. + url_parse_result = urlparse.urlparse(self._server_url) + if not url_parse_result.scheme: + raise ValueError('Could not determine scheme from server url.') + + if not url_parse_result.hostname: + raise ValueError('Could not determine hostname from server url.') + + self.server = ServerDetails( + url_parse_result.scheme, + url_parse_result.hostname, + url_parse_result.port + ) + + def get_server_url(self): + '''Return URL to server.''' + return '{0}://{1}'.format( + self.server.scheme, self.get_network_location() + ) + + def get_network_location(self): + '''Return network location part of url (hostname with optional port).''' + if self.server.port: + return '{0}:{1}'.format(self.server.hostname, self.server.port) + else: + return self.server.hostname + + @property + def secure(self): + '''Return whether secure connection used.''' + return self.server.scheme == 'https' + + def connect(self): + '''Initialise connection to server. + + Raise :exc:`ftrack_api.exception.EventHubConnectionError` if already + connected or connection fails. + + ''' + + self._deprecation_warning_auto_connect = False + + if self.connected: + raise ftrack_api.exception.EventHubConnectionError( + 'Already connected.' + ) + + # Reset flag tracking whether disconnection was intentional. + self._intentional_disconnect = False + + try: + # Connect to socket.io server using websocket transport. + session = self._get_socket_io_session() + + if 'websocket' not in session.supportedTransports: + raise ValueError( + 'Server does not support websocket sessions.' + ) + + scheme = 'wss' if self.secure else 'ws' + url = '{0}://{1}/socket.io/1/websocket/{2}'.format( + scheme, self.get_network_location(), session.id + ) + + # timeout is set to 60 seconds to avoid the issue where the socket + # ends up in a bad state where it is reported as connected but the + # connection has been closed. The issue happens often when connected + # to a secure socket and the computer goes to sleep. + # More information on how the timeout works can be found here: + # https://docs.python.org/2/library/socket.html#socket.socket.setblocking + self._connection = websocket.create_connection(url, timeout=60) + + except Exception as error: + error_message = ( + 'Failed to connect to event server at {server_url} with ' + 'error: "{error}".' + ) + + error_details = { + 'error': unicode(error), + 'server_url': self.get_server_url() + } + + self.logger.debug( + L( + error_message, **error_details + ), + exc_info=1 + ) + raise ftrack_api.exception.EventHubConnectionError( + error_message, + details=error_details + ) + + # Start background processing thread. + self._processor_thread = _ProcessorThread(self) + self._processor_thread.start() + + # Subscribe to reply events if not already. Note: Only adding the + # subscriber locally as the following block will notify server of all + # existing subscribers, which would cause the server to report a + # duplicate subscriber error if EventHub.subscribe was called here. + try: + self._add_subscriber( + 'topic=ftrack.meta.reply', + self._handle_reply, + subscriber=dict( + id=self.id + ) + ) + except ftrack_api.exception.NotUniqueError: + pass + + # Now resubscribe any existing stored subscribers. This can happen when + # reconnecting automatically for example. + for subscriber in self._subscribers[:]: + self._notify_server_about_subscriber(subscriber) + + @property + def connected(self): + '''Return if connected.''' + return self._connection is not None and self._connection.connected + + def disconnect(self, unsubscribe=True): + '''Disconnect from server. + + Raise :exc:`ftrack_api.exception.EventHubConnectionError` if not + currently connected. + + If *unsubscribe* is True then unsubscribe all current subscribers + automatically before disconnecting. + + ''' + if not self.connected: + raise ftrack_api.exception.EventHubConnectionError( + 'Not currently connected.' + ) + + else: + # Set flag to indicate disconnection was intentional. + self._intentional_disconnect = True + + # Set blocking to true on socket to make sure unsubscribe events + # are emitted before closing the connection. + self._connection.sock.setblocking(1) + + # Unsubscribe all subscribers. + if unsubscribe: + for subscriber in self._subscribers[:]: + self.unsubscribe(subscriber.metadata['id']) + + # Now disconnect. + self._connection.close() + self._connection = None + + # Shutdown background processing thread. + self._processor_thread.cancel() + + # Join to it if it is not current thread to help ensure a clean + # shutdown. + if threading.current_thread() != self._processor_thread: + self._processor_thread.join(self._wait_timeout) + + def reconnect(self, attempts=10, delay=5): + '''Reconnect to server. + + Make *attempts* number of attempts with *delay* in seconds between each + attempt. + + .. note:: + + All current subscribers will be automatically resubscribed after + successful reconnection. + + Raise :exc:`ftrack_api.exception.EventHubConnectionError` if fail to + reconnect. + + ''' + try: + self.disconnect(unsubscribe=False) + except ftrack_api.exception.EventHubConnectionError: + pass + + for attempt in range(attempts): + self.logger.debug(L( + 'Reconnect attempt {0} of {1}', attempt, attempts + )) + + # Silence logging temporarily to avoid lots of failed connection + # related information. + try: + logging.disable(logging.CRITICAL) + + try: + self.connect() + except ftrack_api.exception.EventHubConnectionError: + time.sleep(delay) + else: + break + + finally: + logging.disable(logging.NOTSET) + + if not self.connected: + raise ftrack_api.exception.EventHubConnectionError( + 'Failed to reconnect to event server at {0} after {1} attempts.' + .format(self.get_server_url(), attempts) + ) + + def wait(self, duration=None): + '''Wait for events and handle as they arrive. + + If *duration* is specified, then only process events until duration is + reached. *duration* is in seconds though float values can be used for + smaller values. + + ''' + started = time.time() + + while True: + try: + event = self._event_queue.get(timeout=0.1) + except queue.Empty: + pass + else: + self._handle(event) + + # Additional special processing of events. + if event['topic'] == 'ftrack.meta.disconnected': + break + + if duration is not None: + if (time.time() - started) > duration: + break + + def get_subscriber_by_identifier(self, identifier): + '''Return subscriber with matching *identifier*. + + Return None if no subscriber with *identifier* found. + + ''' + for subscriber in self._subscribers[:]: + if subscriber.metadata.get('id') == identifier: + return subscriber + + return None + + def subscribe(self, subscription, callback, subscriber=None, priority=100): + '''Register *callback* for *subscription*. + + A *subscription* is a string that can specify in detail which events the + callback should receive. The filtering is applied against each event + object. Nested references are supported using '.' separators. + For example, 'topic=foo and data.eventType=Shot' would match the + following event:: + + + + The *callback* should accept an instance of + :class:`ftrack_api.event.base.Event` as its sole argument. + + Callbacks are called in order of *priority*. The lower the priority + number the sooner it will be called, with 0 being the first. The + default priority is 100. Note that priority only applies against other + callbacks registered with this hub and not as a global priority. + + An earlier callback can prevent processing of subsequent callbacks by + calling :meth:`Event.stop` on the passed `event` before + returning. + + .. warning:: + + Handlers block processing of other received events. For long + running callbacks it is advisable to delegate the main work to + another process or thread. + + A *callback* can be attached to *subscriber* information that details + the subscriber context. A subscriber context will be generated + automatically if not supplied. + + .. note:: + + The subscription will be stored locally, but until the server + receives notification of the subscription it is possible the + callback will not be called. + + Return subscriber identifier. + + Raise :exc:`ftrack_api.exception.NotUniqueError` if a subscriber with + the same identifier already exists. + + ''' + # Add subscriber locally. + subscriber = self._add_subscriber( + subscription, callback, subscriber, priority + ) + + # Notify server now if possible. + try: + self._notify_server_about_subscriber(subscriber) + except ftrack_api.exception.EventHubConnectionError: + self.logger.debug(L( + 'Failed to notify server about new subscriber {0} ' + 'as server not currently reachable.', subscriber.metadata['id'] + )) + + return subscriber.metadata['id'] + + def _add_subscriber( + self, subscription, callback, subscriber=None, priority=100 + ): + '''Add subscriber locally. + + See :meth:`subscribe` for argument descriptions. + + Return :class:`ftrack_api.event.subscriber.Subscriber` instance. + + Raise :exc:`ftrack_api.exception.NotUniqueError` if a subscriber with + the same identifier already exists. + + ''' + if subscriber is None: + subscriber = {} + + subscriber.setdefault('id', uuid.uuid4().hex) + + # Check subscriber not already subscribed. + existing_subscriber = self.get_subscriber_by_identifier( + subscriber['id'] + ) + + if existing_subscriber is not None: + raise ftrack_api.exception.NotUniqueError( + 'Subscriber with identifier {0} already exists.' + .format(subscriber['id']) + ) + + subscriber = ftrack_api.event.subscriber.Subscriber( + subscription=subscription, + callback=callback, + metadata=subscriber, + priority=priority + ) + + self._subscribers.append(subscriber) + + return subscriber + + def _notify_server_about_subscriber(self, subscriber): + '''Notify server of new *subscriber*.''' + subscribe_event = ftrack_api.event.base.Event( + topic='ftrack.meta.subscribe', + data=dict( + subscriber=subscriber.metadata, + subscription=str(subscriber.subscription) + ) + ) + + self._publish( + subscribe_event, + callback=functools.partial(self._on_subscribed, subscriber) + ) + + def _on_subscribed(self, subscriber, response): + '''Handle acknowledgement of subscription.''' + if response.get('success') is False: + self.logger.warning(L( + 'Server failed to subscribe subscriber {0}: {1}', + subscriber.metadata['id'], response.get('message') + )) + + def unsubscribe(self, subscriber_identifier): + '''Unsubscribe subscriber with *subscriber_identifier*. + + .. note:: + + If the server is not reachable then it won't be notified of the + unsubscription. However, the subscriber will be removed locally + regardless. + + ''' + subscriber = self.get_subscriber_by_identifier(subscriber_identifier) + + if subscriber is None: + raise ftrack_api.exception.NotFoundError( + 'Cannot unsubscribe missing subscriber with identifier {0}' + .format(subscriber_identifier) + ) + + self._subscribers.pop(self._subscribers.index(subscriber)) + + # Notify the server if possible. + unsubscribe_event = ftrack_api.event.base.Event( + topic='ftrack.meta.unsubscribe', + data=dict(subscriber=subscriber.metadata) + ) + + try: + self._publish( + unsubscribe_event, + callback=functools.partial(self._on_unsubscribed, subscriber) + ) + except ftrack_api.exception.EventHubConnectionError: + self.logger.debug(L( + 'Failed to notify server to unsubscribe subscriber {0} as ' + 'server not currently reachable.', subscriber.metadata['id'] + )) + + def _on_unsubscribed(self, subscriber, response): + '''Handle acknowledgement of unsubscribing *subscriber*.''' + if response.get('success') is not True: + self.logger.warning(L( + 'Server failed to unsubscribe subscriber {0}: {1}', + subscriber.metadata['id'], response.get('message') + )) + + def _prepare_event(self, event): + '''Prepare *event* for sending.''' + event['source'].setdefault('id', self.id) + event['source'].setdefault('user', { + 'username': self._api_user + }) + + def _prepare_reply_event(self, event, source_event, source=None): + '''Prepare *event* as a reply to another *source_event*. + + Modify *event*, setting appropriate values to target event correctly as + a reply. + + ''' + event['target'] = 'id={0}'.format(source_event['source']['id']) + event['in_reply_to_event'] = source_event['id'] + if source is not None: + event['source'] = source + + def publish( + self, event, synchronous=False, on_reply=None, on_error='raise' + ): + '''Publish *event*. + + If *synchronous* is specified as True then this method will wait and + return a list of results from any called callbacks. + + .. note:: + + Currently, if synchronous is True then only locally registered + callbacks will be called and no event will be sent to the server. + This may change in future. + + *on_reply* is an optional callable to call with any reply event that is + received in response to the published *event*. + + .. note:: + + Will not be called when *synchronous* is True. + + If *on_error* is set to 'ignore' then errors raised during publish of + event will be caught by this method and ignored. + + ''' + if self._deprecation_warning_auto_connect and not synchronous: + warnings.warn( + self._future_signature_warning, FutureWarning + ) + + try: + return self._publish( + event, synchronous=synchronous, on_reply=on_reply + ) + except Exception: + if on_error == 'ignore': + pass + else: + raise + + def publish_reply(self, source_event, data, source=None): + '''Publish a reply event to *source_event* with supplied *data*. + + If *source* is specified it will be used for the source value of the + sent event. + + ''' + reply_event = ftrack_api.event.base.Event( + 'ftrack.meta.reply', + data=data + ) + self._prepare_reply_event(reply_event, source_event, source=source) + self.publish(reply_event) + + def _publish(self, event, synchronous=False, callback=None, on_reply=None): + '''Publish *event*. + + If *synchronous* is specified as True then this method will wait and + return a list of results from any called callbacks. + + .. note:: + + Currently, if synchronous is True then only locally registered + callbacks will be called and no event will be sent to the server. + This may change in future. + + A *callback* can also be specified. This callback will be called once + the server acknowledges receipt of the sent event. A default callback + that checks for errors from the server will be used if not specified. + + *on_reply* is an optional callable to call with any reply event that is + received in response to the published *event*. Note that there is no + guarantee that a reply will be sent. + + Raise :exc:`ftrack_api.exception.EventHubConnectionError` if not + currently connected. + + ''' + # Prepare event adding any relevant additional information. + self._prepare_event(event) + + if synchronous: + # Bypass emitting event to server and instead call locally + # registered handlers directly, collecting and returning results. + return self._handle(event, synchronous=synchronous) + + if not self.connected: + raise ftrack_api.exception.EventHubConnectionError( + 'Cannot publish event asynchronously as not connected to ' + 'server.' + ) + + # Use standard callback if none specified. + if callback is None: + callback = functools.partial(self._on_published, event) + + # Emit event to central server for asynchronous processing. + try: + # Register on reply callback if specified. + if on_reply is not None: + # TODO: Add cleanup process that runs after a set duration to + # garbage collect old reply callbacks and prevent dictionary + # growing too large. + self._reply_callbacks[event['id']] = on_reply + + try: + self._emit_event_packet( + self._event_namespace, event, callback=callback + ) + except ftrack_api.exception.EventHubConnectionError: + # Connection may have dropped temporarily. Wait a few moments to + # see if background thread reconnects automatically. + time.sleep(15) + + self._emit_event_packet( + self._event_namespace, event, callback=callback + ) + except: + raise + + except Exception: + # Failure to send event should not cause caller to fail. + # TODO: This behaviour is inconsistent with the failing earlier on + # lack of connection and also with the error handling parameter of + # EventHub.publish. Consider refactoring. + self.logger.exception(L('Error sending event {0}.', event)) + + def _on_published(self, event, response): + '''Handle acknowledgement of published event.''' + if response.get('success', False) is False: + self.logger.error(L( + 'Server responded with error while publishing event {0}. ' + 'Error was: {1}', event, response.get('message') + )) + + def _handle(self, event, synchronous=False): + '''Handle *event*. + + If *synchronous* is True, do not send any automatic reply events. + + ''' + # Sort by priority, lower is higher. + # TODO: Use a sorted list to avoid sorting each time in order to improve + # performance. + subscribers = sorted( + self._subscribers, key=operator.attrgetter('priority') + ) + + results = [] + + target = event.get('target', None) + target_expression = None + if target: + try: + target_expression = self._expression_parser.parse(target) + except Exception: + self.logger.exception(L( + 'Cannot handle event as failed to parse event target ' + 'information: {0}', event + )) + return + + for subscriber in subscribers: + # Check if event is targeted to the subscriber. + if ( + target_expression is not None + and not target_expression.match(subscriber.metadata) + ): + continue + + # Check if subscriber interested in the event. + if not subscriber.interested_in(event): + continue + + response = None + + try: + response = subscriber.callback(event) + results.append(response) + except Exception: + self.logger.exception(L( + 'Error calling subscriber {0} for event {1}.', + subscriber, event + )) + + # Automatically publish a non None response as a reply when not in + # synchronous mode. + if not synchronous: + if self._deprecation_warning_auto_connect: + warnings.warn( + self._future_signature_warning, FutureWarning + ) + + if response is not None: + try: + self.publish_reply( + event, data=response, source=subscriber.metadata + ) + + except Exception: + self.logger.exception(L( + 'Error publishing response {0} from subscriber {1} ' + 'for event {2}.', response, subscriber, event + )) + + # Check whether to continue processing topic event. + if event.is_stopped(): + self.logger.debug(L( + 'Subscriber {0} stopped event {1}. Will not process ' + 'subsequent subscriber callbacks for this event.', + subscriber, event + )) + break + + return results + + def _handle_reply(self, event): + '''Handle reply *event*, passing it to any registered callback.''' + callback = self._reply_callbacks.get(event['in_reply_to_event'], None) + if callback is not None: + callback(event) + + def subscription(self, subscription, callback, subscriber=None, + priority=100): + '''Return context manager with *callback* subscribed to *subscription*. + + The subscribed callback will be automatically unsubscribed on exit + of the context manager. + + ''' + return _SubscriptionContext( + self, subscription, callback, subscriber=subscriber, + priority=priority, + ) + + # Socket.IO interface. + # + + def _get_socket_io_session(self): + '''Connect to server and retrieve session information.''' + socket_io_url = ( + '{0}://{1}/socket.io/1/?api_user={2}&api_key={3}' + ).format( + self.server.scheme, + self.get_network_location(), + self._api_user, + self._api_key + ) + try: + response = requests.get( + socket_io_url, + timeout=60 # 60 seconds timeout to recieve errors faster. + ) + except requests.exceptions.Timeout as error: + raise ftrack_api.exception.EventHubConnectionError( + 'Timed out connecting to server: {0}.'.format(error) + ) + except requests.exceptions.SSLError as error: + raise ftrack_api.exception.EventHubConnectionError( + 'Failed to negotiate SSL with server: {0}.'.format(error) + ) + except requests.exceptions.ConnectionError as error: + raise ftrack_api.exception.EventHubConnectionError( + 'Failed to connect to server: {0}.'.format(error) + ) + else: + status = response.status_code + if status != 200: + raise ftrack_api.exception.EventHubConnectionError( + 'Received unexpected status code {0}.'.format(status) + ) + + # Parse result and return session information. + parts = response.text.split(':') + return SocketIoSession( + parts[0], + parts[1], + parts[3].split(',') + ) + + def _add_packet_callback(self, callback): + '''Store callback against a new unique packet ID. + + Return the unique packet ID. + + ''' + with self._lock: + self._unique_packet_id += 1 + unique_identifier = self._unique_packet_id + + self._packet_callbacks[unique_identifier] = callback + + return '{0}+'.format(unique_identifier) + + def _pop_packet_callback(self, packet_identifier): + '''Pop and return callback for *packet_identifier*.''' + return self._packet_callbacks.pop(packet_identifier) + + def _emit_event_packet(self, namespace, event, callback): + '''Send *event* packet under *namespace*.''' + data = self._encode( + dict(name=namespace, args=[event]) + ) + self._send_packet( + self._code_name_mapping['event'], data=data, callback=callback + ) + + def _acknowledge_packet(self, packet_identifier, *args): + '''Send acknowledgement of packet with *packet_identifier*.''' + packet_identifier = packet_identifier.rstrip('+') + data = str(packet_identifier) + if args: + data += '+{1}'.format(self._encode(args)) + + self._send_packet(self._code_name_mapping['acknowledge'], data=data) + + def _send_packet(self, code, data='', callback=None): + '''Send packet via connection.''' + path = '' + packet_identifier = ( + self._add_packet_callback(callback) if callback else '' + ) + packet_parts = (str(code), packet_identifier, path, data) + packet = ':'.join(packet_parts) + + try: + self._connection.send(packet) + self.logger.debug(L(u'Sent packet: {0}', packet)) + except socket.error as error: + raise ftrack_api.exception.EventHubConnectionError( + 'Failed to send packet: {0}'.format(error) + ) + + def _receive_packet(self): + '''Receive and return packet via connection.''' + try: + packet = self._connection.recv() + except Exception as error: + raise ftrack_api.exception.EventHubConnectionError( + 'Error receiving packet: {0}'.format(error) + ) + + try: + parts = packet.split(':', 3) + except AttributeError: + raise ftrack_api.exception.EventHubPacketError( + 'Received invalid packet {0}'.format(packet) + ) + + code, packet_identifier, path, data = None, None, None, None + + count = len(parts) + if count == 4: + code, packet_identifier, path, data = parts + elif count == 3: + code, packet_identifier, path = parts + elif count == 1: + code = parts[0] + else: + raise ftrack_api.exception.EventHubPacketError( + 'Received invalid packet {0}'.format(packet) + ) + + self.logger.debug(L('Received packet: {0}', packet)) + return code, packet_identifier, path, data + + def _handle_packet(self, code, packet_identifier, path, data): + '''Handle packet received from server.''' + code_name = self._code_name_mapping[code] + + if code_name == 'connect': + self.logger.debug('Connected to event server.') + event = ftrack_api.event.base.Event('ftrack.meta.connected') + self._prepare_event(event) + self._event_queue.put(event) + + elif code_name == 'disconnect': + self.logger.debug('Disconnected from event server.') + if not self._intentional_disconnect: + self.logger.debug( + 'Disconnected unexpectedly. Attempting to reconnect.' + ) + try: + self.reconnect( + attempts=self._auto_reconnect_attempts, + delay=self._auto_reconnect_delay + ) + except ftrack_api.exception.EventHubConnectionError: + self.logger.debug('Failed to reconnect automatically.') + else: + self.logger.debug('Reconnected successfully.') + + if not self.connected: + event = ftrack_api.event.base.Event('ftrack.meta.disconnected') + self._prepare_event(event) + self._event_queue.put(event) + + elif code_name == 'heartbeat': + # Reply with heartbeat. + self._send_packet(self._code_name_mapping['heartbeat']) + + elif code_name == 'message': + self.logger.debug(L('Message received: {0}', data)) + + elif code_name == 'event': + payload = self._decode(data) + args = payload.get('args', []) + + if len(args) == 1: + event_payload = args[0] + if isinstance(event_payload, collections.Mapping): + try: + event = ftrack_api.event.base.Event(**event_payload) + except Exception: + self.logger.exception(L( + 'Failed to convert payload into event: {0}', + event_payload + )) + return + + self._event_queue.put(event) + + elif code_name == 'acknowledge': + parts = data.split('+', 1) + acknowledged_packet_identifier = int(parts[0]) + args = [] + if len(parts) == 2: + args = self._decode(parts[1]) + + try: + callback = self._pop_packet_callback( + acknowledged_packet_identifier + ) + except KeyError: + pass + else: + callback(*args) + + elif code_name == 'error': + self.logger.error(L('Event server reported error: {0}.', data)) + + else: + self.logger.debug(L('{0}: {1}', code_name, data)) + + def _encode(self, data): + '''Return *data* encoded as JSON formatted string.''' + return json.dumps( + data, + default=self._encode_object_hook, + ensure_ascii=False + ) + + def _encode_object_hook(self, item): + '''Return *item* transformed for encoding.''' + if isinstance(item, ftrack_api.event.base.Event): + # Convert to dictionary for encoding. + item = dict(**item) + + if 'in_reply_to_event' in item: + # Convert keys to server convention. + item['inReplyToEvent'] = item.pop('in_reply_to_event') + + return item + + raise TypeError('{0!r} is not JSON serializable'.format(item)) + + def _decode(self, string): + '''Return decoded JSON *string* as Python object.''' + return json.loads(string, object_hook=self._decode_object_hook) + + def _decode_object_hook(self, item): + '''Return *item* transformed.''' + if isinstance(item, collections.Mapping): + if 'inReplyToEvent' in item: + item['in_reply_to_event'] = item.pop('inReplyToEvent') + + return item + + +class _SubscriptionContext(object): + '''Context manager for a one-off subscription.''' + + def __init__(self, hub, subscription, callback, subscriber, priority): + '''Initialise context.''' + self._hub = hub + self._subscription = subscription + self._callback = callback + self._subscriber = subscriber + self._priority = priority + self._subscriberIdentifier = None + + def __enter__(self): + '''Enter context subscribing callback to topic.''' + self._subscriberIdentifier = self._hub.subscribe( + self._subscription, self._callback, subscriber=self._subscriber, + priority=self._priority + ) + + def __exit__(self, exception_type, exception_value, traceback): + '''Exit context unsubscribing callback from topic.''' + self._hub.unsubscribe(self._subscriberIdentifier) + + +class _ProcessorThread(threading.Thread): + '''Process messages from server.''' + + daemon = True + + def __init__(self, client): + '''Initialise thread with Socket.IO *client* instance.''' + super(_ProcessorThread, self).__init__() + self.logger = logging.getLogger( + __name__ + '.' + self.__class__.__name__ + ) + self.client = client + self.done = threading.Event() + + def run(self): + '''Perform work in thread.''' + while not self.done.is_set(): + try: + code, packet_identifier, path, data = self.client._receive_packet() + self.client._handle_packet(code, packet_identifier, path, data) + + except ftrack_api.exception.EventHubPacketError as error: + self.logger.debug(L('Ignoring invalid packet: {0}', error)) + continue + + except ftrack_api.exception.EventHubConnectionError: + self.cancel() + + # Fake a disconnection event in order to trigger reconnection + # when necessary. + self.client._handle_packet('0', '', '', '') + + break + + except Exception as error: + self.logger.debug(L('Aborting processor thread: {0}', error)) + self.cancel() + break + + def cancel(self): + '''Cancel work as soon as possible.''' + self.done.set() diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/subscriber.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/subscriber.py new file mode 100644 index 0000000000..0d38463aaf --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/subscriber.py @@ -0,0 +1,27 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +import ftrack_api.event.subscription + + +class Subscriber(object): + '''Represent event subscriber.''' + + def __init__(self, subscription, callback, metadata, priority): + '''Initialise subscriber.''' + self.subscription = ftrack_api.event.subscription.Subscription( + subscription + ) + self.callback = callback + self.metadata = metadata + self.priority = priority + + def __str__(self): + '''Return string representation.''' + return '<{0} metadata={1} subscription="{2}">'.format( + self.__class__.__name__, self.metadata, self.subscription + ) + + def interested_in(self, event): + '''Return whether subscriber interested in *event*.''' + return self.subscription.includes(event) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/subscription.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/subscription.py new file mode 100644 index 0000000000..0b208d9977 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/event/subscription.py @@ -0,0 +1,23 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +import ftrack_api.event.expression + + +class Subscription(object): + '''Represent a subscription.''' + + parser = ftrack_api.event.expression.Parser() + + def __init__(self, subscription): + '''Initialise with *subscription*.''' + self._subscription = subscription + self._expression = self.parser.parse(subscription) + + def __str__(self): + '''Return string representation.''' + return self._subscription + + def includes(self, event): + '''Return whether subscription includes *event*.''' + return self._expression.match(event) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/exception.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/exception.py new file mode 100644 index 0000000000..8a2eb9bc04 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/exception.py @@ -0,0 +1,392 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +import sys +import traceback + +import ftrack_api.entity.base + + +class Error(Exception): + '''ftrack specific error.''' + + default_message = 'Unspecified error occurred.' + + def __init__(self, message=None, details=None): + '''Initialise exception with *message*. + + If *message* is None, the class 'default_message' will be used. + + *details* should be a mapping of extra information that can be used in + the message and also to provide more context. + + ''' + if message is None: + message = self.default_message + + self.message = message + self.details = details + if self.details is None: + self.details = {} + + self.traceback = traceback.format_exc() + + def __str__(self): + '''Return string representation.''' + keys = {} + for key, value in self.details.iteritems(): + if isinstance(value, unicode): + value = value.encode(sys.getfilesystemencoding()) + keys[key] = value + + return str(self.message.format(**keys)) + + +class AuthenticationError(Error): + '''Raise when an authentication error occurs.''' + + default_message = 'Authentication error.' + + +class ServerError(Error): + '''Raise when the server reports an error.''' + + default_message = 'Server reported error processing request.' + + +class ServerCompatibilityError(ServerError): + '''Raise when server appears incompatible.''' + + default_message = 'Server incompatible.' + + +class NotFoundError(Error): + '''Raise when something that should exist is not found.''' + + default_message = 'Not found.' + + +class NotUniqueError(Error): + '''Raise when unique value required and duplicate detected.''' + + default_message = 'Non-unique value detected.' + + +class IncorrectResultError(Error): + '''Raise when a result is incorrect.''' + + default_message = 'Incorrect result detected.' + + +class NoResultFoundError(IncorrectResultError): + '''Raise when a result was expected but no result was found.''' + + default_message = 'Expected result, but no result was found.' + + +class MultipleResultsFoundError(IncorrectResultError): + '''Raise when a single result expected, but multiple results found.''' + + default_message = 'Expected single result, but received multiple results.' + + +class EntityTypeError(Error): + '''Raise when an entity type error occurs.''' + + default_message = 'Entity type error.' + + +class UnrecognisedEntityTypeError(EntityTypeError): + '''Raise when an unrecognised entity type detected.''' + + default_message = 'Entity type "{entity_type}" not recognised.' + + def __init__(self, entity_type, **kw): + '''Initialise with *entity_type* that is unrecognised.''' + kw.setdefault('details', {}).update(dict( + entity_type=entity_type + )) + super(UnrecognisedEntityTypeError, self).__init__(**kw) + + +class OperationError(Error): + '''Raise when an operation error occurs.''' + + default_message = 'Operation error.' + + +class InvalidStateError(Error): + '''Raise when an invalid state detected.''' + + default_message = 'Invalid state.' + + +class InvalidStateTransitionError(InvalidStateError): + '''Raise when an invalid state transition detected.''' + + default_message = ( + 'Invalid transition from {current_state!r} to {target_state!r} state ' + 'for entity {entity!r}' + ) + + def __init__(self, current_state, target_state, entity, **kw): + '''Initialise error.''' + kw.setdefault('details', {}).update(dict( + current_state=current_state, + target_state=target_state, + entity=entity + )) + super(InvalidStateTransitionError, self).__init__(**kw) + + +class AttributeError(Error): + '''Raise when an error related to an attribute occurs.''' + + default_message = 'Attribute error.' + + +class ImmutableAttributeError(AttributeError): + '''Raise when modification of immutable attribute attempted.''' + + default_message = ( + 'Cannot modify value of immutable {attribute.name!r} attribute.' + ) + + def __init__(self, attribute, **kw): + '''Initialise error.''' + kw.setdefault('details', {}).update(dict( + attribute=attribute + )) + super(ImmutableAttributeError, self).__init__(**kw) + + +class CollectionError(Error): + '''Raise when an error related to collections occurs.''' + + default_message = 'Collection error.' + + def __init__(self, collection, **kw): + '''Initialise error.''' + kw.setdefault('details', {}).update(dict( + collection=collection + )) + super(CollectionError, self).__init__(**kw) + + +class ImmutableCollectionError(CollectionError): + '''Raise when modification of immutable collection attempted.''' + + default_message = ( + 'Cannot modify value of immutable collection {collection!r}.' + ) + + +class DuplicateItemInCollectionError(CollectionError): + '''Raise when duplicate item in collection detected.''' + + default_message = ( + 'Item {item!r} already exists in collection {collection!r}.' + ) + + def __init__(self, item, collection, **kw): + '''Initialise error.''' + kw.setdefault('details', {}).update(dict( + item=item + )) + super(DuplicateItemInCollectionError, self).__init__(collection, **kw) + + +class ParseError(Error): + '''Raise when a parsing error occurs.''' + + default_message = 'Failed to parse.' + + +class EventHubError(Error): + '''Raise when issues related to event hub occur.''' + + default_message = 'Event hub error occurred.' + + +class EventHubConnectionError(EventHubError): + '''Raise when event hub encounters connection problem.''' + + default_message = 'Event hub is not connected.' + + +class EventHubPacketError(EventHubError): + '''Raise when event hub encounters an issue with a packet.''' + + default_message = 'Invalid packet.' + + +class PermissionDeniedError(Error): + '''Raise when permission is denied.''' + + default_message = 'Permission denied.' + + +class LocationError(Error): + '''Base for errors associated with locations.''' + + default_message = 'Unspecified location error' + + +class ComponentNotInAnyLocationError(LocationError): + '''Raise when component not available in any location.''' + + default_message = 'Component not available in any location.' + + +class ComponentNotInLocationError(LocationError): + '''Raise when component(s) not in location.''' + + default_message = ( + 'Component(s) {formatted_components} not found in location {location}.' + ) + + def __init__(self, components, location, **kw): + '''Initialise with *components* and *location*.''' + if isinstance(components, ftrack_api.entity.base.Entity): + components = [components] + + kw.setdefault('details', {}).update(dict( + components=components, + formatted_components=', '.join( + [str(component) for component in components] + ), + location=location + )) + + super(ComponentNotInLocationError, self).__init__(**kw) + + +class ComponentInLocationError(LocationError): + '''Raise when component(s) already exists in location.''' + + default_message = ( + 'Component(s) {formatted_components} already exist in location ' + '{location}.' + ) + + def __init__(self, components, location, **kw): + '''Initialise with *components* and *location*.''' + if isinstance(components, ftrack_api.entity.base.Entity): + components = [components] + + kw.setdefault('details', {}).update(dict( + components=components, + formatted_components=', '.join( + [str(component) for component in components] + ), + location=location + )) + + super(ComponentInLocationError, self).__init__(**kw) + + +class AccessorError(Error): + '''Base for errors associated with accessors.''' + + default_message = 'Unspecified accessor error' + + +class AccessorOperationFailedError(AccessorError): + '''Base for failed operations on accessors.''' + + default_message = 'Operation {operation} failed: {error}' + + def __init__( + self, operation='', resource_identifier=None, error=None, **kw + ): + kw.setdefault('details', {}).update(dict( + operation=operation, + resource_identifier=resource_identifier, + error=error + )) + super(AccessorOperationFailedError, self).__init__(**kw) + + +class AccessorUnsupportedOperationError(AccessorOperationFailedError): + '''Raise when operation is unsupported.''' + + default_message = 'Operation {operation} unsupported.' + + +class AccessorPermissionDeniedError(AccessorOperationFailedError): + '''Raise when permission denied.''' + + default_message = ( + 'Cannot {operation} {resource_identifier}. Permission denied.' + ) + + +class AccessorResourceIdentifierError(AccessorError): + '''Raise when a error related to a resource_identifier occurs.''' + + default_message = 'Resource identifier is invalid: {resource_identifier}.' + + def __init__(self, resource_identifier, **kw): + kw.setdefault('details', {}).update(dict( + resource_identifier=resource_identifier + )) + super(AccessorResourceIdentifierError, self).__init__(**kw) + + +class AccessorFilesystemPathError(AccessorResourceIdentifierError): + '''Raise when a error related to an accessor filesystem path occurs.''' + + default_message = ( + 'Could not determine filesystem path from resource identifier: ' + '{resource_identifier}.' + ) + + +class AccessorResourceError(AccessorError): + '''Base for errors associated with specific resource.''' + + default_message = 'Unspecified resource error: {resource_identifier}' + + def __init__(self, operation='', resource_identifier=None, error=None, + **kw): + kw.setdefault('details', {}).update(dict( + operation=operation, + resource_identifier=resource_identifier + )) + super(AccessorResourceError, self).__init__(**kw) + + +class AccessorResourceNotFoundError(AccessorResourceError): + '''Raise when a required resource is not found.''' + + default_message = 'Resource not found: {resource_identifier}' + + +class AccessorParentResourceNotFoundError(AccessorResourceError): + '''Raise when a parent resource (such as directory) is not found.''' + + default_message = 'Parent resource is missing: {resource_identifier}' + + +class AccessorResourceInvalidError(AccessorResourceError): + '''Raise when a resource is not the right type.''' + + default_message = 'Resource invalid: {resource_identifier}' + + +class AccessorContainerNotEmptyError(AccessorResourceError): + '''Raise when container is not empty.''' + + default_message = 'Container is not empty: {resource_identifier}' + + +class StructureError(Error): + '''Base for errors associated with structures.''' + + default_message = 'Unspecified structure error' + + +class ConnectionClosedError(Error): + '''Raise when attempt to use closed connection detected.''' + + default_message = "Connection closed." diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/formatter.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/formatter.py new file mode 100644 index 0000000000..c282fcc814 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/formatter.py @@ -0,0 +1,131 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +import termcolor + +import ftrack_api.entity.base +import ftrack_api.collection +import ftrack_api.symbol +import ftrack_api.inspection + + +#: Useful filters to pass to :func:`format`.` +FILTER = { + 'ignore_unset': ( + lambda entity, name, value: value is not ftrack_api.symbol.NOT_SET + ) +} + + +def format( + entity, formatters=None, attribute_filter=None, recursive=False, + indent=0, indent_first_line=True, _seen=None +): + '''Return formatted string representing *entity*. + + *formatters* can be used to customise formatting of elements. It should be a + mapping with one or more of the following keys: + + * header - Used to format entity type. + * label - Used to format attribute names. + + Specify an *attribute_filter* to control which attributes to include. By + default all attributes are included. The *attribute_filter* should be a + callable that accepts `(entity, attribute_name, attribute_value)` and + returns True if the attribute should be included in the output. For example, + to filter out all unset values:: + + attribute_filter=ftrack_api.formatter.FILTER['ignore_unset'] + + If *recursive* is True then recurse into Collections and format each entity + present. + + *indent* specifies the overall indentation in spaces of the formatted text, + whilst *indent_first_line* determines whether to apply that indent to the + first generated line. + + .. warning:: + + Iterates over all *entity* attributes which may cause multiple queries + to the server. Turn off auto populating in the session to prevent this. + + ''' + # Initialise default formatters. + if formatters is None: + formatters = dict() + + formatters.setdefault( + 'header', lambda text: termcolor.colored( + text, 'white', 'on_blue', attrs=['bold'] + ) + ) + formatters.setdefault( + 'label', lambda text: termcolor.colored( + text, 'blue', attrs=['bold'] + ) + ) + + # Determine indents. + spacer = ' ' * indent + if indent_first_line: + first_line_spacer = spacer + else: + first_line_spacer = '' + + # Avoid infinite recursion on circular references. + if _seen is None: + _seen = set() + + identifier = str(ftrack_api.inspection.identity(entity)) + if identifier in _seen: + return ( + first_line_spacer + + formatters['header'](entity.entity_type) + '{...}' + ) + + _seen.add(identifier) + information = list() + + information.append( + first_line_spacer + formatters['header'](entity.entity_type) + ) + for key, value in sorted(entity.items()): + if attribute_filter is not None: + if not attribute_filter(entity, key, value): + continue + + child_indent = indent + len(key) + 3 + + if isinstance(value, ftrack_api.entity.base.Entity): + value = format( + value, + formatters=formatters, + attribute_filter=attribute_filter, + recursive=recursive, + indent=child_indent, + indent_first_line=False, + _seen=_seen.copy() + ) + + if isinstance(value, ftrack_api.collection.Collection): + if recursive: + child_values = [] + for index, child in enumerate(value): + child_value = format( + child, + formatters=formatters, + attribute_filter=attribute_filter, + recursive=recursive, + indent=child_indent, + indent_first_line=index != 0, + _seen=_seen.copy() + ) + child_values.append(child_value) + + value = '\n'.join(child_values) + + information.append( + spacer + u' {0}: {1}'.format(formatters['label'](key), value) + ) + + return '\n'.join(information) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/inspection.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/inspection.py new file mode 100644 index 0000000000..d8b815200e --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/inspection.py @@ -0,0 +1,135 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import collections + +import ftrack_api.symbol +import ftrack_api.operation + + +def identity(entity): + '''Return unique identity of *entity*.''' + return ( + str(entity.entity_type), + primary_key(entity).values() + ) + + +def primary_key(entity): + '''Return primary key of *entity* as an ordered mapping of {field: value}. + + To get just the primary key values:: + + primary_key(entity).values() + + ''' + primary_key = collections.OrderedDict() + for name in entity.primary_key_attributes: + value = entity[name] + if value is ftrack_api.symbol.NOT_SET: + raise KeyError( + 'Missing required value for primary key attribute "{0}" on ' + 'entity {1!r}.'.format(name, entity) + ) + + primary_key[str(name)] = str(value) + + return primary_key + + +def _state(operation, state): + '''Return state following *operation* against current *state*.''' + if ( + isinstance( + operation, ftrack_api.operation.CreateEntityOperation + ) + and state is ftrack_api.symbol.NOT_SET + ): + state = ftrack_api.symbol.CREATED + + elif ( + isinstance( + operation, ftrack_api.operation.UpdateEntityOperation + ) + and state is ftrack_api.symbol.NOT_SET + ): + state = ftrack_api.symbol.MODIFIED + + elif isinstance( + operation, ftrack_api.operation.DeleteEntityOperation + ): + state = ftrack_api.symbol.DELETED + + return state + + +def state(entity): + '''Return current *entity* state. + + .. seealso:: :func:`ftrack_api.inspection.states`. + + ''' + value = ftrack_api.symbol.NOT_SET + + for operation in entity.session.recorded_operations: + # Determine if operation refers to an entity and whether that entity + # is *entity*. + if ( + isinstance( + operation, + ( + ftrack_api.operation.CreateEntityOperation, + ftrack_api.operation.UpdateEntityOperation, + ftrack_api.operation.DeleteEntityOperation + ) + ) + and operation.entity_type == entity.entity_type + and operation.entity_key == primary_key(entity) + ): + value = _state(operation, value) + + return value + + +def states(entities): + '''Return current states of *entities*. + + An optimised function for determining states of multiple entities in one + go. + + .. note:: + + All *entities* should belong to the same session. + + .. seealso:: :func:`ftrack_api.inspection.state`. + + ''' + if not entities: + return [] + + session = entities[0].session + + entities_by_identity = collections.OrderedDict() + for entity in entities: + key = (entity.entity_type, str(primary_key(entity).values())) + entities_by_identity[key] = ftrack_api.symbol.NOT_SET + + for operation in session.recorded_operations: + if ( + isinstance( + operation, + ( + ftrack_api.operation.CreateEntityOperation, + ftrack_api.operation.UpdateEntityOperation, + ftrack_api.operation.DeleteEntityOperation + ) + ) + ): + key = (operation.entity_type, str(operation.entity_key.values())) + if key not in entities_by_identity: + continue + + value = _state(operation, entities_by_identity[key]) + entities_by_identity[key] = value + + return entities_by_identity.values() diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/logging.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/logging.py new file mode 100644 index 0000000000..41969c5b2a --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/logging.py @@ -0,0 +1,43 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2016 ftrack + +import functools +import warnings + + +def deprecation_warning(message): + def decorator(function): + @functools.wraps(function) + def wrapper(*args, **kwargs): + warnings.warn( + message, + PendingDeprecationWarning + ) + return function(*args, **kwargs) + return wrapper + + return decorator + + +class LazyLogMessage(object): + '''A log message that can be evaluated lazily for improved performance. + + Example:: + + # Formatting of string will not occur unless debug logging enabled. + logger.debug(LazyLogMessage( + 'Hello {0}', 'world' + )) + + ''' + + def __init__(self, message, *args, **kwargs): + '''Initialise with *message* format string and arguments.''' + self.message = message + self.args = args + self.kwargs = kwargs + + def __str__(self): + '''Return string representation.''' + return self.message.format(*self.args, **self.kwargs) + diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/operation.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/operation.py new file mode 100644 index 0000000000..bb3bb4ee2c --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/operation.py @@ -0,0 +1,115 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import copy + + +class Operations(object): + '''Stack of operations.''' + + def __init__(self): + '''Initialise stack.''' + self._stack = [] + super(Operations, self).__init__() + + def clear(self): + '''Clear all operations.''' + del self._stack[:] + + def push(self, operation): + '''Push *operation* onto stack.''' + self._stack.append(operation) + + def pop(self): + '''Pop and return most recent operation from stack.''' + return self._stack.pop() + + def __len__(self): + '''Return count of operations.''' + return len(self._stack) + + def __iter__(self): + '''Return iterator over operations.''' + return iter(self._stack) + + +class Operation(object): + '''Represent an operation.''' + + +class CreateEntityOperation(Operation): + '''Represent create entity operation.''' + + def __init__(self, entity_type, entity_key, entity_data): + '''Initialise operation. + + *entity_type* should be the type of entity in string form (as returned + from :attr:`ftrack_api.entity.base.Entity.entity_type`). + + *entity_key* should be the unique key for the entity and should follow + the form returned from :func:`ftrack_api.inspection.primary_key`. + + *entity_data* should be a mapping of the initial data to populate the + entity with when creating. + + .. note:: + + Shallow copies will be made of each value in *entity_data*. + + ''' + super(CreateEntityOperation, self).__init__() + self.entity_type = entity_type + self.entity_key = entity_key + self.entity_data = {} + for key, value in entity_data.items(): + self.entity_data[key] = copy.copy(value) + + +class UpdateEntityOperation(Operation): + '''Represent update entity operation.''' + + def __init__( + self, entity_type, entity_key, attribute_name, old_value, new_value + ): + '''Initialise operation. + + *entity_type* should be the type of entity in string form (as returned + from :attr:`ftrack_api.entity.base.Entity.entity_type`). + + *entity_key* should be the unique key for the entity and should follow + the form returned from :func:`ftrack_api.inspection.primary_key`. + + *attribute_name* should be the string name of the attribute being + modified and *old_value* and *new_value* should reflect the change in + value. + + .. note:: + + Shallow copies will be made of both *old_value* and *new_value*. + + ''' + super(UpdateEntityOperation, self).__init__() + self.entity_type = entity_type + self.entity_key = entity_key + self.attribute_name = attribute_name + self.old_value = copy.copy(old_value) + self.new_value = copy.copy(new_value) + + +class DeleteEntityOperation(Operation): + '''Represent delete entity operation.''' + + def __init__(self, entity_type, entity_key): + '''Initialise operation. + + *entity_type* should be the type of entity in string form (as returned + from :attr:`ftrack_api.entity.base.Entity.entity_type`). + + *entity_key* should be the unique key for the entity and should follow + the form returned from :func:`ftrack_api.inspection.primary_key`. + + ''' + super(DeleteEntityOperation, self).__init__() + self.entity_type = entity_type + self.entity_key = entity_key + diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/plugin.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/plugin.py new file mode 100644 index 0000000000..2c7a9a4500 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/plugin.py @@ -0,0 +1,121 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +from __future__ import absolute_import + +import logging +import os +import uuid +import imp +import inspect + + +def discover(paths, positional_arguments=None, keyword_arguments=None): + '''Find and load plugins in search *paths*. + + Each discovered module should implement a register function that accepts + *positional_arguments* and *keyword_arguments* as \*args and \*\*kwargs + respectively. + + If a register function does not accept variable arguments, then attempt to + only pass accepted arguments to the function by inspecting its signature. + + ''' + logger = logging.getLogger(__name__ + '.discover') + + if positional_arguments is None: + positional_arguments = [] + + if keyword_arguments is None: + keyword_arguments = {} + + for path in paths: + # Ignore empty paths that could resolve to current directory. + path = path.strip() + if not path: + continue + + for base, directories, filenames in os.walk(path): + for filename in filenames: + name, extension = os.path.splitext(filename) + if extension != '.py': + continue + + module_path = os.path.join(base, filename) + unique_name = uuid.uuid4().hex + + try: + module = imp.load_source(unique_name, module_path) + except Exception as error: + logger.warning( + 'Failed to load plugin from "{0}": {1}' + .format(module_path, error) + ) + continue + + try: + module.register + except AttributeError: + logger.warning( + 'Failed to load plugin that did not define a ' + '"register" function at the module level: {0}' + .format(module_path) + ) + else: + # Attempt to only pass arguments that are accepted by the + # register function. + specification = inspect.getargspec(module.register) + + selected_positional_arguments = positional_arguments + selected_keyword_arguments = keyword_arguments + + if ( + not specification.varargs and + len(positional_arguments) > len(specification.args) + ): + logger.warning( + 'Culling passed arguments to match register ' + 'function signature.' + ) + + selected_positional_arguments = positional_arguments[ + len(specification.args): + ] + selected_keyword_arguments = {} + + elif not specification.keywords: + # Remove arguments that have been passed as positionals. + remainder = specification.args[ + len(positional_arguments): + ] + + # Determine remaining available keyword arguments. + defined_keyword_arguments = [] + if specification.defaults: + defined_keyword_arguments = specification.args[ + -len(specification.defaults): + ] + + remaining_keyword_arguments = set([ + keyword_argument for keyword_argument + in defined_keyword_arguments + if keyword_argument in remainder + ]) + + if not set(keyword_arguments.keys()).issubset( + remaining_keyword_arguments + ): + logger.warning( + 'Culling passed arguments to match register ' + 'function signature.' + ) + selected_keyword_arguments = { + key: value + for key, value in keyword_arguments.items() + if key in remaining_keyword_arguments + } + + module.register( + *selected_positional_arguments, + **selected_keyword_arguments + ) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/query.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/query.py new file mode 100644 index 0000000000..ea101a29d4 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/query.py @@ -0,0 +1,202 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +import re +import collections + +import ftrack_api.exception + + +class QueryResult(collections.Sequence): + '''Results from a query.''' + + OFFSET_EXPRESSION = re.compile('(?Poffset (?P\d+))') + LIMIT_EXPRESSION = re.compile('(?Plimit (?P\d+))') + + def __init__(self, session, expression, page_size=500): + '''Initialise result set. + + *session* should be an instance of :class:`ftrack_api.session.Session` + that will be used for executing the query *expression*. + + *page_size* should be an integer specifying the maximum number of + records to fetch in one request allowing the results to be fetched + incrementally in a transparent manner for optimal performance. Any + offset or limit specified in *expression* are honoured for final result + set, but intermediate queries may be issued with different offsets and + limits in order to fetch pages. When an embedded limit is smaller than + the given *page_size* it will be used instead and no paging will take + place. + + .. warning:: + + Setting *page_size* to a very large amount may negatively impact + performance of not only the caller, but the server in general. + + ''' + super(QueryResult, self).__init__() + self._session = session + self._results = [] + + ( + self._expression, + self._offset, + self._limit + ) = self._extract_offset_and_limit(expression) + + self._page_size = page_size + if self._limit is not None and self._limit < self._page_size: + # Optimise case where embedded limit is less than fetching a + # single page. + self._page_size = self._limit + + self._next_offset = self._offset + if self._next_offset is None: + # Initialise with zero offset. + self._next_offset = 0 + + def _extract_offset_and_limit(self, expression): + '''Process *expression* extracting offset and limit. + + Return (expression, offset, limit). + + ''' + offset = None + match = self.OFFSET_EXPRESSION.search(expression) + if match: + offset = int(match.group('value')) + expression = ( + expression[:match.start('offset')] + + expression[match.end('offset'):] + ) + + limit = None + match = self.LIMIT_EXPRESSION.search(expression) + if match: + limit = int(match.group('value')) + expression = ( + expression[:match.start('limit')] + + expression[match.end('limit'):] + ) + + return expression.strip(), offset, limit + + def __getitem__(self, index): + '''Return value at *index*.''' + while self._can_fetch_more() and index >= len(self._results): + self._fetch_more() + + return self._results[index] + + def __len__(self): + '''Return number of items.''' + while self._can_fetch_more(): + self._fetch_more() + + return len(self._results) + + def _can_fetch_more(self): + '''Return whether more results are available to fetch.''' + return self._next_offset is not None + + def _fetch_more(self): + '''Fetch next page of results if available.''' + if not self._can_fetch_more(): + return + + expression = '{0} offset {1} limit {2}'.format( + self._expression, self._next_offset, self._page_size + ) + records, metadata = self._session._query(expression) + self._results.extend(records) + + if self._limit is not None and (len(self._results) >= self._limit): + # Original limit reached. + self._next_offset = None + del self._results[self._limit:] + else: + # Retrieve next page offset from returned metadata. + self._next_offset = metadata.get('next', {}).get('offset', None) + + def all(self): + '''Fetch and return all data.''' + return list(self) + + def one(self): + '''Return exactly one single result from query by applying a limit. + + Raise :exc:`ValueError` if an existing limit is already present in the + expression. + + Raise :exc:`ValueError` if an existing offset is already present in the + expression as offset is inappropriate when expecting a single item. + + Raise :exc:`~ftrack_api.exception.MultipleResultsFoundError` if more + than one result was available or + :exc:`~ftrack_api.exception.NoResultFoundError` if no results were + available. + + .. note:: + + Both errors subclass + :exc:`~ftrack_api.exception.IncorrectResultError` if you want to + catch only one error type. + + ''' + expression = self._expression + + if self._limit is not None: + raise ValueError( + 'Expression already contains a limit clause.' + ) + + if self._offset is not None: + raise ValueError( + 'Expression contains an offset clause which does not make ' + 'sense when selecting a single item.' + ) + + # Apply custom limit as optimisation. A limit of 2 is used rather than + # 1 so that it is possible to test for multiple matching entries + # case. + expression += ' limit 2' + + results, metadata = self._session._query(expression) + + if not results: + raise ftrack_api.exception.NoResultFoundError() + + if len(results) != 1: + raise ftrack_api.exception.MultipleResultsFoundError() + + return results[0] + + def first(self): + '''Return first matching result from query by applying a limit. + + Raise :exc:`ValueError` if an existing limit is already present in the + expression. + + If no matching result available return None. + + ''' + expression = self._expression + + if self._limit is not None: + raise ValueError( + 'Expression already contains a limit clause.' + ) + + # Apply custom offset if present. + if self._offset is not None: + expression += ' offset {0}'.format(self._offset) + + # Apply custom limit as optimisation. + expression += ' limit 1' + + results, metadata = self._session._query(expression) + + if results: + return results[0] + + return None diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/resource_identifier_transformer/__init__.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/resource_identifier_transformer/__init__.py new file mode 100644 index 0000000000..1aab07ed77 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/resource_identifier_transformer/__init__.py @@ -0,0 +1,2 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/resource_identifier_transformer/base.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/resource_identifier_transformer/base.py new file mode 100644 index 0000000000..ee069b57b6 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/resource_identifier_transformer/base.py @@ -0,0 +1,50 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + + +class ResourceIdentifierTransformer(object): + '''Transform resource identifiers. + + Provide ability to modify resource identifier before it is stored centrally + (:meth:`encode`), or after it has been retrieved, but before it is used + locally (:meth:`decode`). + + For example, you might want to decompose paths into a set of key, value + pairs to store centrally and then compose a path from those values when + reading back. + + .. note:: + + This is separate from any transformations an + :class:`ftrack_api.accessor.base.Accessor` may perform and is targeted + towards common transformations. + + ''' + + def __init__(self, session): + '''Initialise resource identifier transformer. + + *session* should be the :class:`ftrack_api.session.Session` instance + to use for communication with the server. + + ''' + self.session = session + super(ResourceIdentifierTransformer, self).__init__() + + def encode(self, resource_identifier, context=None): + '''Return encoded *resource_identifier* for storing centrally. + + A mapping of *context* values may be supplied to guide the + transformation. + + ''' + return resource_identifier + + def decode(self, resource_identifier, context=None): + '''Return decoded *resource_identifier* for use locally. + + A mapping of *context* values may be supplied to guide the + transformation. + + ''' + return resource_identifier diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/session.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/session.py new file mode 100644 index 0000000000..1a5da44432 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/session.py @@ -0,0 +1,2515 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +from __future__ import absolute_import + +import json +import logging +import collections +import datetime +import os +import getpass +import functools +import itertools +import distutils.version +import hashlib +import tempfile +import threading +import atexit +import warnings + +import requests +import requests.auth +import arrow +import clique + +import ftrack_api +import ftrack_api.exception +import ftrack_api.entity.factory +import ftrack_api.entity.base +import ftrack_api.entity.location +import ftrack_api.cache +import ftrack_api.symbol +import ftrack_api.query +import ftrack_api.attribute +import ftrack_api.collection +import ftrack_api.event.hub +import ftrack_api.event.base +import ftrack_api.plugin +import ftrack_api.inspection +import ftrack_api.operation +import ftrack_api.accessor.disk +import ftrack_api.structure.origin +import ftrack_api.structure.entity_id +import ftrack_api.accessor.server +import ftrack_api._centralized_storage_scenario +import ftrack_api.logging +from ftrack_api.logging import LazyLogMessage as L + +try: + from weakref import WeakMethod +except ImportError: + from ftrack_api._weakref import WeakMethod + + +class SessionAuthentication(requests.auth.AuthBase): + '''Attach ftrack session authentication information to requests.''' + + def __init__(self, api_key, api_user): + '''Initialise with *api_key* and *api_user*.''' + self.api_key = api_key + self.api_user = api_user + super(SessionAuthentication, self).__init__() + + def __call__(self, request): + '''Modify *request* to have appropriate headers.''' + request.headers.update({ + 'ftrack-api-key': self.api_key, + 'ftrack-user': self.api_user + }) + return request + + +class Session(object): + '''An isolated session for interaction with an ftrack server.''' + + def __init__( + self, server_url=None, api_key=None, api_user=None, auto_populate=True, + plugin_paths=None, cache=None, cache_key_maker=None, + auto_connect_event_hub=None, schema_cache_path=None, + plugin_arguments=None + ): + '''Initialise session. + + *server_url* should be the URL of the ftrack server to connect to + including any port number. If not specified attempt to look up from + :envvar:`FTRACK_SERVER`. + + *api_key* should be the API key to use for authentication whilst + *api_user* should be the username of the user in ftrack to record + operations against. If not specified, *api_key* should be retrieved + from :envvar:`FTRACK_API_KEY` and *api_user* from + :envvar:`FTRACK_API_USER`. + + If *auto_populate* is True (the default), then accessing entity + attributes will cause them to be automatically fetched from the server + if they are not already. This flag can be changed on the session + directly at any time. + + *plugin_paths* should be a list of paths to search for plugins. If not + specified, default to looking up :envvar:`FTRACK_EVENT_PLUGIN_PATH`. + + *cache* should be an instance of a cache that fulfils the + :class:`ftrack_api.cache.Cache` interface and will be used as the cache + for the session. It can also be a callable that will be called with the + session instance as sole argument. The callable should return ``None`` + if a suitable cache could not be configured, but session instantiation + can continue safely. + + .. note:: + + The session will add the specified cache to a pre-configured layered + cache that specifies the top level cache as a + :class:`ftrack_api.cache.MemoryCache`. Therefore, it is unnecessary + to construct a separate memory cache for typical behaviour. Working + around this behaviour or removing the memory cache can lead to + unexpected behaviour. + + *cache_key_maker* should be an instance of a key maker that fulfils the + :class:`ftrack_api.cache.KeyMaker` interface and will be used to + generate keys for objects being stored in the *cache*. If not specified, + a :class:`~ftrack_api.cache.StringKeyMaker` will be used. + + If *auto_connect_event_hub* is True then embedded event hub will be + automatically connected to the event server and allow for publishing and + subscribing to **non-local** events. If False, then only publishing and + subscribing to **local** events will be possible until the hub is + manually connected using :meth:`EventHub.connect + `. + + .. note:: + + The event hub connection is performed in a background thread to + improve session startup time. If a registered plugin requires a + connected event hub then it should check the event hub connection + status explicitly. Subscribing to events does *not* require a + connected event hub. + + Enable schema caching by setting *schema_cache_path* to a folder path. + If not set, :envvar:`FTRACK_API_SCHEMA_CACHE_PATH` will be used to + determine the path to store cache in. If the environment variable is + also not specified then a temporary directory will be used. Set to + `False` to disable schema caching entirely. + + *plugin_arguments* should be an optional mapping (dict) of keyword + arguments to pass to plugin register functions upon discovery. If a + discovered plugin has a signature that is incompatible with the passed + arguments, the discovery mechanism will attempt to reduce the passed + arguments to only those that the plugin accepts. Note that a warning + will be logged in this case. + + ''' + super(Session, self).__init__() + self.logger = logging.getLogger( + __name__ + '.' + self.__class__.__name__ + ) + self._closed = False + + if server_url is None: + server_url = os.environ.get('FTRACK_SERVER') + + if not server_url: + raise TypeError( + 'Required "server_url" not specified. Pass as argument or set ' + 'in environment variable FTRACK_SERVER.' + ) + + self._server_url = server_url + + if api_key is None: + api_key = os.environ.get( + 'FTRACK_API_KEY', + # Backwards compatibility + os.environ.get('FTRACK_APIKEY') + ) + + if not api_key: + raise TypeError( + 'Required "api_key" not specified. Pass as argument or set in ' + 'environment variable FTRACK_API_KEY.' + ) + + self._api_key = api_key + + if api_user is None: + api_user = os.environ.get('FTRACK_API_USER') + if not api_user: + try: + api_user = getpass.getuser() + except Exception: + pass + + if not api_user: + raise TypeError( + 'Required "api_user" not specified. Pass as argument, set in ' + 'environment variable FTRACK_API_USER or one of the standard ' + 'environment variables used by Python\'s getpass module.' + ) + + self._api_user = api_user + + # Currently pending operations. + self.recorded_operations = ftrack_api.operation.Operations() + self.record_operations = True + + self.cache_key_maker = cache_key_maker + if self.cache_key_maker is None: + self.cache_key_maker = ftrack_api.cache.StringKeyMaker() + + # Enforce always having a memory cache at top level so that the same + # in-memory instance is returned from session. + self.cache = ftrack_api.cache.LayeredCache([ + ftrack_api.cache.MemoryCache() + ]) + + if cache is not None: + if callable(cache): + cache = cache(self) + + if cache is not None: + self.cache.caches.append(cache) + + self._managed_request = None + self._request = requests.Session() + self._request.auth = SessionAuthentication( + self._api_key, self._api_user + ) + + self.auto_populate = auto_populate + + # Fetch server information and in doing so also check credentials. + self._server_information = self._fetch_server_information() + + # Now check compatibility of server based on retrieved information. + self.check_server_compatibility() + + # Construct event hub and load plugins. + self._event_hub = ftrack_api.event.hub.EventHub( + self._server_url, + self._api_user, + self._api_key, + ) + + self._auto_connect_event_hub_thread = None + if auto_connect_event_hub in (None, True): + # Connect to event hub in background thread so as not to block main + # session usage waiting for event hub connection. + self._auto_connect_event_hub_thread = threading.Thread( + target=self._event_hub.connect + ) + self._auto_connect_event_hub_thread.daemon = True + self._auto_connect_event_hub_thread.start() + + # To help with migration from auto_connect_event_hub default changing + # from True to False. + self._event_hub._deprecation_warning_auto_connect = ( + auto_connect_event_hub is None + ) + + # Register to auto-close session on exit. + atexit.register(WeakMethod(self.close)) + + self._plugin_paths = plugin_paths + if self._plugin_paths is None: + self._plugin_paths = os.environ.get( + 'FTRACK_EVENT_PLUGIN_PATH', '' + ).split(os.pathsep) + + self._discover_plugins(plugin_arguments=plugin_arguments) + + # TODO: Make schemas read-only and non-mutable (or at least without + # rebuilding types)? + if schema_cache_path is not False: + if schema_cache_path is None: + schema_cache_path = os.environ.get( + 'FTRACK_API_SCHEMA_CACHE_PATH', tempfile.gettempdir() + ) + + schema_cache_path = os.path.join( + schema_cache_path, 'ftrack_api_schema_cache.json' + ) + + self.schemas = self._load_schemas(schema_cache_path) + self.types = self._build_entity_type_classes(self.schemas) + + ftrack_api._centralized_storage_scenario.register(self) + + self._configure_locations() + self.event_hub.publish( + ftrack_api.event.base.Event( + topic='ftrack.api.session.ready', + data=dict( + session=self + ) + ), + synchronous=True + ) + + def __enter__(self): + '''Return session as context manager.''' + return self + + def __exit__(self, exception_type, exception_value, traceback): + '''Exit session context, closing session in process.''' + self.close() + + @property + def _request(self): + '''Return request session. + + Raise :exc:`ftrack_api.exception.ConnectionClosedError` if session has + been closed and connection unavailable. + + ''' + if self._managed_request is None: + raise ftrack_api.exception.ConnectionClosedError() + + return self._managed_request + + @_request.setter + def _request(self, value): + '''Set request session to *value*.''' + self._managed_request = value + + @property + def closed(self): + '''Return whether session has been closed.''' + return self._closed + + @property + def server_information(self): + '''Return server information such as server version.''' + return self._server_information.copy() + + @property + def server_url(self): + '''Return server ulr used for session.''' + return self._server_url + + @property + def api_user(self): + '''Return username used for session.''' + return self._api_user + + @property + def api_key(self): + '''Return API key used for session.''' + return self._api_key + + @property + def event_hub(self): + '''Return event hub.''' + return self._event_hub + + @property + def _local_cache(self): + '''Return top level memory cache.''' + return self.cache.caches[0] + + def check_server_compatibility(self): + '''Check compatibility with connected server.''' + server_version = self.server_information.get('version') + if server_version is None: + raise ftrack_api.exception.ServerCompatibilityError( + 'Could not determine server version.' + ) + + # Perform basic version check. + if server_version != 'dev': + min_server_version = '3.3.11' + if ( + distutils.version.LooseVersion(min_server_version) + > distutils.version.LooseVersion(server_version) + ): + raise ftrack_api.exception.ServerCompatibilityError( + 'Server version {0} incompatible with this version of the ' + 'API which requires a server version >= {1}'.format( + server_version, + min_server_version + ) + ) + + def close(self): + '''Close session. + + Close connections to server. Clear any pending operations and local + cache. + + Use this to ensure that session is cleaned up properly after use. + + ''' + if self.closed: + self.logger.debug('Session already closed.') + return + + self._closed = True + + self.logger.debug('Closing session.') + if self.recorded_operations: + self.logger.warning( + 'Closing session with pending operations not persisted.' + ) + + # Clear pending operations. + self.recorded_operations.clear() + + # Clear top level cache (expected to be enforced memory cache). + self._local_cache.clear() + + # Close connections. + self._request.close() + self._request = None + + try: + self.event_hub.disconnect() + if self._auto_connect_event_hub_thread: + self._auto_connect_event_hub_thread.join() + except ftrack_api.exception.EventHubConnectionError: + pass + + self.logger.debug('Session closed.') + + def reset(self): + '''Reset session clearing local state. + + Clear all pending operations and expunge all entities from session. + + Also clear the local cache. If the cache used by the session is a + :class:`~ftrack_api.cache.LayeredCache` then only clear top level cache. + Otherwise, clear the entire cache. + + Plugins are not rediscovered or reinitialised, but certain plugin events + are re-emitted to properly configure session aspects that are dependant + on cache (such as location plugins). + + .. warning:: + + Previously attached entities are not reset in memory and will retain + their state, but should not be used. Doing so will cause errors. + + ''' + if self.recorded_operations: + self.logger.warning( + 'Resetting session with pending operations not persisted.' + ) + + # Clear pending operations. + self.recorded_operations.clear() + + # Clear top level cache (expected to be enforced memory cache). + self._local_cache.clear() + + # Re-configure certain session aspects that may be dependant on cache. + self._configure_locations() + + self.event_hub.publish( + ftrack_api.event.base.Event( + topic='ftrack.api.session.reset', + data=dict( + session=self + ) + ), + synchronous=True + ) + + def auto_populating(self, auto_populate): + '''Temporarily set auto populate to *auto_populate*. + + The current setting will be restored automatically when done. + + Example:: + + with session.auto_populating(False): + print entity['name'] + + ''' + return AutoPopulatingContext(self, auto_populate) + + def operation_recording(self, record_operations): + '''Temporarily set operation recording to *record_operations*. + + The current setting will be restored automatically when done. + + Example:: + + with session.operation_recording(False): + entity['name'] = 'change_not_recorded' + + ''' + return OperationRecordingContext(self, record_operations) + + @property + def created(self): + '''Return list of newly created entities.''' + entities = self._local_cache.values() + states = ftrack_api.inspection.states(entities) + + return [ + entity for (entity, state) in itertools.izip(entities, states) + if state is ftrack_api.symbol.CREATED + ] + + @property + def modified(self): + '''Return list of locally modified entities.''' + entities = self._local_cache.values() + states = ftrack_api.inspection.states(entities) + + return [ + entity for (entity, state) in itertools.izip(entities, states) + if state is ftrack_api.symbol.MODIFIED + ] + + @property + def deleted(self): + '''Return list of deleted entities.''' + entities = self._local_cache.values() + states = ftrack_api.inspection.states(entities) + + return [ + entity for (entity, state) in itertools.izip(entities, states) + if state is ftrack_api.symbol.DELETED + ] + + def reset_remote(self, reset_type, entity=None): + '''Perform a server side reset. + + *reset_type* is a server side supported reset type, + passing the optional *entity* to perform the option upon. + + Please refer to ftrack documentation for a complete list of + supported server side reset types. + ''' + + payload = { + 'action': 'reset_remote', + 'reset_type': reset_type + } + + if entity is not None: + payload.update({ + 'entity_type': entity.entity_type, + 'entity_key': entity.get('id') + }) + + result = self.call( + [payload] + ) + + return result[0]['data'] + + def create(self, entity_type, data=None, reconstructing=False): + '''Create and return an entity of *entity_type* with initial *data*. + + If specified, *data* should be a dictionary of key, value pairs that + should be used to populate attributes on the entity. + + If *reconstructing* is False then create a new entity setting + appropriate defaults for missing data. If True then reconstruct an + existing entity. + + Constructed entity will be automatically :meth:`merged ` + into the session. + + ''' + entity = self._create(entity_type, data, reconstructing=reconstructing) + entity = self.merge(entity) + return entity + + def _create(self, entity_type, data, reconstructing): + '''Create and return an entity of *entity_type* with initial *data*.''' + try: + EntityTypeClass = self.types[entity_type] + except KeyError: + raise ftrack_api.exception.UnrecognisedEntityTypeError(entity_type) + + return EntityTypeClass(self, data=data, reconstructing=reconstructing) + + def ensure(self, entity_type, data, identifying_keys=None): + '''Retrieve entity of *entity_type* with *data*, creating if necessary. + + *data* should be a dictionary of the same form passed to :meth:`create`. + + By default, check for an entity that has matching *data*. If + *identifying_keys* is specified as a list of keys then only consider the + values from *data* for those keys when searching for existing entity. If + *data* is missing an identifying key then raise :exc:`KeyError`. + + If no *identifying_keys* specified then use all of the keys from the + passed *data*. Raise :exc:`ValueError` if no *identifying_keys* can be + determined. + + Each key should be a string. + + .. note:: + + Currently only top level scalars supported. To ensure an entity by + looking at relationships, manually issue the :meth:`query` and + :meth:`create` calls. + + If more than one entity matches the determined filter criteria then + raise :exc:`~ftrack_api.exception.MultipleResultsFoundError`. + + If no matching entity found then create entity using supplied *data*. + + If a matching entity is found, then update it if necessary with *data*. + + .. note:: + + If entity created or updated then a :meth:`commit` will be issued + automatically. If this behaviour is undesired, perform the + :meth:`query` and :meth:`create` calls manually. + + Return retrieved or created entity. + + Example:: + + # First time, a new entity with `username=martin` is created. + entity = session.ensure('User', {'username': 'martin'}) + + # After that, the existing entity is retrieved. + entity = session.ensure('User', {'username': 'martin'}) + + # When existing entity retrieved, entity may also be updated to + # match supplied data. + entity = session.ensure( + 'User', {'username': 'martin', 'email': 'martin@example.com'} + ) + + ''' + if not identifying_keys: + identifying_keys = data.keys() + + self.logger.debug(L( + 'Ensuring entity {0!r} with data {1!r} using identifying keys ' + '{2!r}', entity_type, data, identifying_keys + )) + + if not identifying_keys: + raise ValueError( + 'Could not determine any identifying data to check against ' + 'when ensuring {0!r} with data {1!r}. Identifying keys: {2!r}' + .format(entity_type, data, identifying_keys) + ) + + expression = '{0} where'.format(entity_type) + criteria = [] + for identifying_key in identifying_keys: + value = data[identifying_key] + + if isinstance(value, basestring): + value = '"{0}"'.format(value) + + elif isinstance( + value, (arrow.Arrow, datetime.datetime, datetime.date) + ): + # Server does not store microsecond or timezone currently so + # need to strip from query. + # TODO: When datetime handling improved, update this logic. + value = ( + arrow.get(value).naive.replace(microsecond=0).isoformat() + ) + value = '"{0}"'.format(value) + + criteria.append('{0} is {1}'.format(identifying_key, value)) + + expression = '{0} {1}'.format( + expression, ' and '.join(criteria) + ) + + try: + entity = self.query(expression).one() + + except ftrack_api.exception.NoResultFoundError: + self.logger.debug('Creating entity as did not already exist.') + + # Create entity. + entity = self.create(entity_type, data) + self.commit() + + else: + self.logger.debug('Retrieved matching existing entity.') + + # Update entity if required. + updated = False + for key, target_value in data.items(): + if entity[key] != target_value: + entity[key] = target_value + updated = True + + if updated: + self.logger.debug('Updating existing entity to match new data.') + self.commit() + + return entity + + def delete(self, entity): + '''Mark *entity* for deletion.''' + if self.record_operations: + self.recorded_operations.push( + ftrack_api.operation.DeleteEntityOperation( + entity.entity_type, + ftrack_api.inspection.primary_key(entity) + ) + ) + + def get(self, entity_type, entity_key): + '''Return entity of *entity_type* with unique *entity_key*. + + First check for an existing entry in the configured cache, otherwise + issue a query to the server. + + If no matching entity found, return None. + + ''' + self.logger.debug(L('Get {0} with key {1}', entity_type, entity_key)) + + primary_key_definition = self.types[entity_type].primary_key_attributes + if isinstance(entity_key, basestring): + entity_key = [entity_key] + + if len(entity_key) != len(primary_key_definition): + raise ValueError( + 'Incompatible entity_key {0!r} supplied. Entity type {1} ' + 'expects a primary key composed of {2} values ({3}).' + .format( + entity_key, entity_type, len(primary_key_definition), + ', '.join(primary_key_definition) + ) + ) + + entity = None + try: + entity = self._get(entity_type, entity_key) + + + except KeyError: + + # Query for matching entity. + self.logger.debug( + 'Entity not present in cache. Issuing new query.' + ) + condition = [] + for key, value in zip(primary_key_definition, entity_key): + condition.append('{0} is "{1}"'.format(key, value)) + + expression = '{0} where ({1})'.format( + entity_type, ' and '.join(condition) + ) + + results = self.query(expression).all() + if results: + entity = results[0] + + return entity + + def _get(self, entity_type, entity_key): + '''Return cached entity of *entity_type* with unique *entity_key*. + + Raise :exc:`KeyError` if no such entity in the cache. + + ''' + # Check cache for existing entity emulating + # ftrack_api.inspection.identity result object to pass to key maker. + cache_key = self.cache_key_maker.key( + (str(entity_type), map(str, entity_key)) + ) + self.logger.debug(L( + 'Checking cache for entity with key {0}', cache_key + )) + entity = self.cache.get(cache_key) + self.logger.debug(L( + 'Retrieved existing entity from cache: {0} at {1}', + entity, id(entity) + )) + + return entity + + def query(self, expression, page_size=500): + '''Query against remote data according to *expression*. + + *expression* is not executed directly. Instead return an + :class:`ftrack_api.query.QueryResult` instance that will execute remote + call on access. + + *page_size* specifies the maximum page size that the returned query + result object should be configured with. + + .. seealso:: :ref:`querying` + + ''' + self.logger.debug(L('Query {0!r}', expression)) + + # Add in sensible projections if none specified. Note that this is + # done here rather than on the server to allow local modification of the + # schema setting to include commonly used custom attributes for example. + # TODO: Use a proper parser perhaps? + if not expression.startswith('select'): + entity_type = expression.split(' ', 1)[0] + EntityTypeClass = self.types[entity_type] + projections = EntityTypeClass.default_projections + + expression = 'select {0} from {1}'.format( + ', '.join(projections), + expression + ) + + query_result = ftrack_api.query.QueryResult( + self, expression, page_size=page_size + ) + return query_result + + def _query(self, expression): + '''Execute *query* and return (records, metadata). + + Records will be a list of entities retrieved via the query and metadata + a dictionary of accompanying information about the result set. + + ''' + # TODO: Actually support batching several queries together. + # TODO: Should batches have unique ids to match them up later. + batch = [{ + 'action': 'query', + 'expression': expression + }] + + # TODO: When should this execute? How to handle background=True? + results = self.call(batch) + + # Merge entities into local cache and return merged entities. + data = [] + merged = dict() + for entity in results[0]['data']: + data.append(self._merge_recursive(entity, merged)) + + return data, results[0]['metadata'] + + def merge(self, value, merged=None): + '''Merge *value* into session and return merged value. + + *merged* should be a mapping to record merges during run and should be + used to avoid infinite recursion. If not set will default to a + dictionary. + + ''' + if merged is None: + merged = {} + + with self.operation_recording(False): + return self._merge(value, merged) + + def _merge(self, value, merged): + '''Return merged *value*.''' + log_debug = self.logger.isEnabledFor(logging.DEBUG) + + if isinstance(value, ftrack_api.entity.base.Entity): + log_debug and self.logger.debug( + 'Merging entity into session: {0} at {1}' + .format(value, id(value)) + ) + + return self._merge_entity(value, merged=merged) + + elif isinstance(value, ftrack_api.collection.Collection): + log_debug and self.logger.debug( + 'Merging collection into session: {0!r} at {1}' + .format(value, id(value)) + ) + + merged_collection = [] + for entry in value: + merged_collection.append( + self._merge(entry, merged=merged) + ) + + return merged_collection + + elif isinstance(value, ftrack_api.collection.MappedCollectionProxy): + log_debug and self.logger.debug( + 'Merging mapped collection into session: {0!r} at {1}' + .format(value, id(value)) + ) + + merged_collection = [] + for entry in value.collection: + merged_collection.append( + self._merge(entry, merged=merged) + ) + + return merged_collection + + else: + return value + + def _merge_recursive(self, entity, merged=None): + '''Merge *entity* and all its attributes recursivly.''' + log_debug = self.logger.isEnabledFor(logging.DEBUG) + + if merged is None: + merged = {} + + attached = self.merge(entity, merged) + + for attribute in entity.attributes: + # Remote attributes. + remote_value = attribute.get_remote_value(entity) + + if isinstance( + remote_value, + ( + ftrack_api.entity.base.Entity, + ftrack_api.collection.Collection, + ftrack_api.collection.MappedCollectionProxy + ) + ): + log_debug and self.logger.debug( + 'Merging remote value for attribute {0}.'.format(attribute) + ) + + if isinstance(remote_value, ftrack_api.entity.base.Entity): + self._merge_recursive(remote_value, merged=merged) + + elif isinstance( + remote_value, ftrack_api.collection.Collection + ): + for entry in remote_value: + self._merge_recursive(entry, merged=merged) + + elif isinstance( + remote_value, ftrack_api.collection.MappedCollectionProxy + ): + for entry in remote_value.collection: + self._merge_recursive(entry, merged=merged) + + return attached + + def _merge_entity(self, entity, merged=None): + '''Merge *entity* into session returning merged entity. + + Merge is recursive so any references to other entities will also be + merged. + + *entity* will never be modified in place. Ensure that the returned + merged entity instance is used. + + ''' + log_debug = self.logger.isEnabledFor(logging.DEBUG) + + if merged is None: + merged = {} + + with self.auto_populating(False): + entity_key = self.cache_key_maker.key( + ftrack_api.inspection.identity(entity) + ) + + # Check whether this entity has already been processed. + attached_entity = merged.get(entity_key) + if attached_entity is not None: + log_debug and self.logger.debug( + 'Entity already processed for key {0} as {1} at {2}' + .format(entity_key, attached_entity, id(attached_entity)) + ) + + return attached_entity + else: + log_debug and self.logger.debug( + 'Entity not already processed for key {0}.' + .format(entity_key) + ) + + # Check for existing instance of entity in cache. + log_debug and self.logger.debug( + 'Checking for entity in cache with key {0}'.format(entity_key) + ) + try: + attached_entity = self.cache.get(entity_key) + + log_debug and self.logger.debug( + 'Retrieved existing entity from cache: {0} at {1}' + .format(attached_entity, id(attached_entity)) + ) + + except KeyError: + # Construct new minimal instance to store in cache. + attached_entity = self._create( + entity.entity_type, {}, reconstructing=True + ) + + log_debug and self.logger.debug( + 'Entity not present in cache. Constructed new instance: ' + '{0} at {1}'.format(attached_entity, id(attached_entity)) + ) + + # Mark entity as seen to avoid infinite loops. + merged[entity_key] = attached_entity + + changes = attached_entity.merge(entity, merged=merged) + if changes: + self.cache.set(entity_key, attached_entity) + self.logger.debug('Cache updated with merged entity.') + + else: + self.logger.debug( + 'Cache not updated with merged entity as no differences ' + 'detected.' + ) + + return attached_entity + + def populate(self, entities, projections): + '''Populate *entities* with attributes specified by *projections*. + + Any locally set values included in the *projections* will not be + overwritten with the retrieved remote value. If this 'synchronise' + behaviour is required, first clear the relevant values on the entity by + setting them to :attr:`ftrack_api.symbol.NOT_SET`. Deleting the key will + have the same effect:: + + >>> print(user['username']) + martin + >>> del user['username'] + >>> print(user['username']) + Symbol(NOT_SET) + + .. note:: + + Entities that have been created and not yet persisted will be + skipped as they have no remote values to fetch. + + ''' + self.logger.debug(L( + 'Populate {0!r} projections for {1}.', projections, entities + )) + + if not isinstance( + entities, (list, tuple, ftrack_api.query.QueryResult) + ): + entities = [entities] + + # TODO: How to handle a mixed collection of different entity types + # Should probably fail, but need to consider handling hierarchies such + # as User and Group both deriving from Resource. Actually, could just + # proceed and ignore projections that are not present in entity type. + + entities_to_process = [] + + for entity in entities: + if ftrack_api.inspection.state(entity) is ftrack_api.symbol.CREATED: + # Created entities that are not yet persisted have no remote + # values. Don't raise an error here as it is reasonable to + # iterate over an entities properties and see that some of them + # are NOT_SET. + self.logger.debug(L( + 'Skipping newly created entity {0!r} for population as no ' + 'data will exist in the remote for this entity yet.', entity + )) + continue + + entities_to_process.append(entity) + + if entities_to_process: + reference_entity = entities_to_process[0] + entity_type = reference_entity.entity_type + query = 'select {0} from {1}'.format(projections, entity_type) + + primary_key_definition = reference_entity.primary_key_attributes + entity_keys = [ + ftrack_api.inspection.primary_key(entity).values() + for entity in entities_to_process + ] + + if len(primary_key_definition) > 1: + # Composite keys require full OR syntax unfortunately. + conditions = [] + for entity_key in entity_keys: + condition = [] + for key, value in zip(primary_key_definition, entity_key): + condition.append('{0} is "{1}"'.format(key, value)) + + conditions.append('({0})'.format('and '.join(condition))) + + query = '{0} where {1}'.format(query, ' or '.join(conditions)) + + else: + primary_key = primary_key_definition[0] + + if len(entity_keys) > 1: + query = '{0} where {1} in ({2})'.format( + query, primary_key, + ','.join([ + str(entity_key[0]) for entity_key in entity_keys + ]) + ) + else: + query = '{0} where {1} is {2}'.format( + query, primary_key, str(entity_keys[0][0]) + ) + + result = self.query(query) + + # Fetch all results now. Doing so will cause them to populate the + # relevant entities in the cache. + result.all() + + # TODO: Should we check that all requested attributes were + # actually populated? If some weren't would we mark that to avoid + # repeated calls or perhaps raise an error? + + # TODO: Make atomic. + def commit(self): + '''Commit all local changes to the server.''' + batch = [] + + with self.auto_populating(False): + for operation in self.recorded_operations: + + # Convert operation to payload. + if isinstance( + operation, ftrack_api.operation.CreateEntityOperation + ): + # At present, data payload requires duplicating entity + # type in data and also ensuring primary key added. + entity_data = { + '__entity_type__': operation.entity_type, + } + entity_data.update(operation.entity_key) + entity_data.update(operation.entity_data) + + payload = OperationPayload({ + 'action': 'create', + 'entity_type': operation.entity_type, + 'entity_key': operation.entity_key.values(), + 'entity_data': entity_data + }) + + elif isinstance( + operation, ftrack_api.operation.UpdateEntityOperation + ): + entity_data = { + # At present, data payload requires duplicating entity + # type. + '__entity_type__': operation.entity_type, + operation.attribute_name: operation.new_value + } + + payload = OperationPayload({ + 'action': 'update', + 'entity_type': operation.entity_type, + 'entity_key': operation.entity_key.values(), + 'entity_data': entity_data + }) + + elif isinstance( + operation, ftrack_api.operation.DeleteEntityOperation + ): + payload = OperationPayload({ + 'action': 'delete', + 'entity_type': operation.entity_type, + 'entity_key': operation.entity_key.values() + }) + + else: + raise ValueError( + 'Cannot commit. Unrecognised operation type {0} ' + 'detected.'.format(type(operation)) + ) + + batch.append(payload) + + # Optimise batch. + # TODO: Might be better to perform these on the operations list instead + # so all operation contextual information available. + + # If entity was created and deleted in one batch then remove all + # payloads for that entity. + created = set() + deleted = set() + + for payload in batch: + if payload['action'] == 'create': + created.add( + (payload['entity_type'], str(payload['entity_key'])) + ) + + elif payload['action'] == 'delete': + deleted.add( + (payload['entity_type'], str(payload['entity_key'])) + ) + + created_then_deleted = deleted.intersection(created) + if created_then_deleted: + optimised_batch = [] + for payload in batch: + entity_type = payload.get('entity_type') + entity_key = str(payload.get('entity_key')) + + if (entity_type, entity_key) in created_then_deleted: + continue + + optimised_batch.append(payload) + + batch = optimised_batch + + # Remove early update operations so that only last operation on + # attribute is applied server side. + updates_map = set() + for payload in reversed(batch): + if payload['action'] in ('update', ): + for key, value in payload['entity_data'].items(): + if key == '__entity_type__': + continue + + identity = ( + payload['entity_type'], str(payload['entity_key']), key + ) + if identity in updates_map: + del payload['entity_data'][key] + else: + updates_map.add(identity) + + # Remove NOT_SET values from entity_data. + for payload in batch: + entity_data = payload.get('entity_data', {}) + for key, value in entity_data.items(): + if value is ftrack_api.symbol.NOT_SET: + del entity_data[key] + + # Remove payloads with redundant entity_data. + optimised_batch = [] + for payload in batch: + entity_data = payload.get('entity_data') + if entity_data is not None: + keys = entity_data.keys() + if not keys or keys == ['__entity_type__']: + continue + + optimised_batch.append(payload) + + batch = optimised_batch + + # Collapse updates that are consecutive into one payload. Also, collapse + # updates that occur immediately after creation into the create payload. + optimised_batch = [] + previous_payload = None + + for payload in batch: + if ( + previous_payload is not None + and payload['action'] == 'update' + and previous_payload['action'] in ('create', 'update') + and previous_payload['entity_type'] == payload['entity_type'] + and previous_payload['entity_key'] == payload['entity_key'] + ): + previous_payload['entity_data'].update(payload['entity_data']) + continue + + else: + optimised_batch.append(payload) + previous_payload = payload + + batch = optimised_batch + + # Process batch. + if batch: + result = self.call(batch) + + # Clear recorded operations. + self.recorded_operations.clear() + + # As optimisation, clear local values which are not primary keys to + # avoid redundant merges when merging references. Note: primary keys + # remain as needed for cache retrieval on new entities. + with self.auto_populating(False): + with self.operation_recording(False): + for entity in self._local_cache.values(): + for attribute in entity: + if attribute not in entity.primary_key_attributes: + del entity[attribute] + + # Process results merging into cache relevant data. + for entry in result: + + if entry['action'] in ('create', 'update'): + # Merge returned entities into local cache. + self.merge(entry['data']) + + elif entry['action'] == 'delete': + # TODO: Detach entity - need identity returned? + # TODO: Expunge entity from cache. + pass + # Clear remaining local state, including local values for primary + # keys on entities that were merged. + with self.auto_populating(False): + with self.operation_recording(False): + for entity in self._local_cache.values(): + entity.clear() + + def rollback(self): + '''Clear all recorded operations and local state. + + Typically this would be used following a failed :meth:`commit` in order + to revert the session to a known good state. + + Newly created entities not yet persisted will be detached from the + session / purged from cache and no longer contribute, but the actual + objects are not deleted from memory. They should no longer be used and + doing so could cause errors. + + ''' + with self.auto_populating(False): + with self.operation_recording(False): + + # Detach all newly created entities and remove from cache. This + # is done because simply clearing the local values of newly + # created entities would result in entities with no identity as + # primary key was local while not persisted. In addition, it + # makes no sense for failed created entities to exist in session + # or cache. + for operation in self.recorded_operations: + if isinstance( + operation, ftrack_api.operation.CreateEntityOperation + ): + entity_key = str(( + str(operation.entity_type), + operation.entity_key.values() + )) + try: + self.cache.remove(entity_key) + except KeyError: + pass + + # Clear locally stored modifications on remaining entities. + for entity in self._local_cache.values(): + entity.clear() + + self.recorded_operations.clear() + + def _fetch_server_information(self): + '''Return server information.''' + result = self.call([{'action': 'query_server_information'}]) + return result[0] + + def _discover_plugins(self, plugin_arguments=None): + '''Find and load plugins in search paths. + + Each discovered module should implement a register function that + accepts this session as first argument. Typically the function should + register appropriate event listeners against the session's event hub. + + def register(session): + session.event_hub.subscribe( + 'topic=ftrack.api.session.construct-entity-type', + construct_entity_type + ) + + *plugin_arguments* should be an optional mapping of keyword arguments + and values to pass to plugin register functions upon discovery. + + ''' + plugin_arguments = plugin_arguments or {} + ftrack_api.plugin.discover( + self._plugin_paths, [self], plugin_arguments + ) + + def _read_schemas_from_cache(self, schema_cache_path): + '''Return schemas and schema hash from *schema_cache_path*. + + *schema_cache_path* should be the path to the file containing the + schemas in JSON format. + + ''' + self.logger.debug(L( + 'Reading schemas from cache {0!r}', schema_cache_path + )) + + if not os.path.exists(schema_cache_path): + self.logger.info(L( + 'Cache file not found at {0!r}.', schema_cache_path + )) + + return [], None + + with open(schema_cache_path, 'r') as schema_file: + schemas = json.load(schema_file) + hash_ = hashlib.md5( + json.dumps(schemas, sort_keys=True) + ).hexdigest() + + return schemas, hash_ + + def _write_schemas_to_cache(self, schemas, schema_cache_path): + '''Write *schemas* to *schema_cache_path*. + + *schema_cache_path* should be a path to a file that the schemas can be + written to in JSON format. + + ''' + self.logger.debug(L( + 'Updating schema cache {0!r} with new schemas.', schema_cache_path + )) + + with open(schema_cache_path, 'w') as local_cache_file: + json.dump(schemas, local_cache_file, indent=4) + + def _load_schemas(self, schema_cache_path): + '''Load schemas. + + First try to load schemas from cache at *schema_cache_path*. If the + cache is not available or the cache appears outdated then load schemas + from server and store fresh copy in cache. + + If *schema_cache_path* is set to `False`, always load schemas from + server bypassing cache. + + ''' + local_schema_hash = None + schemas = [] + + if schema_cache_path: + try: + schemas, local_schema_hash = self._read_schemas_from_cache( + schema_cache_path + ) + except (IOError, TypeError, AttributeError, ValueError): + # Catch any known exceptions when trying to read the local + # schema cache to prevent API from being unusable. + self.logger.exception(L( + 'Schema cache could not be loaded from {0!r}', + schema_cache_path + )) + + # Use `dictionary.get` to retrieve hash to support older version of + # ftrack server not returning a schema hash. + server_hash = self._server_information.get( + 'schema_hash', False + ) + if local_schema_hash != server_hash: + self.logger.debug(L( + 'Loading schemas from server due to hash not matching.' + 'Local: {0!r} != Server: {1!r}', local_schema_hash, server_hash + )) + schemas = self.call([{'action': 'query_schemas'}])[0] + + if schema_cache_path: + try: + self._write_schemas_to_cache(schemas, schema_cache_path) + except (IOError, TypeError): + self.logger.exception(L( + 'Failed to update schema cache {0!r}.', + schema_cache_path + )) + + else: + self.logger.debug(L( + 'Using cached schemas from {0!r}', schema_cache_path + )) + + return schemas + + def _build_entity_type_classes(self, schemas): + '''Build default entity type classes.''' + fallback_factory = ftrack_api.entity.factory.StandardFactory() + classes = {} + + for schema in schemas: + results = self.event_hub.publish( + ftrack_api.event.base.Event( + topic='ftrack.api.session.construct-entity-type', + data=dict( + schema=schema, + schemas=schemas + ) + ), + synchronous=True + ) + + results = [result for result in results if result is not None] + + if not results: + self.logger.debug(L( + 'Using default StandardFactory to construct entity type ' + 'class for "{0}"', schema['id'] + )) + entity_type_class = fallback_factory.create(schema) + + elif len(results) > 1: + raise ValueError( + 'Expected single entity type to represent schema "{0}" but ' + 'received {1} entity types instead.' + .format(schema['id'], len(results)) + ) + + else: + entity_type_class = results[0] + + classes[entity_type_class.entity_type] = entity_type_class + + return classes + + def _configure_locations(self): + '''Configure locations.''' + # First configure builtin locations, by injecting them into local cache. + + # Origin. + location = self.create( + 'Location', + data=dict( + name='ftrack.origin', + id=ftrack_api.symbol.ORIGIN_LOCATION_ID + ), + reconstructing=True + ) + ftrack_api.mixin( + location, ftrack_api.entity.location.OriginLocationMixin, + name='OriginLocation' + ) + location.accessor = ftrack_api.accessor.disk.DiskAccessor(prefix='') + location.structure = ftrack_api.structure.origin.OriginStructure() + location.priority = 100 + + # Unmanaged. + location = self.create( + 'Location', + data=dict( + name='ftrack.unmanaged', + id=ftrack_api.symbol.UNMANAGED_LOCATION_ID + ), + reconstructing=True + ) + ftrack_api.mixin( + location, ftrack_api.entity.location.UnmanagedLocationMixin, + name='UnmanagedLocation' + ) + location.accessor = ftrack_api.accessor.disk.DiskAccessor(prefix='') + location.structure = ftrack_api.structure.origin.OriginStructure() + # location.resource_identifier_transformer = ( + # ftrack_api.resource_identifier_transformer.internal.InternalResourceIdentifierTransformer(session) + # ) + location.priority = 90 + + # Review. + location = self.create( + 'Location', + data=dict( + name='ftrack.review', + id=ftrack_api.symbol.REVIEW_LOCATION_ID + ), + reconstructing=True + ) + ftrack_api.mixin( + location, ftrack_api.entity.location.UnmanagedLocationMixin, + name='UnmanagedLocation' + ) + location.accessor = ftrack_api.accessor.disk.DiskAccessor(prefix='') + location.structure = ftrack_api.structure.origin.OriginStructure() + location.priority = 110 + + # Server. + location = self.create( + 'Location', + data=dict( + name='ftrack.server', + id=ftrack_api.symbol.SERVER_LOCATION_ID + ), + reconstructing=True + ) + ftrack_api.mixin( + location, ftrack_api.entity.location.ServerLocationMixin, + name='ServerLocation' + ) + location.accessor = ftrack_api.accessor.server._ServerAccessor( + session=self + ) + location.structure = ftrack_api.structure.entity_id.EntityIdStructure() + location.priority = 150 + + # Master location based on server scenario. + storage_scenario = self.server_information.get('storage_scenario') + + if ( + storage_scenario and + storage_scenario.get('scenario') + ): + self.event_hub.publish( + ftrack_api.event.base.Event( + topic='ftrack.storage-scenario.activate', + data=dict( + storage_scenario=storage_scenario + ) + ), + synchronous=True + ) + + # Next, allow further configuration of locations via events. + self.event_hub.publish( + ftrack_api.event.base.Event( + topic='ftrack.api.session.configure-location', + data=dict( + session=self + ) + ), + synchronous=True + ) + + @ftrack_api.logging.deprecation_warning( + 'Session._call is now available as public method Session.call. The ' + 'private method will be removed in version 2.0.' + ) + def _call(self, data): + '''Make request to server with *data* batch describing the actions. + + .. note:: + + This private method is now available as public method + :meth:`entity_reference`. This alias remains for backwards + compatibility, but will be removed in version 2.0. + + ''' + return self.call(data) + + def call(self, data): + '''Make request to server with *data* batch describing the actions.''' + url = self._server_url + '/api' + headers = { + 'content-type': 'application/json', + 'accept': 'application/json' + } + data = self.encode(data, entity_attribute_strategy='modified_only') + + self.logger.debug(L('Calling server {0} with {1!r}', url, data)) + + response = self._request.post( + url, + headers=headers, + data=data + ) + + self.logger.debug(L('Call took: {0}', response.elapsed.total_seconds())) + + self.logger.debug(L('Response: {0!r}', response.text)) + try: + result = self.decode(response.text) + + except Exception: + error_message = ( + 'Server reported error in unexpected format. Raw error was: {0}' + .format(response.text) + ) + self.logger.exception(error_message) + raise ftrack_api.exception.ServerError(error_message) + + else: + if 'exception' in result: + # Handle exceptions. + error_message = 'Server reported error: {0}({1})'.format( + result['exception'], result['content'] + ) + self.logger.exception(error_message) + raise ftrack_api.exception.ServerError(error_message) + + return result + + def encode(self, data, entity_attribute_strategy='set_only'): + '''Return *data* encoded as JSON formatted string. + + *entity_attribute_strategy* specifies how entity attributes should be + handled. The following strategies are available: + + * *all* - Encode all attributes, loading any that are currently NOT_SET. + * *set_only* - Encode only attributes that are currently set without + loading any from the remote. + * *modified_only* - Encode only attributes that have been modified + locally. + * *persisted_only* - Encode only remote (persisted) attribute values. + + ''' + entity_attribute_strategies = ( + 'all', 'set_only', 'modified_only', 'persisted_only' + ) + if entity_attribute_strategy not in entity_attribute_strategies: + raise ValueError( + 'Unsupported entity_attribute_strategy "{0}". Must be one of ' + '{1}'.format( + entity_attribute_strategy, + ', '.join(entity_attribute_strategies) + ) + ) + + return json.dumps( + data, + sort_keys=True, + default=functools.partial( + self._encode, + entity_attribute_strategy=entity_attribute_strategy + ) + ) + + def _encode(self, item, entity_attribute_strategy='set_only'): + '''Return JSON encodable version of *item*. + + *entity_attribute_strategy* specifies how entity attributes should be + handled. See :meth:`Session.encode` for available strategies. + + ''' + if isinstance(item, (arrow.Arrow, datetime.datetime, datetime.date)): + return { + '__type__': 'datetime', + 'value': item.isoformat() + } + + if isinstance(item, OperationPayload): + data = dict(item.items()) + if "entity_data" in data: + for key, value in data["entity_data"].items(): + if isinstance(value, ftrack_api.entity.base.Entity): + data["entity_data"][key] = self.entity_reference(value) + + return data + + if isinstance(item, ftrack_api.entity.base.Entity): + data = self.entity_reference(item) + + with self.auto_populating(True): + + for attribute in item.attributes: + value = ftrack_api.symbol.NOT_SET + + if entity_attribute_strategy == 'all': + value = attribute.get_value(item) + + elif entity_attribute_strategy == 'set_only': + if attribute.is_set(item): + value = attribute.get_local_value(item) + if value is ftrack_api.symbol.NOT_SET: + value = attribute.get_remote_value(item) + + elif entity_attribute_strategy == 'modified_only': + if attribute.is_modified(item): + value = attribute.get_local_value(item) + + elif entity_attribute_strategy == 'persisted_only': + if not attribute.computed: + value = attribute.get_remote_value(item) + + if value is not ftrack_api.symbol.NOT_SET: + if isinstance( + attribute, ftrack_api.attribute.ReferenceAttribute + ): + if isinstance(value, ftrack_api.entity.base.Entity): + value = self.entity_reference(value) + + data[attribute.name] = value + + return data + + if isinstance( + item, ftrack_api.collection.MappedCollectionProxy + ): + # Use proxied collection for serialisation. + item = item.collection + + if isinstance(item, ftrack_api.collection.Collection): + data = [] + for entity in item: + data.append(self.entity_reference(entity)) + + return data + + raise TypeError('{0!r} is not JSON serializable'.format(item)) + + def entity_reference(self, entity): + '''Return entity reference that uniquely identifies *entity*. + + Return a mapping containing the __entity_type__ of the entity along with + the key, value pairs that make up it's primary key. + + ''' + reference = { + '__entity_type__': entity.entity_type + } + with self.auto_populating(False): + reference.update(ftrack_api.inspection.primary_key(entity)) + + return reference + + @ftrack_api.logging.deprecation_warning( + 'Session._entity_reference is now available as public method ' + 'Session.entity_reference. The private method will be removed ' + 'in version 2.0.' + ) + def _entity_reference(self, entity): + '''Return entity reference that uniquely identifies *entity*. + + Return a mapping containing the __entity_type__ of the entity along + with the key, value pairs that make up it's primary key. + + .. note:: + + This private method is now available as public method + :meth:`entity_reference`. This alias remains for backwards + compatibility, but will be removed in version 2.0. + + ''' + return self.entity_reference(entity) + + def decode(self, string): + '''Return decoded JSON *string* as Python object.''' + with self.operation_recording(False): + return json.loads(string, object_hook=self._decode) + + def _decode(self, item): + '''Return *item* transformed into appropriate representation.''' + if isinstance(item, collections.Mapping): + if '__type__' in item: + if item['__type__'] == 'datetime': + item = arrow.get(item['value']) + + elif '__entity_type__' in item: + item = self._create( + item['__entity_type__'], item, reconstructing=True + ) + + return item + + def _get_locations(self, filter_inaccessible=True): + '''Helper to returns locations ordered by priority. + + If *filter_inaccessible* is True then only accessible locations will be + included in result. + + ''' + # Optimise this call. + locations = self.query('Location') + + # Filter. + if filter_inaccessible: + locations = filter( + lambda location: location.accessor, + locations + ) + + # Sort by priority. + locations = sorted( + locations, key=lambda location: location.priority + ) + + return locations + + def pick_location(self, component=None): + '''Return suitable location to use. + + If no *component* specified then return highest priority accessible + location. Otherwise, return highest priority accessible location that + *component* is available in. + + Return None if no suitable location could be picked. + + ''' + if component: + return self.pick_locations([component])[0] + + else: + locations = self._get_locations() + if locations: + return locations[0] + else: + return None + + def pick_locations(self, components): + '''Return suitable locations for *components*. + + Return list of locations corresponding to *components* where each + picked location is the highest priority accessible location for that + component. If a component has no location available then its + corresponding entry will be None. + + ''' + candidate_locations = self._get_locations() + availabilities = self.get_component_availabilities( + components, locations=candidate_locations + ) + + locations = [] + for component, availability in zip(components, availabilities): + location = None + + for candidate_location in candidate_locations: + if availability.get(candidate_location['id']) > 0.0: + location = candidate_location + break + + locations.append(location) + + return locations + + def create_component( + self, path, data=None, location='auto' + ): + '''Create a new component from *path* with additional *data* + + .. note:: + + This is a helper method. To create components manually use the + standard :meth:`Session.create` method. + + *path* can be a string representing a filesystem path to the data to + use for the component. The *path* can also be specified as a sequence + string, in which case a sequence component with child components for + each item in the sequence will be created automatically. The accepted + format for a sequence is '{head}{padding}{tail} [{ranges}]'. For + example:: + + '/path/to/file.%04d.ext [1-5, 7, 8, 10-20]' + + .. seealso:: + + `Clique documentation `_ + + *data* should be a dictionary of any additional data to construct the + component with (as passed to :meth:`Session.create`). + + If *location* is specified then automatically add component to that + location. The default of 'auto' will automatically pick a suitable + location to add the component to if one is available. To not add to any + location specifiy locations as None. + + .. note:: + + A :meth:`Session.commit` may be + automatically issued as part of the components registration in the + location. + ''' + if data is None: + data = {} + + if location == 'auto': + # Check if the component name matches one of the ftrackreview + # specific names. Add the component to the ftrack.review location if + # so. This is used to not break backwards compatibility. + if data.get('name') in ( + 'ftrackreview-mp4', 'ftrackreview-webm', 'ftrackreview-image' + ): + location = self.get( + 'Location', ftrack_api.symbol.REVIEW_LOCATION_ID + ) + + else: + location = self.pick_location() + + try: + collection = clique.parse(path) + + except ValueError: + # Assume is a single file. + if 'size' not in data: + data['size'] = self._get_filesystem_size(path) + + data.setdefault('file_type', os.path.splitext(path)[-1]) + + return self._create_component( + 'FileComponent', path, data, location + ) + + else: + # Calculate size of container and members. + member_sizes = {} + container_size = data.get('size') + + if container_size is not None: + if len(collection.indexes) > 0: + member_size = int( + round(container_size / len(collection.indexes)) + ) + for item in collection: + member_sizes[item] = member_size + + else: + container_size = 0 + for item in collection: + member_sizes[item] = self._get_filesystem_size(item) + container_size += member_sizes[item] + + # Create sequence component + container_path = collection.format('{head}{padding}{tail}') + data.setdefault('padding', collection.padding) + data.setdefault('file_type', os.path.splitext(container_path)[-1]) + data.setdefault('size', container_size) + + container = self._create_component( + 'SequenceComponent', container_path, data, location=None + ) + + # Create member components for sequence. + for member_path in collection: + member_data = { + 'name': collection.match(member_path).group('index'), + 'container': container, + 'size': member_sizes[member_path], + 'file_type': os.path.splitext(member_path)[-1] + } + + component = self._create_component( + 'FileComponent', member_path, member_data, location=None + ) + container['members'].append(component) + + if location: + origin_location = self.get( + 'Location', ftrack_api.symbol.ORIGIN_LOCATION_ID + ) + location.add_component( + container, origin_location, recursive=True + ) + + return container + + def _create_component(self, entity_type, path, data, location): + '''Create and return component. + + See public function :py:func:`createComponent` for argument details. + + ''' + component = self.create(entity_type, data) + + # Add to special origin location so that it is possible to add to other + # locations. + origin_location = self.get( + 'Location', ftrack_api.symbol.ORIGIN_LOCATION_ID + ) + origin_location.add_component(component, path, recursive=False) + + if location: + location.add_component(component, origin_location, recursive=False) + + return component + + def _get_filesystem_size(self, path): + '''Return size from *path*''' + try: + size = os.path.getsize(path) + except OSError: + size = 0 + + return size + + def get_component_availability(self, component, locations=None): + '''Return availability of *component*. + + If *locations* is set then limit result to availability of *component* + in those *locations*. + + Return a dictionary of {location_id:percentage_availability} + + ''' + return self.get_component_availabilities( + [component], locations=locations + )[0] + + def get_component_availabilities(self, components, locations=None): + '''Return availabilities of *components*. + + If *locations* is set then limit result to availabilities of + *components* in those *locations*. + + Return a list of dictionaries of {location_id:percentage_availability}. + The list indexes correspond to those of *components*. + + ''' + availabilities = [] + + if locations is None: + locations = self.query('Location') + + # Separate components into two lists, those that are containers and + # those that are not, so that queries can be optimised. + standard_components = [] + container_components = [] + + for component in components: + if 'members' in component.keys(): + container_components.append(component) + else: + standard_components.append(component) + + # Perform queries. + if standard_components: + self.populate( + standard_components, 'component_locations.location_id' + ) + + if container_components: + self.populate( + container_components, + 'members, component_locations.location_id' + ) + + base_availability = {} + for location in locations: + base_availability[location['id']] = 0.0 + + for component in components: + availability = base_availability.copy() + availabilities.append(availability) + + is_container = 'members' in component.keys() + if is_container and len(component['members']): + member_availabilities = self.get_component_availabilities( + component['members'], locations=locations + ) + multiplier = 1.0 / len(component['members']) + for member, member_availability in zip( + component['members'], member_availabilities + ): + for location_id, ratio in member_availability.items(): + availability[location_id] += ( + ratio * multiplier + ) + else: + for component_location in component['component_locations']: + location_id = component_location['location_id'] + if location_id in availability: + availability[location_id] = 100.0 + + for location_id, percentage in availability.items(): + # Avoid quantization error by rounding percentage and clamping + # to range 0-100. + adjusted_percentage = round(percentage, 9) + adjusted_percentage = max(0.0, min(adjusted_percentage, 100.0)) + availability[location_id] = adjusted_percentage + + return availabilities + + @ftrack_api.logging.deprecation_warning( + 'Session.delayed_job has been deprecated in favour of session.call. ' + 'Please refer to the release notes for more information.' + ) + def delayed_job(self, job_type): + '''Execute a delayed job on the server, a `ftrack.entity.job.Job` is returned. + + *job_type* should be one of the allowed job types. There is currently + only one remote job type "SYNC_USERS_LDAP". + ''' + if job_type not in (ftrack_api.symbol.JOB_SYNC_USERS_LDAP, ): + raise ValueError( + u'Invalid Job type: {0}.'.format(job_type) + ) + + operation = { + 'action': 'delayed_job', + 'job_type': job_type.name + } + + try: + result = self.call( + [operation] + )[0] + + except ftrack_api.exception.ServerError as error: + raise + + return result['data'] + + def get_widget_url(self, name, entity=None, theme=None): + '''Return an authenticated URL for widget with *name* and given options. + + The returned URL will be authenticated using a token which will expire + after 6 minutes. + + *name* should be the name of the widget to return and should be one of + 'info', 'tasks' or 'tasks_browser'. + + Certain widgets require an entity to be specified. If so, specify it by + setting *entity* to a valid entity instance. + + *theme* sets the theme of the widget and can be either 'light' or 'dark' + (defaulting to 'dark' if an invalid option given). + + ''' + operation = { + 'action': 'get_widget_url', + 'name': name, + 'theme': theme + } + if entity: + operation['entity_type'] = entity.entity_type + operation['entity_key'] = ( + ftrack_api.inspection.primary_key(entity).values() + ) + + try: + result = self.call([operation]) + + except ftrack_api.exception.ServerError as error: + # Raise informative error if the action is not supported. + if 'Invalid action u\'get_widget_url\'' in error.message: + raise ftrack_api.exception.ServerCompatibilityError( + 'Server version {0!r} does not support "get_widget_url", ' + 'please update server and try again.'.format( + self.server_information.get('version') + ) + ) + else: + raise + + else: + return result[0]['widget_url'] + + def encode_media(self, media, version_id=None, keep_original='auto'): + '''Return a new Job that encode *media* to make it playable in browsers. + + *media* can be a path to a file or a FileComponent in the ftrack.server + location. + + The job will encode *media* based on the file type and job data contains + information about encoding in the following format:: + + { + 'output': [{ + 'format': 'video/mp4', + 'component_id': 'e2dc0524-b576-11d3-9612-080027331d74' + }, { + 'format': 'image/jpeg', + 'component_id': '07b82a97-8cf9-11e3-9383-20c9d081909b' + }], + 'source_component_id': 'e3791a09-7e11-4792-a398-3d9d4eefc294', + 'keep_original': True + } + + The output components are associated with the job via the job_components + relation. + + An image component will always be generated if possible that can be used + as a thumbnail. + + If *media* is a file path, a new source component will be created and + added to the ftrack server location and a call to :meth:`commit` will be + issued. If *media* is a FileComponent, it will be assumed to be in + available in the ftrack.server location. + + If *version_id* is specified, the new components will automatically be + associated with the AssetVersion. Otherwise, the components will not + be associated to a version even if the supplied *media* belongs to one. + A server version of 3.3.32 or higher is required for the version_id + argument to function properly. + + If *keep_original* is not set, the original media will be kept if it + is a FileComponent, and deleted if it is a file path. You can specify + True or False to change this behavior. + ''' + if isinstance(media, basestring): + # Media is a path to a file. + server_location = self.get( + 'Location', ftrack_api.symbol.SERVER_LOCATION_ID + ) + if keep_original == 'auto': + keep_original = False + + component_data = None + if keep_original: + component_data = dict(version_id=version_id) + + component = self.create_component( + path=media, + data=component_data, + location=server_location + ) + + # Auto commit to ensure component exists when sent to server. + self.commit() + + elif ( + hasattr(media, 'entity_type') and + media.entity_type in ('FileComponent',) + ): + # Existing file component. + component = media + if keep_original == 'auto': + keep_original = True + + else: + raise ValueError( + 'Unable to encode media of type: {0}'.format(type(media)) + ) + + operation = { + 'action': 'encode_media', + 'component_id': component['id'], + 'version_id': version_id, + 'keep_original': keep_original + } + + try: + result = self.call([operation]) + + except ftrack_api.exception.ServerError as error: + # Raise informative error if the action is not supported. + if 'Invalid action u\'encode_media\'' in error.message: + raise ftrack_api.exception.ServerCompatibilityError( + 'Server version {0!r} does not support "encode_media", ' + 'please update server and try again.'.format( + self.server_information.get('version') + ) + ) + else: + raise + + return self.get('Job', result[0]['job_id']) + + def get_upload_metadata( + self, component_id, file_name, file_size, checksum=None + ): + '''Return URL and headers used to upload data for *component_id*. + + *file_name* and *file_size* should match the components details. + + The returned URL should be requested using HTTP PUT with the specified + headers. + + The *checksum* is used as the Content-MD5 header and should contain + the base64-encoded 128-bit MD5 digest of the message (without the + headers) according to RFC 1864. This can be used as a message integrity + check to verify that the data is the same data that was originally sent. + ''' + operation = { + 'action': 'get_upload_metadata', + 'component_id': component_id, + 'file_name': file_name, + 'file_size': file_size, + 'checksum': checksum + } + + try: + result = self.call([operation]) + + except ftrack_api.exception.ServerError as error: + # Raise informative error if the action is not supported. + if 'Invalid action u\'get_upload_metadata\'' in error.message: + raise ftrack_api.exception.ServerCompatibilityError( + 'Server version {0!r} does not support ' + '"get_upload_metadata", please update server and try ' + 'again.'.format( + self.server_information.get('version') + ) + ) + else: + raise + + return result[0] + + def send_user_invite(self, user): + '''Send a invitation to the provided *user*. + + *user* is a User instance + + ''' + + self.send_user_invites( + [user] + ) + + def send_user_invites(self, users): + '''Send a invitation to the provided *user*. + + *users* is a list of User instances + + ''' + + operations = [] + + for user in users: + operations.append( + { + 'action':'send_user_invite', + 'user_id': user['id'] + } + ) + + try: + self.call(operations) + + except ftrack_api.exception.ServerError as error: + # Raise informative error if the action is not supported. + if 'Invalid action u\'send_user_invite\'' in error.message: + raise ftrack_api.exception.ServerCompatibilityError( + 'Server version {0!r} does not support ' + '"send_user_invite", please update server and ' + 'try again.'.format( + self.server_information.get('version') + ) + ) + else: + raise + + def send_review_session_invite(self, invitee): + '''Send an invite to a review session to *invitee*. + + *invitee* is a instance of ReviewSessionInvitee. + + .. note:: + + The *invitee* must be committed. + + ''' + self.send_review_session_invites([invitee]) + + def send_review_session_invites(self, invitees): + '''Send an invite to a review session to a list of *invitees*. + + *invitee* is a list of ReviewSessionInvitee objects. + + .. note:: + + All *invitees* must be committed. + + ''' + operations = [] + + for invitee in invitees: + operations.append( + { + 'action': 'send_review_session_invite', + 'review_session_invitee_id': invitee['id'] + } + ) + + try: + self.call(operations) + except ftrack_api.exception.ServerError as error: + # Raise informative error if the action is not supported. + if 'Invalid action u\'send_review_session_invite\'' in error.message: + raise ftrack_api.exception.ServerCompatibilityError( + 'Server version {0!r} does not support ' + '"send_review_session_invite", please update server and ' + 'try again.'.format( + self.server_information.get('version') + ) + ) + else: + raise + + +class AutoPopulatingContext(object): + '''Context manager for temporary change of session auto_populate value.''' + + def __init__(self, session, auto_populate): + '''Initialise context.''' + super(AutoPopulatingContext, self).__init__() + self._session = session + self._auto_populate = auto_populate + self._current_auto_populate = None + + def __enter__(self): + '''Enter context switching to desired auto populate setting.''' + self._current_auto_populate = self._session.auto_populate + self._session.auto_populate = self._auto_populate + + def __exit__(self, exception_type, exception_value, traceback): + '''Exit context resetting auto populate to original setting.''' + self._session.auto_populate = self._current_auto_populate + + +class OperationRecordingContext(object): + '''Context manager for temporary change of session record_operations.''' + + def __init__(self, session, record_operations): + '''Initialise context.''' + super(OperationRecordingContext, self).__init__() + self._session = session + self._record_operations = record_operations + self._current_record_operations = None + + def __enter__(self): + '''Enter context.''' + self._current_record_operations = self._session.record_operations + self._session.record_operations = self._record_operations + + def __exit__(self, exception_type, exception_value, traceback): + '''Exit context.''' + self._session.record_operations = self._current_record_operations + + +class OperationPayload(collections.MutableMapping): + '''Represent operation payload.''' + + def __init__(self, *args, **kwargs): + '''Initialise payload.''' + super(OperationPayload, self).__init__() + self._data = dict() + self.update(dict(*args, **kwargs)) + + def __str__(self): + '''Return string representation.''' + return '<{0} {1}>'.format( + self.__class__.__name__, str(self._data) + ) + + def __getitem__(self, key): + '''Return value for *key*.''' + return self._data[key] + + def __setitem__(self, key, value): + '''Set *value* for *key*.''' + self._data[key] = value + + def __delitem__(self, key): + '''Remove *key*.''' + del self._data[key] + + def __iter__(self): + '''Iterate over all keys.''' + return iter(self._data) + + def __len__(self): + '''Return count of keys.''' + return len(self._data) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/__init__.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/__init__.py new file mode 100644 index 0000000000..1aab07ed77 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/__init__.py @@ -0,0 +1,2 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/base.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/base.py new file mode 100644 index 0000000000..eae3784dc2 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/base.py @@ -0,0 +1,38 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +from abc import ABCMeta, abstractmethod + + +class Structure(object): + '''Structure plugin interface. + + A structure plugin should compute appropriate paths for data. + + ''' + + __metaclass__ = ABCMeta + + def __init__(self, prefix=''): + '''Initialise structure.''' + self.prefix = prefix + self.path_separator = '/' + super(Structure, self).__init__() + + @abstractmethod + def get_resource_identifier(self, entity, context=None): + '''Return a resource identifier for supplied *entity*. + + *context* can be a mapping that supplies additional information. + + ''' + + def _get_sequence_expression(self, sequence): + '''Return a sequence expression for *sequence* component.''' + padding = sequence['padding'] + if padding: + expression = '%0{0}d'.format(padding) + else: + expression = '%d' + + return expression diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/entity_id.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/entity_id.py new file mode 100644 index 0000000000..ae466bf6d9 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/entity_id.py @@ -0,0 +1,12 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import ftrack_api.structure.base + + +class EntityIdStructure(ftrack_api.structure.base.Structure): + '''Entity id pass-through structure.''' + + def get_resource_identifier(self, entity, context=None): + '''Return a *resourceIdentifier* for supplied *entity*.''' + return entity['id'] diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/id.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/id.py new file mode 100644 index 0000000000..acc3e21b02 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/id.py @@ -0,0 +1,91 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +import os + +import ftrack_api.symbol +import ftrack_api.structure.base + + +class IdStructure(ftrack_api.structure.base.Structure): + '''Id based structure supporting Components only. + + A components unique id will be used to form a path to store the data at. + To avoid millions of entries in one directory each id is chunked into four + prefix directories with the remainder used to name the file:: + + /prefix/1/2/3/4/56789 + + If the component has a defined filetype it will be added to the path:: + + /prefix/1/2/3/4/56789.exr + + Components that are children of container components will be placed inside + the id structure of their parent:: + + /prefix/1/2/3/4/56789/355827648d.exr + /prefix/1/2/3/4/56789/ajf24215b5.exr + + However, sequence children will be named using their label as an index and + a common prefix of 'file.':: + + /prefix/1/2/3/4/56789/file.0001.exr + /prefix/1/2/3/4/56789/file.0002.exr + + ''' + + def get_resource_identifier(self, entity, context=None): + '''Return a resource identifier for supplied *entity*. + + *context* can be a mapping that supplies additional information. + + ''' + if entity.entity_type in ('FileComponent',): + # When in a container, place the file inside a directory named + # after the container. + container = entity['container'] + if container and container is not ftrack_api.symbol.NOT_SET: + path = self.get_resource_identifier(container) + + if container.entity_type in ('SequenceComponent',): + # Label doubles as index for now. + name = 'file.{0}{1}'.format( + entity['name'], entity['file_type'] + ) + parts = [os.path.dirname(path), name] + + else: + # Just place uniquely identified file into directory + name = entity['id'] + entity['file_type'] + parts = [path, name] + + else: + name = entity['id'][4:] + entity['file_type'] + parts = ([self.prefix] + list(entity['id'][:4]) + [name]) + + elif entity.entity_type in ('SequenceComponent',): + name = 'file' + + # Add a sequence identifier. + sequence_expression = self._get_sequence_expression(entity) + name += '.{0}'.format(sequence_expression) + + if ( + entity['file_type'] and + entity['file_type'] is not ftrack_api.symbol.NOT_SET + ): + name += entity['file_type'] + + parts = ([self.prefix] + list(entity['id'][:4]) + + [entity['id'][4:]] + [name]) + + elif entity.entity_type in ('ContainerComponent',): + # Just an id directory + parts = ([self.prefix] + + list(entity['id'][:4]) + [entity['id'][4:]]) + + else: + raise NotImplementedError('Cannot generate path for unsupported ' + 'entity {0}'.format(entity)) + + return self.path_separator.join(parts).strip('/') diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/origin.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/origin.py new file mode 100644 index 0000000000..0d4d3a57f5 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/origin.py @@ -0,0 +1,28 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +from .base import Structure + + +class OriginStructure(Structure): + '''Origin structure that passes through existing resource identifier.''' + + def get_resource_identifier(self, entity, context=None): + '''Return a resource identifier for supplied *entity*. + + *context* should be a mapping that includes at least a + 'source_resource_identifier' key that refers to the resource identifier + to pass through. + + ''' + if context is None: + context = {} + + resource_identifier = context.get('source_resource_identifier') + if resource_identifier is None: + raise ValueError( + 'Could not generate resource identifier as no source resource ' + 'identifier found in passed context.' + ) + + return resource_identifier diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/standard.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/standard.py new file mode 100644 index 0000000000..0b0602df00 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/structure/standard.py @@ -0,0 +1,217 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import os +import re +import unicodedata + +import ftrack_api.symbol +import ftrack_api.structure.base + + +class StandardStructure(ftrack_api.structure.base.Structure): + '''Project hierarchy based structure that only supports Components. + + The resource identifier is generated from the project code, the name + of objects in the project structure, asset name and version number:: + + my_project/folder_a/folder_b/asset_name/v003 + + If the component is a `FileComponent` then the name of the component and the + file type are used as filename in the resource_identifier:: + + my_project/folder_a/folder_b/asset_name/v003/foo.jpg + + If the component is a `SequenceComponent` then a sequence expression, + `%04d`, is used. E.g. a component with the name `foo` yields:: + + my_project/folder_a/folder_b/asset_name/v003/foo.%04d.jpg + + For the member components their index in the sequence is used:: + + my_project/folder_a/folder_b/asset_name/v003/foo.0042.jpg + + The name of the component is added to the resource identifier if the + component is a `ContainerComponent`. E.g. a container component with the + name `bar` yields:: + + my_project/folder_a/folder_b/asset_name/v003/bar + + For a member of that container the file name is based on the component name + and file type:: + + my_project/folder_a/folder_b/asset_name/v003/bar/baz.pdf + + ''' + + def __init__( + self, project_versions_prefix=None, illegal_character_substitute='_' + ): + '''Initialise structure. + + If *project_versions_prefix* is defined, insert after the project code + for versions published directly under the project:: + + my_project//v001/foo.jpg + + Replace illegal characters with *illegal_character_substitute* if + defined. + + .. note:: + + Nested component containers/sequences are not supported. + + ''' + super(StandardStructure, self).__init__() + self.project_versions_prefix = project_versions_prefix + self.illegal_character_substitute = illegal_character_substitute + + def _get_parts(self, entity): + '''Return resource identifier parts from *entity*.''' + session = entity.session + + version = entity['version'] + + if version is ftrack_api.symbol.NOT_SET and entity['version_id']: + version = session.get('AssetVersion', entity['version_id']) + + error_message = ( + 'Component {0!r} must be attached to a committed ' + 'version and a committed asset with a parent context.'.format( + entity + ) + ) + + if ( + version is ftrack_api.symbol.NOT_SET or + version in session.created + ): + raise ftrack_api.exception.StructureError(error_message) + + link = version['link'] + + if not link: + raise ftrack_api.exception.StructureError(error_message) + + structure_names = [ + item['name'] + for item in link[1:-1] + ] + + project_id = link[0]['id'] + project = session.get('Project', project_id) + asset = version['asset'] + + version_number = self._format_version(version['version']) + + parts = [] + parts.append(project['name']) + + if structure_names: + parts.extend(structure_names) + elif self.project_versions_prefix: + # Add *project_versions_prefix* if configured and the version is + # published directly under the project. + parts.append(self.project_versions_prefix) + + parts.append(asset['name']) + parts.append(version_number) + + return [self.sanitise_for_filesystem(part) for part in parts] + + def _format_version(self, number): + '''Return a formatted string representing version *number*.''' + return 'v{0:03d}'.format(number) + + def sanitise_for_filesystem(self, value): + '''Return *value* with illegal filesystem characters replaced. + + An illegal character is one that is not typically valid for filesystem + usage, such as non ascii characters, or can be awkward to use in a + filesystem, such as spaces. Replace these characters with + the character specified by *illegal_character_substitute* on + initialisation. If no character was specified as substitute then return + *value* unmodified. + + ''' + if self.illegal_character_substitute is None: + return value + + if isinstance(value, str): + value = value.decode('utf-8') + + value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore') + value = re.sub('[^\w\.-]', self.illegal_character_substitute, value) + return unicode(value.strip().lower()) + + def get_resource_identifier(self, entity, context=None): + '''Return a resource identifier for supplied *entity*. + + *context* can be a mapping that supplies additional information, but + is unused in this implementation. + + + Raise a :py:exc:`ftrack_api.exeption.StructureError` if *entity* is not + attached to a committed version and a committed asset with a parent + context. + + ''' + if entity.entity_type in ('FileComponent',): + container = entity['container'] + + if container: + # Get resource identifier for container. + container_path = self.get_resource_identifier(container) + + if container.entity_type in ('SequenceComponent',): + # Strip the sequence component expression from the parent + # container and back the correct filename, i.e. + # /sequence/component/sequence_component_name.0012.exr. + name = '{0}.{1}{2}'.format( + container['name'], entity['name'], entity['file_type'] + ) + parts = [ + os.path.dirname(container_path), + self.sanitise_for_filesystem(name) + ] + + else: + # Container is not a sequence component so add it as a + # normal component inside the container. + name = entity['name'] + entity['file_type'] + parts = [ + container_path, self.sanitise_for_filesystem(name) + ] + + else: + # File component does not have a container, construct name from + # component name and file type. + parts = self._get_parts(entity) + name = entity['name'] + entity['file_type'] + parts.append(self.sanitise_for_filesystem(name)) + + elif entity.entity_type in ('SequenceComponent',): + # Create sequence expression for the sequence component and add it + # to the parts. + parts = self._get_parts(entity) + sequence_expression = self._get_sequence_expression(entity) + parts.append( + '{0}.{1}{2}'.format( + self.sanitise_for_filesystem(entity['name']), + sequence_expression, + self.sanitise_for_filesystem(entity['file_type']) + ) + ) + + elif entity.entity_type in ('ContainerComponent',): + # Add the name of the container to the resource identifier parts. + parts = self._get_parts(entity) + parts.append(self.sanitise_for_filesystem(entity['name'])) + + else: + raise NotImplementedError( + 'Cannot generate resource identifier for unsupported ' + 'entity {0!r}'.format(entity) + ) + + return self.path_separator.join(parts) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/symbol.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/symbol.py new file mode 100644 index 0000000000..f46760f634 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/source/ftrack_api/symbol.py @@ -0,0 +1,77 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +import os + + +class Symbol(object): + '''A constant symbol.''' + + def __init__(self, name, value=True): + '''Initialise symbol with unique *name* and *value*. + + *value* is used for nonzero testing. + + ''' + self.name = name + self.value = value + + def __str__(self): + '''Return string representation.''' + return self.name + + def __repr__(self): + '''Return representation.''' + return '{0}({1})'.format(self.__class__.__name__, self.name) + + def __nonzero__(self): + '''Return whether symbol represents non-zero value.''' + return bool(self.value) + + def __copy__(self): + '''Return shallow copy. + + Overridden to always return same instance. + + ''' + return self + + +#: Symbol representing that no value has been set or loaded. +NOT_SET = Symbol('NOT_SET', False) + +#: Symbol representing created state. +CREATED = Symbol('CREATED') + +#: Symbol representing modified state. +MODIFIED = Symbol('MODIFIED') + +#: Symbol representing deleted state. +DELETED = Symbol('DELETED') + +#: Topic published when component added to a location. +COMPONENT_ADDED_TO_LOCATION_TOPIC = 'ftrack.location.component-added' + +#: Topic published when component removed from a location. +COMPONENT_REMOVED_FROM_LOCATION_TOPIC = 'ftrack.location.component-removed' + +#: Identifier of builtin origin location. +ORIGIN_LOCATION_ID = 'ce9b348f-8809-11e3-821c-20c9d081909b' + +#: Identifier of builtin unmanaged location. +UNMANAGED_LOCATION_ID = 'cb268ecc-8809-11e3-a7e2-20c9d081909b' + +#: Identifier of builtin review location. +REVIEW_LOCATION_ID = 'cd41be70-8809-11e3-b98a-20c9d081909b' + +#: Identifier of builtin connect location. +CONNECT_LOCATION_ID = '07b82a97-8cf9-11e3-9383-20c9d081909b' + +#: Identifier of builtin server location. +SERVER_LOCATION_ID = '3a372bde-05bc-11e4-8908-20c9d081909b' + +#: Chunk size used when working with data, default to 1Mb. +CHUNK_SIZE = int(os.getenv('FTRACK_API_FILE_CHUNK_SIZE', 0)) or 1024*1024 + +#: Symbol representing syncing users with ldap +JOB_SYNC_USERS_LDAP = Symbol('SYNC_USERS_LDAP') diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/fixture/media/colour_wheel.mov b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/fixture/media/colour_wheel.mov new file mode 100644 index 0000000000000000000000000000000000000000..db34709c2426d85147e9512b4de3c66c7dd48a00 GIT binary patch literal 17627 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 K0|H@L{l5Sv%EZ9{ literal 0 HcmV?d00001 diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/fixture/media/image-resized-10.png b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/fixture/media/image-resized-10.png new file mode 100644 index 0000000000000000000000000000000000000000..da6ec772092e788b9db8dd7bf98b9d713255bd72 GIT binary patch literal 115 zcmeAS@N?(olHy`uVBq!ia0vp^AT|dF8<0HkD{mW+vhs9s45^rt{Nwxo|NpDSef}SR z)AjRwLB+TFMqi%)*FV4eU*G8XwcaNAPd!`P&HpncCH@&33UInDb7J`WPs~|dc=2+|Ns5_85n>V41r7_ z2Z$G#W^V*)67zI%45?szdvPP9g98K0!D87L`y+K3D$*XGO*?h$S#W)QaTODT0|NsG z0|O(20s{jJLjwbY0MII^pec-jRhCRa3|q)F2jR8_Zn(Wbw&7?&LAE(a!FvT)I8$Oc za(q!@4wBoXsALXGT7oAksu>=jjG1By8QyR)iowr~<@`_ format, for example:: + + /tmp/asfjsfjoj3/%04d.jpg [1-3] + + ''' + items = [] + for index in range(3): + item_path = os.path.join( + temporary_directory, '{0:04d}.jpg'.format(index) + ) + with open(item_path, 'w') as file_descriptor: + file_descriptor.write(uuid.uuid4().hex) + file_descriptor.close() + + items.append(item_path) + + collections, _ = clique.assemble(items) + sequence_path = collections[0].format() + + return sequence_path + + +@pytest.fixture() +def video_path(): + '''Return a path to a video file.''' + video = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + '..', + 'fixture', + 'media', + 'colour_wheel.mov' + ) + ) + + return video + + +@pytest.fixture() +def session(): + '''Return session instance.''' + return ftrack_api.Session() + + +@pytest.fixture() +def session_no_autoconnect_hub(): + '''Return session instance not auto connected to hub.''' + return ftrack_api.Session(auto_connect_event_hub=False) + + +@pytest.fixture() +def unique_name(): + '''Return a unique name.''' + return 'test-{0}'.format(uuid.uuid4()) + + +@pytest.fixture() +def temporary_path(request): + '''Return temporary path.''' + path = tempfile.mkdtemp() + + def cleanup(): + '''Remove created path.''' + try: + shutil.rmtree(path) + except OSError: + pass + + request.addfinalizer(cleanup) + + return path + + +@pytest.fixture() +def new_user(request, session, unique_name): + '''Return a newly created unique user.''' + entity = session.create('User', {'username': unique_name}) + session.commit() + + def cleanup(): + '''Remove created entity.''' + session.delete(entity) + session.commit() + + request.addfinalizer(cleanup) + + return entity + + +@pytest.fixture() +def user(session): + '''Return the same user entity for entire session.''' + # Jenkins user + entity = session.get('User', 'd07ae5d0-66e1-11e1-b5e9-f23c91df25eb') + assert entity is not None + + return entity + + +@pytest.fixture() +def project_schema(session): + '''Return project schema.''' + # VFX Scheme + entity = session.get( + 'ProjectSchema', '69cb7f92-4dbf-11e1-9902-f23c91df25eb' + ) + assert entity is not None + return entity + + +@pytest.fixture() +def new_project_tree(request, session, user): + '''Return new project with basic tree.''' + project_schema = session.query('ProjectSchema').first() + default_shot_status = project_schema.get_statuses('Shot')[0] + default_task_type = project_schema.get_types('Task')[0] + default_task_status = project_schema.get_statuses( + 'Task', default_task_type['id'] + )[0] + + project_name = 'python_api_test_{0}'.format(uuid.uuid1().hex) + project = session.create('Project', { + 'name': project_name, + 'full_name': project_name + '_full', + 'project_schema': project_schema + }) + + for sequence_number in range(1): + sequence = session.create('Sequence', { + 'name': 'sequence_{0:03d}'.format(sequence_number), + 'parent': project + }) + + for shot_number in range(1): + shot = session.create('Shot', { + 'name': 'shot_{0:03d}'.format(shot_number * 10), + 'parent': sequence, + 'status': default_shot_status + }) + + for task_number in range(1): + task = session.create('Task', { + 'name': 'task_{0:03d}'.format(task_number), + 'parent': shot, + 'status': default_task_status, + 'type': default_task_type + }) + + session.create('Appointment', { + 'type': 'assignment', + 'context': task, + 'resource': user + }) + + session.commit() + + def cleanup(): + '''Remove created entity.''' + session.delete(project) + session.commit() + + request.addfinalizer(cleanup) + + return project + + +@pytest.fixture() +def new_project(request, session, user): + '''Return new empty project.''' + project_schema = session.query('ProjectSchema').first() + project_name = 'python_api_test_{0}'.format(uuid.uuid1().hex) + project = session.create('Project', { + 'name': project_name, + 'full_name': project_name + '_full', + 'project_schema': project_schema + }) + + session.commit() + + def cleanup(): + '''Remove created entity.''' + session.delete(project) + session.commit() + + request.addfinalizer(cleanup) + + return project + + +@pytest.fixture() +def project(session): + '''Return same project for entire session.''' + # Test project. + entity = session.get('Project', '5671dcb0-66de-11e1-8e6e-f23c91df25eb') + assert entity is not None + + return entity + + +@pytest.fixture() +def new_task(request, session, unique_name): + '''Return a new task.''' + project = session.query( + 'Project where id is 5671dcb0-66de-11e1-8e6e-f23c91df25eb' + ).one() + project_schema = project['project_schema'] + default_task_type = project_schema.get_types('Task')[0] + default_task_status = project_schema.get_statuses( + 'Task', default_task_type['id'] + )[0] + + task = session.create('Task', { + 'name': unique_name, + 'parent': project, + 'status': default_task_status, + 'type': default_task_type + }) + + session.commit() + + def cleanup(): + '''Remove created entity.''' + session.delete(task) + session.commit() + + request.addfinalizer(cleanup) + + return task + + +@pytest.fixture() +def task(session): + '''Return same task for entire session.''' + # Tests/python_api/tasks/t1 + entity = session.get('Task', 'adb4ad6c-7679-11e2-8df2-f23c91df25eb') + assert entity is not None + + return entity + + +@pytest.fixture() +def new_scope(request, session, unique_name): + '''Return a new scope.''' + scope = session.create('Scope', { + 'name': unique_name + }) + + session.commit() + + def cleanup(): + '''Remove created entity.''' + session.delete(scope) + session.commit() + + request.addfinalizer(cleanup) + + return scope + + +@pytest.fixture() +def new_job(request, session, unique_name, user): + '''Return a new scope.''' + job = session.create('Job', { + 'type': 'api_job', + 'user': user + }) + + session.commit() + + def cleanup(): + '''Remove created entity.''' + session.delete(job) + session.commit() + + request.addfinalizer(cleanup) + + return job + + +@pytest.fixture() +def new_note(request, session, unique_name, new_task, user): + '''Return a new note attached to a task.''' + note = new_task.create_note(unique_name, user) + session.commit() + + def cleanup(): + '''Remove created entity.''' + session.delete(note) + session.commit() + + request.addfinalizer(cleanup) + + return note + + +@pytest.fixture() +def new_asset_version(request, session): + '''Return a new asset version.''' + asset_version = session.create('AssetVersion', { + 'asset_id': 'dd9a7e2e-c5eb-11e1-9885-f23c91df25eb' + }) + session.commit() + + # Do not cleanup the version as that will sometimes result in a deadlock + # database error. + + return asset_version + + +@pytest.fixture() +def new_component(request, session, temporary_file): + '''Return a new component not in any location except origin.''' + component = session.create_component(temporary_file, location=None) + session.commit() + + def cleanup(): + '''Remove created entity.''' + session.delete(component) + session.commit() + + request.addfinalizer(cleanup) + + return component + + +@pytest.fixture() +def new_container_component(request, session, temporary_directory): + '''Return a new container component not in any location except origin.''' + component = session.create('ContainerComponent') + + # Add to special origin location so that it is possible to add to other + # locations. + origin_location = session.get( + 'Location', ftrack_api.symbol.ORIGIN_LOCATION_ID + ) + origin_location.add_component( + component, temporary_directory, recursive=False + ) + + session.commit() + + def cleanup(): + '''Remove created entity.''' + session.delete(component) + session.commit() + + request.addfinalizer(cleanup) + + return component + + +@pytest.fixture() +def new_sequence_component(request, session, temporary_sequence): + '''Return a new sequence component not in any location except origin.''' + component = session.create_component(temporary_sequence, location=None) + session.commit() + + def cleanup(): + '''Remove created entity.''' + session.delete(component) + session.commit() + + request.addfinalizer(cleanup) + + return component + + +@pytest.fixture +def mocked_schemas(): + '''Return a list of mocked schemas.''' + return [{ + 'id': 'Foo', + 'type': 'object', + 'properties': { + 'id': { + 'type': 'string' + }, + 'string': { + 'type': 'string' + }, + 'integer': { + 'type': 'integer' + }, + 'number': { + 'type': 'number' + }, + 'boolean': { + 'type': 'boolean' + }, + 'bars': { + 'type': 'array', + 'items': { + 'ref': '$Bar' + } + }, + 'date': { + 'type': 'string', + 'format': 'date-time' + } + }, + 'immutable': [ + 'id' + ], + 'primary_key': [ + 'id' + ], + 'required': [ + 'id' + ], + 'default_projections': [ + 'id' + ] + }, { + 'id': 'Bar', + 'type': 'object', + 'properties': { + 'id': { + 'type': 'string' + }, + 'name': { + 'type': 'string' + }, + 'computed_value': { + 'type': 'string', + } + }, + 'computed': [ + 'computed_value' + ], + 'immutable': [ + 'id' + ], + 'primary_key': [ + 'id' + ], + 'required': [ + 'id' + ], + 'default_projections': [ + 'id' + ] + }] + + +@pytest.yield_fixture +def mocked_schema_session(mocker, mocked_schemas): + '''Return a session instance with mocked schemas.''' + with mocker.patch.object( + ftrack_api.Session, + '_load_schemas', + return_value=mocked_schemas + ): + # Mock _configure_locations since it will fail if no location schemas + # exist. + with mocker.patch.object( + ftrack_api.Session, + '_configure_locations' + ): + patched_session = ftrack_api.Session() + yield patched_session diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/__init__.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/__init__.py new file mode 100644 index 0000000000..bc98f15de2 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/__init__.py @@ -0,0 +1,2 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_asset_version.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_asset_version.py new file mode 100644 index 0000000000..78d61a62d1 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_asset_version.py @@ -0,0 +1,54 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack +import json + + +def test_create_component(new_asset_version, temporary_file): + '''Create component on asset version.''' + session = new_asset_version.session + component = new_asset_version.create_component( + temporary_file, location=None + ) + assert component['version'] is new_asset_version + + # Have to delete component before can delete asset version. + session.delete(component) + + +def test_create_component_specifying_different_version( + new_asset_version, temporary_file +): + '''Create component on asset version ignoring specified version.''' + session = new_asset_version.session + component = new_asset_version.create_component( + temporary_file, location=None, + data=dict( + version_id='this-value-should-be-ignored', + version='this-value-should-be-overridden' + ) + ) + assert component['version'] is new_asset_version + + # Have to delete component before can delete asset version. + session.delete(component) + + +def test_encode_media(new_asset_version, video_path): + '''Encode media based on a file path + + Encoded components should be associated with the version. + ''' + session = new_asset_version.session + job = new_asset_version.encode_media(video_path) + assert job.entity_type == 'Job' + + job_data = json.loads(job['data']) + assert 'output' in job_data + assert len(job_data['output']) + assert 'component_id' in job_data['output'][0] + + component_id = job_data['output'][0]['component_id'] + component = session.get('FileComponent', component_id) + + # Component should be associated with the version. + assert component['version_id'] == new_asset_version['id'] diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_base.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_base.py new file mode 100644 index 0000000000..aff456e238 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_base.py @@ -0,0 +1,14 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2016 ftrack + +import pytest + + +def test_hash(project, task, user): + '''Entities can be hashed.''' + test_set = set() + test_set.add(project) + test_set.add(task) + test_set.add(user) + + assert test_set == set((project, task, user)) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_component.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_component.py new file mode 100644 index 0000000000..347c74a50d --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_component.py @@ -0,0 +1,70 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack +import os + +import pytest + + +def test_get_availability(new_component): + '''Retrieve availability in locations.''' + session = new_component.session + availability = new_component.get_availability() + + # Note: Currently the origin location is also 0.0 as the link is not + # persisted to the server. This may change in future and this test would + # need updating as a result. + assert set(availability.values()) == set([0.0]) + + # Add to a location. + source_location = session.query( + 'Location where name is "ftrack.origin"' + ).one() + + target_location = session.query( + 'Location where name is "ftrack.unmanaged"' + ).one() + + target_location.add_component(new_component, source_location) + + # Recalculate availability. + + # Currently have to manually expire the related attribute. This should be + # solved in future by bi-directional relationship updating. + del new_component['component_locations'] + + availability = new_component.get_availability() + target_availability = availability.pop(target_location['id']) + assert target_availability == 100.0 + + # All other locations should still be 0. + assert set(availability.values()) == set([0.0]) + +@pytest.fixture() +def image_path(): + '''Return a path to an image file.''' + image_path = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + '..', + '..', + 'fixture', + 'media', + 'image.png' + ) + ) + + return image_path + +def test_create_task_thumbnail(task, image_path): + '''Successfully create thumbnail component and set as task thumbnail.''' + component = task.create_thumbnail(image_path) + component.session.commit() + assert component['id'] == task['thumbnail_id'] + + +def test_create_thumbnail_with_data(task, image_path, unique_name): + '''Successfully create thumbnail component with custom data.''' + data = {'name': unique_name} + component = task.create_thumbnail(image_path, data=data) + component.session.commit() + assert component['name'] == unique_name diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_factory.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_factory.py new file mode 100644 index 0000000000..5d5a0baa7c --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_factory.py @@ -0,0 +1,25 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import ftrack_api.entity.factory + + +class CustomUser(ftrack_api.entity.base.Entity): + '''Represent custom user.''' + + +def test_extend_standard_factory_with_bases(session): + '''Successfully add extra bases to standard factory.''' + standard_factory = ftrack_api.entity.factory.StandardFactory() + + schemas = session._load_schemas(False) + user_schema = [ + schema for schema in schemas if schema['id'] == 'User' + ].pop() + + user_class = standard_factory.create(user_schema, bases=[CustomUser]) + session.types[user_class.entity_type] = user_class + + user = session.query('User').first() + + assert CustomUser in type(user).__mro__ diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_job.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_job.py new file mode 100644 index 0000000000..52ddbda0ac --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_job.py @@ -0,0 +1,42 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import pytest + + +def test_create_job(session, user): + '''Create job.''' + job = session.create('Job', { + 'user': user + }) + + assert job + session.commit() + assert job['type'] == 'api_job' + + session.delete(job) + session.commit() + + +def test_create_job_with_valid_type(session, user): + '''Create job explicitly specifying valid type.''' + job = session.create('Job', { + 'user': user, + 'type': 'api_job' + }) + + assert job + session.commit() + assert job['type'] == 'api_job' + + session.delete(job) + session.commit() + + +def test_create_job_using_faulty_type(session, user): + '''Fail to create job with faulty type.''' + with pytest.raises(ValueError): + session.create('Job', { + 'user': user, + 'type': 'not-allowed-type' + }) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_location.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_location.py new file mode 100644 index 0000000000..5bb90e451f --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_location.py @@ -0,0 +1,516 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import os +import base64 +import filecmp + +import pytest +import requests + +import ftrack_api.exception +import ftrack_api.accessor.disk +import ftrack_api.structure.origin +import ftrack_api.structure.id +import ftrack_api.entity.location +import ftrack_api.resource_identifier_transformer.base as _transformer +import ftrack_api.symbol + + +class Base64ResourceIdentifierTransformer( + _transformer.ResourceIdentifierTransformer +): + '''Resource identifier transformer for test purposes. + + Store resource identifier as base 64 encoded string. + + ''' + + def encode(self, resource_identifier, context=None): + '''Return encoded *resource_identifier* for storing centrally. + + A mapping of *context* values may be supplied to guide the + transformation. + + ''' + return base64.encodestring(resource_identifier) + + def decode(self, resource_identifier, context=None): + '''Return decoded *resource_identifier* for use locally. + + A mapping of *context* values may be supplied to guide the + transformation. + + ''' + return base64.decodestring(resource_identifier) + + +@pytest.fixture() +def new_location(request, session, unique_name, temporary_directory): + '''Return new managed location.''' + location = session.create('Location', { + 'name': 'test-location-{}'.format(unique_name) + }) + + location.accessor = ftrack_api.accessor.disk.DiskAccessor( + prefix=os.path.join(temporary_directory, 'location') + ) + location.structure = ftrack_api.structure.id.IdStructure() + location.priority = 10 + + session.commit() + + def cleanup(): + '''Remove created entity.''' + # First auto-remove all components in location. + for location_component in location['location_components']: + session.delete(location_component) + + # At present, need this intermediate commit otherwise server errors + # complaining that location still has components in it. + session.commit() + + session.delete(location) + session.commit() + + request.addfinalizer(cleanup) + + return location + + +@pytest.fixture() +def new_unmanaged_location(request, session, unique_name): + '''Return new unmanaged location.''' + location = session.create('Location', { + 'name': 'test-location-{}'.format(unique_name) + }) + + # TODO: Change to managed and use a temporary directory cleaned up after. + ftrack_api.mixin( + location, ftrack_api.entity.location.UnmanagedLocationMixin, + name='UnmanagedTestLocation' + ) + location.accessor = ftrack_api.accessor.disk.DiskAccessor(prefix='') + location.structure = ftrack_api.structure.origin.OriginStructure() + location.priority = 10 + + session.commit() + + def cleanup(): + '''Remove created entity.''' + # First auto-remove all components in location. + for location_component in location['location_components']: + session.delete(location_component) + + # At present, need this intermediate commit otherwise server errors + # complaining that location still has components in it. + session.commit() + + session.delete(location) + session.commit() + + request.addfinalizer(cleanup) + + return location + + +@pytest.fixture() +def origin_location(session): + '''Return origin location.''' + return session.query('Location where name is "ftrack.origin"').one() + +@pytest.fixture() +def server_location(session): + '''Return server location.''' + return session.get('Location', ftrack_api.symbol.SERVER_LOCATION_ID) + + +@pytest.fixture() +def server_image_component(request, session, server_location): + image_file = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + '..', + '..', + 'fixture', + 'media', + 'image.png' + ) + ) + component = session.create_component( + image_file, location=server_location + ) + + def cleanup(): + server_location.remove_component(component) + request.addfinalizer(cleanup) + + return component + + +@pytest.mark.parametrize('name', [ + 'named', + None +], ids=[ + 'named', + 'unnamed' +]) +def test_string_representation(session, name): + '''Return string representation.''' + location = session.create('Location', {'id': '1'}) + if name: + location['name'] = name + assert str(location) == '' + else: + assert str(location) == '' + + +def test_add_components(new_location, origin_location, session, temporary_file): + '''Add components.''' + component_a = session.create_component( + temporary_file, location=None + ) + component_b = session.create_component( + temporary_file, location=None + ) + + assert ( + new_location.get_component_availabilities([component_a, component_b]) + == [0.0, 0.0] + ) + + new_location.add_components( + [component_a, component_b], [origin_location, origin_location] + ) + + # Recalculate availability. + + # Currently have to manually expire the related attribute. This should be + # solved in future by bi-directional relationship updating. + del component_a['component_locations'] + del component_b['component_locations'] + + assert ( + new_location.get_component_availabilities([component_a, component_b]) + == [100.0, 100.0] + ) + + +def test_add_components_from_single_location( + new_location, origin_location, session, temporary_file +): + '''Add components from single location.''' + component_a = session.create_component( + temporary_file, location=None + ) + component_b = session.create_component( + temporary_file, location=None + ) + + assert ( + new_location.get_component_availabilities([component_a, component_b]) + == [0.0, 0.0] + ) + + new_location.add_components([component_a, component_b], origin_location) + + # Recalculate availability. + + # Currently have to manually expire the related attribute. This should be + # solved in future by bi-directional relationship updating. + del component_a['component_locations'] + del component_b['component_locations'] + + assert ( + new_location.get_component_availabilities([component_a, component_b]) + == [100.0, 100.0] + ) + + +def test_add_components_with_mismatching_sources(new_location, new_component): + '''Fail to add components when sources mismatched.''' + with pytest.raises(ValueError): + new_location.add_components([new_component], []) + + +def test_add_components_with_undefined_structure(new_location, mocker): + '''Fail to add components when location structure undefined.''' + mocker.patch.object(new_location, 'structure', None) + + with pytest.raises(ftrack_api.exception.LocationError): + new_location.add_components([], []) + + +def test_add_components_already_in_location( + session, temporary_file, new_location, new_component, origin_location +): + '''Fail to add components already in location.''' + new_location.add_component(new_component, origin_location) + + another_new_component = session.create_component( + temporary_file, location=None + ) + + with pytest.raises(ftrack_api.exception.ComponentInLocationError): + new_location.add_components( + [another_new_component, new_component], origin_location + ) + + +def test_add_component_when_data_already_exists( + new_location, new_component, origin_location +): + '''Fail to add component when data already exists.''' + # Inject pre-existing data on disk. + resource_identifier = new_location.structure.get_resource_identifier( + new_component + ) + container = new_location.accessor.get_container(resource_identifier) + new_location.accessor.make_container(container) + data = new_location.accessor.open(resource_identifier, 'w') + data.close() + + with pytest.raises(ftrack_api.exception.LocationError): + new_location.add_component(new_component, origin_location) + + +def test_add_component_missing_source_accessor( + new_location, new_component, origin_location, mocker +): + '''Fail to add component when source is missing accessor.''' + mocker.patch.object(origin_location, 'accessor', None) + + with pytest.raises(ftrack_api.exception.LocationError): + new_location.add_component(new_component, origin_location) + + +def test_add_component_missing_target_accessor( + new_location, new_component, origin_location, mocker +): + '''Fail to add component when target is missing accessor.''' + mocker.patch.object(new_location, 'accessor', None) + + with pytest.raises(ftrack_api.exception.LocationError): + new_location.add_component(new_component, origin_location) + + +def test_add_container_component( + new_container_component, new_location, origin_location +): + '''Add container component.''' + new_location.add_component(new_container_component, origin_location) + + assert ( + new_location.get_component_availability(new_container_component) + == 100.0 + ) + + +def test_add_sequence_component_recursively( + new_sequence_component, new_location, origin_location +): + '''Add sequence component recursively.''' + new_location.add_component( + new_sequence_component, origin_location, recursive=True + ) + + assert ( + new_location.get_component_availability(new_sequence_component) + == 100.0 + ) + + +def test_add_sequence_component_non_recursively( + new_sequence_component, new_location, origin_location +): + '''Add sequence component non recursively.''' + new_location.add_component( + new_sequence_component, origin_location, recursive=False + ) + + assert ( + new_location.get_component_availability(new_sequence_component) + == 0.0 + ) + + +def test_remove_components( + session, new_location, origin_location, temporary_file +): + '''Remove components.''' + component_a = session.create_component( + temporary_file, location=None + ) + component_b = session.create_component( + temporary_file, location=None + ) + + new_location.add_components([component_a, component_b], origin_location) + assert ( + new_location.get_component_availabilities([component_a, component_b]) + == [100.0, 100.0] + ) + + new_location.remove_components([ + component_a, component_b + ]) + + # Recalculate availability. + + # Currently have to manually expire the related attribute. This should be + # solved in future by bi-directional relationship updating. + del component_a['component_locations'] + del component_b['component_locations'] + + assert ( + new_location.get_component_availabilities([component_a, component_b]) + == [0.0, 0.0] + ) + + +def test_remove_sequence_component_recursively( + new_sequence_component, new_location, origin_location +): + '''Remove sequence component recursively.''' + new_location.add_component( + new_sequence_component, origin_location, recursive=True + ) + + new_location.remove_component( + new_sequence_component, recursive=True + ) + + assert ( + new_location.get_component_availability(new_sequence_component) + == 0.0 + ) + + +def test_remove_sequence_component_non_recursively( + new_sequence_component, new_location, origin_location +): + '''Remove sequence component non recursively.''' + new_location.add_component( + new_sequence_component, origin_location, recursive=False + ) + + new_location.remove_component( + new_sequence_component, recursive=False + ) + + assert ( + new_location.get_component_availability(new_sequence_component) + == 0.0 + ) + + +def test_remove_component_missing_accessor( + new_location, new_component, origin_location, mocker +): + '''Fail to remove component when location is missing accessor.''' + new_location.add_component(new_component, origin_location) + mocker.patch.object(new_location, 'accessor', None) + + with pytest.raises(ftrack_api.exception.LocationError): + new_location.remove_component(new_component) + + +def test_resource_identifier_transformer( + new_component, new_unmanaged_location, origin_location, mocker +): + '''Transform resource identifier.''' + session = new_unmanaged_location.session + + transformer = Base64ResourceIdentifierTransformer(session) + mocker.patch.object( + new_unmanaged_location, 'resource_identifier_transformer', transformer + ) + + new_unmanaged_location.add_component(new_component, origin_location) + + original_resource_identifier = origin_location.get_resource_identifier( + new_component + ) + assert ( + new_component['component_locations'][0]['resource_identifier'] + == base64.encodestring(original_resource_identifier) + ) + + assert ( + new_unmanaged_location.get_resource_identifier(new_component) + == original_resource_identifier + ) + + +def test_get_filesystem_path(new_component, new_location, origin_location): + '''Retrieve filesystem path.''' + new_location.add_component(new_component, origin_location) + resource_identifier = new_location.structure.get_resource_identifier( + new_component + ) + expected = os.path.normpath( + os.path.join(new_location.accessor.prefix, resource_identifier) + ) + assert new_location.get_filesystem_path(new_component) == expected + + +def test_get_context(new_component, new_location, origin_location): + '''Retrieve context for component.''' + resource_identifier = origin_location.get_resource_identifier( + new_component + ) + context = new_location._get_context(new_component, origin_location) + assert context == { + 'source_resource_identifier': resource_identifier + } + + +def test_get_context_for_component_not_in_source(new_component, new_location): + '''Retrieve context for component not in source location.''' + context = new_location._get_context(new_component, new_location) + assert context == {} + + +def test_data_transfer(session, new_location, origin_location): + '''Transfer a real file and make sure it is identical.''' + video_file = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + '..', + '..', + 'fixture', + 'media', + 'colour_wheel.mov' + ) + ) + component = session.create_component( + video_file, location=new_location + ) + new_video_file = new_location.get_filesystem_path(component) + + assert filecmp.cmp(video_file, new_video_file) + + +def test_get_thumbnail_url(server_location, server_image_component): + '''Test download a thumbnail image from server location''' + thumbnail_url = server_location.get_thumbnail_url( + server_image_component, + size=10 + ) + assert thumbnail_url + + response = requests.get(thumbnail_url) + response.raise_for_status() + + image_file = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + '..', + '..', + 'fixture', + 'media', + 'image-resized-10.png' + ) + ) + expected_image_contents = open(image_file).read() + assert response.content == expected_image_contents diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_metadata.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_metadata.py new file mode 100644 index 0000000000..3a81fdbe85 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_metadata.py @@ -0,0 +1,135 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import uuid + +import ftrack_api + + +def test_query_metadata(new_project): + '''Query metadata.''' + session = new_project.session + + metadata_key = uuid.uuid1().hex + metadata_value = uuid.uuid1().hex + new_project['metadata'][metadata_key] = metadata_value + session.commit() + + results = session.query( + 'Project where metadata.key is {0}'.format(metadata_key) + ) + + assert len(results) == 1 + assert new_project['id'] == results[0]['id'] + + results = session.query( + 'Project where metadata.value is {0}'.format(metadata_value) + ) + + assert len(results) == 1 + assert new_project['id'] == results[0]['id'] + + results = session.query( + 'Project where metadata.key is {0} and ' + 'metadata.value is {1}'.format(metadata_key, metadata_value) + ) + + assert len(results) == 1 + assert new_project['id'] == results[0]['id'] + + +def test_set_get_metadata_from_different_sessions(new_project): + '''Get and set metadata using different sessions.''' + session = new_project.session + + metadata_key = uuid.uuid1().hex + metadata_value = uuid.uuid1().hex + new_project['metadata'][metadata_key] = metadata_value + session.commit() + + new_session = ftrack_api.Session() + project = new_session.query( + 'Project where id is {0}'.format(new_project['id']) + )[0] + + assert project['metadata'][metadata_key] == metadata_value + + project['metadata'][metadata_key] = uuid.uuid1().hex + + new_session.commit() + + new_session = ftrack_api.Session() + project = new_session.query( + 'Project where id is {0}'.format(project['id']) + )[0] + + assert project['metadata'][metadata_key] != metadata_value + + +def test_get_set_multiple_metadata(new_project): + '''Get and set multiple metadata.''' + session = new_project.session + + new_project['metadata'] = { + 'key1': 'value1', + 'key2': 'value2' + } + session.commit() + + assert set(new_project['metadata'].keys()) == set(['key1', 'key2']) + + new_session = ftrack_api.Session() + retrieved = new_session.query( + 'Project where id is {0}'.format(new_project['id']) + )[0] + + assert set(retrieved['metadata'].keys()) == set(['key1', 'key2']) + + +def test_metadata_parent_type_remains_in_schema_id_format(session, new_project): + '''Metadata parent_type remains in schema id format post commit.''' + entity = session.create('Metadata', { + 'key': 'key', 'value': 'value', + 'parent_type': new_project.entity_type, + 'parent_id': new_project['id'] + }) + + session.commit() + + assert entity['parent_type'] == new_project.entity_type + + +def test_set_metadata_twice(new_project): + '''Set metadata twice in a row.''' + session = new_project.session + + new_project['metadata'] = { + 'key1': 'value1', + 'key2': 'value2' + } + session.commit() + + assert set(new_project['metadata'].keys()) == set(['key1', 'key2']) + + new_project['metadata'] = { + 'key3': 'value3', + 'key4': 'value4' + } + session.commit() + + +def test_set_same_metadata_on_retrieved_entity(new_project): + '''Set same metadata on retrieved entity.''' + session = new_project.session + + new_project['metadata'] = { + 'key1': 'value1' + } + session.commit() + + project = session.get('Project', new_project['id']) + + project['metadata'] = { + 'key1': 'value1' + } + session.commit() diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_note.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_note.py new file mode 100644 index 0000000000..5d854eaed4 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_note.py @@ -0,0 +1,67 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import ftrack_api +import ftrack_api.inspection + + +def test_create_reply(session, new_note, user, unique_name): + '''Create reply to a note.''' + reply_text = 'My reply on note' + new_note.create_reply(reply_text, user) + + session.commit() + + assert len(new_note['replies']) == 1 + + assert reply_text == new_note['replies'][0]['content'] + + +def test_create_note_on_entity(session, new_task, user, unique_name): + '''Create note attached to an entity.''' + note = new_task.create_note(unique_name, user) + session.commit() + + session.reset() + retrieved_task = session.get(*ftrack_api.inspection.identity(new_task)) + assert len(retrieved_task['notes']) == 1 + assert ( + ftrack_api.inspection.identity(retrieved_task['notes'][0]) + == ftrack_api.inspection.identity(note) + ) + + +def test_create_note_on_entity_specifying_recipients( + session, new_task, user, unique_name, new_user +): + '''Create note with specified recipients attached to an entity.''' + recipient = new_user + note = new_task.create_note(unique_name, user, recipients=[recipient]) + session.commit() + + session.reset() + retrieved_note = session.get(*ftrack_api.inspection.identity(note)) + + # Note: The calling user is automatically added server side so there will be + # 2 recipients. + assert len(retrieved_note['recipients']) == 2 + specified_recipient_present = False + for entry in retrieved_note['recipients']: + if entry['resource_id'] == recipient['id']: + specified_recipient_present = True + break + + assert specified_recipient_present + + +def test_create_note_on_entity_specifying_category( + session, new_task, user, unique_name +): + '''Create note with specified category attached to an entity.''' + category = session.query('NoteCategory').first() + note = new_task.create_note(unique_name, user, category=category) + session.commit() + + session.reset() + retrieved_note = session.get(*ftrack_api.inspection.identity(note)) + assert retrieved_note['category']['id'] == category['id'] diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_project_schema.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_project_schema.py new file mode 100644 index 0000000000..10ef485aed --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_project_schema.py @@ -0,0 +1,64 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import inspect + +import pytest + + +@pytest.mark.parametrize('schema, expected', [ + ('Task', [ + 'Not started', 'In progress', 'Awaiting approval', 'Approved' + ]), + ('Shot', [ + 'Normal', 'Omitted', 'On Hold' + ]), + ('AssetVersion', [ + 'Approved', 'Pending' + ]), + ('AssetBuild', [ + 'Normal', 'Omitted', 'On Hold' + ]), + ('Invalid', ValueError) +], ids=[ + 'task', + 'shot', + 'asset version', + 'asset build', + 'invalid' +]) +def test_get_statuses(project_schema, schema, expected): + '''Retrieve statuses for schema and optional type.''' + if inspect.isclass(expected) and issubclass(expected, Exception): + with pytest.raises(expected): + project_schema.get_statuses(schema) + + else: + statuses = project_schema.get_statuses(schema) + status_names = [status['name'] for status in statuses] + assert sorted(status_names) == sorted(expected) + + +@pytest.mark.parametrize('schema, expected', [ + ('Task', [ + 'Generic', 'Animation', 'Modeling', 'Previz', 'Lookdev', 'Hair', + 'Cloth', 'FX', 'Lighting', 'Compositing', 'Tracking', 'Rigging', + 'test 1', 'test type 2' + ]), + ('AssetBuild', ['Character', 'Prop', 'Environment', 'Matte Painting']), + ('Invalid', ValueError) +], ids=[ + 'task', + 'asset build', + 'invalid' +]) +def test_get_types(project_schema, schema, expected): + '''Retrieve types for schema.''' + if inspect.isclass(expected) and issubclass(expected, Exception): + with pytest.raises(expected): + project_schema.get_types(schema) + + else: + types = project_schema.get_types(schema) + type_names = [type_['name'] for type_ in types] + assert sorted(type_names) == sorted(expected) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_scopes.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_scopes.py new file mode 100644 index 0000000000..1a5afe70c9 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_scopes.py @@ -0,0 +1,24 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + + +def test_add_remove_and_query_scopes_for_tasks(session, new_task, new_scope): + '''Add, remove and query scopes for task.''' + query_string = 'Task where scopes.name is {0}'.format(new_scope['name']) + tasks = session.query(query_string) + + assert len(tasks) == 0 + + new_task['scopes'].append(new_scope) + session.commit() + + tasks = session.query(query_string) + + assert len(tasks) == 1 and tasks[0] == new_task + + new_task['scopes'].remove(new_scope) + session.commit() + + tasks = session.query(query_string) + + assert len(tasks) == 0 diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_user.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_user.py new file mode 100644 index 0000000000..4d7e455042 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/entity/test_user.py @@ -0,0 +1,49 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2016 ftrack + + +def test_force_start_timer(new_user, task): + '''Successfully force starting a timer when another timer is running.''' + first_timer = new_user.start_timer(context=task) + second_timer = new_user.start_timer(context=task, force=True) + + assert first_timer['id'] + assert second_timer['id'] + assert first_timer['id'] != second_timer['id'] + + +def test_timer_creates_timelog(new_user, task, unique_name): + '''Successfully create time log when stopping timer. + + A timer which was immediately stopped should have a duration less than + a minute. + + ''' + comment = 'comment' + unique_name + timer = new_user.start_timer( + context=task, + name=unique_name, + comment=comment + ) + timer_start = timer['start'] + timelog = new_user.stop_timer() + + assert timelog['user_id'] == new_user['id'] + assert timelog['context_id']== task['id'] + assert timelog['name'] == unique_name + assert timelog['comment'] == comment + assert timelog['start'] == timer_start + assert isinstance(timelog['duration'], (int, long, float)) + assert timelog['duration'] < 60 + + +def test_reset_user_api_key(new_user): + '''Test resetting of api keys.''' + + api_keys = list() + for i in range(0, 10): + api_keys.append(new_user.reset_api_key()) + + # make sure all api keys are unique + assert len(set(api_keys)) == 10 + diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/__init__.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/__init__.py new file mode 100644 index 0000000000..bc98f15de2 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/__init__.py @@ -0,0 +1,2 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/event_hub_server_heartbeat.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/event_hub_server_heartbeat.py new file mode 100644 index 0000000000..09b270a043 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/event_hub_server_heartbeat.py @@ -0,0 +1,92 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +import sys +import time +import logging +import argparse + +import ftrack_api +from ftrack_api.event.base import Event + + +TOPIC = 'test_event_hub_server_heartbeat' +RECEIVED = [] + + +def callback(event): + '''Track received messages.''' + counter = event['data']['counter'] + RECEIVED.append(counter) + print('Received message {0} ({1} in total)'.format(counter, len(RECEIVED))) + + +def main(arguments=None): + '''Publish and receive heartbeat test.''' + parser = argparse.ArgumentParser() + parser.add_argument('mode', choices=['publish', 'subscribe']) + + namespace = parser.parse_args(arguments) + logging.basicConfig(level=logging.INFO) + + session = ftrack_api.Session() + + message_count = 100 + sleep_time_per_message = 1 + + if namespace.mode == 'publish': + max_atempts = 100 + retry_interval = 0.1 + atempt = 0 + while not session.event_hub.connected: + print ( + 'Session is not yet connected to event hub, sleeping for 0.1s' + ) + time.sleep(retry_interval) + + atempt = atempt + 1 + if atempt > max_atempts: + raise Exception( + 'Unable to connect to server within {0} seconds'.format( + max_atempts * retry_interval + ) + ) + + print('Sending {0} messages...'.format(message_count)) + + for counter in range(1, message_count + 1): + session.event_hub.publish( + Event(topic=TOPIC, data=dict(counter=counter)) + ) + print('Sent message {0}'.format(counter)) + + if counter < message_count: + time.sleep(sleep_time_per_message) + + elif namespace.mode == 'subscribe': + session.event_hub.subscribe('topic={0}'.format(TOPIC), callback) + session.event_hub.wait( + duration=( + ((message_count - 1) * sleep_time_per_message) + 15 + ) + ) + + if len(RECEIVED) != message_count: + print( + '>> Failed to receive all messages. Dropped {0} <<' + .format(message_count - len(RECEIVED)) + ) + return False + + # Give time to flush all buffers. + time.sleep(5) + + return True + + +if __name__ == '__main__': + result = main(sys.argv[1:]) + if not result: + raise SystemExit(1) + else: + raise SystemExit(0) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_base.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_base.py new file mode 100644 index 0000000000..d9496fe070 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_base.py @@ -0,0 +1,36 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import ftrack_api.event.base + + +def test_string_representation(): + '''String representation.''' + event = ftrack_api.event.base.Event('test', id='some-id') + assert str(event) == ( + "" + ) + + +def test_stop(): + '''Set stopped flag on event.''' + event = ftrack_api.event.base.Event('test', id='some-id') + + assert event.is_stopped() is False + + event.stop() + assert event.is_stopped() is True + + +def test_is_stopped(): + '''Report stopped status of event.''' + event = ftrack_api.event.base.Event('test', id='some-id') + + assert event.is_stopped() is False + + event.stop() + assert event.is_stopped() is True + + event.stop() + assert event.is_stopped() is True diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_expression.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_expression.py new file mode 100644 index 0000000000..4cf68b58f0 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_expression.py @@ -0,0 +1,174 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import operator +import inspect + +import pytest + +from ftrack_api.event.expression import ( + Expression, All, Any, Not, Condition, Parser +) +from ftrack_api.exception import ParseError + + +@pytest.fixture() +def candidate(): + '''Return common candidate to test expressions against.''' + return { + 'id': 10, + 'name': 'value', + 'change': { + 'name': 'value', + 'new_value': 10 + } + } + + +@pytest.mark.parametrize('expression, expected', [ + pytest.mark.xfail(('', Expression())), + ('invalid', ParseError), + ('key=value nor other=value', ParseError), + ('key=value', Condition('key', operator.eq, 'value')), + ('key="value"', Condition('key', operator.eq, 'value')), + ( + 'a=b and ((c=d or e!=f) and not g.h > 10)', + All([ + Condition('a', operator.eq, 'b'), + All([ + Any([ + Condition('c', operator.eq, 'd'), + Condition('e', operator.ne, 'f') + ]), + Not( + Condition('g.h', operator.gt, 10) + ) + ]) + ]) + ) +], ids=[ + 'empty expression', + 'invalid expression', + 'invalid conjunction', + 'basic condition', + 'basic quoted condition', + 'complex condition' +]) +def test_parser_parse(expression, expected): + '''Parse expression into Expression instances.''' + parser = Parser() + + if inspect.isclass(expected)and issubclass(expected, Exception): + with pytest.raises(expected): + parser.parse(expression) + else: + assert str(parser.parse(expression)) == str(expected) + + +@pytest.mark.parametrize('expression, expected', [ + (Expression(), ''), + (All([Expression(), Expression()]), ' ]>'), + (Any([Expression(), Expression()]), ' ]>'), + (Not(Expression()), '>'), + (Condition('key', '=', 'value'), '') +], ids=[ + 'Expression', + 'All', + 'Any', + 'Not', + 'Condition' +]) +def test_string_representation(expression, expected): + '''String representation of expression.''' + assert str(expression) == expected + + +@pytest.mark.parametrize('expression, expected', [ + # Expression + (Expression(), True), + + # All + (All(), True), + (All([Expression(), Expression()]), True), + (All([Expression(), Condition('test', operator.eq, 'value')]), False), + + # Any + (Any(), False), + (Any([Expression(), Condition('test', operator.eq, 'value')]), True), + (Any([ + Condition('test', operator.eq, 'value'), + Condition('other', operator.eq, 'value') + ]), False), + + # Not + (Not(Expression()), False), + (Not(Not(Expression())), True) +], ids=[ + 'Expression-always matches', + + 'All-no expressions always matches', + 'All-all match', + 'All-not all match', + + 'Any-no expressions never matches', + 'Any-some match', + 'Any-none match', + + 'Not-invert positive match', + 'Not-double negative is positive match' +]) +def test_match(expression, candidate, expected): + '''Determine if candidate matches expression.''' + assert expression.match(candidate) is expected + + +def parametrize_test_condition_match(metafunc): + '''Parametrize condition_match tests.''' + identifiers = [] + data = [] + + matrix = { + # Operator, match, no match + operator.eq: { + 'match': 10, 'no-match': 20, + 'wildcard-match': 'valu*', 'wildcard-no-match': 'values*' + }, + operator.ne: {'match': 20, 'no-match': 10}, + operator.ge: {'match': 10, 'no-match': 20}, + operator.le: {'match': 10, 'no-match': 0}, + operator.gt: {'match': 0, 'no-match': 10}, + operator.lt: {'match': 20, 'no-match': 10} + } + + for operator_function, values in matrix.items(): + for value_label, value in values.items(): + if value_label.startswith('wildcard'): + key_options = { + 'plain': 'name', + 'nested': 'change.name' + } + else: + key_options = { + 'plain': 'id', + 'nested': 'change.new_value' + } + + for key_label, key in key_options.items(): + identifiers.append('{} operator {} key {}'.format( + operator_function.__name__, key_label, value_label + )) + + data.append(( + key, operator_function, value, + 'no-match' not in value_label + )) + + metafunc.parametrize( + 'key, operator, value, expected', data, ids=identifiers + ) + + +def test_condition_match(key, operator, value, candidate, expected): + '''Determine if candidate matches condition expression.''' + condition = Condition(key, operator, value) + assert condition.match(candidate) is expected diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_hub.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_hub.py new file mode 100644 index 0000000000..6f1920dddf --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_hub.py @@ -0,0 +1,701 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import inspect +import json +import os +import time +import subprocess +import sys + +import pytest + +import ftrack_api.event.hub +import ftrack_api.event.subscriber +from ftrack_api.event.base import Event +import ftrack_api.exception + + +class MockClass(object): + '''Mock class for testing.''' + + def method(self): + '''Mock method for testing.''' + + +def mockFunction(): + '''Mock function for testing.''' + + +class MockConnection(object): + '''Mock connection for testing.''' + + @property + def connected(self): + '''Return whether connected.''' + return True + + def close(self): + '''Close mock connection.''' + pass + + +def assert_callbacks(hub, callbacks): + '''Assert hub has exactly *callbacks* subscribed.''' + # Subscribers always starts with internal handle_reply subscriber. + subscribers = hub._subscribers[:] + subscribers.pop(0) + + if len(subscribers) != len(callbacks): + raise AssertionError( + 'Number of subscribers ({0}) != number of callbacks ({1})' + .format(len(subscribers), len(callbacks)) + ) + + for index, subscriber in enumerate(subscribers): + if subscriber.callback != callbacks[index]: + raise AssertionError( + 'Callback at {0} != subscriber callback at same index.' + .format(index) + ) + + +@pytest.fixture() +def event_hub(request, session): + '''Return event hub to test against. + + Hub is automatically connected at start of test and disconnected at end. + + ''' + hub = ftrack_api.event.hub.EventHub( + session.server_url, session.api_user, session.api_key + ) + hub.connect() + + def cleanup(): + '''Cleanup.''' + if hub.connected: + hub.disconnect() + + request.addfinalizer(cleanup) + + return hub + + +@pytest.mark.parametrize('server_url, expected', [ + ('https://test.ftrackapp.com', 'https://test.ftrackapp.com'), + ('https://test.ftrackapp.com:9000', 'https://test.ftrackapp.com:9000') +], ids=[ + 'with port', + 'without port' +]) +def test_get_server_url(server_url, expected): + '''Return server url.''' + event_hub = ftrack_api.event.hub.EventHub( + server_url, 'user', 'key' + ) + assert event_hub.get_server_url() == expected + + +@pytest.mark.parametrize('server_url, expected', [ + ('https://test.ftrackapp.com', 'test.ftrackapp.com'), + ('https://test.ftrackapp.com:9000', 'test.ftrackapp.com:9000') +], ids=[ + 'with port', + 'without port' +]) +def test_get_network_location(server_url, expected): + '''Return network location of server url.''' + event_hub = ftrack_api.event.hub.EventHub( + server_url, 'user', 'key' + ) + assert event_hub.get_network_location() == expected + + +@pytest.mark.parametrize('server_url, expected', [ + ('https://test.ftrackapp.com', True), + ('http://test.ftrackapp.com', False) +], ids=[ + 'secure', + 'not secure' +]) +def test_secure_property(server_url, expected, mocker): + '''Return whether secure connection used.''' + event_hub = ftrack_api.event.hub.EventHub( + server_url, 'user', 'key' + ) + assert event_hub.secure is expected + + +def test_connected_property(session): + '''Return connected state.''' + event_hub = ftrack_api.event.hub.EventHub( + session.server_url, session.api_user, session.api_key + ) + assert event_hub.connected is False + + event_hub.connect() + assert event_hub.connected is True + + event_hub.disconnect() + assert event_hub.connected is False + + +@pytest.mark.parametrize('server_url, expected', [ + ('https://test.ftrackapp.com', 'https://test.ftrackapp.com'), + ('https://test.ftrackapp.com:9000', 'https://test.ftrackapp.com:9000'), + ('test.ftrackapp.com', ValueError), + ('https://:9000', ValueError), +], ids=[ + 'with port', + 'without port', + 'missing scheme', + 'missing hostname' +]) +def test_initialise_against_server_url(server_url, expected): + '''Initialise against server url.''' + if inspect.isclass(expected) and issubclass(expected, Exception): + with pytest.raises(expected): + ftrack_api.event.hub.EventHub( + server_url, 'user', 'key' + ) + else: + event_hub = ftrack_api.event.hub.EventHub( + server_url, 'user', 'key' + ) + assert event_hub.get_server_url() == expected + + +def test_connect(session): + '''Connect.''' + event_hub = ftrack_api.event.hub.EventHub( + session.server_url, session.api_user, session.api_key + ) + event_hub.connect() + + assert event_hub.connected is True + event_hub.disconnect() + + +def test_connect_when_already_connected(event_hub): + '''Fail to connect when already connected''' + assert event_hub.connected is True + + with pytest.raises(ftrack_api.exception.EventHubConnectionError) as error: + event_hub.connect() + + assert 'Already connected' in str(error) + + +def test_connect_failure(session, mocker): + '''Fail to connect to server.''' + event_hub = ftrack_api.event.hub.EventHub( + session.server_url, session.api_user, session.api_key + ) + + def force_fail(*args, **kwargs): + '''Force connection failure.''' + raise Exception('Forced fail.') + + mocker.patch('websocket.create_connection', force_fail) + with pytest.raises(ftrack_api.exception.EventHubConnectionError): + event_hub.connect() + + +def test_connect_missing_required_transport(session, mocker, caplog): + '''Fail to connect to server that does not provide correct transport.''' + event_hub = ftrack_api.event.hub.EventHub( + session.server_url, session.api_user, session.api_key + ) + + original_get_socket_io_session = event_hub._get_socket_io_session + + def _get_socket_io_session(): + '''Patched to return no transports.''' + session = original_get_socket_io_session() + return ftrack_api.event.hub.SocketIoSession( + session[0], session[1], [] + ) + + mocker.patch.object( + event_hub, '_get_socket_io_session', _get_socket_io_session + ) + + with pytest.raises(ftrack_api.exception.EventHubConnectionError): + event_hub.connect() + + logs = caplog.records() + assert ( + 'Server does not support websocket sessions.' in str(logs[-1].exc_info) + ) + + +def test_disconnect(event_hub): + '''Disconnect and unsubscribe all subscribers.''' + event_hub.disconnect() + assert len(event_hub._subscribers) == 0 + assert event_hub.connected is False + + +def test_disconnect_without_unsubscribing(event_hub): + '''Disconnect without unsubscribing all subscribers.''' + event_hub.disconnect(unsubscribe=False) + assert len(event_hub._subscribers) > 0 + assert event_hub.connected is False + + +def test_close_connection_from_manually_connected_hub(session_no_autoconnect_hub): + '''Close connection from manually connected hub.''' + session_no_autoconnect_hub.event_hub.connect() + session_no_autoconnect_hub.close() + assert session_no_autoconnect_hub.event_hub.connected is False + + +def test_disconnect_when_not_connected(session): + '''Fail to disconnect when not connected''' + event_hub = ftrack_api.event.hub.EventHub( + session.server_url, session.api_user, session.api_key + ) + with pytest.raises(ftrack_api.exception.EventHubConnectionError) as error: + event_hub.disconnect() + + assert 'Not currently connected' in str(error) + + +def test_reconnect(event_hub): + '''Reconnect successfully.''' + assert event_hub.connected is True + event_hub.reconnect() + assert event_hub.connected is True + + +def test_reconnect_when_not_connected(session): + '''Reconnect successfully even if not already connected.''' + event_hub = ftrack_api.event.hub.EventHub( + session.server_url, session.api_user, session.api_key + ) + assert event_hub.connected is False + + event_hub.reconnect() + assert event_hub.connected is True + + event_hub.disconnect() + + +def test_fail_to_reconnect(session, mocker): + '''Fail to reconnect.''' + event_hub = ftrack_api.event.hub.EventHub( + session.server_url, session.api_user, session.api_key + ) + event_hub.connect() + assert event_hub.connected is True + + def force_fail(*args, **kwargs): + '''Force connection failure.''' + raise Exception('Forced fail.') + + mocker.patch('websocket.create_connection', force_fail) + + attempts = 2 + with pytest.raises(ftrack_api.exception.EventHubConnectionError) as error: + event_hub.reconnect(attempts=attempts, delay=0.5) + + assert 'Failed to reconnect to event server' in str(error) + assert 'after {} attempts'.format(attempts) in str(error) + + +def test_wait(event_hub): + '''Wait for event and handle as they arrive.''' + called = {'callback': False} + + def callback(event): + called['callback'] = True + + event_hub.subscribe('topic=test-subscribe', callback) + + event_hub.publish(Event(topic='test-subscribe')) + + # Until wait, the event should not have been processed even if received. + time.sleep(1) + assert called == {'callback': False} + + event_hub.wait(2) + assert called == {'callback': True} + + +def test_wait_interrupted_by_disconnect(event_hub): + '''Interrupt wait loop with disconnect event.''' + wait_time = 5 + start = time.time() + + # Inject event directly for test purposes. + event = Event(topic='ftrack.meta.disconnected') + event_hub._event_queue.put(event) + + event_hub.wait(wait_time) + + assert time.time() - start < wait_time + + +@pytest.mark.parametrize('identifier, registered', [ + ('registered-test-subscriber', True), + ('unregistered-test-subscriber', False) +], ids=[ + 'registered', + 'missing' +]) +def test_get_subscriber_by_identifier(event_hub, identifier, registered): + '''Return subscriber by identifier.''' + def callback(event): + pass + + subscriber = { + 'id': 'registered-test-subscriber' + } + + event_hub.subscribe('topic=test-subscribe', callback, subscriber) + retrieved = event_hub.get_subscriber_by_identifier(identifier) + + if registered: + assert isinstance(retrieved, ftrack_api.event.subscriber.Subscriber) + assert retrieved.metadata.get('id') == subscriber['id'] + else: + assert retrieved is None + + +def test_subscribe(event_hub): + '''Subscribe to topics.''' + called = {'a': False, 'b': False} + + def callback_a(event): + called['a'] = True + + def callback_b(event): + called['b'] = True + + event_hub.subscribe('topic=test-subscribe', callback_a) + event_hub.subscribe('topic=test-subscribe-other', callback_b) + + event_hub.publish(Event(topic='test-subscribe')) + event_hub.wait(2) + + assert called == {'a': True, 'b': False} + + +def test_subscribe_before_connected(session): + '''Subscribe to topic before connected.''' + event_hub = ftrack_api.event.hub.EventHub( + session.server_url, session.api_user, session.api_key + ) + + called = {'callback': False} + + def callback(event): + called['callback'] = True + + identifier = 'test-subscriber' + event_hub.subscribe( + 'topic=test-subscribe', callback, subscriber={'id': identifier} + ) + assert event_hub.get_subscriber_by_identifier(identifier) is not None + + event_hub.connect() + + try: + event_hub.publish(Event(topic='test-subscribe')) + event_hub.wait(2) + finally: + event_hub.disconnect() + + assert called == {'callback': True} + + +def test_duplicate_subscriber(event_hub): + '''Fail to subscribe same subscriber more than once.''' + subscriber = {'id': 'test-subscriber'} + event_hub.subscribe('topic=test', None, subscriber=subscriber) + + with pytest.raises(ftrack_api.exception.NotUniqueError) as error: + event_hub.subscribe('topic=test', None, subscriber=subscriber) + + assert '{0} already exists'.format(subscriber['id']) in str(error) + + +def test_unsubscribe(event_hub): + '''Unsubscribe a specific callback.''' + def callback_a(event): + pass + + def callback_b(event): + pass + + identifier_a = event_hub.subscribe('topic=test', callback_a) + identifier_b = event_hub.subscribe('topic=test', callback_b) + + assert_callbacks(event_hub, [callback_a, callback_b]) + + event_hub.unsubscribe(identifier_a) + + # Unsubscribe requires confirmation event so wait here to give event a + # chance to process. + time.sleep(5) + + assert_callbacks(event_hub, [callback_b]) + + +def test_unsubscribe_whilst_disconnected(event_hub): + '''Unsubscribe whilst disconnected.''' + identifier = event_hub.subscribe('topic=test', None) + event_hub.disconnect(unsubscribe=False) + + event_hub.unsubscribe(identifier) + assert_callbacks(event_hub, []) + + +def test_unsubscribe_missing_subscriber(event_hub): + '''Fail to unsubscribe a non-subscribed subscriber.''' + identifier = 'non-subscribed-subscriber' + with pytest.raises(ftrack_api.exception.NotFoundError) as error: + event_hub.unsubscribe(identifier) + + assert ( + 'missing subscriber with identifier {}'.format(identifier) + in str(error) + ) + + +@pytest.mark.parametrize('event_data', [ + dict(source=dict(id='1', user=dict(username='auto'))), + dict(source=dict(user=dict(username='auto'))), + dict(source=dict(id='1')), + dict() +], ids=[ + 'pre-prepared', + 'missing id', + 'missing user', + 'no source' +]) +def test_prepare_event(session, event_data): + '''Prepare event.''' + # Replace username `auto` in event data with API user. + try: + if event_data['source']['user']['username'] == 'auto': + event_data['source']['user']['username'] = session.api_user + except KeyError: + pass + + event_hub = ftrack_api.event.hub.EventHub( + session.server_url, session.api_user, session.api_key + ) + event_hub.id = '1' + + event = Event('test', id='event-id', **event_data) + expected = Event( + 'test', id='event-id', source=dict(id='1', user=dict(username=session.api_user)) + ) + event_hub._prepare_event(event) + assert event == expected + + +def test_prepare_reply_event(session): + '''Prepare reply event.''' + event_hub = ftrack_api.event.hub.EventHub( + session.server_url, session.api_user, session.api_key + ) + + source_event = Event('source', source=dict(id='source-id')) + reply_event = Event('reply') + + event_hub._prepare_reply_event(reply_event, source_event) + assert source_event['source']['id'] in reply_event['target'] + assert reply_event['in_reply_to_event'] == source_event['id'] + + event_hub._prepare_reply_event(reply_event, source_event, {'id': 'source'}) + assert reply_event['source'] == {'id': 'source'} + + +def test_publish(event_hub): + '''Publish asynchronous event.''' + called = {'callback': False} + + def callback(event): + called['callback'] = True + + event_hub.subscribe('topic=test-subscribe', callback) + + event_hub.publish(Event(topic='test-subscribe')) + event_hub.wait(2) + + assert called == {'callback': True} + + +def test_publish_raising_error(event_hub): + '''Raise error, when configured, on failed publish.''' + # Note that the event hub currently only fails publish when not connected. + # All other errors are inconsistently swallowed. + event_hub.disconnect() + event = Event(topic='a-topic', data=dict(status='fail')) + + with pytest.raises(Exception): + event_hub.publish(event, on_error='raise') + + +def test_publish_ignoring_error(event_hub): + '''Ignore error, when configured, on failed publish.''' + # Note that the event hub currently only fails publish when not connected. + # All other errors are inconsistently swallowed. + event_hub.disconnect() + event = Event(topic='a-topic', data=dict(status='fail')) + event_hub.publish(event, on_error='ignore') + + +def test_publish_logs_other_errors(event_hub, caplog, mocker): + '''Log publish errors other than connection error.''' + # Mock connection to force error. + mocker.patch.object(event_hub, '_connection', MockConnection()) + + event = Event(topic='a-topic', data=dict(status='fail')) + event_hub.publish(event) + + expected = 'Error sending event {0}.'.format(event) + messages = [record.getMessage().strip() for record in caplog.records()] + assert expected in messages, 'Expected log message missing in output.' + + +def test_synchronous_publish(event_hub): + '''Publish event synchronously and collect results.''' + def callback_a(event): + return 'A' + + def callback_b(event): + return 'B' + + def callback_c(event): + return 'C' + + event_hub.subscribe('topic=test', callback_a, priority=50) + event_hub.subscribe('topic=test', callback_b, priority=60) + event_hub.subscribe('topic=test', callback_c, priority=70) + + results = event_hub.publish(Event(topic='test'), synchronous=True) + assert results == ['A', 'B', 'C'] + + +def test_publish_with_reply(event_hub): + '''Publish asynchronous event with on reply handler.''' + + def replier(event): + '''Replier.''' + return 'Replied' + + event_hub.subscribe('topic=test', replier) + + called = {'callback': None} + + def on_reply(event): + called['callback'] = event['data'] + + event_hub.publish(Event(topic='test'), on_reply=on_reply) + event_hub.wait(2) + + assert called['callback'] == 'Replied' + + +def test_publish_with_multiple_replies(event_hub): + '''Publish asynchronous event and retrieve multiple replies.''' + + def replier_one(event): + '''Replier.''' + return 'One' + + def replier_two(event): + '''Replier.''' + return 'Two' + + event_hub.subscribe('topic=test', replier_one) + event_hub.subscribe('topic=test', replier_two) + + called = {'callback': []} + + def on_reply(event): + called['callback'].append(event['data']) + + event_hub.publish(Event(topic='test'), on_reply=on_reply) + event_hub.wait(2) + + assert sorted(called['callback']) == ['One', 'Two'] + + +@pytest.mark.slow +def test_server_heartbeat_response(): + '''Maintain connection by responding to server heartbeat request.''' + test_script = os.path.join( + os.path.dirname(__file__), 'event_hub_server_heartbeat.py' + ) + + # Start subscriber that will listen for all three messages. + subscriber = subprocess.Popen([sys.executable, test_script, 'subscribe']) + + # Give subscriber time to connect to server. + time.sleep(10) + + # Start publisher to publish three messages. + publisher = subprocess.Popen([sys.executable, test_script, 'publish']) + + publisher.wait() + subscriber.wait() + + assert subscriber.returncode == 0 + + +def test_stop_event(event_hub): + '''Stop processing of subsequent local handlers when stop flag set.''' + called = { + 'a': False, + 'b': False, + 'c': False + } + + def callback_a(event): + called['a'] = True + + def callback_b(event): + called['b'] = True + event.stop() + + def callback_c(event): + called['c'] = True + + event_hub.subscribe('topic=test', callback_a, priority=50) + event_hub.subscribe('topic=test', callback_b, priority=60) + event_hub.subscribe('topic=test', callback_c, priority=70) + + event_hub.publish(Event(topic='test')) + event_hub.wait(2) + + assert called == { + 'a': True, + 'b': True, + 'c': False + } + + +def test_encode(session): + '''Encode event data.''' + encoded = session.event_hub._encode( + dict(name='ftrack.event', args=[Event('test')]) + ) + assert 'inReplyToEvent' in encoded + assert 'in_reply_to_event' not in encoded + + +def test_decode(session): + '''Decode event data.''' + decoded = session.event_hub._decode( + json.dumps({ + 'inReplyToEvent': 'id' + }) + ) + + assert 'in_reply_to_event' in decoded + assert 'inReplyToEvent' not in decoded diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_subscriber.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_subscriber.py new file mode 100644 index 0000000000..dc8ac69fd9 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_subscriber.py @@ -0,0 +1,33 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import pytest + +import ftrack_api.event.subscriber +from ftrack_api.event.base import Event + + +def test_string_representation(): + '''String representation.''' + subscriber = ftrack_api.event.subscriber.Subscriber( + 'topic=test', lambda x: None, {'meta': 'info'}, 100 + ) + + assert str(subscriber) == ( + '' + ) + + +@pytest.mark.parametrize('expression, event, expected', [ + ('topic=test', Event(topic='test'), True), + ('topic=test', Event(topic='other-test'), False) +], ids=[ + 'interested', + 'not interested' +]) +def test_interested_in(expression, event, expected): + '''Determine if subscriber interested in event.''' + subscriber = ftrack_api.event.subscriber.Subscriber( + expression, lambda x: None, {'meta': 'info'}, 100 + ) + assert subscriber.interested_in(event) is expected diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_subscription.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_subscription.py new file mode 100644 index 0000000000..1535309f25 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/event/test_subscription.py @@ -0,0 +1,28 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import pytest + +import ftrack_api.event.subscription +from ftrack_api.event.base import Event + + +def test_string_representation(): + '''String representation is subscription expression.''' + expression = 'topic=some-topic' + subscription = ftrack_api.event.subscription.Subscription(expression) + + assert str(subscription) == expression + + +@pytest.mark.parametrize('expression, event, expected', [ + ('topic=test', Event(topic='test'), True), + ('topic=test', Event(topic='other-test'), False) +], ids=[ + 'match', + 'no match' +]) +def test_includes(expression, event, expected): + '''Subscription includes event.''' + subscription = ftrack_api.event.subscription.Subscription(expression) + assert subscription.includes(event) is expected diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/resource_identifier_transformer/__init__.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/resource_identifier_transformer/__init__.py new file mode 100644 index 0000000000..bc98f15de2 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/resource_identifier_transformer/__init__.py @@ -0,0 +1,2 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/resource_identifier_transformer/test_base.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/resource_identifier_transformer/test_base.py new file mode 100644 index 0000000000..51c896f96b --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/resource_identifier_transformer/test_base.py @@ -0,0 +1,36 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import pytest + +import ftrack_api.resource_identifier_transformer.base as _transformer + + +@pytest.fixture() +def transformer(session): + '''Return instance of ResourceIdentifierTransformer.''' + return _transformer.ResourceIdentifierTransformer(session) + + +@pytest.mark.parametrize('resource_identifier, context, expected', [ + ('identifier', None, 'identifier'), + ('identifier', {'user': {'username': 'user'}}, 'identifier') +], ids=[ + 'no context', + 'basic context' +]) +def test_encode(transformer, resource_identifier, context, expected): + '''Encode resource identifier.''' + assert transformer.encode(resource_identifier, context) == expected + + +@pytest.mark.parametrize('resource_identifier, context, expected', [ + ('identifier', None, 'identifier'), + ('identifier', {'user': {'username': 'user'}}, 'identifier') +], ids=[ + 'no context', + 'basic context' +]) +def test_decode(transformer, resource_identifier, context, expected): + '''Encode resource identifier.''' + assert transformer.decode(resource_identifier, context) == expected diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/__init__.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/__init__.py new file mode 100644 index 0000000000..bc98f15de2 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/__init__.py @@ -0,0 +1,2 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_base.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_base.py new file mode 100644 index 0000000000..dbf91ead20 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_base.py @@ -0,0 +1,31 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import pytest + +import ftrack_api.structure.base + + +class Concrete(ftrack_api.structure.base.Structure): + '''Concrete implementation to allow testing non-abstract methods.''' + + def get_resource_identifier(self, entity, context=None): + '''Return a resource identifier for supplied *entity*. + + *context* can be a mapping that supplies additional information. + + ''' + return 'resource_identifier' + + +@pytest.mark.parametrize('sequence, expected', [ + ({'padding': None}, '%d'), + ({'padding': 4}, '%04d') +], ids=[ + 'no padding', + 'padded' +]) +def test_get_sequence_expression(sequence, expected): + '''Get sequence expression from sequence.''' + structure = Concrete() + assert structure._get_sequence_expression(sequence) == expected diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_entity_id.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_entity_id.py new file mode 100644 index 0000000000..01ccb35ac8 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_entity_id.py @@ -0,0 +1,49 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import inspect + +import pytest +import mock + +import ftrack_api +import ftrack_api.structure.entity_id + + +@pytest.fixture(scope='session') +def structure(): + '''Return structure.''' + return ftrack_api.structure.entity_id.EntityIdStructure() + + +# Note: When it is possible to use indirect=True on just a few arguments, the +# called functions here can change to standard fixtures. +# https://github.com/pytest-dev/pytest/issues/579 + +def valid_entity(): + '''Return valid entity.''' + session = ftrack_api.Session() + + entity = session.create('FileComponent', { + 'id': 'f6cd40cb-d1c0-469f-a2d5-10369be8a724', + 'name': 'file_component', + 'file_type': '.png' + }) + + return entity + + +@pytest.mark.parametrize('entity, context, expected', [ + (valid_entity(), {}, 'f6cd40cb-d1c0-469f-a2d5-10369be8a724'), + (mock.Mock(), {}, Exception) +], ids=[ + 'valid-entity', + 'non-entity' +]) +def test_get_resource_identifier(structure, entity, context, expected): + '''Get resource identifier.''' + if inspect.isclass(expected) and issubclass(expected, Exception): + with pytest.raises(expected): + structure.get_resource_identifier(entity, context) + else: + assert structure.get_resource_identifier(entity, context) == expected diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_id.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_id.py new file mode 100644 index 0000000000..ef81da2d65 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_id.py @@ -0,0 +1,115 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import inspect + +import pytest + +import ftrack_api +import ftrack_api.structure.id + + +@pytest.fixture(scope='session') +def structure(): + '''Return structure.''' + return ftrack_api.structure.id.IdStructure(prefix='path') + + +# Note: When it is possible to use indirect=True on just a few arguments, the +# called functions here can change to standard fixtures. +# https://github.com/pytest-dev/pytest/issues/579 + +def file_component(container=None): + '''Return file component.''' + session = ftrack_api.Session() + + entity = session.create('FileComponent', { + 'id': 'f6cd40cb-d1c0-469f-a2d5-10369be8a724', + 'name': '0001', + 'file_type': '.png', + 'container': container + }) + + return entity + + +def sequence_component(padding=0): + '''Return sequence component with *padding*.''' + session = ftrack_api.Session() + + entity = session.create('SequenceComponent', { + 'id': 'ff17edad-2129-483b-8b59-d1a654c8497b', + 'name': 'sequence_component', + 'file_type': '.png', + 'padding': padding + }) + + return entity + + +def container_component(): + '''Return container component.''' + session = ftrack_api.Session() + + entity = session.create('ContainerComponent', { + 'id': '03ab9967-f86c-4b55-8252-cd187d0c244a', + 'name': 'container_component' + }) + + return entity + + +def unsupported_entity(): + '''Return an unsupported entity.''' + session = ftrack_api.Session() + + entity = session.create('User', { + 'username': 'martin' + }) + + return entity + + +@pytest.mark.parametrize('entity, context, expected', [ + ( + file_component(), {}, + 'path/f/6/c/d/40cb-d1c0-469f-a2d5-10369be8a724.png' + ), + ( + file_component(container_component()), {}, + 'path/0/3/a/b/9967-f86c-4b55-8252-cd187d0c244a/' + 'f6cd40cb-d1c0-469f-a2d5-10369be8a724.png' + ), + ( + file_component(sequence_component()), {}, + 'path/f/f/1/7/edad-2129-483b-8b59-d1a654c8497b/file.0001.png' + ), + ( + sequence_component(padding=0), {}, + 'path/f/f/1/7/edad-2129-483b-8b59-d1a654c8497b/file.%d.png' + ), + ( + sequence_component(padding=4), {}, + 'path/f/f/1/7/edad-2129-483b-8b59-d1a654c8497b/file.%04d.png' + ), + ( + container_component(), {}, + 'path/0/3/a/b/9967-f86c-4b55-8252-cd187d0c244a' + ), + (unsupported_entity(), {}, NotImplementedError) +], ids=[ + 'file-component', + 'file-component-in-container', + 'file-component-in-sequence', + 'unpadded-sequence-component', + 'padded-sequence-component', + 'container-component', + 'unsupported-entity' +]) +def test_get_resource_identifier(structure, entity, context, expected): + '''Get resource identifier.''' + if inspect.isclass(expected) and issubclass(expected, Exception): + with pytest.raises(expected): + structure.get_resource_identifier(entity, context) + else: + assert structure.get_resource_identifier(entity, context) == expected diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_origin.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_origin.py new file mode 100644 index 0000000000..e294e04a70 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_origin.py @@ -0,0 +1,33 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import inspect + +import pytest +import mock + +import ftrack_api.structure.origin + + +@pytest.fixture(scope='session') +def structure(): + '''Return structure.''' + return ftrack_api.structure.origin.OriginStructure() + + +@pytest.mark.parametrize('entity, context, expected', [ + (mock.Mock(), {'source_resource_identifier': 'identifier'}, 'identifier'), + (mock.Mock(), {}, ValueError), + (mock.Mock(), None, ValueError) +], ids=[ + 'valid-context', + 'invalid-context', + 'unspecified-context' +]) +def test_get_resource_identifier(structure, entity, context, expected): + '''Get resource identifier.''' + if inspect.isclass(expected) and issubclass(expected, Exception): + with pytest.raises(expected): + structure.get_resource_identifier(entity, context) + else: + assert structure.get_resource_identifier(entity, context) == expected diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_standard.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_standard.py new file mode 100644 index 0000000000..dd72f8ec3f --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/structure/test_standard.py @@ -0,0 +1,309 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import uuid + +import pytest + +import ftrack_api +import ftrack_api.structure.standard + + +@pytest.fixture(scope='session') +def new_project(request): + '''Return new empty project.''' + session = ftrack_api.Session() + + project_schema = session.query('ProjectSchema').first() + project_name = 'python_api_test_{0}'.format(uuid.uuid1().hex) + project = session.create('Project', { + 'name': project_name, + 'full_name': project_name + '_full', + 'project_schema': project_schema + }) + + session.commit() + + def cleanup(): + '''Remove created entity.''' + session.delete(project) + session.commit() + + request.addfinalizer(cleanup) + + return project + + +def new_container_component(): + '''Return container component.''' + session = ftrack_api.Session() + + entity = session.create('ContainerComponent', { + 'name': 'container_component' + }) + + return entity + + +def new_sequence_component(): + '''Return sequence component.''' + session = ftrack_api.Session() + + entity = session.create_component( + '/tmp/foo/%04d.jpg [1-10]', location=None, data={'name': 'baz'} + ) + + return entity + + +def new_file_component(name='foo', container=None): + '''Return file component with *name* and *container*.''' + if container: + session = container.session + else: + session = ftrack_api.Session() + + entity = session.create('FileComponent', { + 'name': name, + 'file_type': '.png', + 'container': container + }) + + return entity + + +# Reusable fixtures. +file_component = new_file_component() +container_component = new_container_component() +sequence_component = new_sequence_component() + + +# Note: to improve test performance the same project is reused throughout the +# tests. This means that all hierarchical names must be unique, otherwise an +# IntegrityError will be raised on the server. + +@pytest.mark.parametrize( + 'component, hierarchy, expected, structure, asset_name', + [ + ( + file_component, + [], + '{project_name}/my_new_asset/v001/foo.png', + ftrack_api.structure.standard.StandardStructure(), + 'my_new_asset' + ), + ( + file_component, + [], + '{project_name}/foobar/my_new_asset/v001/foo.png', + ftrack_api.structure.standard.StandardStructure( + project_versions_prefix='foobar' + ), + 'my_new_asset' + ), + ( + file_component, + ['baz1', 'bar'], + '{project_name}/baz1/bar/my_new_asset/v001/foo.png', + ftrack_api.structure.standard.StandardStructure(), + 'my_new_asset' + ), + ( + sequence_component, + ['baz2', 'bar'], + '{project_name}/baz2/bar/my_new_asset/v001/baz.%04d.jpg', + ftrack_api.structure.standard.StandardStructure(), + 'my_new_asset' + ), + ( + sequence_component['members'][3], + ['baz3', 'bar'], + '{project_name}/baz3/bar/my_new_asset/v001/baz.0004.jpg', + ftrack_api.structure.standard.StandardStructure(), + 'my_new_asset' + ), + ( + container_component, + ['baz4', 'bar'], + '{project_name}/baz4/bar/my_new_asset/v001/container_component', + ftrack_api.structure.standard.StandardStructure(), + 'my_new_asset' + ), + ( + new_file_component(container=container_component), + ['baz5', 'bar'], + ( + '{project_name}/baz5/bar/my_new_asset/v001/container_component/' + 'foo.png' + ), + ftrack_api.structure.standard.StandardStructure(), + 'my_new_asset' + ), + ( + file_component, + [u'björn'], + '{project_name}/bjorn/my_new_asset/v001/foo.png', + ftrack_api.structure.standard.StandardStructure(), + 'my_new_asset' + ), + ( + file_component, + [u'björn!'], + '{project_name}/bjorn_/my_new_asset/v001/foo.png', + ftrack_api.structure.standard.StandardStructure(), + 'my_new_asset' + ), + ( + new_file_component(name=u'fää'), + [], + '{project_name}/my_new_asset/v001/faa.png', + ftrack_api.structure.standard.StandardStructure(), + 'my_new_asset' + ), + ( + new_file_component(name=u'fo/o'), + [], + '{project_name}/my_new_asset/v001/fo_o.png', + ftrack_api.structure.standard.StandardStructure(), + 'my_new_asset' + ), + ( + file_component, + [], + '{project_name}/aao/v001/foo.png', + ftrack_api.structure.standard.StandardStructure(), + u'åäö' + ), + ( + file_component, + [], + '{project_name}/my_ne____w_asset/v001/foo.png', + ftrack_api.structure.standard.StandardStructure(), + u'my_ne!!!!w_asset' + ), + ( + file_component, + [u'björn2'], + u'{project_name}/björn2/my_new_asset/v001/foo.png', + ftrack_api.structure.standard.StandardStructure( + illegal_character_substitute=None + ), + 'my_new_asset' + ), + ( + file_component, + [u'bj!rn'], + '{project_name}/bj^rn/my_new_asset/v001/foo.png', + ftrack_api.structure.standard.StandardStructure( + illegal_character_substitute='^' + ), + 'my_new_asset' + ) + ], ids=[ + 'file_component_on_project', + 'file_component_on_project_with_prefix', + 'file_component_with_hierarchy', + 'sequence_component', + 'sequence_component_member', + 'container_component', + 'container_component_member', + 'slugify_non_ascii_hierarchy', + 'slugify_illegal_hierarchy', + 'slugify_non_ascii_component_name', + 'slugify_illegal_component_name', + 'slugify_non_ascii_asset_name', + 'slugify_illegal_asset_name', + 'slugify_none', + 'slugify_other_character' + ] +) +def test_get_resource_identifier( + component, hierarchy, expected, structure, asset_name, new_project +): + '''Get resource identifier.''' + session = component.session + + # Create structure, asset and version. + context_id = new_project['id'] + for name in hierarchy: + context_id = session.create('Folder', { + 'name': name, + 'project_id': new_project['id'], + 'parent_id': context_id + })['id'] + + asset = session.create( + 'Asset', {'name': asset_name, 'context_id': context_id} + ) + version = session.create('AssetVersion', {'asset': asset}) + + # Update component with version. + if component['container']: + component['container']['version'] = version + else: + component['version'] = version + + session.commit() + + assert structure.get_resource_identifier(component) == expected.format( + project_name=new_project['name'] + ) + + +def test_unsupported_entity(user): + '''Fail to get resource identifier for unsupported entity.''' + structure = ftrack_api.structure.standard.StandardStructure() + with pytest.raises(NotImplementedError): + structure.get_resource_identifier(user) + + +def test_component_without_version_relation(new_project): + '''Get an identifer for component without a version relation.''' + session = new_project.session + + asset = session.create( + 'Asset', {'name': 'foo', 'context_id': new_project['id']} + ) + version = session.create('AssetVersion', {'asset': asset}) + + session.commit() + + file_component = new_file_component() + file_component['version_id'] = version['id'] + + structure = ftrack_api.structure.standard.StandardStructure() + structure.get_resource_identifier(file_component) + + +def test_component_without_committed_version_relation(): + '''Fail to get an identifer for component without a committed version.''' + file_component = new_file_component() + session = file_component.session + version = session.create('AssetVersion', {}) + + file_component['version'] = version + + structure = ftrack_api.structure.standard.StandardStructure() + + with pytest.raises(ftrack_api.exception.StructureError): + structure.get_resource_identifier(file_component) + + +@pytest.mark.xfail( + raises=ftrack_api.exception.ServerError, + reason='Due to user permission errors.' +) +def test_component_without_committed_asset_relation(): + '''Fail to get an identifer for component without a committed asset.''' + file_component = new_file_component() + session = file_component.session + version = session.create('AssetVersion', {}) + + file_component['version'] = version + + session.commit() + + structure = ftrack_api.structure.standard.StandardStructure() + + with pytest.raises(ftrack_api.exception.StructureError): + structure.get_resource_identifier(file_component) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_attribute.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_attribute.py new file mode 100644 index 0000000000..555adb2d89 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_attribute.py @@ -0,0 +1,146 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import pytest + +import ftrack_api.attribute +import ftrack_api.exception + + +@pytest.mark.parametrize('attributes', [ + [], + [ftrack_api.attribute.Attribute('test')] +], ids=[ + 'no initial attributes', + 'with initial attributes' +]) +def test_initialise_attributes_collection(attributes): + '''Initialise attributes collection.''' + attribute_collection = ftrack_api.attribute.Attributes(attributes) + assert sorted(list(attribute_collection)) == sorted(attributes) + + +def test_add_attribute_to_attributes_collection(): + '''Add valid attribute to attributes collection.''' + attribute_collection = ftrack_api.attribute.Attributes() + attribute = ftrack_api.attribute.Attribute('test') + + assert attribute_collection.keys() == [] + attribute_collection.add(attribute) + assert attribute_collection.keys() == ['test'] + + +def test_add_duplicate_attribute_to_attributes_collection(): + '''Fail to add attribute with duplicate name to attributes collection.''' + attribute_collection = ftrack_api.attribute.Attributes() + attribute = ftrack_api.attribute.Attribute('test') + + attribute_collection.add(attribute) + with pytest.raises(ftrack_api.exception.NotUniqueError): + attribute_collection.add(attribute) + + +def test_remove_attribute_from_attributes_collection(): + '''Remove attribute from attributes collection.''' + attribute_collection = ftrack_api.attribute.Attributes() + attribute = ftrack_api.attribute.Attribute('test') + + attribute_collection.add(attribute) + assert len(attribute_collection) == 1 + + attribute_collection.remove(attribute) + assert len(attribute_collection) == 0 + + +def test_remove_missing_attribute_from_attributes_collection(): + '''Fail to remove attribute not present in attributes collection.''' + attribute_collection = ftrack_api.attribute.Attributes() + attribute = ftrack_api.attribute.Attribute('test') + + with pytest.raises(KeyError): + attribute_collection.remove(attribute) + + +def test_get_attribute_from_attributes_collection(): + '''Get attribute from attributes collection.''' + attribute_collection = ftrack_api.attribute.Attributes() + attribute = ftrack_api.attribute.Attribute('test') + attribute_collection.add(attribute) + + retrieved_attribute = attribute_collection.get('test') + + assert retrieved_attribute is attribute + + +def test_get_missing_attribute_from_attributes_collection(): + '''Get attribute not present in attributes collection.''' + attribute_collection = ftrack_api.attribute.Attributes() + assert attribute_collection.get('test') is None + + +@pytest.mark.parametrize('attributes, expected', [ + ([], []), + ([ftrack_api.attribute.Attribute('test')], ['test']) +], ids=[ + 'no initial attributes', + 'with initial attributes' +]) +def test_attribute_collection_keys(attributes, expected): + '''Retrieve keys for attribute collection.''' + attribute_collection = ftrack_api.attribute.Attributes(attributes) + assert sorted(attribute_collection.keys()) == sorted(expected) + + +@pytest.mark.parametrize('attribute, expected', [ + (None, False), + (ftrack_api.attribute.Attribute('b'), True), + (ftrack_api.attribute.Attribute('c'), False) +], ids=[ + 'none attribute', + 'present attribute', + 'missing attribute' +]) +def test_attributes_collection_contains(attribute, expected): + '''Check presence in attributes collection.''' + attribute_collection = ftrack_api.attribute.Attributes([ + ftrack_api.attribute.Attribute('a'), + ftrack_api.attribute.Attribute('b') + ]) + + assert (attribute in attribute_collection) is expected + + +@pytest.mark.parametrize('attributes, expected', [ + ([], 0), + ([ftrack_api.attribute.Attribute('test')], 1), + ( + [ + ftrack_api.attribute.Attribute('a'), + ftrack_api.attribute.Attribute('b') + ], + 2 + ) +], ids=[ + 'no attributes', + 'single attribute', + 'multiple attributes' +]) +def test_attributes_collection_count(attributes, expected): + '''Count attributes in attributes collection.''' + attribute_collection = ftrack_api.attribute.Attributes(attributes) + assert len(attribute_collection) == expected + + +def test_iterate_over_attributes_collection(): + '''Iterate over attributes collection.''' + attributes = [ + ftrack_api.attribute.Attribute('a'), + ftrack_api.attribute.Attribute('b') + ] + + attribute_collection = ftrack_api.attribute.Attributes(attributes) + for attribute in attribute_collection: + attributes.remove(attribute) + + assert len(attributes) == 0 + diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_cache.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_cache.py new file mode 100644 index 0000000000..7915737253 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_cache.py @@ -0,0 +1,416 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import os +import uuid +import tempfile + +import pytest + +import ftrack_api.cache + + +@pytest.fixture(params=['proxy', 'layered', 'memory', 'file', 'serialised']) +def cache(request): + '''Return cache.''' + if request.param == 'proxy': + cache = ftrack_api.cache.ProxyCache( + ftrack_api.cache.MemoryCache() + ) + + elif request.param == 'layered': + cache = ftrack_api.cache.LayeredCache( + [ftrack_api.cache.MemoryCache()] + ) + + elif request.param == 'memory': + cache = ftrack_api.cache.MemoryCache() + + elif request.param == 'file': + cache_path = os.path.join( + tempfile.gettempdir(), '{0}.dbm'.format(uuid.uuid4().hex) + ) + + cache = ftrack_api.cache.FileCache(cache_path) + + def cleanup(): + '''Cleanup.''' + try: + os.remove(cache_path) + except OSError: + # BSD DB (Mac OSX) implementation of the interface will append + # a .db extension. + os.remove(cache_path + '.db') + + request.addfinalizer(cleanup) + + elif request.param == 'serialised': + cache = ftrack_api.cache.SerialisedCache( + ftrack_api.cache.MemoryCache(), + encode=lambda value: value, + decode=lambda value: value + ) + + else: + raise ValueError( + 'Unrecognised cache fixture type {0!r}'.format(request.param) + ) + + return cache + + + +class Class(object): + '''Class for testing.''' + + def method(self, key): + '''Method for testing.''' + + +def function(mutable, x, y=2): + '''Function for testing.''' + mutable['called'] = True + return {'result': x + y} + + +def assert_memoised_call( + memoiser, function, expected, args=None, kw=None, memoised=True +): + '''Assert *function* call via *memoiser* was *memoised*.''' + mapping = {'called': False} + if args is not None: + args = (mapping,) + args + else: + args = (mapping,) + + result = memoiser.call(function, args, kw) + + assert result == expected + assert mapping['called'] is not memoised + + +def test_get(cache): + '''Retrieve item from cache.''' + cache.set('key', 'value') + assert cache.get('key') == 'value' + + +def test_get_missing_key(cache): + '''Fail to retrieve missing item from cache.''' + with pytest.raises(KeyError): + cache.get('key') + + +def test_set(cache): + '''Set item in cache.''' + with pytest.raises(KeyError): + cache.get('key') + + cache.set('key', 'value') + assert cache.get('key') == 'value' + + +def test_remove(cache): + '''Remove item from cache.''' + cache.set('key', 'value') + cache.remove('key') + + with pytest.raises(KeyError): + cache.get('key') + + +def test_remove_missing_key(cache): + '''Fail to remove missing key.''' + with pytest.raises(KeyError): + cache.remove('key') + + +def test_keys(cache): + '''Retrieve keys of items in cache.''' + assert cache.keys() == [] + cache.set('a', 'a_value') + cache.set('b', 'b_value') + cache.set('c', 'c_value') + assert sorted(cache.keys()) == sorted(['a', 'b', 'c']) + + +def test_clear(cache): + '''Remove items from cache.''' + cache.set('a', 'a_value') + cache.set('b', 'b_value') + cache.set('c', 'c_value') + + assert cache.keys() + cache.clear() + + assert not cache.keys() + + +def test_clear_using_pattern(cache): + '''Remove items that match pattern from cache.''' + cache.set('matching_key', 'value') + cache.set('another_matching_key', 'value') + cache.set('key_not_matching', 'value') + + assert cache.keys() + cache.clear(pattern='.*matching_key$') + + assert cache.keys() == ['key_not_matching'] + + +def test_clear_encountering_missing_key(cache, mocker): + '''Clear missing key.''' + # Force reporting keys that are not actually valid for test purposes. + mocker.patch.object(cache, 'keys', lambda: ['missing']) + assert cache.keys() == ['missing'] + + # Should not error even though key not valid. + cache.clear() + + # The key was not successfully removed so should still be present. + assert cache.keys() == ['missing'] + + +def test_layered_cache_propagates_value_on_get(): + '''Layered cache propagates value on get.''' + caches = [ + ftrack_api.cache.MemoryCache(), + ftrack_api.cache.MemoryCache(), + ftrack_api.cache.MemoryCache() + ] + + cache = ftrack_api.cache.LayeredCache(caches) + + # Set item on second level cache only. + caches[1].set('key', 'value') + + # Retrieving key via layered cache should propagate it automatically to + # higher level caches only. + assert cache.get('key') == 'value' + assert caches[0].get('key') == 'value' + + with pytest.raises(KeyError): + caches[2].get('key') + + +def test_layered_cache_remove_at_depth(): + '''Remove key that only exists at depth in LayeredCache.''' + caches = [ + ftrack_api.cache.MemoryCache(), + ftrack_api.cache.MemoryCache() + ] + + cache = ftrack_api.cache.LayeredCache(caches) + + # Set item on second level cache only. + caches[1].set('key', 'value') + + # Removing key that only exists at depth should not raise key error. + cache.remove('key') + + # Ensure key was removed. + assert not cache.keys() + + +def test_expand_references(): + '''Test that references are expanded from serialized cache.''' + + cache_path = os.path.join( + tempfile.gettempdir(), '{0}.dbm'.format(uuid.uuid4().hex) + ) + + def make_cache(session, cache_path): + '''Create a serialised file cache.''' + serialized_file_cache = ftrack_api.cache.SerialisedCache( + ftrack_api.cache.FileCache(cache_path), + encode=session.encode, + decode=session.decode + ) + + return serialized_file_cache + + # Populate the serialized file cache. + session = ftrack_api.Session( + cache=lambda session, cache_path=cache_path:make_cache( + session, cache_path + ) + ) + + expanded_results = dict() + + query_string = 'select asset.parent from AssetVersion where asset is_not None limit 10' + + for sequence in session.query(query_string): + asset = sequence.get('asset') + + expanded_results.setdefault( + asset.get('id'), asset.get('parent') + ) + + # Fetch the data from cache. + new_session = ftrack_api.Session( + cache=lambda session, cache_path=cache_path:make_cache( + session, cache_path + ) + ) + + + new_session_two = ftrack_api.Session( + cache=lambda session, cache_path=cache_path:make_cache( + session, cache_path + ) + ) + + + # Make sure references are merged. + for sequence in new_session.query(query_string): + asset = sequence.get('asset') + + assert ( + asset.get('parent') == expanded_results[asset.get('id')] + ) + + # Use for fetching directly using get. + assert ( + new_session_two.get(asset.entity_type, asset.get('id')).get('parent') == + expanded_results[asset.get('id')] + ) + + + +@pytest.mark.parametrize('items, key', [ + (({},), '{}'), + (({}, {}), '{}{}') +], ids=[ + 'single object', + 'multiple objects' +]) +def test_string_key_maker_key(items, key): + '''Generate key using string key maker.''' + key_maker = ftrack_api.cache.StringKeyMaker() + assert key_maker.key(*items) == key + + +@pytest.mark.parametrize('items, key', [ + ( + ({},), + '\x01\x01' + ), + ( + ({'a': 'b'}, [1, 2]), + '\x01' + '\x80\x02U\x01a.' '\x02' '\x80\x02U\x01b.' + '\x01' + '\x00' + '\x03' + '\x80\x02K\x01.' '\x00' '\x80\x02K\x02.' + '\x03' + ), + ( + (function,), + '\x04function\x00unit.test_cache' + ), + ( + (Class,), + '\x04Class\x00unit.test_cache' + ), + ( + (Class.method,), + '\x04method\x00Class\x00unit.test_cache' + ), + ( + (callable,), + '\x04callable' + ) +], ids=[ + 'single mapping', + 'multiple objects', + 'function', + 'class', + 'method', + 'builtin' +]) +def test_object_key_maker_key(items, key): + '''Generate key using string key maker.''' + key_maker = ftrack_api.cache.ObjectKeyMaker() + assert key_maker.key(*items) == key + + +def test_memoised_call(): + '''Call memoised function.''' + memoiser = ftrack_api.cache.Memoiser() + + # Initial call should not be memoised so function is executed. + assert_memoised_call( + memoiser, function, args=(1,), expected={'result': 3}, memoised=False + ) + + # Identical call should be memoised so function is not executed again. + assert_memoised_call( + memoiser, function, args=(1,), expected={'result': 3}, memoised=True + ) + + # Differing call is not memoised so function is executed. + assert_memoised_call( + memoiser, function, args=(3,), expected={'result': 5}, memoised=False + ) + + +def test_memoised_call_variations(): + '''Call memoised function with identical arguments using variable format.''' + memoiser = ftrack_api.cache.Memoiser() + expected = {'result': 3} + + # Call function once to ensure is memoised. + assert_memoised_call( + memoiser, function, args=(1,), expected=expected, memoised=False + ) + + # Each of the following calls should equate to the same key and make + # use of the memoised value. + for args, kw in [ + ((), {'x': 1}), + ((), {'x': 1, 'y': 2}), + ((1,), {'y': 2}), + ((1,), {}) + ]: + assert_memoised_call( + memoiser, function, args=args, kw=kw, expected=expected + ) + + # The following calls should all be treated as new variations and so + # not use any memoised value. + assert_memoised_call( + memoiser, function, kw={'x': 2}, expected={'result': 4}, memoised=False + ) + assert_memoised_call( + memoiser, function, kw={'x': 3, 'y': 2}, expected={'result': 5}, + memoised=False + ) + assert_memoised_call( + memoiser, function, args=(4, ), kw={'y': 2}, expected={'result': 6}, + memoised=False + ) + assert_memoised_call( + memoiser, function, args=(5, ), expected={'result': 7}, memoised=False + ) + + +def test_memoised_mutable_return_value(): + '''Avoid side effects for returned mutable arguments when memoising.''' + memoiser = ftrack_api.cache.Memoiser() + arguments = ({'called': False}, 1) + + result_a = memoiser.call(function, arguments) + assert result_a == {'result': 3} + assert arguments[0]['called'] + + # Modify mutable externally and check that stored memoised value is + # unchanged. + del result_a['result'] + + arguments[0]['called'] = False + result_b = memoiser.call(function, arguments) + + assert result_b == {'result': 3} + assert not arguments[0]['called'] diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_collection.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_collection.py new file mode 100644 index 0000000000..15c3e5cf39 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_collection.py @@ -0,0 +1,574 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import copy +import uuid + +import mock +import pytest + +import ftrack_api.collection +import ftrack_api.symbol +import ftrack_api.inspection +import ftrack_api.exception +import ftrack_api.operation + + +def create_mock_entity(session): + '''Return new mock entity for *session*.''' + entity = mock.MagicMock() + entity.session = session + entity.primary_key_attributes = ['id'] + entity['id'] = str(uuid.uuid4()) + return entity + + +@pytest.fixture +def mock_entity(session): + '''Return mock entity.''' + return create_mock_entity(session) + + +@pytest.fixture +def mock_entities(session): + '''Return list of two mock entities.''' + return [ + create_mock_entity(session), + create_mock_entity(session) + ] + + +@pytest.fixture +def mock_attribute(): + '''Return mock attribute.''' + attribute = mock.MagicMock() + attribute.name = 'test' + return attribute + + +def test_collection_initialisation_does_not_modify_entity_state( + mock_entity, mock_attribute, mock_entities +): + '''Initialising collection does not modify entity state.''' + ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=mock_entities + ) + + assert ftrack_api.inspection.state(mock_entity) is ftrack_api.symbol.NOT_SET + + +def test_immutable_collection_initialisation( + mock_entity, mock_attribute, mock_entities +): + '''Initialise immutable collection.''' + collection = ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=mock_entities, mutable=False + ) + + assert list(collection) == mock_entities + assert collection.mutable is False + + +def test_collection_shallow_copy( + mock_entity, mock_attribute, mock_entities, session +): + '''Shallow copying collection should avoid indirect mutation.''' + collection = ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=mock_entities + ) + + with mock_entity.session.operation_recording(False): + collection_copy = copy.copy(collection) + new_entity = create_mock_entity(session) + collection_copy.append(new_entity) + + assert list(collection) == mock_entities + assert list(collection_copy) == mock_entities + [new_entity] + + +def test_collection_insert( + mock_entity, mock_attribute, mock_entities, session +): + '''Insert a value into collection.''' + collection = ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=mock_entities + ) + + new_entity = create_mock_entity(session) + collection.insert(0, new_entity) + assert list(collection) == [new_entity] + mock_entities + + +def test_collection_insert_duplicate( + mock_entity, mock_attribute, mock_entities +): + '''Fail to insert a duplicate value into collection.''' + collection = ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=mock_entities + ) + + with pytest.raises(ftrack_api.exception.DuplicateItemInCollectionError): + collection.insert(0, mock_entities[1]) + + +def test_immutable_collection_insert( + mock_entity, mock_attribute, mock_entities, session +): + '''Fail to insert a value into immutable collection.''' + collection = ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=mock_entities, mutable=False + ) + + with pytest.raises(ftrack_api.exception.ImmutableCollectionError): + collection.insert(0, create_mock_entity(session)) + + +def test_collection_set_item( + mock_entity, mock_attribute, mock_entities, session +): + '''Set item at index in collection.''' + collection = ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=mock_entities + ) + + new_entity = create_mock_entity(session) + collection[0] = new_entity + assert list(collection) == [new_entity, mock_entities[1]] + + +def test_collection_re_set_item( + mock_entity, mock_attribute, mock_entities +): + '''Re-set value at exact same index in collection.''' + collection = ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=mock_entities + ) + + collection[0] = mock_entities[0] + assert list(collection) == mock_entities + + +def test_collection_set_duplicate_item( + mock_entity, mock_attribute, mock_entities +): + '''Fail to set a duplicate value into collection at different index.''' + collection = ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=mock_entities + ) + + with pytest.raises(ftrack_api.exception.DuplicateItemInCollectionError): + collection[0] = mock_entities[1] + + +def test_immutable_collection_set_item( + mock_entity, mock_attribute, mock_entities +): + '''Fail to set item at index in immutable collection.''' + collection = ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=mock_entities, mutable=False + ) + + with pytest.raises(ftrack_api.exception.ImmutableCollectionError): + collection[0] = mock_entities[0] + + +def test_collection_delete_item( + mock_entity, mock_attribute, mock_entities +): + '''Remove item at index from collection.''' + collection = ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=mock_entities + ) + del collection[0] + assert list(collection) == [mock_entities[1]] + + +def test_collection_delete_item_at_invalid_index( + mock_entity, mock_attribute, mock_entities +): + '''Fail to remove item at missing index from immutable collection.''' + collection = ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=mock_entities + ) + + with pytest.raises(IndexError): + del collection[4] + + +def test_immutable_collection_delete_item( + mock_entity, mock_attribute, mock_entities +): + '''Fail to remove item at index from immutable collection.''' + collection = ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=mock_entities, mutable=False + ) + + with pytest.raises(ftrack_api.exception.ImmutableCollectionError): + del collection[0] + + +def test_collection_count( + mock_entity, mock_attribute, mock_entities, session +): + '''Count items in collection.''' + collection = ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=mock_entities + ) + assert len(collection) == 2 + + collection.append(create_mock_entity(session)) + assert len(collection) == 3 + + del collection[0] + assert len(collection) == 2 + + +@pytest.mark.parametrize('other, expected', [ + ([], False), + ([1, 2], True), + ([1, 2, 3], False), + ([1], False) +], ids=[ + 'empty', + 'same', + 'additional', + 'missing' +]) +def test_collection_equal(mocker, mock_entity, mock_attribute, other, expected): + '''Determine collection equality against another collection.''' + # Temporarily override determination of entity identity so that it works + # against simple scalar values for purpose of test. + mocker.patch.object( + ftrack_api.inspection, 'identity', lambda entity: str(entity) + ) + + collection_a = ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=[1, 2] + ) + + collection_b = ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=other + ) + assert (collection_a == collection_b) is expected + + +def test_collection_not_equal_to_non_collection( + mocker, mock_entity, mock_attribute +): + '''Collection not equal to a non-collection.''' + # Temporarily override determination of entity identity so that it works + # against simple scalar values for purpose of test. + mocker.patch.object( + ftrack_api.inspection, 'identity', lambda entity: str(entity) + ) + + collection = ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=[1, 2] + ) + + assert (collection != {}) is True + + +def test_collection_notify_on_modification( + mock_entity, mock_attribute, mock_entities, session +): + '''Record UpdateEntityOperation on collection modification.''' + collection = ftrack_api.collection.Collection( + mock_entity, mock_attribute, data=mock_entities + ) + assert len(session.recorded_operations) == 0 + + collection.append(create_mock_entity(session)) + assert len(session.recorded_operations) == 1 + operation = session.recorded_operations.pop() + assert isinstance(operation, ftrack_api.operation.UpdateEntityOperation) + assert operation.new_value == collection + + +def test_mapped_collection_proxy_shallow_copy(new_project, unique_name): + '''Shallow copying mapped collection proxy avoids indirect mutation.''' + metadata = new_project['metadata'] + + with new_project.session.operation_recording(False): + metadata_copy = copy.copy(metadata) + metadata_copy[unique_name] = True + + assert unique_name not in metadata + assert unique_name in metadata_copy + + +def test_mapped_collection_proxy_mutable_property(new_project): + '''Mapped collection mutable property maps to underlying collection.''' + metadata = new_project['metadata'] + + assert metadata.mutable is True + assert metadata.collection.mutable is True + + metadata.mutable = False + assert metadata.collection.mutable is False + + +def test_mapped_collection_proxy_attribute_property( + new_project, mock_attribute +): + '''Mapped collection attribute property maps to underlying collection.''' + metadata = new_project['metadata'] + + assert metadata.attribute is metadata.collection.attribute + + metadata.attribute = mock_attribute + assert metadata.collection.attribute is mock_attribute + + +def test_mapped_collection_proxy_get_item(new_project, unique_name): + '''Retrieve item in mapped collection proxy.''' + session = new_project.session + + # Prepare data. + metadata = new_project['metadata'] + value = 'value' + metadata[unique_name] = value + session.commit() + + # Check in clean session retrieval of value. + session.reset() + retrieved = session.get(*ftrack_api.inspection.identity(new_project)) + + assert retrieved is not new_project + assert retrieved['metadata'].keys() == [unique_name] + assert retrieved['metadata'][unique_name] == value + + +def test_mapped_collection_proxy_set_item(new_project, unique_name): + '''Set new item in mapped collection proxy.''' + session = new_project.session + + metadata = new_project['metadata'] + assert unique_name not in metadata + + value = 'value' + metadata[unique_name] = value + assert metadata[unique_name] == value + + # Check change persisted correctly. + session.commit() + session.reset() + retrieved = session.get(*ftrack_api.inspection.identity(new_project)) + + assert retrieved is not new_project + assert retrieved['metadata'].keys() == [unique_name] + assert retrieved['metadata'][unique_name] == value + + +def test_mapped_collection_proxy_update_item(new_project, unique_name): + '''Update existing item in mapped collection proxy.''' + session = new_project.session + + # Prepare a pre-existing value. + metadata = new_project['metadata'] + value = 'value' + metadata[unique_name] = value + session.commit() + + # Set new value. + new_value = 'new_value' + metadata[unique_name] = new_value + + # Confirm change persisted correctly. + session.commit() + session.reset() + retrieved = session.get(*ftrack_api.inspection.identity(new_project)) + + assert retrieved is not new_project + assert retrieved['metadata'].keys() == [unique_name] + assert retrieved['metadata'][unique_name] == new_value + + +def test_mapped_collection_proxy_delete_item(new_project, unique_name): + '''Remove existing item from mapped collection proxy.''' + session = new_project.session + + # Prepare a pre-existing value to remove. + metadata = new_project['metadata'] + value = 'value' + metadata[unique_name] = value + session.commit() + + # Now remove value. + del new_project['metadata'][unique_name] + assert unique_name not in new_project['metadata'] + + # Confirm change persisted correctly. + session.commit() + session.reset() + retrieved = session.get(*ftrack_api.inspection.identity(new_project)) + + assert retrieved is not new_project + assert retrieved['metadata'].keys() == [] + assert unique_name not in retrieved['metadata'] + + +def test_mapped_collection_proxy_delete_missing_item(new_project, unique_name): + '''Fail to remove item for missing key from mapped collection proxy.''' + metadata = new_project['metadata'] + assert unique_name not in metadata + with pytest.raises(KeyError): + del metadata[unique_name] + + +def test_mapped_collection_proxy_iterate_keys(new_project, unique_name): + '''Iterate over keys in mapped collection proxy.''' + metadata = new_project['metadata'] + metadata.update({ + 'a': 'value-a', + 'b': 'value-b', + 'c': 'value-c' + }) + + # Commit here as otherwise cleanup operation will fail because transaction + # will include updating metadata to refer to a deleted entity. + new_project.session.commit() + + iterated = set() + for key in metadata: + iterated.add(key) + + assert iterated == set(['a', 'b', 'c']) + + +def test_mapped_collection_proxy_count(new_project, unique_name): + '''Count items in mapped collection proxy.''' + metadata = new_project['metadata'] + metadata.update({ + 'a': 'value-a', + 'b': 'value-b', + 'c': 'value-c' + }) + + # Commit here as otherwise cleanup operation will fail because transaction + # will include updating metadata to refer to a deleted entity. + new_project.session.commit() + + assert len(metadata) == 3 + + +def test_mapped_collection_on_create(session, unique_name, project): + '''Test that it is possible to set relational attributes on create''' + metadata = { + 'a': 'value-a', + 'b': 'value-b', + 'c': 'value-c' + } + + task_id = session.create( + 'Task', { + 'name': unique_name, + 'parent': project, + 'metadata': metadata, + + } + ).get('id') + + session.commit() + + # Reset the session and check that we have the expected + # values. + session.reset() + + task = session.get( + 'Task', task_id + ) + + for key, value in metadata.items(): + assert value == task['metadata'][key] + + +def test_collection_refresh(new_asset_version, new_component): + '''Test collection reload.''' + session_two = ftrack_api.Session(auto_connect_event_hub=False) + + query_string = 'select components from AssetVersion where id is "{0}"'.format( + new_asset_version.get('id') + ) + + # Fetch the new asset version in a new session. + new_asset_version_two = session_two.query( + query_string + ).one() + + # Modify our asset version + new_asset_version.get('components').append( + new_component + ) + + new_asset_version.session.commit() + + # Query the same asset version again and make sure we get the newly + # populated data. + session_two.query( + query_string + ).all() + + assert ( + new_asset_version.get('components') == new_asset_version_two.get('components') + ) + + # Make a local change to our asset version + new_asset_version_two.get('components').pop() + + # Query the same asset version again and make sure our local changes + # are not overwritten. + + session_two.query( + query_string + ).all() + + assert len(new_asset_version_two.get('components')) == 0 + + +def test_mapped_collection_reload(new_asset_version): + '''Test mapped collection reload.''' + session_two = ftrack_api.Session(auto_connect_event_hub=False) + + query_string = 'select metadata from AssetVersion where id is "{0}"'.format( + new_asset_version.get('id') + ) + + # Fetch the new asset version in a new session. + new_asset_version_two = session_two.query( + query_string + ).one() + + # Modify our asset version + new_asset_version['metadata']['test'] = str(uuid.uuid4()) + + new_asset_version.session.commit() + + # Query the same asset version again and make sure we get the newly + # populated data. + session_two.query( + query_string + ).all() + + assert ( + new_asset_version['metadata']['test'] == new_asset_version_two['metadata']['test'] + ) + + local_data = str(uuid.uuid4()) + + new_asset_version_two['metadata']['test'] = local_data + + # Modify our asset version again + new_asset_version['metadata']['test'] = str(uuid.uuid4()) + + new_asset_version.session.commit() + + # Query the same asset version again and make sure our local changes + # are not overwritten. + session_two.query( + query_string + ).all() + + assert ( + new_asset_version_two['metadata']['test'] == local_data + ) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_custom_attribute.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_custom_attribute.py new file mode 100644 index 0000000000..7a9b0fadaa --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_custom_attribute.py @@ -0,0 +1,251 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import uuid + +import pytest + +import ftrack_api + +@pytest.fixture( + params=[ + 'AssetVersion', 'Shot', 'AssetVersionList', 'TypedContextList', 'User', + 'Asset' + ] +) +def new_entity_and_custom_attribute(request, session): + '''Return tuple with new entity, custom attribute name and value.''' + if request.param == 'AssetVersion': + entity = session.create( + request.param, { + 'asset': session.query('Asset').first() + } + ) + return (entity, 'versiontest', 123) + + elif request.param == 'Shot': + sequence = session.query('Sequence').first() + entity = session.create( + request.param, { + 'parent_id': sequence['id'], + 'project_id': sequence['project_id'], + 'name': str(uuid.uuid1()) + } + ) + return (entity, 'fstart', 1005) + + elif request.param == 'Asset': + shot = session.query('Shot').first() + entity = session.create( + request.param, { + 'context_id': shot['project_id'], + 'name': str(uuid.uuid1()) + } + ) + return (entity, 'htest', 1005) + + elif request.param in ('AssetVersionList', 'TypedContextList'): + entity = session.create( + request.param, { + 'project_id': session.query('Project').first()['id'], + 'category_id': session.query('ListCategory').first()['id'], + 'name': str(uuid.uuid1()) + } + ) + return (entity, 'listbool', True) + + elif request.param == 'User': + entity = session.create( + request.param, { + 'first_name': 'Custom attribute test', + 'last_name': 'Custom attribute test', + 'username': str(uuid.uuid1()) + } + ) + return (entity, 'teststring', 'foo') + + +@pytest.mark.parametrize( + 'entity_type, entity_model_name, custom_attribute_name', + [ + ('Task', 'task', 'customNumber'), + ('AssetVersion', 'assetversion', 'NumberField') + ], + ids=[ + 'task', + 'asset_version' + ] +) +def test_read_set_custom_attribute( + session, entity_type, entity_model_name, custom_attribute_name +): + '''Retrieve custom attribute value set on instance.''' + custom_attribute_value = session.query( + 'CustomAttributeValue where configuration.key is ' + '{custom_attribute_name}' + .format( + custom_attribute_name=custom_attribute_name + ) + ).first() + + entity = session.query( + 'select custom_attributes from {entity_type} where id is ' + '{entity_id}'.format( + entity_type=entity_type, + entity_id=custom_attribute_value['entity_id'], + ) + ).first() + + assert custom_attribute_value + + assert entity['id'] == entity['custom_attributes'].collection.entity['id'] + assert entity is entity['custom_attributes'].collection.entity + assert ( + entity['custom_attributes'][custom_attribute_name] == + custom_attribute_value['value'] + ) + + assert custom_attribute_name in entity['custom_attributes'].keys() + + +@pytest.mark.parametrize( + 'entity_type, custom_attribute_name', + [ + ('Task', 'customNumber'), + ('Shot', 'fstart'), + ( + 'AssetVersion', 'NumberField' + ) + ], + ids=[ + 'task', + 'shot', + 'asset_version' + ] +) +def test_write_set_custom_attribute_value( + session, entity_type, custom_attribute_name +): + '''Overwrite existing instance level custom attribute value.''' + entity = session.query( + 'select custom_attributes from {entity_type} where ' + 'custom_attributes.configuration.key is {custom_attribute_name}'.format( + entity_type=entity_type, + custom_attribute_name=custom_attribute_name + ) + ).first() + + entity['custom_attributes'][custom_attribute_name] = 42 + + assert entity['custom_attributes'][custom_attribute_name] == 42 + + session.commit() + + +@pytest.mark.parametrize( + 'entity_type, custom_attribute_name', + [ + ('Task', 'fstart'), + ('Shot', 'Not existing'), + ('AssetVersion', 'fstart') + ], + ids=[ + 'task', + 'shot', + 'asset_version' + ] +) +def test_read_custom_attribute_that_does_not_exist( + session, entity_type, custom_attribute_name +): + '''Fail to read value from a custom attribute that does not exist.''' + entity = session.query( + 'select custom_attributes from {entity_type}'.format( + entity_type=entity_type + ) + ).first() + + with pytest.raises(KeyError): + entity['custom_attributes'][custom_attribute_name] + + +@pytest.mark.parametrize( + 'entity_type, custom_attribute_name', + [ + ('Task', 'fstart'), + ('Shot', 'Not existing'), + ('AssetVersion', 'fstart') + ], + ids=[ + 'task', + 'shot', + 'asset_version' + ] +) +def test_write_custom_attribute_that_does_not_exist( + session, entity_type, custom_attribute_name +): + '''Fail to write a value to a custom attribute that does not exist.''' + entity = session.query( + 'select custom_attributes from {entity_type}'.format( + entity_type=entity_type + ) + ).first() + + with pytest.raises(KeyError): + entity['custom_attributes'][custom_attribute_name] = 42 + + +def test_set_custom_attribute_on_new_but_persisted_version( + session, new_asset_version +): + '''Set custom attribute on new persisted version.''' + new_asset_version['custom_attributes']['versiontest'] = 5 + session.commit() + + +@pytest.mark.xfail( + raises=ftrack_api.exception.ServerError, + reason='Due to user permission errors.' +) +def test_batch_create_entity_and_custom_attributes( + new_entity_and_custom_attribute +): + '''Write custom attribute value and entity in the same batch.''' + entity, name, value = new_entity_and_custom_attribute + session = entity.session + entity['custom_attributes'][name] = value + + assert entity['custom_attributes'][name] == value + session.commit() + + assert entity['custom_attributes'][name] == value + + +def test_refresh_custom_attribute(new_asset_version): + '''Test custom attribute refresh.''' + session_two = ftrack_api.Session() + + query_string = 'select custom_attributes from AssetVersion where id is "{0}"'.format( + new_asset_version.get('id') + ) + + asset_version_two = session_two.query( + query_string + ).first() + + new_asset_version['custom_attributes']['versiontest'] = 42 + + new_asset_version.session.commit() + + asset_version_two = session_two.query( + query_string + ).first() + + assert ( + new_asset_version['custom_attributes']['versiontest'] == + asset_version_two['custom_attributes']['versiontest'] + ) + + + diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_data.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_data.py new file mode 100644 index 0000000000..c53dda9630 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_data.py @@ -0,0 +1,129 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import os +import tempfile + +import pytest + +import ftrack_api.data + + +@pytest.fixture() +def content(): + '''Return initial content.''' + return 'test data' + + +@pytest.fixture(params=['file', 'file_wrapper', 'string']) +def data(request, content): + '''Return cache.''' + + if request.param == 'string': + data_object = ftrack_api.data.String(content) + + elif request.param == 'file': + file_handle, path = tempfile.mkstemp() + file_object = os.fdopen(file_handle, 'r+') + file_object.write(content) + file_object.flush() + file_object.close() + + data_object = ftrack_api.data.File(path, 'r+') + + def cleanup(): + '''Cleanup.''' + data_object.close() + os.remove(path) + + request.addfinalizer(cleanup) + + elif request.param == 'file_wrapper': + file_handle, path = tempfile.mkstemp() + file_object = os.fdopen(file_handle, 'r+') + file_object.write(content) + file_object.seek(0) + + data_object = ftrack_api.data.FileWrapper(file_object) + + def cleanup(): + '''Cleanup.''' + data_object.close() + os.remove(path) + + request.addfinalizer(cleanup) + + else: + raise ValueError('Unrecognised parameter: {0}'.format(request.param)) + + return data_object + + +def test_read(data, content): + '''Return content from current position up to *limit*.''' + assert data.read(5) == content[:5] + assert data.read() == content[5:] + + +def test_write(data, content): + '''Write content at current position.''' + assert data.read() == content + data.write('more test data') + data.seek(0) + assert data.read() == content + 'more test data' + + +def test_flush(data): + '''Flush buffers ensuring data written.''' + # TODO: Implement better test than just calling function. + data.flush() + + +def test_seek(data, content): + '''Move internal pointer to *position*.''' + data.seek(5) + assert data.read() == content[5:] + + +def test_tell(data): + '''Return current position of internal pointer.''' + assert data.tell() == 0 + data.seek(5) + assert data.tell() == 5 + + +def test_close(data): + '''Flush buffers and prevent further access.''' + data.close() + with pytest.raises(ValueError) as error: + data.read() + + assert 'I/O operation on closed file' in str(error.value) + + +class Dummy(ftrack_api.data.Data): + '''Dummy string.''' + + def read(self, limit=None): + '''Return content from current position up to *limit*.''' + + def write(self, content): + '''Write content at current position.''' + + +def test_unsupported_tell(): + '''Fail when tell unsupported.''' + data = Dummy() + with pytest.raises(NotImplementedError) as error: + data.tell() + + assert 'Tell not supported' in str(error.value) + + +def test_unsupported_seek(): + '''Fail when seek unsupported.''' + data = Dummy() + with pytest.raises(NotImplementedError) as error: + data.seek(5) + + assert 'Seek not supported' in str(error.value) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_formatter.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_formatter.py new file mode 100644 index 0000000000..ae565cb3f5 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_formatter.py @@ -0,0 +1,70 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import termcolor + +import ftrack_api.formatter + + +def colored(text, *args, **kwargs): + '''Pass through so there are no escape sequences in output.''' + return text + + +def test_format(user, mocker): + '''Return formatted representation of entity.''' + mocker.patch.object(termcolor, 'colored', colored) + + result = ftrack_api.formatter.format(user) + + # Cannot test entire string as too variable so check for key text. + assert result.startswith('User\n') + assert ' username: jenkins' in result + assert ' email: ' in result + + +def test_format_using_custom_formatters(user): + '''Return formatted representation of entity using custom formatters.''' + result = ftrack_api.formatter.format( + user, formatters={ + 'header': lambda text: '*{0}*'.format(text), + 'label': lambda text: '-{0}'.format(text) + } + ) + + # Cannot test entire string as too variable so check for key text. + assert result.startswith('*User*\n') + assert ' -username: jenkins' in result + assert ' -email: ' in result + + +def test_format_filtering(new_user, mocker): + '''Return formatted representation using custom filter.''' + mocker.patch.object(termcolor, 'colored', colored) + + with new_user.session.auto_populating(False): + result = ftrack_api.formatter.format( + new_user, + attribute_filter=ftrack_api.formatter.FILTER['ignore_unset'] + ) + + # Cannot test entire string as too variable so check for key text. + assert result.startswith('User\n') + assert ' username: {0}'.format(new_user['username']) in result + assert ' email: ' not in result + + +def test_format_recursive(user, mocker): + '''Return formatted recursive representation.''' + mocker.patch.object(termcolor, 'colored', colored) + + user.session.populate(user, 'timelogs.user') + + with user.session.auto_populating(False): + result = ftrack_api.formatter.format(user, recursive=True) + + # Cannot test entire string as too variable so check for key text. + assert result.startswith('User\n') + assert ' username: jenkins' + assert ' timelogs: Timelog' in result + assert ' user: User{...}' in result diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_inspection.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_inspection.py new file mode 100644 index 0000000000..57b44613a8 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_inspection.py @@ -0,0 +1,101 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2014 ftrack + +import ftrack_api.inspection +import ftrack_api.symbol + + +def test_identity(user): + '''Retrieve identity of *user*.''' + identity = ftrack_api.inspection.identity(user) + assert identity[0] == 'User' + assert identity[1] == ['d07ae5d0-66e1-11e1-b5e9-f23c91df25eb'] + + +def test_primary_key(user): + '''Retrieve primary key of *user*.''' + primary_key = ftrack_api.inspection.primary_key(user) + assert primary_key == { + 'id': 'd07ae5d0-66e1-11e1-b5e9-f23c91df25eb' + } + + +def test_created_entity_state(session, unique_name): + '''Created entity has CREATED state.''' + new_user = session.create('User', {'username': unique_name}) + assert ftrack_api.inspection.state(new_user) is ftrack_api.symbol.CREATED + + # Even after a modification the state should remain as CREATED. + new_user['username'] = 'changed' + assert ftrack_api.inspection.state(new_user) is ftrack_api.symbol.CREATED + + +def test_retrieved_entity_state(user): + '''Retrieved entity has NOT_SET state.''' + assert ftrack_api.inspection.state(user) is ftrack_api.symbol.NOT_SET + + +def test_modified_entity_state(user): + '''Modified entity has MODIFIED state.''' + user['username'] = 'changed' + assert ftrack_api.inspection.state(user) is ftrack_api.symbol.MODIFIED + + +def test_deleted_entity_state(session, user): + '''Deleted entity has DELETED state.''' + session.delete(user) + assert ftrack_api.inspection.state(user) is ftrack_api.symbol.DELETED + + +def test_post_commit_entity_state(session, unique_name): + '''Entity has NOT_SET state post commit.''' + new_user = session.create('User', {'username': unique_name}) + assert ftrack_api.inspection.state(new_user) is ftrack_api.symbol.CREATED + + session.commit() + + assert ftrack_api.inspection.state(new_user) is ftrack_api.symbol.NOT_SET + + +def test_states(session, unique_name, user): + '''Determine correct states for multiple entities.''' + # NOT_SET + user_a = session.create('User', {'username': unique_name}) + session.commit() + + # CREATED + user_b = session.create('User', {'username': unique_name}) + user_b['username'] = 'changed' + + # MODIFIED + user_c = user + user_c['username'] = 'changed' + + # DELETED + user_d = session.create('User', {'username': unique_name}) + session.delete(user_d) + + # Assert states. + states = ftrack_api.inspection.states([user_a, user_b, user_c, user_d]) + + assert states == [ + ftrack_api.symbol.NOT_SET, + ftrack_api.symbol.CREATED, + ftrack_api.symbol.MODIFIED, + ftrack_api.symbol.DELETED + ] + + +def test_states_for_no_entities(): + '''Return empty list of states when no entities passed.''' + states = ftrack_api.inspection.states([]) + assert states == [] + + +def test_skip_operations_for_non_inspected_entities(session, unique_name): + '''Skip operations for non inspected entities.''' + user_a = session.create('User', {'username': unique_name + '-1'}) + user_b = session.create('User', {'username': unique_name + '-2'}) + + states = ftrack_api.inspection.states([user_a]) + assert states == [ftrack_api.symbol.CREATED] diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_operation.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_operation.py new file mode 100644 index 0000000000..702bfae355 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_operation.py @@ -0,0 +1,79 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import ftrack_api.operation + + +def test_operations_initialise(): + '''Initialise empty operations stack.''' + operations = ftrack_api.operation.Operations() + assert len(operations) == 0 + + +def test_operations_push(): + '''Push new operation onto stack.''' + operations = ftrack_api.operation.Operations() + assert len(operations) == 0 + + operation = ftrack_api.operation.Operation() + operations.push(operation) + assert list(operations)[-1] is operation + + +def test_operations_pop(): + '''Pop and return operation from stack.''' + operations = ftrack_api.operation.Operations() + assert len(operations) == 0 + + operations.push(ftrack_api.operation.Operation()) + operations.push(ftrack_api.operation.Operation()) + operation = ftrack_api.operation.Operation() + operations.push(operation) + + assert len(operations) == 3 + popped = operations.pop() + assert popped is operation + assert len(operations) == 2 + + +def test_operations_count(): + '''Count operations in stack.''' + operations = ftrack_api.operation.Operations() + assert len(operations) == 0 + + operations.push(ftrack_api.operation.Operation()) + assert len(operations) == 1 + + operations.pop() + assert len(operations) == 0 + + +def test_operations_clear(): + '''Clear operations stack.''' + operations = ftrack_api.operation.Operations() + operations.push(ftrack_api.operation.Operation()) + operations.push(ftrack_api.operation.Operation()) + operations.push(ftrack_api.operation.Operation()) + assert len(operations) == 3 + + operations.clear() + assert len(operations) == 0 + + +def test_operations_iter(): + '''Iterate over operations stack.''' + operations = ftrack_api.operation.Operations() + operation_a = ftrack_api.operation.Operation() + operation_b = ftrack_api.operation.Operation() + operation_c = ftrack_api.operation.Operation() + + operations.push(operation_a) + operations.push(operation_b) + operations.push(operation_c) + + assert len(operations) == 3 + for operation, expected in zip( + operations, [operation_a, operation_b, operation_c] + ): + assert operation is expected + diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_package.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_package.py new file mode 100644 index 0000000000..247b496d96 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_package.py @@ -0,0 +1,48 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import ftrack_api + + +class Class(object): + '''Class.''' + + +class Mixin(object): + '''Mixin.''' + + def method(self): + '''Method.''' + return True + + +def test_mixin(): + '''Mixin class to instance.''' + instance_a = Class() + instance_b = Class() + + assert not hasattr(instance_a, 'method') + assert not hasattr(instance_b, 'method') + + ftrack_api.mixin(instance_a, Mixin) + + assert hasattr(instance_a, 'method') + assert instance_a.method() is True + assert not hasattr(instance_b, 'method') + + +def test_mixin_same_class_multiple_times(): + '''Mixin class to instance multiple times.''' + instance = Class() + assert not hasattr(instance, 'method') + assert len(instance.__class__.mro()) == 2 + + ftrack_api.mixin(instance, Mixin) + assert hasattr(instance, 'method') + assert instance.method() is True + assert len(instance.__class__.mro()) == 4 + + ftrack_api.mixin(instance, Mixin) + assert hasattr(instance, 'method') + assert instance.method() is True + assert len(instance.__class__.mro()) == 4 diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_plugin.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_plugin.py new file mode 100644 index 0000000000..252c813a9b --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_plugin.py @@ -0,0 +1,192 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import os +import textwrap +import logging +import re + +import pytest + +import ftrack_api.plugin + + +@pytest.fixture() +def valid_plugin(temporary_path): + '''Return path to directory containing a valid plugin.''' + with open(os.path.join(temporary_path, 'plugin.py'), 'w') as file_object: + file_object.write(textwrap.dedent(''' + def register(*args, **kw): + print "Registered", args, kw + ''')) + + return temporary_path + + +@pytest.fixture() +def python_non_plugin(temporary_path): + '''Return path to directory containing Python file that is non plugin.''' + with open(os.path.join(temporary_path, 'non.py'), 'w') as file_object: + file_object.write(textwrap.dedent(''' + print "Not a plugin" + + def not_called(): + print "Not called" + ''')) + + return temporary_path + + +@pytest.fixture() +def non_plugin(temporary_path): + '''Return path to directory containing file that is non plugin.''' + with open(os.path.join(temporary_path, 'non.txt'), 'w') as file_object: + file_object.write('Never seen') + + return temporary_path + + +@pytest.fixture() +def broken_plugin(temporary_path): + '''Return path to directory containing broken plugin.''' + with open(os.path.join(temporary_path, 'broken.py'), 'w') as file_object: + file_object.write('syntax error') + + return temporary_path + + +@pytest.fixture() +def plugin(request, temporary_path): + '''Return path containing a plugin with requested specification.''' + specification = request.param + output = re.sub('(\w+)=\w+', '"\g<1>={}".format(\g<1>)', specification) + output = re.sub('\*args', 'args', output) + output = re.sub('\*\*kwargs', 'sorted(kwargs.items())', output) + + with open(os.path.join(temporary_path, 'plugin.py'), 'w') as file_object: + content = textwrap.dedent(''' + def register({}): + print {} + '''.format(specification, output)) + file_object.write(content) + + return temporary_path + + +def test_discover_empty_paths(capsys): + '''Discover no plugins when paths are empty.''' + ftrack_api.plugin.discover([' ']) + output, error = capsys.readouterr() + assert not output + assert not error + + +def test_discover_valid_plugin(valid_plugin, capsys): + '''Discover valid plugin.''' + ftrack_api.plugin.discover([valid_plugin], (1, 2), {'3': 4}) + output, error = capsys.readouterr() + assert 'Registered (1, 2) {\'3\': 4}' in output + + +def test_discover_python_non_plugin(python_non_plugin, capsys): + '''Discover Python non plugin.''' + ftrack_api.plugin.discover([python_non_plugin]) + output, error = capsys.readouterr() + assert 'Not a plugin' in output + assert 'Not called' not in output + + +def test_discover_non_plugin(non_plugin, capsys): + '''Discover non plugin.''' + ftrack_api.plugin.discover([non_plugin]) + output, error = capsys.readouterr() + assert not output + assert not error + + +def test_discover_broken_plugin(broken_plugin, caplog): + '''Discover broken plugin.''' + ftrack_api.plugin.discover([broken_plugin]) + + records = caplog.records() + assert len(records) == 1 + assert records[0].levelno is logging.WARNING + assert 'Failed to load plugin' in records[0].message + + +@pytest.mark.parametrize( + 'plugin, positional, keyword, expected', + [ + ( + 'a, b=False, c=False, d=False', + (1, 2), {'c': True, 'd': True, 'e': True}, + '1 b=2 c=True d=True' + ), + ( + '*args', + (1, 2), {'b': True, 'c': False}, + '(1, 2)' + ), + ( + '**kwargs', + tuple(), {'b': True, 'c': False}, + '[(\'b\', True), (\'c\', False)]' + ), + ( + 'a=False, b=False', + (True,), {'b': True}, + 'a=True b=True' + ), + ( + 'a, c=False, *args', + (1, 2, 3, 4), {}, + '1 c=2 (3, 4)' + ), + ( + 'a, c=False, **kwargs', + tuple(), {'a': 1, 'b': 2, 'c': 3, 'd': 4}, + '1 c=3 [(\'b\', 2), (\'d\', 4)]' + ), + ], + indirect=['plugin'], + ids=[ + 'mixed-explicit', + 'variable-args-only', + 'variable-kwargs-only', + 'keyword-from-positional', + 'trailing-variable-args', + 'trailing-keyword-args' + ] +) +def test_discover_plugin_with_specific_signature( + plugin, positional, keyword, expected, capsys +): + '''Discover plugin passing only supported arguments.''' + ftrack_api.plugin.discover( + [plugin], positional, keyword + ) + output, error = capsys.readouterr() + assert expected in output + + +def test_discover_plugin_varying_signatures(temporary_path, capsys): + '''Discover multiple plugins with varying signatures.''' + with open(os.path.join(temporary_path, 'plugin_a.py'), 'w') as file_object: + file_object.write(textwrap.dedent(''' + def register(a): + print (a,) + ''')) + + with open(os.path.join(temporary_path, 'plugin_b.py'), 'w') as file_object: + file_object.write(textwrap.dedent(''' + def register(a, b=False): + print (a,), {'b': b} + ''')) + + ftrack_api.plugin.discover( + [temporary_path], (True,), {'b': True} + ) + + output, error = capsys.readouterr() + assert '(True,)'in output + assert '(True,) {\'b\': True}' in output diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_query.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_query.py new file mode 100644 index 0000000000..f8e3f9dec3 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_query.py @@ -0,0 +1,164 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import math + +import pytest + +import ftrack_api +import ftrack_api.query +import ftrack_api.exception + + +def test_index(session): + '''Index into query result.''' + results = session.query('User') + assert isinstance(results[2], session.types['User']) + + +def test_len(session): + '''Return count of results using len.''' + results = session.query('User where username is jenkins') + assert len(results) == 1 + + +def test_all(session): + '''Return all results using convenience method.''' + results = session.query('User').all() + assert isinstance(results, list) + assert len(results) + + +def test_implicit_iteration(session): + '''Implicitly iterate through query result.''' + results = session.query('User') + assert isinstance(results, ftrack_api.query.QueryResult) + + records = [] + for record in results: + records.append(record) + + assert len(records) == len(results) + + +def test_one(session): + '''Return single result using convenience method.''' + user = session.query('User where username is jenkins').one() + assert user['username'] == 'jenkins' + + +def test_one_fails_for_no_results(session): + '''Fail to fetch single result when no results available.''' + with pytest.raises(ftrack_api.exception.NoResultFoundError): + session.query('User where username is does_not_exist').one() + + +def test_one_fails_for_multiple_results(session): + '''Fail to fetch single result when multiple results available.''' + with pytest.raises(ftrack_api.exception.MultipleResultsFoundError): + session.query('User').one() + + +def test_one_with_existing_limit(session): + '''Fail to return single result when existing limit in expression.''' + with pytest.raises(ValueError): + session.query('User where username is jenkins limit 0').one() + + +def test_one_with_existing_offset(session): + '''Fail to return single result when existing offset in expression.''' + with pytest.raises(ValueError): + session.query('User where username is jenkins offset 2').one() + + +def test_one_with_prefetched_data(session): + '''Return single result ignoring prefetched data.''' + query = session.query('User where username is jenkins') + query.all() + + user = query.one() + assert user['username'] == 'jenkins' + + +def test_first(session): + '''Return first result using convenience method.''' + users = session.query('User').all() + + user = session.query('User').first() + assert user == users[0] + + +def test_first_returns_none_when_no_results(session): + '''Return None when no results available.''' + user = session.query('User where username is does_not_exist').first() + assert user is None + + +def test_first_with_existing_limit(session): + '''Fail to return first result when existing limit in expression.''' + with pytest.raises(ValueError): + session.query('User where username is jenkins limit 0').first() + + +def test_first_with_existing_offset(session): + '''Return first result whilst respecting custom offset.''' + users = session.query('User').all() + + user = session.query('User offset 2').first() + assert user == users[2] + + +def test_first_with_prefetched_data(session): + '''Return first result ignoring prefetched data.''' + query = session.query('User where username is jenkins') + query.all() + + user = query.first() + assert user['username'] == 'jenkins' + + +def test_paging(session, mocker): + '''Page through results.''' + mocker.patch.object(session, 'call', wraps=session.call) + + page_size = 5 + query = session.query('User limit 50', page_size=page_size) + records = query.all() + + assert session.call.call_count == ( + math.ceil(len(records) / float(page_size)) + ) + + +def test_paging_respects_offset_and_limit(session, mocker): + '''Page through results respecting offset and limit.''' + users = session.query('User').all() + + mocker.patch.object(session, 'call', wraps=session.call) + + page_size = 6 + query = session.query('User offset 2 limit 8', page_size=page_size) + records = query.all() + + assert session.call.call_count == 2 + assert len(records) == 8 + assert records == users[2:10] + + +def test_paging_respects_limit_smaller_than_page_size(session, mocker): + '''Use initial limit when less than page size.''' + mocker.patch.object(session, 'call', wraps=session.call) + + page_size = 100 + query = session.query('User limit 10', page_size=page_size) + records = query.all() + + assert session.call.call_count == 1 + session.call.assert_called_once_with( + [{ + 'action': 'query', + 'expression': 'select id from User offset 0 limit 10' + }] + ) + + assert len(records) == 10 \ No newline at end of file diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_session.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_session.py new file mode 100644 index 0000000000..5087efcc08 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_session.py @@ -0,0 +1,1519 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import os +import tempfile +import functools +import uuid +import textwrap +import datetime +import json +import random + +import pytest +import mock +import arrow +import requests + +import ftrack_api +import ftrack_api.cache +import ftrack_api.inspection +import ftrack_api.symbol +import ftrack_api.exception +import ftrack_api.session +import ftrack_api.collection + + +@pytest.fixture(params=['memory', 'persisted']) +def cache(request): + '''Return cache.''' + if request.param == 'memory': + cache = None # There is already a default Memory cache present. + elif request.param == 'persisted': + cache_path = os.path.join( + tempfile.gettempdir(), '{0}.dbm'.format(uuid.uuid4().hex) + ) + + cache = lambda session: ftrack_api.cache.SerialisedCache( + ftrack_api.cache.FileCache(cache_path), + encode=functools.partial( + session.encode, entity_attribute_strategy='persisted_only' + ), + decode=session.decode + ) + + def cleanup(): + '''Cleanup.''' + try: + os.remove(cache_path) + except OSError: + # BSD DB (Mac OSX) implementation of the interface will append + # a .db extension. + os.remove(cache_path + '.db') + + request.addfinalizer(cleanup) + + return cache + + +@pytest.fixture() +def temporary_invalid_schema_cache(request): + '''Return schema cache path to invalid schema cache file.''' + schema_cache_path = os.path.join( + tempfile.gettempdir(), + 'ftrack_api_schema_cache_test_{0}.json'.format(uuid.uuid4().hex) + ) + + with open(schema_cache_path, 'w') as file_: + file_.write('${invalid json}') + + def cleanup(): + '''Cleanup.''' + os.remove(schema_cache_path) + + request.addfinalizer(cleanup) + + return schema_cache_path + + +@pytest.fixture() +def temporary_valid_schema_cache(request, mocked_schemas): + '''Return schema cache path to valid schema cache file.''' + schema_cache_path = os.path.join( + tempfile.gettempdir(), + 'ftrack_api_schema_cache_test_{0}.json'.format(uuid.uuid4().hex) + ) + + with open(schema_cache_path, 'w') as file_: + json.dump(mocked_schemas, file_, indent=4) + + def cleanup(): + '''Cleanup.''' + os.remove(schema_cache_path) + + request.addfinalizer(cleanup) + + return schema_cache_path + + +class SelectiveCache(ftrack_api.cache.ProxyCache): + '''Proxy cache that should not cache newly created entities.''' + + def set(self, key, value): + '''Set *value* for *key*.''' + if isinstance(value, ftrack_api.entity.base.Entity): + if ( + ftrack_api.inspection.state(value) + is ftrack_api.symbol.CREATED + ): + return + + super(SelectiveCache, self).set(key, value) + + +def test_get_entity(session, user): + '''Retrieve an entity by type and id.''' + matching = session.get(*ftrack_api.inspection.identity(user)) + assert matching == user + + +def test_get_non_existant_entity(session): + '''Retrieve a non-existant entity by type and id.''' + matching = session.get('User', 'non-existant-id') + assert matching is None + + +def test_get_entity_of_invalid_type(session): + '''Fail to retrieve an entity using an invalid type.''' + with pytest.raises(KeyError): + session.get('InvalidType', 'id') + + +def test_create(session): + '''Create entity.''' + user = session.create('User', {'username': 'martin'}) + with session.auto_populating(False): + assert user['id'] is not ftrack_api.symbol.NOT_SET + assert user['username'] == 'martin' + assert user['email'] is ftrack_api.symbol.NOT_SET + + +def test_create_using_only_defaults(session): + '''Create entity using defaults only.''' + user = session.create('User') + with session.auto_populating(False): + assert user['id'] is not ftrack_api.symbol.NOT_SET + assert user['username'] is ftrack_api.symbol.NOT_SET + + +def test_create_using_server_side_defaults(session): + '''Create entity using server side defaults.''' + user = session.create('User') + with session.auto_populating(False): + assert user['id'] is not ftrack_api.symbol.NOT_SET + assert user['username'] is ftrack_api.symbol.NOT_SET + + session.commit() + assert user['username'] is not ftrack_api.symbol.NOT_SET + + +def test_create_overriding_defaults(session): + '''Create entity overriding defaults.''' + uid = str(uuid.uuid4()) + user = session.create('User', {'id': uid}) + with session.auto_populating(False): + assert user['id'] == uid + + +def test_create_with_reference(session): + '''Create entity with a reference to another.''' + status = session.query('Status')[0] + task = session.create('Task', {'status': status}) + assert task['status'] is status + + +def test_ensure_new_entity(session, unique_name): + '''Ensure entity, creating first.''' + entity = session.ensure('User', {'username': unique_name}) + assert entity['username'] == unique_name + + +def test_ensure_entity_with_non_string_data_types(session): + '''Ensure entity against non-string data types, creating first.''' + datetime = arrow.get() + + task = session.query('Task').first() + user = session.query( + 'User where username is {}'.format(session.api_user) + ).first() + + first = session.ensure( + 'Timelog', + { + 'start': datetime, + 'duration': 10, + 'user_id': user['id'], + 'context_id': task['id'] + } + ) + + with mock.patch.object(session, 'create') as mocked: + session.ensure( + 'Timelog', + { + 'start': datetime, + 'duration': 10, + 'user_id': user['id'], + 'context_id': task['id'] + } + ) + assert not mocked.called + + assert first['start'] == datetime + assert first['duration'] == 10 + + +def test_ensure_entity_with_identifying_keys(session, unique_name): + '''Ensure entity, checking using keys subset and then creating.''' + entity = session.ensure( + 'User', {'username': unique_name, 'email': 'test@example.com'}, + identifying_keys=['username'] + ) + assert entity['username'] == unique_name + + +def test_ensure_entity_with_invalid_identifying_keys(session, unique_name): + '''Fail to ensure entity when identifying key missing from data.''' + with pytest.raises(KeyError): + session.ensure( + 'User', {'username': unique_name, 'email': 'test@example.com'}, + identifying_keys=['invalid'] + ) + + +def test_ensure_entity_with_missing_identifying_keys(session): + '''Fail to ensure entity when no identifying keys determined.''' + with pytest.raises(ValueError): + session.ensure('User', {}) + + +def test_ensure_existing_entity(session, unique_name): + '''Ensure existing entity.''' + entity = session.ensure('User', {'first_name': unique_name}) + + # Second call should not commit any new entity, just retrieve the existing. + with mock.patch.object(session, 'create') as mocked: + retrieved = session.ensure('User', {'first_name': unique_name}) + assert not mocked.called + assert retrieved == entity + + +def test_ensure_update_existing_entity(session, unique_name): + '''Ensure and update existing entity.''' + entity = session.ensure( + 'User', {'first_name': unique_name, 'email': 'anon@example.com'} + ) + assert entity['email'] == 'anon@example.com' + + # Second call should commit updates. + retrieved = session.ensure( + 'User', {'first_name': unique_name, 'email': 'test@example.com'}, + identifying_keys=['first_name'] + ) + assert retrieved == entity + assert retrieved['email'] == 'test@example.com' + + +def test_reconstruct_entity(session): + '''Reconstruct entity.''' + uid = str(uuid.uuid4()) + data = { + 'id': uid, + 'username': 'martin', + 'email': 'martin@example.com' + } + user = session.create('User', data, reconstructing=True) + + for attribute in user.attributes: + # No local attributes should be set. + assert attribute.get_local_value(user) is ftrack_api.symbol.NOT_SET + + # Only remote attributes that had explicit values should be set. + value = attribute.get_remote_value(user) + if attribute.name in data: + assert value == data[attribute.name] + else: + assert value is ftrack_api.symbol.NOT_SET + + +def test_reconstruct_entity_does_not_apply_defaults(session): + '''Reconstruct entity does not apply defaults.''' + # Note: Use private method to avoid merge which requires id be set. + user = session._create('User', {}, reconstructing=True) + with session.auto_populating(False): + assert user['id'] is ftrack_api.symbol.NOT_SET + + +def test_reconstruct_empty_entity(session): + '''Reconstruct empty entity.''' + # Note: Use private method to avoid merge which requires id be set. + user = session._create('User', {}, reconstructing=True) + + for attribute in user.attributes: + # No local attributes should be set. + assert attribute.get_local_value(user) is ftrack_api.symbol.NOT_SET + + # No remote attributes should be set. + assert attribute.get_remote_value(user) is ftrack_api.symbol.NOT_SET + + +def test_delete_operation_ordering(session, unique_name): + '''Delete entities in valid order.''' + # Construct entities. + project_schema = session.query('ProjectSchema').first() + project = session.create('Project', { + 'name': unique_name, + 'full_name': unique_name, + 'project_schema': project_schema + }) + + sequence = session.create('Sequence', { + 'name': unique_name, + 'parent': project + }) + + session.commit() + + # Delete in order that should succeed. + session.delete(sequence) + session.delete(project) + + session.commit() + + +def test_create_then_delete_operation_ordering(session, unique_name): + '''Create and delete entity in one transaction.''' + entity = session.create('User', {'username': unique_name}) + session.delete(entity) + session.commit() + + +def test_create_and_modify_to_have_required_attribute(session, unique_name): + '''Create and modify entity to have required attribute in transaction.''' + entity = session.create('Scope', {}) + other = session.create('Scope', {'name': unique_name}) + entity['name'] = '{0}2'.format(unique_name) + session.commit() + + +def test_ignore_in_create_entity_payload_values_set_to_not_set( + mocker, unique_name, session +): + '''Ignore in commit, created entity data set to NOT_SET''' + mocked = mocker.patch.object(session, 'call') + + # Should ignore 'email' attribute in payload. + new_user = session.create( + 'User', {'username': unique_name, 'email': 'test'} + ) + new_user['email'] = ftrack_api.symbol.NOT_SET + session.commit() + payloads = mocked.call_args[0][0] + assert len(payloads) == 1 + + +def test_ignore_operation_that_modifies_attribute_to_not_set( + mocker, session, user +): + '''Ignore in commit, operation that sets attribute value to NOT_SET''' + mocked = mocker.patch.object(session, 'call') + + # Should result in no call to server. + user['email'] = ftrack_api.symbol.NOT_SET + session.commit() + + assert not mocked.called + + +def test_operation_optimisation_on_commit(session, mocker): + '''Optimise operations on commit.''' + mocked = mocker.patch.object(session, 'call') + + user_a = session.create('User', {'username': 'bob'}) + user_a['username'] = 'foo' + user_a['email'] = 'bob@example.com' + + user_b = session.create('User', {'username': 'martin'}) + user_b['email'] = 'martin@ftrack.com' + + user_a['email'] = 'bob@example.com' + user_a['first_name'] = 'Bob' + + user_c = session.create('User', {'username': 'neverexist'}) + user_c['email'] = 'ignore@example.com' + session.delete(user_c) + + user_a_entity_key = ftrack_api.inspection.primary_key(user_a).values() + user_b_entity_key = ftrack_api.inspection.primary_key(user_b).values() + + session.commit() + + # The above operations should have translated into three payloads to call + # (two creates and one update). + payloads = mocked.call_args[0][0] + assert len(payloads) == 3 + + assert payloads[0]['action'] == 'create' + assert payloads[0]['entity_key'] == user_a_entity_key + assert set(payloads[0]['entity_data'].keys()) == set([ + '__entity_type__', 'id', 'resource_type', 'username' + ]) + + assert payloads[1]['action'] == 'create' + assert payloads[1]['entity_key'] == user_b_entity_key + assert set(payloads[1]['entity_data'].keys()) == set([ + '__entity_type__', 'id', 'resource_type', 'username', 'email' + ]) + + assert payloads[2]['action'] == 'update' + assert payloads[2]['entity_key'] == user_a_entity_key + assert set(payloads[2]['entity_data'].keys()) == set([ + '__entity_type__', 'email', 'first_name' + ]) + + +def test_state_collection(session, unique_name, user): + '''Session state collection holds correct entities.''' + # NOT_SET + user_a = session.create('User', {'username': unique_name}) + session.commit() + + # CREATED + user_b = session.create('User', {'username': unique_name}) + user_b['username'] = 'changed' + + # MODIFIED + user_c = user + user_c['username'] = 'changed' + + # DELETED + user_d = session.create('User', {'username': unique_name}) + session.delete(user_d) + + assert session.created == [user_b] + assert session.modified == [user_c] + assert session.deleted == [user_d] + + +def test_get_entity_with_composite_primary_key(session, new_project): + '''Retrieve entity that uses a composite primary key.''' + entity = session.create('Metadata', { + 'key': 'key', 'value': 'value', + 'parent_type': new_project.entity_type, + 'parent_id': new_project['id'] + }) + + session.commit() + + # Avoid cache. + new_session = ftrack_api.Session() + retrieved_entity = new_session.get( + 'Metadata', ftrack_api.inspection.primary_key(entity).values() + ) + + assert retrieved_entity == entity + + +def test_get_entity_with_incomplete_composite_primary_key(session, new_project): + '''Fail to retrieve entity using incomplete composite primary key.''' + entity = session.create('Metadata', { + 'key': 'key', 'value': 'value', + 'parent_type': new_project.entity_type, + 'parent_id': new_project['id'] + }) + + session.commit() + + # Avoid cache. + new_session = ftrack_api.Session() + with pytest.raises(ValueError): + new_session.get( + 'Metadata', ftrack_api.inspection.primary_key(entity).values()[0] + ) + + +def test_populate_entity(session, new_user): + '''Populate entity that uses single primary key.''' + with session.auto_populating(False): + assert new_user['email'] is ftrack_api.symbol.NOT_SET + + session.populate(new_user, 'email') + assert new_user['email'] is not ftrack_api.symbol.NOT_SET + + +def test_populate_entities(session, unique_name): + '''Populate multiple entities that use single primary key.''' + users = [] + for index in range(3): + users.append( + session.create( + 'User', {'username': '{0}-{1}'.format(unique_name, index)} + ) + ) + + session.commit() + + with session.auto_populating(False): + for user in users: + assert user['email'] is ftrack_api.symbol.NOT_SET + + session.populate(users, 'email') + + for user in users: + assert user['email'] is not ftrack_api.symbol.NOT_SET + + +def test_populate_entity_with_composite_primary_key(session, new_project): + '''Populate entity that uses a composite primary key.''' + entity = session.create('Metadata', { + 'key': 'key', 'value': 'value', + 'parent_type': new_project.entity_type, + 'parent_id': new_project['id'] + }) + + session.commit() + + # Avoid cache. + new_session = ftrack_api.Session() + retrieved_entity = new_session.get( + 'Metadata', ftrack_api.inspection.primary_key(entity).values() + ) + + # Manually change already populated remote value so can test it gets reset + # on populate call. + retrieved_entity.attributes.get('value').set_remote_value( + retrieved_entity, 'changed' + ) + + new_session.populate(retrieved_entity, 'value') + assert retrieved_entity['value'] == 'value' + + +@pytest.mark.parametrize('server_information, compatible', [ + ({}, False), + ({'version': '3.3.11'}, True), + ({'version': '3.3.12'}, True), + ({'version': '3.4'}, True), + ({'version': '3.4.1'}, True), + ({'version': '3.5.16'}, True), + ({'version': '3.3.10'}, False) +], ids=[ + 'No information', + 'Valid current version', + 'Valid higher version', + 'Valid higher version', + 'Valid higher version', + 'Valid higher version', + 'Invalid lower version' +]) +def test_check_server_compatibility( + server_information, compatible, session +): + '''Check server compatibility.''' + with mock.patch.dict( + session._server_information, server_information, clear=True + ): + if compatible: + session.check_server_compatibility() + else: + with pytest.raises(ftrack_api.exception.ServerCompatibilityError): + session.check_server_compatibility() + + +def test_encode_entity_using_all_attributes_strategy(mocked_schema_session): + '''Encode entity using "all" entity_attribute_strategy.''' + new_bar = mocked_schema_session.create( + 'Bar', + { + 'name': 'myBar', + 'id': 'bar_unique_id' + } + ) + + new_foo = mocked_schema_session.create( + 'Foo', + { + 'id': 'a_unique_id', + 'string': 'abc', + 'integer': 42, + 'number': 12345678.9, + 'boolean': False, + 'date': arrow.get('2015-11-18 15:24:09'), + 'bars': [new_bar] + } + ) + + encoded = mocked_schema_session.encode( + new_foo, entity_attribute_strategy='all' + ) + + assert encoded == textwrap.dedent(''' + {"__entity_type__": "Foo", + "bars": [{"__entity_type__": "Bar", "id": "bar_unique_id"}], + "boolean": false, + "date": {"__type__": "datetime", "value": "2015-11-18T15:24:09+00:00"}, + "id": "a_unique_id", + "integer": 42, + "number": 12345678.9, + "string": "abc"} + ''').replace('\n', '') + + +def test_encode_entity_using_only_set_attributes_strategy( + mocked_schema_session +): + '''Encode entity using "set_only" entity_attribute_strategy.''' + new_foo = mocked_schema_session.create( + 'Foo', + { + 'id': 'a_unique_id', + 'string': 'abc', + 'integer': 42 + } + ) + + encoded = mocked_schema_session.encode( + new_foo, entity_attribute_strategy='set_only' + ) + + assert encoded == textwrap.dedent(''' + {"__entity_type__": "Foo", + "id": "a_unique_id", + "integer": 42, + "string": "abc"} + ''').replace('\n', '') + + +def test_encode_computed_attribute_using_persisted_only_attributes_strategy( + mocked_schema_session +): + '''Encode computed attribute, "persisted_only" entity_attribute_strategy.''' + new_bar = mocked_schema_session._create( + 'Bar', + { + 'name': 'myBar', + 'id': 'bar_unique_id', + 'computed_value': 'FOO' + }, + reconstructing=True + ) + + encoded = mocked_schema_session.encode( + new_bar, entity_attribute_strategy='persisted_only' + ) + + assert encoded == textwrap.dedent(''' + {"__entity_type__": "Bar", + "id": "bar_unique_id", + "name": "myBar"} + ''').replace('\n', '') + + +def test_encode_entity_using_only_modified_attributes_strategy( + mocked_schema_session +): + '''Encode entity using "modified_only" entity_attribute_strategy.''' + new_foo = mocked_schema_session._create( + 'Foo', + { + 'id': 'a_unique_id', + 'string': 'abc', + 'integer': 42 + }, + reconstructing=True + ) + + new_foo['string'] = 'Modified' + + encoded = mocked_schema_session.encode( + new_foo, entity_attribute_strategy='modified_only' + ) + + assert encoded == textwrap.dedent(''' + {"__entity_type__": "Foo", + "id": "a_unique_id", + "string": "Modified"} + ''').replace('\n', '') + + +def test_encode_entity_using_invalid_strategy(session, new_task): + '''Fail to encode entity using invalid strategy.''' + with pytest.raises(ValueError): + session.encode(new_task, entity_attribute_strategy='invalid') + + +def test_encode_operation_payload(session): + '''Encode operation payload.''' + sequence_component = session.create_component( + "/path/to/sequence.%d.jpg [1]", location=None + ) + file_component = sequence_component["members"][0] + + encoded = session.encode([ + ftrack_api.session.OperationPayload({ + 'action': 'create', + 'entity_data': { + '__entity_type__': u'FileComponent', + u'container': sequence_component, + 'id': file_component['id'] + }, + 'entity_key': [file_component['id']], + 'entity_type': u'FileComponent' + }), + ftrack_api.session.OperationPayload({ + 'action': 'update', + 'entity_data': { + '__entity_type__': u'SequenceComponent', + u'members': ftrack_api.collection.Collection( + sequence_component, + sequence_component.attributes.get('members'), + data=[file_component] + ) + }, + 'entity_key': [sequence_component['id']], + 'entity_type': u'SequenceComponent' + }) + ]) + + expected = textwrap.dedent(''' + [{{"action": "create", + "entity_data": {{"__entity_type__": "FileComponent", + "container": {{"__entity_type__": "SequenceComponent", + "id": "{0[id]}"}}, + "id": "{1[id]}"}}, + "entity_key": ["{1[id]}"], + "entity_type": "FileComponent"}}, + {{"action": "update", + "entity_data": {{"__entity_type__": "SequenceComponent", + "members": [{{"__entity_type__": "FileComponent", "id": "{1[id]}"}}]}}, + "entity_key": ["{0[id]}"], + "entity_type": "SequenceComponent"}}] + '''.format(sequence_component, file_component)).replace('\n', '') + + assert encoded == expected + + +def test_decode_partial_entity( + session, new_task +): + '''Decode partially encoded entity.''' + encoded = session.encode( + new_task, entity_attribute_strategy='set_only' + ) + + entity = session.decode(encoded) + + assert entity == new_task + assert entity is not new_task + + +def test_reset(mocker): + '''Reset session.''' + plugin_path = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', 'fixture', 'plugin') + ) + session = ftrack_api.Session(plugin_paths=[plugin_path]) + + assert hasattr(session.types.get('User'), 'stub') + location = session.query('Location where name is "test.location"').one() + assert location.accessor is not ftrack_api.symbol.NOT_SET + + mocked_close = mocker.patch.object(session._request, 'close') + mocked_fetch = mocker.patch.object(session, '_load_schemas') + + session.reset() + + # Assert custom entity type maintained. + assert hasattr(session.types.get('User'), 'stub') + + # Assert location plugin re-configured. + location = session.query('Location where name is "test.location"').one() + assert location.accessor is not ftrack_api.symbol.NOT_SET + + # Assert connection not closed and no schema fetch issued. + assert not mocked_close.called + assert not mocked_fetch.called + + +def test_rollback_scalar_attribute_change(session, new_user): + '''Rollback scalar attribute change via session.''' + assert not session.recorded_operations + current_first_name = new_user['first_name'] + + new_user['first_name'] = 'NewName' + assert new_user['first_name'] == 'NewName' + assert session.recorded_operations + + session.rollback() + + assert not session.recorded_operations + assert new_user['first_name'] == current_first_name + + +def test_rollback_collection_attribute_change(session, new_user): + '''Rollback collection attribute change via session.''' + assert not session.recorded_operations + current_timelogs = new_user['timelogs'] + assert list(current_timelogs) == [] + + timelog = session.create('Timelog', {}) + new_user['timelogs'].append(timelog) + assert list(new_user['timelogs']) == [timelog] + assert session.recorded_operations + + session.rollback() + + assert not session.recorded_operations + assert list(new_user['timelogs']) == [] + + +def test_rollback_entity_creation(session): + '''Rollback entity creation via session.''' + assert not session.recorded_operations + + new_user = session.create('User') + assert session.recorded_operations + assert new_user in session.created + + session.rollback() + + assert not session.recorded_operations + assert new_user not in session.created + assert new_user not in session._local_cache.values() + + +def test_rollback_entity_deletion(session, new_user): + '''Rollback entity deletion via session.''' + assert not session.recorded_operations + + session.delete(new_user) + assert session.recorded_operations + assert new_user in session.deleted + + session.rollback() + assert not session.recorded_operations + assert new_user not in session.deleted + assert new_user in session._local_cache.values() + + +# Caching +# ------------------------------------------------------------------------------ + + +def test_get_entity_bypassing_cache(session, user, mocker): + '''Retrieve an entity by type and id bypassing cache.''' + mocker.patch.object(session, 'call', wraps=session.call) + + session.cache.remove( + session.cache_key_maker.key(ftrack_api.inspection.identity(user)) + ) + + matching = session.get(*ftrack_api.inspection.identity(user)) + + # Check a different instance returned. + assert matching is not user + + # Check instances have the same identity. + assert matching == user + + # Check cache was bypassed and server was called. + assert session.call.called + + +def test_get_entity_from_cache(cache, task, mocker): + '''Retrieve an entity by type and id from cache.''' + session = ftrack_api.Session(cache=cache) + + # Prepare cache. + session.merge(task) + + # Disable server calls. + mocker.patch.object(session, 'call') + + # Retrieve entity from cache. + entity = session.get(*ftrack_api.inspection.identity(task)) + + assert entity is not None, 'Failed to retrieve entity from cache.' + assert entity == task + assert entity is not task + + # Check that no call was made to server. + assert not session.call.called + + +def test_get_entity_tree_from_cache(cache, new_project_tree, mocker): + '''Retrieve an entity tree from cache.''' + session = ftrack_api.Session(cache=cache) + + # Prepare cache. + # TODO: Maybe cache should be prepopulated for a better check here. + session.query( + 'select children, children.children, children.children.children, ' + 'children.children.children.assignments, ' + 'children.children.children.assignments.resource ' + 'from Project where id is "{0}"' + .format(new_project_tree['id']) + ).one() + + # Disable server calls. + mocker.patch.object(session, 'call') + + # Retrieve entity from cache. + entity = session.get(*ftrack_api.inspection.identity(new_project_tree)) + + assert entity is not None, 'Failed to retrieve entity from cache.' + assert entity == new_project_tree + assert entity is not new_project_tree + + # Check tree. + with session.auto_populating(False): + for sequence in entity['children']: + for shot in sequence['children']: + for task in shot['children']: + assignments = task['assignments'] + for assignment in assignments: + resource = assignment['resource'] + + assert resource is not ftrack_api.symbol.NOT_SET + + # Check that no call was made to server. + assert not session.call.called + + +def test_get_metadata_from_cache(session, mocker, cache, new_task): + '''Retrieve an entity along with its metadata from cache.''' + new_task['metadata']['key'] = 'value' + session.commit() + + fresh_session = ftrack_api.Session(cache=cache) + + # Prepare cache. + fresh_session.query( + 'select metadata.key, metadata.value from ' + 'Task where id is "{0}"' + .format(new_task['id']) + ).all() + + # Disable server calls. + mocker.patch.object(fresh_session, 'call') + + # Retrieve entity from cache. + entity = fresh_session.get(*ftrack_api.inspection.identity(new_task)) + + assert entity is not None, 'Failed to retrieve entity from cache.' + assert entity == new_task + assert entity is not new_task + + # Check metadata cached correctly. + with fresh_session.auto_populating(False): + metadata = entity['metadata'] + assert metadata['key'] == 'value' + + assert not fresh_session.call.called + + +def test_merge_circular_reference(cache, temporary_file): + '''Merge circular reference into cache.''' + session = ftrack_api.Session(cache=cache) + # The following will test the condition as a FileComponent will be created + # with corresponding ComponentLocation. The server will return the file + # component data with the component location embedded. The component + # location will in turn have an embedded reference to the file component. + # If the merge does not prioritise the primary keys of the instance then + # any cache that relies on using the identity of the file component will + # fail. + component = session.create_component(path=temporary_file) + assert component + + +def test_create_with_selective_cache(session): + '''Create entity does not store entity in selective cache.''' + cache = ftrack_api.cache.MemoryCache() + session.cache.caches.append(SelectiveCache(cache)) + try: + user = session.create('User', {'username': 'martin'}) + cache_key = session.cache_key_maker.key( + ftrack_api.inspection.identity(user) + ) + + with pytest.raises(KeyError): + cache.get(cache_key) + + finally: + session.cache.caches.pop() + + +def test_correct_file_type_on_sequence_component(session): + '''Create sequence component with correct file type.''' + path = '/path/to/image/sequence.%04d.dpx [1-10]' + sequence_component = session.create_component(path) + + assert sequence_component['file_type'] == '.dpx' + + +def test_read_schemas_from_cache( + session, temporary_valid_schema_cache +): + '''Read valid content from schema cache.''' + expected_hash = 'a98d0627b5e33966e43e1cb89b082db7' + + schemas, hash_ = session._read_schemas_from_cache( + temporary_valid_schema_cache + ) + + assert expected_hash == hash_ + + +def test_fail_to_read_schemas_from_invalid_cache( + session, temporary_invalid_schema_cache +): + '''Fail to read invalid content from schema cache.''' + with pytest.raises(ValueError): + session._read_schemas_from_cache( + temporary_invalid_schema_cache + ) + + +def test_write_schemas_to_cache( + session, temporary_valid_schema_cache +): + '''Write valid content to schema cache.''' + expected_hash = 'a98d0627b5e33966e43e1cb89b082db7' + schemas, _ = session._read_schemas_from_cache(temporary_valid_schema_cache) + + session._write_schemas_to_cache(schemas, temporary_valid_schema_cache) + + schemas, hash_ = session._read_schemas_from_cache( + temporary_valid_schema_cache + ) + + assert expected_hash == hash_ + + +def test_fail_to_write_invalid_schemas_to_cache( + session, temporary_valid_schema_cache +): + '''Fail to write invalid content to schema cache.''' + # Datetime not serialisable by default. + invalid_content = datetime.datetime.now() + + with pytest.raises(TypeError): + session._write_schemas_to_cache( + invalid_content, temporary_valid_schema_cache + ) + + +def test_load_schemas_from_valid_cache( + mocker, session, temporary_valid_schema_cache, mocked_schemas +): + '''Load schemas from cache.''' + expected_schemas = session._load_schemas(temporary_valid_schema_cache) + + mocked = mocker.patch.object(session, 'call') + schemas = session._load_schemas(temporary_valid_schema_cache) + + assert schemas == expected_schemas + assert not mocked.called + + +def test_load_schemas_from_server_when_cache_invalid( + mocker, session, temporary_invalid_schema_cache +): + '''Load schemas from server when cache invalid.''' + mocked = mocker.patch.object(session, 'call', wraps=session.call) + + session._load_schemas(temporary_invalid_schema_cache) + assert mocked.called + + +def test_load_schemas_from_server_when_cache_outdated( + mocker, session, temporary_valid_schema_cache +): + '''Load schemas from server when cache outdated.''' + schemas, _ = session._read_schemas_from_cache(temporary_valid_schema_cache) + schemas.append({ + 'id': 'NewTest' + }) + session._write_schemas_to_cache(schemas, temporary_valid_schema_cache) + + mocked = mocker.patch.object(session, 'call', wraps=session.call) + session._load_schemas(temporary_valid_schema_cache) + + assert mocked.called + + +def test_load_schemas_from_server_not_reporting_schema_hash( + mocker, session, temporary_valid_schema_cache +): + '''Load schemas from server when server does not report schema hash.''' + mocked_write = mocker.patch.object( + session, '_write_schemas_to_cache', + wraps=session._write_schemas_to_cache + ) + + server_information = session._server_information.copy() + server_information.pop('schema_hash') + mocker.patch.object( + session, '_server_information', new=server_information + ) + + session._load_schemas(temporary_valid_schema_cache) + + # Cache still written even if hash not reported. + assert mocked_write.called + + mocked = mocker.patch.object(session, 'call', wraps=session.call) + session._load_schemas(temporary_valid_schema_cache) + + # No hash reported by server so cache should have been bypassed. + assert mocked.called + + +def test_load_schemas_bypassing_cache( + mocker, session, temporary_valid_schema_cache +): + '''Load schemas bypassing cache when set to False.''' + with mocker.patch.object(session, 'call', wraps=session.call): + + session._load_schemas(temporary_valid_schema_cache) + assert session.call.call_count == 1 + + session._load_schemas(False) + assert session.call.call_count == 2 + + +def test_get_tasks_widget_url(session): + '''Tasks widget URL returns valid HTTP status.''' + url = session.get_widget_url('tasks') + response = requests.get(url) + response.raise_for_status() + + +def test_get_info_widget_url(session, task): + '''Info widget URL for *task* returns valid HTTP status.''' + url = session.get_widget_url('info', entity=task, theme='light') + response = requests.get(url) + response.raise_for_status() + + +def test_encode_media_from_path(session, video_path): + '''Encode media based on a file path.''' + job = session.encode_media(video_path) + + assert job.entity_type == 'Job' + + job_data = json.loads(job['data']) + assert 'output' in job_data + assert 'source_component_id' in job_data + assert 'keep_original' in job_data and job_data['keep_original'] is False + assert len(job_data['output']) + assert 'component_id' in job_data['output'][0] + assert 'format' in job_data['output'][0] + + +def test_encode_media_from_component(session, video_path): + '''Encode media based on a component.''' + location = session.query('Location where name is "ftrack.server"').one() + component = session.create_component( + video_path, + location=location + ) + session.commit() + + job = session.encode_media(component) + + assert job.entity_type == 'Job' + + job_data = json.loads(job['data']) + assert 'keep_original' in job_data and job_data['keep_original'] is True + + +def test_create_sequence_component_with_size(session, temporary_sequence): + '''Create a sequence component and verify that is has a size.''' + location = session.query('Location where name is "ftrack.server"').one() + component = session.create_component( + temporary_sequence + ) + + assert component['size'] > 0 + + +def test_plugin_arguments(mocker): + '''Pass plugin arguments to plugin discovery mechanism.''' + mock = mocker.patch( + 'ftrack_api.plugin.discover' + ) + session = ftrack_api.Session( + plugin_paths=[], plugin_arguments={"test": "value"} + ) + assert mock.called + mock.assert_called_once_with([], [session], {"test": "value"}) + +def test_remote_reset(session, new_user): + '''Reset user api key.''' + key_1 = session.reset_remote( + 'api_key', entity=new_user + ) + + key_2 = session.reset_remote( + 'api_key', entity=new_user + ) + + + assert key_1 != key_2 + + +@pytest.mark.parametrize('attribute', [ + ('id',), + ('email',) + +], ids=[ + 'Fail resetting primary key', + 'Fail resetting attribute without default value', +]) +def test_fail_remote_reset(session, user, attribute): + '''Fail trying to rest invalid attributes.''' + + with pytest.raises(ftrack_api.exception.ServerError): + session.reset_remote( + attribute, user + ) + + +def test_close(session): + '''Close session.''' + assert session.closed is False + session.close() + assert session.closed is True + + +def test_close_already_closed_session(session): + '''Close session that is already closed.''' + session.close() + assert session.closed is True + session.close() + assert session.closed is True + + +def test_server_call_after_close(session): + '''Fail to issue calls to server after session closed.''' + session.close() + assert session.closed is True + + with pytest.raises(ftrack_api.exception.ConnectionClosedError): + session.query('User').first() + + +def test_context_manager(session): + '''Use session as context manager.''' + with session: + assert session.closed is False + + assert session.closed is True + + +def test_delayed_job(session): + '''Test the delayed_job action''' + + with pytest.raises(ValueError): + session.delayed_job( + 'DUMMY_JOB' + ) + + +@pytest.mark.skip(reason='No configured ldap server.') +def test_delayed_job_ldap_sync(session): + '''Test the a delayed_job ldap sync action''' + result = session.delayed_job( + ftrack_api.symbol.JOB_SYNC_USERS_LDAP + ) + + assert isinstance( + result, ftrack_api.entity.job.Job + ) + + +def test_query_nested_custom_attributes(session, new_asset_version): + '''Query custom attributes nested and update a value and query again. + + This test will query custom attributes via 2 relations, then update the + value in one API session and read it back in another to verify that it gets + the new value. + + ''' + session_one = session + session_two = ftrack_api.Session( + auto_connect_event_hub=False + ) + + # Read the version via a relation in both sessions. + def get_versions(sessions): + versions = [] + for _session in sessions: + asset = _session.query( + 'select versions.custom_attributes from Asset where id is "{0}"'.format( + new_asset_version.get('asset_id') + ) + ).first() + + for version in asset['versions']: + if version.get('id') == new_asset_version.get('id'): + versions.append(version) + + return versions + + # Get version from both sessions. + versions = get_versions((session_one, session_two)) + + # Read attribute for both sessions. + for version in versions: + version['custom_attributes']['versiontest'] + + # Set attribute on session_one. + versions[0]['custom_attributes']['versiontest'] = random.randint( + 0, 99999 + ) + + session.commit() + + # Read version from server for session_two. + session_two_version = get_versions((session_two, ))[0] + + # Verify that value in session 2 is the same as set and committed in + # session 1. + assert ( + session_two_version['custom_attributes']['versiontest'] == + versions[0]['custom_attributes']['versiontest'] + ) + + +def test_query_nested(session): + '''Query components nested and update a value and query again. + + This test will query components via 2 relations, then update the + value in one API session and read it back in another to verify that it gets + the new value. + + ''' + session_one = session + session_two = ftrack_api.Session( + auto_connect_event_hub=False + ) + + query = ( + 'select versions.components.name from Asset where id is ' + '"12939d0c-6766-11e1-8104-f23c91df25eb"' + ) + + def get_version(session): + '''Return the test version from *session*.''' + asset = session.query(query).first() + asset_version = None + for version in asset['versions']: + if version['version'] == 8: + asset_version = version + break + + return asset_version + + asset_version = get_version(session_one) + asset_version2 = get_version(session_two) + + # This assert is not needed, but reading the collections are to ensure they + # are inflated. + assert ( + asset_version2['components'][0]['name'] == + asset_version['components'][0]['name'] + ) + + asset_version['components'][0]['name'] = str(uuid.uuid4()) + + session.commit() + + asset_version2 = get_version(session_two) + + assert ( + asset_version['components'][0]['name'] == + asset_version2['components'][0]['name'] + ) + + +def test_merge_iterations(session, mocker, project): + '''Ensure merge does not happen to many times when querying.''' + mocker.spy(session, '_merge') + + session.query( + 'select status from Task where project_id is {} limit 10'.format( + project['id'] + ) + ).all() + + assert session._merge.call_count < 75 + + +@pytest.mark.parametrize( + 'get_versions', + [ + lambda component, asset_version, asset: component['version']['asset']['versions'], + lambda component, asset_version, asset: asset_version['asset']['versions'], + lambda component, asset_version, asset: asset['versions'], + ], + ids=[ + 'from_component', + 'from_asset_version', + 'from_asset', + ] +) +def test_query_nested2(session, get_versions): + '''Query version.asset.versions from component and then add new version. + + This test will query versions via multiple relations and ensure a new + version appears when added to a different session and then is queried + again. + + ''' + session_one = session + session_two = ftrack_api.Session( + auto_connect_event_hub=False + ) + + # Get a random component that is linked to a version and asset. + component_id = session_two.query( + 'FileComponent where version.asset_id != None' + ).first()['id'] + + query = ( + 'select version.asset.versions from Component where id is "{}"'.format( + component_id + ) + ) + + component = session_one.query(query).one() + asset_version = component['version'] + asset = component['version']['asset'] + versions = component['version']['asset']['versions'] + length = len(versions) + + session_two.create('AssetVersion', { + 'asset_id': asset['id'] + }) + + session_two.commit() + + component = session_one.query(query).one() + versions = get_versions(component, asset_version, asset) + new_length = len(versions) + + assert length + 1 == new_length + + +def test_session_ready_reset_events(mocker): + '''Session ready and reset events.''' + plugin_path = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', 'fixture', 'plugin') + ) + session = ftrack_api.Session(plugin_paths=[plugin_path]) + + assert session._test_called_events['ftrack.api.session.ready'] is 1 + assert session._test_called_events['ftrack.api.session.reset'] is 0 + + session.reset() + assert session._test_called_events['ftrack.api.session.ready'] is 1 + assert session._test_called_events['ftrack.api.session.reset'] is 1 + + +def test_entity_reference(mocker, session): + '''Return entity reference that uniquely identifies entity.''' + mock_entity = mocker.Mock(entity_type="MockEntityType") + mock_auto_populating = mocker.patch.object(session, "auto_populating") + mock_primary_key = mocker.patch( + "ftrack_api.inspection.primary_key", return_value={"id": "mock-id"} + ) + + reference = session.entity_reference(mock_entity) + + assert reference == { + "__entity_type__": "MockEntityType", + "id": "mock-id" + } + + mock_auto_populating.assert_called_once_with(False) + mock_primary_key.assert_called_once_with(mock_entity) + + +def test__entity_reference(mocker, session): + '''Act as alias to entity_reference.''' + mock_entity = mocker.Mock(entity_type="MockEntityType") + mock_entity_reference = mocker.patch.object(session, "entity_reference") + mocker.patch("warnings.warn") + + session._entity_reference(mock_entity) + + mock_entity_reference.assert_called_once_with(mock_entity) + + +def test__entity_reference_issues_deprecation_warning(mocker, session): + '''Issue deprecation warning for usage of _entity_reference.''' + mocker.patch.object(session, "entity_reference") + mock_warn = mocker.patch("warnings.warn") + + session._entity_reference({}) + + mock_warn.assert_called_once_with( + ( + "Session._entity_reference is now available as public method " + "Session.entity_reference. The private method will be removed " + "in version 2.0." + ), + PendingDeprecationWarning + ) diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_timer.py b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_timer.py new file mode 100644 index 0000000000..cf8b014ee5 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api/test/unit/test_timer.py @@ -0,0 +1,74 @@ +# :coding: utf-8 +# :copyright: Copyright (c) 2015 ftrack + +import pytest +import ftrack_api.exception + + +def test_manually_create_multiple_timers_with_error(session, new_user): + '''Fail to create a second timer.''' + session.create('Timer', { + 'user': new_user + }) + + session.commit() + + with pytest.raises(ftrack_api.exception.ServerError): + session.create('Timer', { + 'user': new_user + }) + + session.commit() + + session.reset() + + +def test_create_multiple_timers_with_error(session, new_user): + '''Fail to create a second timer.''' + new_user.start_timer() + + with pytest.raises(ftrack_api.exception.NotUniqueError): + new_user.start_timer() + + session.reset() + + +def test_start_and_stop_a_timer(session, new_user, new_task): + '''Start a new timer and stop it to create a timelog.''' + new_user.start_timer(new_task) + + new_user.stop_timer() + + timelog = session.query( + 'Timelog where context_id = "{0}"'.format(new_task['id']) + ).one() + + assert timelog['user_id'] == new_user['id'], 'User id is correct.' + assert timelog['context_id'] == new_task['id'], 'Task id is correct.' + + +def test_start_a_timer_when_timer_is_running(session, new_user, new_task): + '''Start a timer when an existing timer is already running.''' + new_user.start_timer(new_task) + + # Create the second timer without context. + new_user.start_timer(force=True) + + # There should be only one existing timelog for this user. + timelogs = session.query( + 'Timelog where user_id = "{0}"'.format(new_user['id']) + ).all() + assert len(timelogs) == 1, 'One timelog exists.' + + timelog = session.query( + 'Timer where user_id = "{0}"'.format(new_user['id']) + ).one() + + # Make sure running timer has no context. + assert timelog['context_id'] is None, 'Timer does not have a context.' + + +def test_stop_timer_without_timer_running(session, new_user): + '''Stop a timer when no timer is running.''' + with pytest.raises(ftrack_api.exception.NoResultFoundError): + new_user.stop_timer() diff --git a/openpype/modules/default_modules/ftrack/scripts/sub_event_processor.py b/openpype/modules/ftrack/scripts/sub_event_processor.py similarity index 100% rename from openpype/modules/default_modules/ftrack/scripts/sub_event_processor.py rename to openpype/modules/ftrack/scripts/sub_event_processor.py diff --git a/openpype/modules/default_modules/ftrack/scripts/sub_event_status.py b/openpype/modules/ftrack/scripts/sub_event_status.py similarity index 100% rename from openpype/modules/default_modules/ftrack/scripts/sub_event_status.py rename to openpype/modules/ftrack/scripts/sub_event_status.py diff --git a/openpype/modules/default_modules/ftrack/scripts/sub_event_storer.py b/openpype/modules/ftrack/scripts/sub_event_storer.py similarity index 100% rename from openpype/modules/default_modules/ftrack/scripts/sub_event_storer.py rename to openpype/modules/ftrack/scripts/sub_event_storer.py diff --git a/openpype/modules/default_modules/ftrack/scripts/sub_legacy_server.py b/openpype/modules/ftrack/scripts/sub_legacy_server.py similarity index 100% rename from openpype/modules/default_modules/ftrack/scripts/sub_legacy_server.py rename to openpype/modules/ftrack/scripts/sub_legacy_server.py diff --git a/openpype/modules/default_modules/ftrack/scripts/sub_user_server.py b/openpype/modules/ftrack/scripts/sub_user_server.py similarity index 100% rename from openpype/modules/default_modules/ftrack/scripts/sub_user_server.py rename to openpype/modules/ftrack/scripts/sub_user_server.py diff --git a/openpype/modules/default_modules/ftrack/tray/__init__.py b/openpype/modules/ftrack/tray/__init__.py similarity index 100% rename from openpype/modules/default_modules/ftrack/tray/__init__.py rename to openpype/modules/ftrack/tray/__init__.py diff --git a/openpype/modules/default_modules/ftrack/tray/ftrack_tray.py b/openpype/modules/ftrack/tray/ftrack_tray.py similarity index 100% rename from openpype/modules/default_modules/ftrack/tray/ftrack_tray.py rename to openpype/modules/ftrack/tray/ftrack_tray.py diff --git a/openpype/modules/default_modules/ftrack/tray/login_dialog.py b/openpype/modules/ftrack/tray/login_dialog.py similarity index 100% rename from openpype/modules/default_modules/ftrack/tray/login_dialog.py rename to openpype/modules/ftrack/tray/login_dialog.py diff --git a/openpype/modules/default_modules/ftrack/tray/login_tools.py b/openpype/modules/ftrack/tray/login_tools.py similarity index 100% rename from openpype/modules/default_modules/ftrack/tray/login_tools.py rename to openpype/modules/ftrack/tray/login_tools.py From bb105209e8bc34a63e2e04bc8de42a603f3368d4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 22 Feb 2022 15:28:05 +0100 Subject: [PATCH 191/483] Revert storing of messagebox - This is NOT done because the original crash was reproducible - but just out of pure legacy reasons for if the error might still occur. It would be worth looking into whether the crash can still be reproduced in recent Blender versions without this logic. --- openpype/tools/workfiles/app.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index 27ffb768a3..3a772a038c 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -544,6 +544,10 @@ class FilesWidget(QtWidgets.QWidget): # file on a refresh of the files model. self.auto_select_latest_modified = True + # Avoid crash in Blender and store the message box + # (setting parent doesn't work as it hides the message box) + self._messagebox = None + files_view = FilesView(self) # Create the Files model @@ -722,7 +726,7 @@ class FilesWidget(QtWidgets.QWidget): self.file_opened.emit() def save_changes_prompt(self): - messagebox = QtWidgets.QMessageBox(parent=self) + self._messagebox = messagebox = QtWidgets.QMessageBox(parent=self) messagebox.setWindowFlags(messagebox.windowFlags() | QtCore.Qt.FramelessWindowHint) messagebox.setIcon(messagebox.Warning) From f6e9d688c61765b098d09a15633700cbf3b25e1d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Feb 2022 15:42:02 +0100 Subject: [PATCH 192/483] fix visibility of placeholder after reset --- openpype/tools/pyblish_pype/window.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/pyblish_pype/window.py b/openpype/tools/pyblish_pype/window.py index ef9be8093c..2195f6f44e 100644 --- a/openpype/tools/pyblish_pype/window.py +++ b/openpype/tools/pyblish_pype/window.py @@ -1138,8 +1138,8 @@ class Window(QtWidgets.QDialog): if self.intent_model.has_items: self.intent_box.setCurrentIndex(self.intent_model.default_index) - if self.comment_box.text(): - self.comment_box.placeholder.setVisible(False) + self.comment_box.placeholder.setVisible(False) + if not self.comment_box.text(): self.comment_box.placeholder.setVisible(True) # Launch controller reset self.controller.reset() From 36420b53408c7caa0b751bd174cfc139d25f8280 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Feb 2022 16:15:46 +0100 Subject: [PATCH 193/483] moved deadline repository files from vendor to deadline module --- .../repository}/custom/plugins/GlobalJobPreLoad.py | 0 .../plugins/HarmonyOpenPype/HarmonyOpenPype.ico | Bin .../plugins/HarmonyOpenPype/HarmonyOpenPype.options | 0 .../plugins/HarmonyOpenPype/HarmonyOpenPype.param | 0 .../plugins/HarmonyOpenPype/HarmonyOpenPype.py | 0 .../custom/plugins/OpenPype/OpenPype.ico | Bin .../custom/plugins/OpenPype/OpenPype.options | 0 .../custom/plugins/OpenPype/OpenPype.param | 0 .../repository}/custom/plugins/OpenPype/OpenPype.py | 0 .../OpenPypeTileAssembler/OpenPypeTileAssembler.ico | Bin .../OpenPypeTileAssembler.options | 0 .../OpenPypeTileAssembler.param | 0 .../OpenPypeTileAssembler/OpenPypeTileAssembler.py | 0 .../modules/deadline/repository}/readme.md | 0 website/docs/module_deadline.md | 2 +- 15 files changed, 1 insertion(+), 1 deletion(-) rename {vendor/deadline => openpype/modules/deadline/repository}/custom/plugins/GlobalJobPreLoad.py (100%) rename {vendor/deadline => openpype/modules/deadline/repository}/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.ico (100%) rename {vendor/deadline => openpype/modules/deadline/repository}/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.options (100%) rename {vendor/deadline => openpype/modules/deadline/repository}/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param (100%) rename {vendor/deadline => openpype/modules/deadline/repository}/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py (100%) rename {vendor/deadline => openpype/modules/deadline/repository}/custom/plugins/OpenPype/OpenPype.ico (100%) rename {vendor/deadline => openpype/modules/deadline/repository}/custom/plugins/OpenPype/OpenPype.options (100%) rename {vendor/deadline => openpype/modules/deadline/repository}/custom/plugins/OpenPype/OpenPype.param (100%) rename {vendor/deadline => openpype/modules/deadline/repository}/custom/plugins/OpenPype/OpenPype.py (100%) rename {vendor/deadline => openpype/modules/deadline/repository}/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.ico (100%) rename {vendor/deadline => openpype/modules/deadline/repository}/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.options (100%) rename {vendor/deadline => openpype/modules/deadline/repository}/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.param (100%) rename {vendor/deadline => openpype/modules/deadline/repository}/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py (100%) rename {vendor/deadline => openpype/modules/deadline/repository}/readme.md (100%) diff --git a/vendor/deadline/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py similarity index 100% rename from vendor/deadline/custom/plugins/GlobalJobPreLoad.py rename to openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py diff --git a/vendor/deadline/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.ico b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.ico similarity index 100% rename from vendor/deadline/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.ico rename to openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.ico diff --git a/vendor/deadline/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.options b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.options similarity index 100% rename from vendor/deadline/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.options rename to openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.options diff --git a/vendor/deadline/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param similarity index 100% rename from vendor/deadline/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param rename to openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param diff --git a/vendor/deadline/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py similarity index 100% rename from vendor/deadline/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py rename to openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py diff --git a/vendor/deadline/custom/plugins/OpenPype/OpenPype.ico b/openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.ico similarity index 100% rename from vendor/deadline/custom/plugins/OpenPype/OpenPype.ico rename to openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.ico diff --git a/vendor/deadline/custom/plugins/OpenPype/OpenPype.options b/openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.options similarity index 100% rename from vendor/deadline/custom/plugins/OpenPype/OpenPype.options rename to openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.options diff --git a/vendor/deadline/custom/plugins/OpenPype/OpenPype.param b/openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.param similarity index 100% rename from vendor/deadline/custom/plugins/OpenPype/OpenPype.param rename to openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.param diff --git a/vendor/deadline/custom/plugins/OpenPype/OpenPype.py b/openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.py similarity index 100% rename from vendor/deadline/custom/plugins/OpenPype/OpenPype.py rename to openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.py diff --git a/vendor/deadline/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.ico b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.ico similarity index 100% rename from vendor/deadline/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.ico rename to openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.ico diff --git a/vendor/deadline/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.options b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.options similarity index 100% rename from vendor/deadline/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.options rename to openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.options diff --git a/vendor/deadline/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.param b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.param similarity index 100% rename from vendor/deadline/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.param rename to openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.param diff --git a/vendor/deadline/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py similarity index 100% rename from vendor/deadline/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py rename to openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py diff --git a/vendor/deadline/readme.md b/openpype/modules/deadline/repository/readme.md similarity index 100% rename from vendor/deadline/readme.md rename to openpype/modules/deadline/repository/readme.md diff --git a/website/docs/module_deadline.md b/website/docs/module_deadline.md index 32dbcfa6df..6b39f0780a 100644 --- a/website/docs/module_deadline.md +++ b/website/docs/module_deadline.md @@ -20,7 +20,7 @@ For [AWS Thinkbox Deadline](https://www.awsthinkbox.com/deadline) support you ne 4. Point OpenPype to your deadline webservice URL in the [OpenPype Admin Settings](admin_settings_system.md#deadline). -5. Install our custom plugin and scripts to your deadline repository. It should be as simple as copying content of `openPype/vendor/deadline/custom` to `path/to/your/deadline/repository/custom`. +5. Install our custom plugin and scripts to your deadline repository. It should be as simple as copying content of `openpype/modules/deadline/repository/custom` to `path/to/your/deadline/repository/custom`. ## Configuration From 30c2e82a0a52c5912147dbe09f12db5630de6b16 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Tue, 22 Feb 2022 16:25:47 +0100 Subject: [PATCH 194/483] Versionning Openpype documentation --- website/docs/admin_distribute.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/website/docs/admin_distribute.md b/website/docs/admin_distribute.md index 9e9d21998d..af19cc9bff 100644 --- a/website/docs/admin_distribute.md +++ b/website/docs/admin_distribute.md @@ -63,4 +63,18 @@ You can run OpenPype with `--use-staging` argument to add use staging versions. :::note Running staging version is identified by orange **P** icon in system tray. -::: \ No newline at end of file +::: + +### OpenPype versionning + +Openpype version control is based on sementic versioning + +:::note +The version of openpye is indicated by the variable `__version__` in the file `.\openpype\version.py` +::: + +For example OpenPype will consider the versions in this order: `3.8.0-nightly` < `3.8.0-nightly.1` < `3.8.0-rc.1` < `3.8.0` < `3.8.1-nightly.1` <`3.8.1` < `3.9.0` < `3.10.0` < `4.0.0` + +See https://semver.org/ for more details + +For studios customizing the source code of Openpype, it is recommended to build by adding a name and a number after the PATCH and not to deploy 3.8.0 from original OpenPype repositoy. For example, your builds will be `3.8.0-yourstudio.1` < `3.8.0-yourstudio.2` < `3.8.1-yourstudio.1` \ No newline at end of file From 48242818e36aa41fd2637ad8543d4986b4e6a5e2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Feb 2022 16:36:36 +0100 Subject: [PATCH 195/483] fix attr name typo --- openpype/tools/launcher/widgets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/launcher/widgets.py b/openpype/tools/launcher/widgets.py index 7437dc1453..30e6531843 100644 --- a/openpype/tools/launcher/widgets.py +++ b/openpype/tools/launcher/widgets.py @@ -257,7 +257,7 @@ class ActionBar(QtWidgets.QWidget): def _start_animation(self, index): # Offset refresh timout - self.launcher_model.start_refresh_timer() + self._launcher_model.start_refresh_timer() action_id = index.data(ACTION_ID_ROLE) item = self.model.items_by_id.get(action_id) if item: @@ -325,7 +325,7 @@ class ActionBar(QtWidgets.QWidget): return # Offset refresh timout - self.launcher_model.start_refresh_timer() + self._launcher_model.start_refresh_timer() actions = index.data(ACTION_ROLE) From e6c3e4e4a07d6061edce77af75484371aae04353 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 22 Feb 2022 17:01:28 +0100 Subject: [PATCH 196/483] fix default dict and load of settings --- .../plugins/publish/collect_sequences_from_job.py | 11 +++++++++++ .../perjob/m50__openpype_publish_render.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/openpype/modules/default_modules/royal_render/plugins/publish/collect_sequences_from_job.py b/openpype/modules/default_modules/royal_render/plugins/publish/collect_sequences_from_job.py index 3f435990e2..4d216c1c0a 100644 --- a/openpype/modules/default_modules/royal_render/plugins/publish/collect_sequences_from_job.py +++ b/openpype/modules/default_modules/royal_render/plugins/publish/collect_sequences_from_job.py @@ -80,6 +80,16 @@ class CollectSequencesFromJob(pyblish.api.ContextPlugin): review = True def process(self, context): + + self.review = ( + context.data + ["project_settings"] + ["royalrender"] + ["publish"] + ["CollectSequencesFromJob"] + ["review"] + ) + if os.environ.get("OPENPYPE_PUBLISH_DATA"): self.log.debug(os.environ.get("OPENPYPE_PUBLISH_DATA")) paths = os.environ["OPENPYPE_PUBLISH_DATA"].split(os.pathsep) @@ -152,6 +162,7 @@ class CollectSequencesFromJob(pyblish.api.ContextPlugin): if "ftrack" not in families: families.append("ftrack") if "review" not in families and self.review: + self.log.info("attaching review") families.append("review") for collection in collections: diff --git a/openpype/modules/default_modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py b/openpype/modules/default_modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py index eafb6ffb84..82a79daf3b 100644 --- a/openpype/modules/default_modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py +++ b/openpype/modules/default_modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py @@ -20,7 +20,7 @@ class OpenPypeContextSelector: def __init__(self): self.job = rr.getJob() - self.context = None + self.context = {} self.openpype_executable = "openpype_gui" if platform.system().lower() == "windows": From 25cd18f2c6a0257757a4bb23ac89b549b4bb3c3f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Feb 2022 17:11:17 +0100 Subject: [PATCH 197/483] moved abstract_submit_deadline to deadline module --- .../deadline}/abstract_submit_deadline.py | 2 +- .../plugins/publish/submit_aftereffects_deadline.py | 8 +++++--- .../deadline/plugins/publish/submit_harmony_deadline.py | 9 +++++---- .../publish/validate_expected_and_rendered_files.py | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) rename openpype/{lib => modules/deadline}/abstract_submit_deadline.py (99%) diff --git a/openpype/lib/abstract_submit_deadline.py b/openpype/modules/deadline/abstract_submit_deadline.py similarity index 99% rename from openpype/lib/abstract_submit_deadline.py rename to openpype/modules/deadline/abstract_submit_deadline.py index f54a2501a3..22902d79ea 100644 --- a/openpype/lib/abstract_submit_deadline.py +++ b/openpype/modules/deadline/abstract_submit_deadline.py @@ -15,7 +15,7 @@ import attr import requests import pyblish.api -from .abstract_metaplugins import AbstractMetaInstancePlugin +from openpype.lib.abstract_metaplugins import AbstractMetaInstancePlugin def requests_post(*args, **kwargs): diff --git a/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py b/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py index 1fff55500e..2918b54d4a 100644 --- a/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py @@ -5,9 +5,9 @@ import pyblish.api from avalon import api -from openpype.lib import abstract_submit_deadline -from openpype.lib.abstract_submit_deadline import DeadlineJobInfo from openpype.lib import env_value_to_bool +from openpype_modules.deadline import abstract_submit_deadline +from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo @attr.s @@ -24,7 +24,9 @@ class DeadlinePluginInfo(): MultiProcess = attr.ib(default=None) -class AfterEffectsSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): +class AfterEffectsSubmitDeadline( + abstract_submit_deadline.AbstractSubmitDeadline +): label = "Submit AE to Deadline" order = pyblish.api.IntegratorOrder + 0.1 diff --git a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py index 9d55d43ba6..918efb6630 100644 --- a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py @@ -8,11 +8,11 @@ import re import attr import pyblish.api - -import openpype.lib.abstract_submit_deadline -from openpype.lib.abstract_submit_deadline import DeadlineJobInfo from avalon import api +from openpype_modules.deadline import abstract_submit_deadline +from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo + class _ZipFile(ZipFile): """Extended check for windows invalid characters.""" @@ -217,7 +217,8 @@ class PluginInfo(object): class HarmonySubmitDeadline( - openpype.lib.abstract_submit_deadline.AbstractSubmitDeadline): + abstract_submit_deadline.AbstractSubmitDeadline +): """Submit render write of Harmony scene to Deadline. Renders are submitted to a Deadline Web Service as diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index 719c7dfe3e..615ba53c1a 100644 --- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -4,8 +4,8 @@ import requests import pyblish.api -from openpype.lib.abstract_submit_deadline import requests_get from openpype.lib.delivery import collect_frames +from openpype_modules.deadline.abstract_submit_deadline import requests_get class ValidateExpectedFiles(pyblish.api.InstancePlugin): From c6df75827508d4e64f48f79e6669d5f19ed78089 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Feb 2022 17:18:04 +0100 Subject: [PATCH 198/483] fix filter bug --- openpype/tools/launcher/models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index c9f222a7d8..effa283318 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -736,7 +736,10 @@ class AssetRecursiveSortFilterModel(QtCore.QSortFilterProxyModel): valid = True if self._name_filter: name = model.data(source_index, ASSET_NAME_ROLE) - if not re.search(self._name_filter, name, re.IGNORECASE): + if ( + name is None + or not re.search(self._name_filter, name, re.IGNORECASE) + ): valid = False if valid and self._task_types_filter: From 1b23611c33cb36d22f7058c1b8601b9f35f85290 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Tue, 22 Feb 2022 18:10:41 +0100 Subject: [PATCH 199/483] Fix BigRoy comment --- website/docs/admin_distribute.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/docs/admin_distribute.md b/website/docs/admin_distribute.md index af19cc9bff..b7cb1a3cf7 100644 --- a/website/docs/admin_distribute.md +++ b/website/docs/admin_distribute.md @@ -65,16 +65,16 @@ You can run OpenPype with `--use-staging` argument to add use staging versions. Running staging version is identified by orange **P** icon in system tray. ::: -### OpenPype versionning +### OpenPype versioning -Openpype version control is based on sementic versioning +OpenPype version control is based on semantic versioning :::note -The version of openpye is indicated by the variable `__version__` in the file `.\openpype\version.py` +The version of OpenPype is indicated by the variable `__version__` in the file `.\openpype\version.py` ::: For example OpenPype will consider the versions in this order: `3.8.0-nightly` < `3.8.0-nightly.1` < `3.8.0-rc.1` < `3.8.0` < `3.8.1-nightly.1` <`3.8.1` < `3.9.0` < `3.10.0` < `4.0.0` See https://semver.org/ for more details -For studios customizing the source code of Openpype, it is recommended to build by adding a name and a number after the PATCH and not to deploy 3.8.0 from original OpenPype repositoy. For example, your builds will be `3.8.0-yourstudio.1` < `3.8.0-yourstudio.2` < `3.8.1-yourstudio.1` \ No newline at end of file +For studios customizing the source code of OpenPype, a practical approach could be to build by adding a name and a number after the PATCH and not to deploy 3.8.0 from original OpenPype repository. For example, your builds will be: `3.8.0-yourstudio.1` < `3.8.0-yourstudio.2` < `3.8.1-yourstudio.1` \ No newline at end of file From 18857a27757622fa20d308f3bbe82078339c4cf2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 22 Feb 2022 22:07:48 +0100 Subject: [PATCH 200/483] Fix `OPENPYPE_LOG_NO_COLORS` being True actually resulting in `use_colors` being False --- openpype/lib/terminal.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/lib/terminal.py b/openpype/lib/terminal.py index bc0744931a..30c559eec6 100644 --- a/openpype/lib/terminal.py +++ b/openpype/lib/terminal.py @@ -49,8 +49,8 @@ class Terminal: """ from openpype.lib import env_value_to_bool - use_colors = env_value_to_bool( - "OPENPYPE_LOG_NO_COLORS", default=Terminal.use_colors + use_colors = not env_value_to_bool( + "OPENPYPE_LOG_NO_COLORS", default=not Terminal.use_colors ) if not use_colors: Terminal.use_colors = use_colors From 101d5bcb9b87d1ac91759c61ad28aa8661e6e2f7 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Wed, 23 Feb 2022 10:21:37 +0100 Subject: [PATCH 201/483] end the sentences with a period --- website/docs/admin_distribute.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/docs/admin_distribute.md b/website/docs/admin_distribute.md index b7cb1a3cf7..2cccce0fbd 100644 --- a/website/docs/admin_distribute.md +++ b/website/docs/admin_distribute.md @@ -67,14 +67,14 @@ Running staging version is identified by orange **P** icon in system tray. ### OpenPype versioning -OpenPype version control is based on semantic versioning +OpenPype version control is based on semantic versioning. :::note -The version of OpenPype is indicated by the variable `__version__` in the file `.\openpype\version.py` +The version of OpenPype is indicated by the variable `__version__` in the file `.\openpype\version.py`. ::: -For example OpenPype will consider the versions in this order: `3.8.0-nightly` < `3.8.0-nightly.1` < `3.8.0-rc.1` < `3.8.0` < `3.8.1-nightly.1` <`3.8.1` < `3.9.0` < `3.10.0` < `4.0.0` +For example OpenPype will consider the versions in this order: `3.8.0-nightly` < `3.8.0-nightly.1` < `3.8.0-rc.1` < `3.8.0` < `3.8.1-nightly.1` <`3.8.1` < `3.9.0` < `3.10.0` < `4.0.0`. -See https://semver.org/ for more details +See https://semver.org/ for more details. -For studios customizing the source code of OpenPype, a practical approach could be to build by adding a name and a number after the PATCH and not to deploy 3.8.0 from original OpenPype repository. For example, your builds will be: `3.8.0-yourstudio.1` < `3.8.0-yourstudio.2` < `3.8.1-yourstudio.1` \ No newline at end of file +For studios customizing the source code of OpenPype, a practical approach could be to build by adding a name and a number after the PATCH and not to deploy 3.8.0 from original OpenPype repository. For example, your builds will be: `3.8.0-yourstudio.1` < `3.8.0-yourstudio.2` < `3.8.1-yourstudio.1`. \ No newline at end of file From a16eb7cc05f94aeaa652258f0809039fca1465e8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Feb 2022 10:23:54 +0100 Subject: [PATCH 202/483] Refactor logic for readability Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/lib/terminal.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/lib/terminal.py b/openpype/lib/terminal.py index 30c559eec6..821aefe2ff 100644 --- a/openpype/lib/terminal.py +++ b/openpype/lib/terminal.py @@ -49,11 +49,13 @@ class Terminal: """ from openpype.lib import env_value_to_bool - use_colors = not env_value_to_bool( - "OPENPYPE_LOG_NO_COLORS", default=not Terminal.use_colors + log_no_colors = env_value_to_bool( + "OPENPYPE_LOG_NO_COLORS", default=None ) - if not use_colors: - Terminal.use_colors = use_colors + if log_no_colors is not None: + Terminal.use_colors = not log_no_colors + + if not Terminal.use_colors: Terminal._initialized = True return From 97fbeb51b79e71f6ee2671a2724fb7c13cc6e9e8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Feb 2022 10:35:42 +0100 Subject: [PATCH 203/483] Fix hound issues --- openpype/lib/terminal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/lib/terminal.py b/openpype/lib/terminal.py index 821aefe2ff..5121b6ec26 100644 --- a/openpype/lib/terminal.py +++ b/openpype/lib/terminal.py @@ -54,7 +54,7 @@ class Terminal: ) if log_no_colors is not None: Terminal.use_colors = not log_no_colors - + if not Terminal.use_colors: Terminal._initialized = True return From eca57b03d62393637956edb3ef573bcfefd80e7b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 23 Feb 2022 10:36:38 +0100 Subject: [PATCH 204/483] OP-2736 - fail gracefully if slack exception --- .../modules/slack/plugins/publish/integrate_slack_api.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 5d014382a3..5b504ca3c7 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -60,6 +60,9 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): message, publish_files) + if not msg_id: + return + msg = { "type": "slack", "msg_id": msg_id, @@ -177,6 +180,8 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): error_str = self._enrich_error(str(e), channel) self.log.warning("Error happened: {}".format(error_str)) + return None, [] + def _python3_call(self, token, channel, message, publish_files): from slack_sdk import WebClient from slack_sdk.errors import SlackApiError @@ -206,6 +211,8 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): error_str = self._enrich_error(str(e.response["error"]), channel) self.log.warning("Error happened {}".format(error_str)) + return None, [] + def _enrich_error(self, error_str, channel): """Enhance known errors with more helpful notations.""" if 'not_in_channel' in error_str: From a7f15cd21b949efeae8e5325e858af21c1c1c8a3 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Feb 2022 11:29:18 +0100 Subject: [PATCH 205/483] Controller is only used when creating a new window --- openpype/tools/pyblish_pype/app.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/tools/pyblish_pype/app.py b/openpype/tools/pyblish_pype/app.py index d15d586103..a252b96427 100644 --- a/openpype/tools/pyblish_pype/app.py +++ b/openpype/tools/pyblish_pype/app.py @@ -90,9 +90,8 @@ def show(parent=None): install_fonts() install_translator(app) - ctrl = control.Controller() - if self._window is None: + ctrl = control.Controller() self._window = window.Window(ctrl, parent) self._window.destroyed.connect(on_destroyed) From 6ed4497444bec2ec7bfe2d8eab1da7470e63224c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Feb 2022 11:33:18 +0100 Subject: [PATCH 206/483] Ensure comment content is checked after the controller reset --- openpype/tools/pyblish_pype/window.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/pyblish_pype/window.py b/openpype/tools/pyblish_pype/window.py index 2195f6f44e..d27ec34345 100644 --- a/openpype/tools/pyblish_pype/window.py +++ b/openpype/tools/pyblish_pype/window.py @@ -1139,10 +1139,10 @@ class Window(QtWidgets.QDialog): self.intent_box.setCurrentIndex(self.intent_model.default_index) self.comment_box.placeholder.setVisible(False) - if not self.comment_box.text(): - self.comment_box.placeholder.setVisible(True) # Launch controller reset self.controller.reset() + if not self.comment_box.text(): + self.comment_box.placeholder.setVisible(True) def validate(self): self.info(self.tr("Preparing validate..")) From ece0d0cdcd8d7f24caa996c00ce6dae8ed18b4ee Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Feb 2022 11:34:28 +0100 Subject: [PATCH 207/483] Clear comment on reset after successful publish --- openpype/tools/pyblish_pype/control.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/tools/pyblish_pype/control.py b/openpype/tools/pyblish_pype/control.py index d479124be1..6f89952c22 100644 --- a/openpype/tools/pyblish_pype/control.py +++ b/openpype/tools/pyblish_pype/control.py @@ -229,7 +229,13 @@ class Controller(QtCore.QObject): self.log.debug("Resetting pyblish context object") comment = None - if self.context is not None and self.context.data.get("comment"): + if ( + self.context is not None and + self.context.data.get("comment") and + # We only preserve the user typed comment if we are *not* + # resetting from a successful publish without errors + self._current_state != "Published" + ): comment = self.context.data["comment"] self.context = pyblish.api.Context() From 21914c24d66c82a521220e843b060644bd866aac Mon Sep 17 00:00:00 2001 From: murphy Date: Wed, 23 Feb 2022 11:40:01 +0100 Subject: [PATCH 208/483] Documentation: fixed broken links - fixed wrong .md link - fixed outdated link to deadline docs --- website/docs/module_deadline.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/docs/module_deadline.md b/website/docs/module_deadline.md index 88332fd748..3b3221bcb0 100644 --- a/website/docs/module_deadline.md +++ b/website/docs/module_deadline.md @@ -12,11 +12,11 @@ import TabItem from '@theme/TabItem'; For [AWS Thinkbox Deadline](https://www.awsthinkbox.com/deadline) support you need to set a few things up in both OpenPype and Deadline itself -1. Deploy OpenPype executable to all nodes of Deadline farm. See [Install & Run](admin_use) +1. Deploy OpenPype executable to all nodes of Deadline farm. See [Install & Run](admin_use.md) 2. Enable Deadline module it in the [settings](admin_settings_system.md#deadline) -3. Set up *Deadline Web API service*. For more details on how to do it, see [here](https://docs.thinkboxsoftware.com/products/deadline/10.0/1_User%20Manual/manual/web-service.html). +3. Set up *Deadline Web API service*. For more details on how to do it, see [here](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/web-service.html). 4. Point OpenPype to your deadline webservice URL in the [settings](admin_settings_system.md#deadline) From 2da1f5e1e46f35e7d0f8383b537247ff403844d9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Feb 2022 11:47:02 +0100 Subject: [PATCH 209/483] Uset task ids from asset versions before tasks are removed --- .../action_delete_asset.py | 24 ++++++++++++--- .../default_modules/ftrack/lib/avalon_sync.py | 30 ++++++++++++++++--- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py index 676dd80e93..94385a36c5 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py @@ -3,8 +3,9 @@ import uuid from datetime import datetime from bson.objectid import ObjectId -from openpype_modules.ftrack.lib import BaseAction, statics_icon from avalon.api import AvalonMongoDB +from openpype_modules.ftrack.lib import BaseAction, statics_icon +from openpype_modules.ftrack.lib.avalon_sync import create_chunks class DeleteAssetSubset(BaseAction): @@ -554,8 +555,8 @@ class DeleteAssetSubset(BaseAction): ftrack_proc_txt, ", ".join(ftrack_ids_to_delete) )) - entities_by_link_len = ( - self._filter_entities_to_delete(ftrack_ids_to_delete, session) + entities_by_link_len = self._prepare_entities_before_delete( + ftrack_ids_to_delete, session ) for link_len in sorted(entities_by_link_len.keys(), reverse=True): for entity in entities_by_link_len[link_len]: @@ -609,7 +610,7 @@ class DeleteAssetSubset(BaseAction): return self.report_handle(report_messages, project_name, event) - def _filter_entities_to_delete(self, ftrack_ids_to_delete, session): + def _prepare_entities_before_delete(self, ftrack_ids_to_delete, session): """Filter children entities to avoid CircularDependencyError.""" joined_ids_to_delete = ", ".join( ["\"{}\"".format(id) for id in ftrack_ids_to_delete] @@ -638,6 +639,21 @@ class DeleteAssetSubset(BaseAction): parent_ids_to_delete.append(entity["id"]) to_delete_entities.append(entity) + # Unset 'task_id' from AssetVersion entities + # - when task is deleted the asset version is not marked for deletion + task_ids = set( + entity["id"] + for entity in to_delete_entities + if entity.entity_type.lower() == "task" + ) + for chunk in create_chunks(task_ids): + asset_versions = session.query(( + "select id, task_id from AssetVersion where task_id in ({})" + ).format(self.join_query_keys(chunk))).all() + for asset_version in asset_versions: + asset_version["task_id"] = None + session.commit() + entities_by_link_len = collections.defaultdict(list) for entity in to_delete_entities: entities_by_link_len[len(entity["link"])].append(entity) diff --git a/openpype/modules/default_modules/ftrack/lib/avalon_sync.py b/openpype/modules/default_modules/ftrack/lib/avalon_sync.py index 06e8784287..db7c592c9b 100644 --- a/openpype/modules/default_modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/default_modules/ftrack/lib/avalon_sync.py @@ -33,6 +33,30 @@ CURRENT_DOC_SCHEMAS = { } +def create_chunks(iterable, chunk_size=None): + """Separate iterable into multiple chunks by size. + + Args: + iterable(list|tuple|set): Object that will be separated into chunks. + chunk_size(int): Size of one chunk. Default value is 200. + + Returns: + list: Chunked items. + """ + chunks = [] + if not iterable: + return chunks + + tupled_iterable = tuple(iterable) + iterable_size = len(tupled_iterable) + if chunk_size is None: + chunk_size = 200 + + for idx in range(0, iterable_size, chunk_size): + chunks.append(tupled_iterable[idx:idx + chunk_size]) + return chunks + + def check_regex(name, entity_type, in_schema=None, schema_patterns=None): schema_name = "asset-3.0" if in_schema: @@ -1147,10 +1171,8 @@ class SyncEntitiesFactory: ids_len = len(tupled_ids) chunk_size = int(5000 / ids_len) all_links = [] - for idx in range(0, ids_len, chunk_size): - entity_ids_joined = join_query_keys( - tupled_ids[idx:idx + chunk_size] - ) + for chunk in create_chunks(ftrack_ids, chunk_size): + entity_ids_joined = join_query_keys(chunk) all_links.extend(self.session.query(( "select from_id, to_id from" From 7f5185b217312e06188091831ec3b6f2f8306f2b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 23 Feb 2022 13:24:55 +0100 Subject: [PATCH 210/483] OP-2736 - safer way to get paths Instances without any representations (for example when sending to render to farm) would fail with incorrect settings. --- openpype/modules/slack/plugins/publish/integrate_slack_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 5b504ca3c7..018a7594bb 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -121,7 +121,7 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): def _get_thumbnail_path(self, instance): """Returns abs url for thumbnail if present in instance repres""" published_path = None - for repre in instance.data['representations']: + for repre in instance.data.get("representations", []): if repre.get('thumbnail') or "thumbnail" in repre.get('tags', []): if os.path.exists(repre["published_path"]): published_path = repre["published_path"] @@ -131,7 +131,7 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): def _get_review_path(self, instance): """Returns abs url for review if present in instance repres""" published_path = None - for repre in instance.data['representations']: + for repre in instance.data.get("representations", []): tags = repre.get('tags', []) if (repre.get("review") or "review" in tags From 2c876c862cba739a106b96fed903d49cc32770d1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Feb 2022 14:04:11 +0100 Subject: [PATCH 211/483] Remove unused import --- .../plugins/publish/validate_expected_and_rendered_files.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index e0c1f14e7c..c0c7ffbbcf 100644 --- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -1,5 +1,4 @@ import os -import json import requests import pyblish.api From b688557c1ed002b1a87014dd260d5146edcc8d53 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 23 Feb 2022 14:17:30 +0100 Subject: [PATCH 212/483] OP-2726 - changed name in burnins to part before @ --- .../ftrack/plugins/publish/collect_username.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py b/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py index 459e441afe..84d7f60a3f 100644 --- a/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py +++ b/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py @@ -60,6 +60,8 @@ class CollectUsername(pyblish.api.ContextPlugin): username = user.get("username") self.log.debug("Resolved ftrack username:: {}".format(username)) os.environ["FTRACK_API_USER"] = username - burnin_name = "{} {}".format(user.get("first_name"), - user.get("last_name")) + + burnin_name = username + if '@' in burnin_name: + burnin_name = burnin_name[:burnin_name.index('@')] os.environ["WEBPUBLISH_OPENPYPE_USERNAME"] = burnin_name From 3a1b09ccce0e70bea58bff313ec03c0db52f9f1d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Feb 2022 14:51:24 +0100 Subject: [PATCH 213/483] Also allow 'sequences' with a single frame --- openpype/lib/delivery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/lib/delivery.py b/openpype/lib/delivery.py index 01fcc907ed..a61603fa05 100644 --- a/openpype/lib/delivery.py +++ b/openpype/lib/delivery.py @@ -17,7 +17,7 @@ def collect_frames(files): Returns: (dict): {'/asset/subset_v001.0001.png': '0001', ....} """ - collections, remainder = clique.assemble(files) + collections, remainder = clique.assemble(files, minimum_items=1) sources_and_frames = {} if collections: From 239de9061446072988b401dce49ef350dbeee6e5 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Feb 2022 14:56:11 +0100 Subject: [PATCH 214/483] Better logging for case where frame might not be captured from source filename --- .../publish/validate_expected_and_rendered_files.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index c0c7ffbbcf..d49e314179 100644 --- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -127,6 +127,14 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): file_name_template = frame_placeholder = None for file_name, frame in sources_and_frames.items(): + + # There might be cases where clique was unable to collect + # collections in `collect_frames` - thus we capture that case + if frame is None: + self.log.warning("Unable to detect frame from filename: " + "{}".format(file_name)) + continue + frame_placeholder = "#" * len(frame) file_name_template = os.path.basename( file_name.replace(frame, frame_placeholder)) From 160d3cb12dd5f15f82f329f8e9d724abcdf94f92 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 15:51:43 +0100 Subject: [PATCH 215/483] resolve: fixing fusion module loading --- openpype/hosts/resolve/api/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/resolve/api/utils.py b/openpype/hosts/resolve/api/utils.py index 3dee17cb01..9b3762f328 100644 --- a/openpype/hosts/resolve/api/utils.py +++ b/openpype/hosts/resolve/api/utils.py @@ -70,9 +70,9 @@ def get_resolve_module(): sys.exit() # assign global var and return bmdvr = bmd.scriptapp("Resolve") - # bmdvf = bmd.scriptapp("Fusion") + bmdvf = bmd.scriptapp("Fusion") resolve.api.bmdvr = bmdvr - resolve.api.bmdvf = bmdvr.Fusion() + resolve.api.bmdvf = bmdvf log.info(("Assigning resolve module to " f"`pype.hosts.resolve.api.bmdvr`: {resolve.api.bmdvr}")) log.info(("Assigning resolve module to " From fc4b7e45737afb8b26907e0f21c6e587ab819756 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Feb 2022 16:12:54 +0100 Subject: [PATCH 216/483] set context environments even for non host applications --- openpype/hooks/pre_global_host_data.py | 11 ++--------- openpype/lib/applications.py | 4 +++- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/openpype/hooks/pre_global_host_data.py b/openpype/hooks/pre_global_host_data.py index bae967e25f..9b82e36171 100644 --- a/openpype/hooks/pre_global_host_data.py +++ b/openpype/hooks/pre_global_host_data.py @@ -14,14 +14,6 @@ class GlobalHostDataHook(PreLaunchHook): def execute(self): """Prepare global objects to `data` that will be used for sure.""" - if not self.application.is_host: - self.log.info( - "Skipped hook {}. Application is not marked as host.".format( - self.__class__.__name__ - ) - ) - return - self.prepare_global_data() if not self.data.get("asset_doc"): @@ -49,7 +41,8 @@ class GlobalHostDataHook(PreLaunchHook): "log": self.log }) - prepare_host_environments(temp_data, self.launch_context.env_group) + if app.is_host: + prepare_host_environments(temp_data, self.launch_context.env_group) prepare_context_environments(temp_data) temp_data.pop("log") diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 393c83e9be..30e671cfad 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1508,10 +1508,12 @@ def prepare_context_environments(data, env_group=None): "AVALON_PROJECT": project_doc["name"], "AVALON_ASSET": asset_doc["name"], "AVALON_TASK": task_name, - "AVALON_APP": app.host_name, "AVALON_APP_NAME": app.full_name, "AVALON_WORKDIR": workdir } + if app.is_host: + context_env["AVALON_APP"]: app.host_name + log.debug( "Context environments set:\n{}".format( json.dumps(context_env, indent=4) From 646651a62fcb6067c9c644e3785e956cda359b10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 23 Feb 2022 16:37:58 +0100 Subject: [PATCH 217/483] rename module --- .gitmodules | 2 +- openpype/modules/base.py | 2 +- openpype/modules/default_modules/ftrack/python2_vendor/arrow | 1 - .../default_modules/ftrack/python2_vendor/ftrack-python-api | 1 - openpype/modules/{royal_render => royalrender}/__init__.py | 0 openpype/modules/{royal_render => royalrender}/api.py | 0 .../plugins/publish/collect_default_rr_path.py | 0 .../plugins/publish/collect_rr_path_from_instance.py | 0 .../plugins/publish/collect_sequences_from_job.py | 0 .../{royal_render => royalrender}/royal_render_module.py | 0 openpype/modules/{royal_render => royalrender}/rr_job.py | 0 .../modules/{royal_render => royalrender}/rr_root/README.md | 0 .../plugins/control_job/perjob/m50__openpype_publish_render.py | 0 13 files changed, 2 insertions(+), 4 deletions(-) delete mode 160000 openpype/modules/default_modules/ftrack/python2_vendor/arrow delete mode 160000 openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api rename openpype/modules/{royal_render => royalrender}/__init__.py (100%) rename openpype/modules/{royal_render => royalrender}/api.py (100%) rename openpype/modules/{royal_render => royalrender}/plugins/publish/collect_default_rr_path.py (100%) rename openpype/modules/{royal_render => royalrender}/plugins/publish/collect_rr_path_from_instance.py (100%) rename openpype/modules/{royal_render => royalrender}/plugins/publish/collect_sequences_from_job.py (100%) rename openpype/modules/{royal_render => royalrender}/royal_render_module.py (100%) rename openpype/modules/{royal_render => royalrender}/rr_job.py (100%) rename openpype/modules/{royal_render => royalrender}/rr_root/README.md (100%) rename openpype/modules/{royal_render => royalrender}/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py (100%) diff --git a/.gitmodules b/.gitmodules index 2c4816801c..67b820a247 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,4 +3,4 @@ url = https://github.com/pypeclub/avalon-core.git [submodule "repos/avalon-unreal-integration"] path = repos/avalon-unreal-integration - url = https://github.com/pypeclub/avalon-unreal-integration.git + url = https://github.com/pypeclub/avalon-unreal-integration.git \ No newline at end of file diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 6c86557337..213a7681f5 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -35,7 +35,7 @@ DEFAULT_OPENPYPE_MODULES = ( "log_viewer", "deadline", "muster", - "royal_render", + "royalrender", "python_console_interpreter", "ftrack", "slack", diff --git a/openpype/modules/default_modules/ftrack/python2_vendor/arrow b/openpype/modules/default_modules/ftrack/python2_vendor/arrow deleted file mode 160000 index b746fedf72..0000000000 --- a/openpype/modules/default_modules/ftrack/python2_vendor/arrow +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b746fedf7286c3755a46f07ab72f4c414cd41fc0 diff --git a/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api b/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api deleted file mode 160000 index d277f474ab..0000000000 --- a/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d277f474ab016e7b53479c36af87cb861d0cc53e diff --git a/openpype/modules/royal_render/__init__.py b/openpype/modules/royalrender/__init__.py similarity index 100% rename from openpype/modules/royal_render/__init__.py rename to openpype/modules/royalrender/__init__.py diff --git a/openpype/modules/royal_render/api.py b/openpype/modules/royalrender/api.py similarity index 100% rename from openpype/modules/royal_render/api.py rename to openpype/modules/royalrender/api.py diff --git a/openpype/modules/royal_render/plugins/publish/collect_default_rr_path.py b/openpype/modules/royalrender/plugins/publish/collect_default_rr_path.py similarity index 100% rename from openpype/modules/royal_render/plugins/publish/collect_default_rr_path.py rename to openpype/modules/royalrender/plugins/publish/collect_default_rr_path.py diff --git a/openpype/modules/royal_render/plugins/publish/collect_rr_path_from_instance.py b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py similarity index 100% rename from openpype/modules/royal_render/plugins/publish/collect_rr_path_from_instance.py rename to openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py diff --git a/openpype/modules/royal_render/plugins/publish/collect_sequences_from_job.py b/openpype/modules/royalrender/plugins/publish/collect_sequences_from_job.py similarity index 100% rename from openpype/modules/royal_render/plugins/publish/collect_sequences_from_job.py rename to openpype/modules/royalrender/plugins/publish/collect_sequences_from_job.py diff --git a/openpype/modules/royal_render/royal_render_module.py b/openpype/modules/royalrender/royal_render_module.py similarity index 100% rename from openpype/modules/royal_render/royal_render_module.py rename to openpype/modules/royalrender/royal_render_module.py diff --git a/openpype/modules/royal_render/rr_job.py b/openpype/modules/royalrender/rr_job.py similarity index 100% rename from openpype/modules/royal_render/rr_job.py rename to openpype/modules/royalrender/rr_job.py diff --git a/openpype/modules/royal_render/rr_root/README.md b/openpype/modules/royalrender/rr_root/README.md similarity index 100% rename from openpype/modules/royal_render/rr_root/README.md rename to openpype/modules/royalrender/rr_root/README.md diff --git a/openpype/modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py b/openpype/modules/royalrender/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py similarity index 100% rename from openpype/modules/royal_render/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py rename to openpype/modules/royalrender/rr_root/plugins/control_job/perjob/m50__openpype_publish_render.py From bc86cd279c40933c6f9ed84a5724bd2997df1196 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Feb 2022 18:47:09 +0100 Subject: [PATCH 218/483] showing report does not use publishing logic --- openpype/tools/publisher/control.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index 04158ad05e..5a84b1d8ca 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -184,11 +184,21 @@ class PublishReport: self._stored_plugins.append(plugin) + plugin_data_item = self._create_plugin_data_item(plugin) + + self._plugin_data_with_plugin.append({ + "plugin": plugin, + "data": plugin_data_item + }) + self._plugin_data.append(plugin_data_item) + return plugin_data_item + + def _create_plugin_data_item(self, plugin): label = None if hasattr(plugin, "label"): label = plugin.label - plugin_data_item = { + return { "name": plugin.__name__, "label": label, "order": plugin.order, @@ -197,12 +207,6 @@ class PublishReport: "skipped": False, "passed": False } - self._plugin_data_with_plugin.append({ - "plugin": plugin, - "data": plugin_data_item - }) - self._plugin_data.append(plugin_data_item) - return plugin_data_item def set_plugin_skipped(self): """Set that current plugin has been skipped.""" @@ -252,7 +256,7 @@ class PublishReport: if publish_plugins: for plugin in publish_plugins: if plugin not in self._stored_plugins: - plugins_data.append(self._add_plugin_data_item(plugin)) + plugins_data.append(self._create_plugin_data_item(plugin)) crashed_file_paths = {} if self._publish_discover_result is not None: From ca82674145885b4ba432a8addc3ca5375220fd8a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Feb 2022 18:47:25 +0100 Subject: [PATCH 219/483] fix multipath result --- openpype/widgets/attribute_defs/files_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index 5aa76d8754..87b98e2378 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -433,7 +433,7 @@ class MultiFilesWidget(QtWidgets.QFrame): filenames = index.data(FILENAMES_ROLE) for filename in filenames: filepaths.add(os.path.join(dirpath, filename)) - return filepaths + return list(filepaths) def set_filters(self, folders_allowed, exts_filter): self._files_proxy_model.set_allow_folders(folders_allowed) From d167439969646ba55ba18b4b9538d7cda3b8eb0d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 20:14:13 +0100 Subject: [PATCH 220/483] flame: hook Flame to ftrack, rename to babypublisher --- .../export_preset/openpype_seg_thumbnails_jpg.xml | 0 .../export_preset/openpype_seg_video_h264.xml | 0 .../modules/__init__.py | 0 .../modules/app_utils.py | 0 .../modules/ftrack_lib.py | 0 .../modules/panel_app.py | 2 +- .../modules/uiwidgets.py | 0 .../openpype_babypublisher.py} | 2 +- 8 files changed, 2 insertions(+), 2 deletions(-) rename openpype/hosts/flame/startup/{openpype_flame_to_ftrack => openpype_babypublisher}/export_preset/openpype_seg_thumbnails_jpg.xml (100%) rename openpype/hosts/flame/startup/{openpype_flame_to_ftrack => openpype_babypublisher}/export_preset/openpype_seg_video_h264.xml (100%) rename openpype/hosts/flame/startup/{openpype_flame_to_ftrack => openpype_babypublisher}/modules/__init__.py (100%) rename openpype/hosts/flame/startup/{openpype_flame_to_ftrack => openpype_babypublisher}/modules/app_utils.py (100%) rename openpype/hosts/flame/startup/{openpype_flame_to_ftrack => openpype_babypublisher}/modules/ftrack_lib.py (100%) rename openpype/hosts/flame/startup/{openpype_flame_to_ftrack => openpype_babypublisher}/modules/panel_app.py (99%) rename openpype/hosts/flame/startup/{openpype_flame_to_ftrack => openpype_babypublisher}/modules/uiwidgets.py (100%) rename openpype/hosts/flame/startup/{openpype_flame_to_ftrack/openpype_flame_to_ftrack.py => openpype_babypublisher/openpype_babypublisher.py} (95%) diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/export_preset/openpype_seg_thumbnails_jpg.xml b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_thumbnails_jpg.xml similarity index 100% rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/export_preset/openpype_seg_thumbnails_jpg.xml rename to openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_thumbnails_jpg.xml diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/export_preset/openpype_seg_video_h264.xml b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml similarity index 100% rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/export_preset/openpype_seg_video_h264.xml rename to openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/__init__.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/__init__.py similarity index 100% rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/__init__.py rename to openpype/hosts/flame/startup/openpype_babypublisher/modules/__init__.py diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/app_utils.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/app_utils.py similarity index 100% rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/app_utils.py rename to openpype/hosts/flame/startup/openpype_babypublisher/modules/app_utils.py diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/ftrack_lib.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py similarity index 100% rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/ftrack_lib.py rename to openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/panel_app.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py similarity index 99% rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/panel_app.py rename to openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py index 648f902872..4f14f0c28a 100644 --- a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/panel_app.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py @@ -78,7 +78,7 @@ class FlameToFtrackPanel(object): # creating ui self.window.setMinimumSize(1500, 600) - self.window.setWindowTitle('Sequence Shots to Ftrack') + self.window.setWindowTitle('OpenPype: Baby-publisher') self.window.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint) self.window.setAttribute(QtCore.Qt.WA_DeleteOnClose) self.window.setFocusPolicy(QtCore.Qt.StrongFocus) diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/uiwidgets.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/uiwidgets.py similarity index 100% rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/uiwidgets.py rename to openpype/hosts/flame/startup/openpype_babypublisher/modules/uiwidgets.py diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/openpype_flame_to_ftrack.py b/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py similarity index 95% rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/openpype_flame_to_ftrack.py rename to openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py index 5a72706ba1..fc69f75866 100644 --- a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/openpype_flame_to_ftrack.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py @@ -30,7 +30,7 @@ def scope_sequence(selection): def get_media_panel_custom_ui_actions(): return [ { - "name": "OpenPype: Ftrack", + "name": "OpenPype: Baby-publisher", "actions": [ { "name": "Create Shots", From 193541ba04d6f2687bd3b5854dd70df5c4827931 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 20:16:19 +0100 Subject: [PATCH 221/483] Flame: get shot name from sequnce.shot_name attribute --- .../startup/openpype_babypublisher/modules/panel_app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py index 4f14f0c28a..a2093ec271 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py @@ -492,11 +492,11 @@ class FlameToFtrackPanel(object): # Add timeline segment to tree QtWidgets.QTreeWidgetItem(self.tree, [ - str(sequence.name)[1:-1], # seq - str(segment.name)[1:-1], # shot + sequence.name.get_value(), # seq name + segment.shot_name.get_value(), # shot name str(clip_duration), # clip duration shot_description, # shot description - str(segment.comment)[1:-1] # task description + segment.comment.get_value() # task description ]).setFlags( QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEnabled From 2ea402faad359fe5ff7d03a429e10d929ed91fce Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 20:26:05 +0100 Subject: [PATCH 222/483] Flame: rename to babypublisher a user settings namespace --- .../flame/startup/openpype_babypublisher/modules/app_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/app_utils.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/app_utils.py index b255d8d3f5..e639c3f482 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/app_utils.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/app_utils.py @@ -8,7 +8,7 @@ PLUGIN_DIR = os.path.dirname(os.path.dirname(__file__)) EXPORT_PRESETS_DIR = os.path.join(PLUGIN_DIR, "export_preset") CONFIG_DIR = os.path.join(os.path.expanduser( - "~/.openpype"), "openpype_flame_to_ftrack") + "~/.openpype"), "openpype_babypublisher") @contextmanager From bbb653a54d05ce0d547ec8c5ce3c65c3fdcf5665 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 20:26:51 +0100 Subject: [PATCH 223/483] flame: rename to babypublisher panel class --- .../flame/startup/openpype_babypublisher/modules/panel_app.py | 2 +- .../startup/openpype_babypublisher/openpype_babypublisher.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py index a2093ec271..4cf5b9923f 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py @@ -37,7 +37,7 @@ class MainWindow(QtWidgets.QWidget): event.accept() -class FlameToFtrackPanel(object): +class FlameBabyPublisherPanel(object): session = None temp_data_dir = None processed_components = [] diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py b/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py index fc69f75866..839b38c510 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py @@ -19,7 +19,7 @@ def flame_panel_executor(selection): print("panel_app module removed from sys.modules") import panel_app - panel_app.FlameToFtrackPanel(selection) + panel_app.FlameBabyPublisherPanel(selection) def scope_sequence(selection): From 51ffb9f3a0dd5c98f036316b56182505b72a0a6a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 20:27:15 +0100 Subject: [PATCH 224/483] flame: babypublisher use Qt.py --- .../flame/startup/openpype_babypublisher/modules/panel_app.py | 2 +- .../flame/startup/openpype_babypublisher/modules/uiwidgets.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py index 4cf5b9923f..bcb98c8afd 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py @@ -1,4 +1,4 @@ -from PySide2 import QtWidgets, QtCore +from Qt import QtWidgets, QtCore import uiwidgets import app_utils diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/uiwidgets.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/uiwidgets.py index 0d4807a4ea..c6db875df0 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/uiwidgets.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/uiwidgets.py @@ -1,4 +1,4 @@ -from PySide2 import QtWidgets, QtCore +from Qt import QtWidgets, QtCore class FlameLabel(QtWidgets.QLabel): From c298c39ba8008b21634d33a4e76df624c44340df Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 21:15:37 +0100 Subject: [PATCH 225/483] flame: babypublisher swap segment name for shot name in presets --- .../export_preset/openpype_seg_thumbnails_jpg.xml | 2 +- .../export_preset/openpype_seg_video_h264.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_thumbnails_jpg.xml b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_thumbnails_jpg.xml index fa43ceece7..44a7bd9770 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_thumbnails_jpg.xml +++ b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_thumbnails_jpg.xml @@ -29,7 +29,7 @@ Jpeg 923688 - <segment name> + <shot name> 100 2 4 diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml index 3ca185b8b4..e3c6ab90ae 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml +++ b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml @@ -27,7 +27,7 @@ QuickTime - <segment name> + <shot name> 0 PCS_709 None From dcb9d97703db793f060630029cad60c79c176184 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 21:15:49 +0100 Subject: [PATCH 226/483] flame: improving code --- .../flame/startup/openpype_babypublisher/modules/panel_app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py index bcb98c8afd..469826be50 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py @@ -472,10 +472,10 @@ class FlameBabyPublisherPanel(object): for tracks in ver.tracks: for segment in tracks.segments: print(segment.attributes) - if str(segment.name)[1:-1] == "": + if segment.name.get_value() == "": continue # get clip frame duration - record_duration = str(segment.record_duration)[1:-1] + record_duration = segment.record_duration.get_value() clip_duration = app_utils.timecode_to_frames( record_duration, frame_rate) From e36fe36cdccf20406fe8deaf2135e2d2db5a1af4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 21:16:05 +0100 Subject: [PATCH 227/483] flame: fixing reloading modules --- .../startup/openpype_babypublisher/openpype_babypublisher.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py b/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py index 839b38c510..4675d163e3 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py @@ -16,9 +16,10 @@ def flame_panel_executor(selection): if "panel_app" in sys.modules.keys(): print("panel_app module is already loaded") del sys.modules["panel_app"] + import panel_app + reload(panel_app) # noqa print("panel_app module removed from sys.modules") - import panel_app panel_app.FlameBabyPublisherPanel(selection) From bac0e287a4d34aa4b02b00a1f6b2d3c58823eb55 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 21:41:08 +0100 Subject: [PATCH 228/483] flame: filter out segments and tracks which are empty and hidden --- .../openpype_babypublisher/modules/panel_app.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py index 469826be50..e087549dc4 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py @@ -469,13 +469,17 @@ class FlameBabyPublisherPanel(object): for sequence in self.selection: frame_rate = float(str(sequence.frame_rate)[:-4]) for ver in sequence.versions: - for tracks in ver.tracks: - for segment in tracks.segments: + for track in ver.tracks: + if len(track.segments) == 0 and track.hidden: + continue + for segment in track.segments: print(segment.attributes) if segment.name.get_value() == "": continue + if segment.hidden.get_value() is True: + continue # get clip frame duration - record_duration = segment.record_duration.get_value() + record_duration = str(segment.record_duration)[1:-1] clip_duration = app_utils.timecode_to_frames( record_duration, frame_rate) From 2cfbe3282c1635d14b03ee6da8ee0ab88b2dd66d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 21:55:43 +0100 Subject: [PATCH 229/483] flame: fixing preset name for shot name --- .../export_preset/openpype_seg_video_h264.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml index e3c6ab90ae..1d2c5a28bb 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml +++ b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml @@ -43,7 +43,7 @@ 2021 /profiles/.33622016/HDTV_720p_8Mbits.cdxprof - <segment name>_<video codec> + <shot name>_<video codec> 50 2 4 From 040688ca62904847225b9ea8b0c108b28cab9fb2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 09:55:42 +0100 Subject: [PATCH 230/483] fixed app and context data fill --- openpype/hooks/pre_global_host_data.py | 5 ++-- openpype/lib/__init__.py | 4 +-- openpype/lib/applications.py | 38 ++++++++++++++------------ 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/openpype/hooks/pre_global_host_data.py b/openpype/hooks/pre_global_host_data.py index 9b82e36171..4c85a511ed 100644 --- a/openpype/hooks/pre_global_host_data.py +++ b/openpype/hooks/pre_global_host_data.py @@ -2,7 +2,7 @@ from openpype.api import Anatomy from openpype.lib import ( PreLaunchHook, EnvironmentPrepData, - prepare_host_environments, + prepare_app_environments, prepare_context_environments ) @@ -41,8 +41,7 @@ class GlobalHostDataHook(PreLaunchHook): "log": self.log }) - if app.is_host: - prepare_host_environments(temp_data, self.launch_context.env_group) + prepare_app_environments(temp_data, self.launch_context.env_group) prepare_context_environments(temp_data) temp_data.pop("log") diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index ebe7648ad7..f79c03ed57 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -130,7 +130,7 @@ from .applications import ( PostLaunchHook, EnvironmentPrepData, - prepare_host_environments, + prepare_app_environments, prepare_context_environments, get_app_environments_for_context, apply_project_environments_value @@ -261,7 +261,7 @@ __all__ = [ "PreLaunchHook", "PostLaunchHook", "EnvironmentPrepData", - "prepare_host_environments", + "prepare_app_environments", "prepare_context_environments", "get_app_environments_for_context", "apply_project_environments_value", diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 30e671cfad..0b51a6629c 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1295,7 +1295,7 @@ def get_app_environments_for_context( "env": env }) - prepare_host_environments(data, env_group) + prepare_app_environments(data, env_group) prepare_context_environments(data, env_group) # Discard avalon connection @@ -1316,7 +1316,7 @@ def _merge_env(env, current_env): return result -def prepare_host_environments(data, env_group=None, implementation_envs=True): +def prepare_app_environments(data, env_group=None, implementation_envs=True): """Modify launch environments based on launched app and context. Args: @@ -1474,6 +1474,22 @@ def prepare_context_environments(data, env_group=None): ) app = data["app"] + context_env = { + "AVALON_PROJECT": project_doc["name"], + "AVALON_ASSET": asset_doc["name"], + "AVALON_TASK": task_name, + "AVALON_APP_NAME": app.full_name + } + + log.debug( + "Context environments set:\n{}".format( + json.dumps(context_env, indent=4) + ) + ) + data["env"].update(context_env) + if not app.is_host: + return + workdir_data = get_workdir_data( project_doc, asset_doc, task_name, app.host_name ) @@ -1504,22 +1520,8 @@ def prepare_context_environments(data, env_group=None): "Couldn't create workdir because: {}".format(str(exc)) ) - context_env = { - "AVALON_PROJECT": project_doc["name"], - "AVALON_ASSET": asset_doc["name"], - "AVALON_TASK": task_name, - "AVALON_APP_NAME": app.full_name, - "AVALON_WORKDIR": workdir - } - if app.is_host: - context_env["AVALON_APP"]: app.host_name - - log.debug( - "Context environments set:\n{}".format( - json.dumps(context_env, indent=4) - ) - ) - data["env"].update(context_env) + data["env"]["AVALON_APP"] = app.host_name + data["env"]["AVALON_WORKDIR"] = workdir _prepare_last_workfile(data, workdir) From c5d737faad0ee755b9b967eb529e0185291e1077 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 24 Feb 2022 10:47:57 +0100 Subject: [PATCH 231/483] Draft implementation of Update all to latest button --- openpype/tools/sceneinventory/window.py | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/openpype/tools/sceneinventory/window.py b/openpype/tools/sceneinventory/window.py index e363a99d07..a05d820ec6 100644 --- a/openpype/tools/sceneinventory/window.py +++ b/openpype/tools/sceneinventory/window.py @@ -1,5 +1,6 @@ import os import sys +import logging from Qt import QtWidgets, QtCore from avalon.vendor import qtawesome @@ -20,6 +21,9 @@ from .model import ( ) from .view import SceneInvetoryView +from ..utils.lib import iter_model_rows + +log = logging.getLogger(__name__) module = sys.modules[__name__] module.window = None @@ -54,6 +58,10 @@ class SceneInventoryWindow(QtWidgets.QDialog): outdated_only_checkbox.setToolTip("Show outdated files only") outdated_only_checkbox.setChecked(False) + icon = qtawesome.icon("fa.arrow-up", color="white") + update_all_button = QtWidgets.QPushButton(self) + update_all_button.setIcon(icon) + icon = qtawesome.icon("fa.refresh", color="white") refresh_button = QtWidgets.QPushButton(self) refresh_button.setIcon(icon) @@ -62,6 +70,7 @@ class SceneInventoryWindow(QtWidgets.QDialog): control_layout.addWidget(filter_label) control_layout.addWidget(text_filter) control_layout.addWidget(outdated_only_checkbox) + control_layout.addWidget(update_all_button) control_layout.addWidget(refresh_button) # endregion control @@ -102,7 +111,9 @@ class SceneInventoryWindow(QtWidgets.QDialog): ) view.data_changed.connect(self.refresh) refresh_button.clicked.connect(self.refresh) + update_all_button.clicked.connect(self._on_update_all) + self._update_all_button = update_all_button self._outdated_only_checkbox = outdated_only_checkbox self._view = view self._model = model @@ -158,6 +169,42 @@ class SceneInventoryWindow(QtWidgets.QDialog): self._outdated_only_checkbox.isChecked() ) + def _on_update_all(self): + """Update all items that are currently 'outdated' in the view""" + + # Get all items from outdated groups + outdated_items = [] + for index in iter_model_rows(self._model, + column=0, + include_root=False): + item = index.data(self._model.ItemRole) + + if not item.get("isGroupNode"): + continue + + # Only the group nodes contain the "highest_version" data and as + # such we find only the groups and take its children. + if not self._model.outdated(item): + continue + + # Collect all children which we want to update + children = item.children() + outdated_items.extend(children) + + if not outdated_items: + log.info("Nothing to update.") + return + + # Trigger update to latest + # Logic copied from SceneInventoryView._build_item_menu_for_selection + for item in outdated_items: + try: + api.update(item, -1) + except AssertionError: + self._show_version_error_dialog(None, [item]) + log.warning("Update failed", exc_info=True) + self._view.data_changed.emit() + def show(root=None, debug=False, parent=None, items=None): """Display Scene Inventory GUI From 0b9e669d3b6421dceefd96e87aed81166708ba52 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 24 Feb 2022 10:49:28 +0100 Subject: [PATCH 232/483] Fix typos in class name and functions --- openpype/tools/sceneinventory/view.py | 4 ++-- openpype/tools/sceneinventory/window.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py index 80f26a881d..2ae8c95be4 100644 --- a/openpype/tools/sceneinventory/view.py +++ b/openpype/tools/sceneinventory/view.py @@ -20,12 +20,12 @@ DEFAULT_COLOR = "#fb9c15" log = logging.getLogger("SceneInventory") -class SceneInvetoryView(QtWidgets.QTreeView): +class SceneInventoryView(QtWidgets.QTreeView): data_changed = QtCore.Signal() hierarchy_view_changed = QtCore.Signal(bool) def __init__(self, parent=None): - super(SceneInvetoryView, self).__init__(parent=parent) + super(SceneInventoryView, self).__init__(parent=parent) # view settings self.setIndentation(12) diff --git a/openpype/tools/sceneinventory/window.py b/openpype/tools/sceneinventory/window.py index a05d820ec6..d92c1f00d4 100644 --- a/openpype/tools/sceneinventory/window.py +++ b/openpype/tools/sceneinventory/window.py @@ -19,7 +19,7 @@ from .model import ( InventoryModel, FilterProxyModel ) -from .view import SceneInvetoryView +from .view import SceneInventoryView from ..utils.lib import iter_model_rows @@ -82,7 +82,7 @@ class SceneInventoryWindow(QtWidgets.QDialog): proxy.setDynamicSortFilter(True) proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) - view = SceneInvetoryView(self) + view = SceneInventoryView(self) view.setModel(proxy) # set some nice default widths for the view @@ -107,7 +107,7 @@ class SceneInventoryWindow(QtWidgets.QDialog): self._on_outdated_state_change ) view.hierarchy_view_changed.connect( - self._on_hiearchy_view_change + self._on_hierarchy_view_change ) view.data_changed.connect(self.refresh) refresh_button.clicked.connect(self.refresh) @@ -157,7 +157,7 @@ class SceneInventoryWindow(QtWidgets.QDialog): kwargs["selected"] = self._view._selected self._model.refresh(**kwargs) - def _on_hiearchy_view_change(self, enabled): + def _on_hierarchy_view_change(self, enabled): self._proxy.set_hierarchy_view(enabled) self._model.set_hierarchy_view(enabled) From 430f0428a2dd72e3b2d8502741302247cebdd95b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 24 Feb 2022 10:52:15 +0100 Subject: [PATCH 233/483] Add tooltips --- openpype/tools/sceneinventory/window.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/sceneinventory/window.py b/openpype/tools/sceneinventory/window.py index d92c1f00d4..d9d34dbb08 100644 --- a/openpype/tools/sceneinventory/window.py +++ b/openpype/tools/sceneinventory/window.py @@ -60,10 +60,12 @@ class SceneInventoryWindow(QtWidgets.QDialog): icon = qtawesome.icon("fa.arrow-up", color="white") update_all_button = QtWidgets.QPushButton(self) + update_all_button.setToolTip("Update all outdated to latest version") update_all_button.setIcon(icon) icon = qtawesome.icon("fa.refresh", color="white") refresh_button = QtWidgets.QPushButton(self) + update_all_button.setToolTip("Refresh") refresh_button.setIcon(icon) control_layout = QtWidgets.QHBoxLayout() From e0670d34eb9a6779f959f5d908247928806afeea Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Feb 2022 11:42:07 +0100 Subject: [PATCH 234/483] flame: fixing getting already created entity --- .../openpype_babypublisher/modules/ftrack_lib.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py index c2168016c6..90311a5ac5 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py @@ -389,13 +389,17 @@ class FtrackEntityOperator: return entity def get_ftrack_entity(self, session, type, name, parent): - query = '{} where name is "{}" and project_id is "{}"'.format( + query_no_parent = '{} where name is "{}" and project_id is "{}"'.format( type, name, self.project_entity["id"]) + query_with_parent = ( + '{} where name is "{}" and project_id is "{}" ' + 'and parent_id is {}').format( + type, name, self.project_entity["id"], parent["id"]) - try: - entity = session.query(query).one() - except Exception: - entity = None + entity = ( + session.query(query_no_parent).first() or + session.query(query_with_parent).first() + ) # if entity doesnt exist then create one if not entity: From 4e9131ccd8153cc3bb1f2aaa683c5819f55894f0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 11:49:44 +0100 Subject: [PATCH 235/483] tray publisher can be enabled using loclal settings --- openpype/modules/interfaces.py | 2 + openpype/modules/traypublish_action.py | 15 +- .../defaults/system_settings/modules.json | 3 - .../schemas/system_schema/schema_modules.json | 14 -- openpype/tools/experimental_tools/dialog.py | 5 +- .../tools/experimental_tools/tools_def.py | 132 ++++++++++-------- .../local_settings/experimental_widget.py | 2 +- 7 files changed, 91 insertions(+), 82 deletions(-) diff --git a/openpype/modules/interfaces.py b/openpype/modules/interfaces.py index 7c301c15b4..13cbea690b 100644 --- a/openpype/modules/interfaces.py +++ b/openpype/modules/interfaces.py @@ -122,6 +122,7 @@ class ITrayAction(ITrayModule): admin_action = False _admin_submenu = None + _action_item = None @property @abstractmethod @@ -149,6 +150,7 @@ class ITrayAction(ITrayModule): tray_menu.addAction(action) action.triggered.connect(self.on_action_trigger) + self._action_item = action def tray_start(self): return diff --git a/openpype/modules/traypublish_action.py b/openpype/modules/traypublish_action.py index 039ce96206..033e24da88 100644 --- a/openpype/modules/traypublish_action.py +++ b/openpype/modules/traypublish_action.py @@ -11,7 +11,7 @@ class TrayPublishAction(OpenPypeModule, ITrayAction): def initialize(self, modules_settings): import openpype - self.enabled = modules_settings[self.name]["enabled"] + self.enabled = True self.publish_paths = [ os.path.join( openpype.PACKAGE_DIR, @@ -21,9 +21,20 @@ class TrayPublishAction(OpenPypeModule, ITrayAction): "publish" ) ] + self._experimental_tools = None def tray_init(self): - return + from openpype.tools.experimental_tools import ExperimentalTools + + self._experimental_tools = ExperimentalTools() + + def tray_menu(self, *args, **kwargs): + super(TrayPublishAction, self).tray_menu(*args, **kwargs) + traypublisher = self._experimental_tools.get("traypublisher") + visible = False + if traypublisher and traypublisher.enabled: + visible = True + self._action_item.setVisible(visible) def on_action_trigger(self): self.run_traypublisher() diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index 70dc584360..d74269922f 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -191,9 +191,6 @@ "standalonepublish_tool": { "enabled": true }, - "traypublish_tool": { - "enabled": false - }, "project_manager": { "enabled": true }, diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index 21c8163cea..52595914ed 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -233,20 +233,6 @@ } ] }, - { - "type": "dict", - "key": "traypublish_tool", - "label": "Tray Publish (beta)", - "collapsible": true, - "checkbox_key": "enabled", - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - } - ] - }, { "type": "dict", "key": "project_manager", diff --git a/openpype/tools/experimental_tools/dialog.py b/openpype/tools/experimental_tools/dialog.py index 295afbe68d..0099492207 100644 --- a/openpype/tools/experimental_tools/dialog.py +++ b/openpype/tools/experimental_tools/dialog.py @@ -82,7 +82,7 @@ class ExperimentalToolsDialog(QtWidgets.QDialog): tool_btns_layout.addWidget(tool_btns_label, 0) experimental_tools = ExperimentalTools( - parent=parent, filter_hosts=True + parent_widget=parent, refresh=False ) # Main layout @@ -116,7 +116,8 @@ class ExperimentalToolsDialog(QtWidgets.QDialog): self._experimental_tools.refresh_availability() buttons_to_remove = set(self._buttons_by_tool_identifier.keys()) - for idx, tool in enumerate(self._experimental_tools.tools): + tools = self._experimental_tools.get_tools_for_host() + for idx, tool in enumerate(tools): identifier = tool.identifier if identifier in buttons_to_remove: buttons_to_remove.remove(identifier) diff --git a/openpype/tools/experimental_tools/tools_def.py b/openpype/tools/experimental_tools/tools_def.py index 316359c0f3..fa2971dc1d 100644 --- a/openpype/tools/experimental_tools/tools_def.py +++ b/openpype/tools/experimental_tools/tools_def.py @@ -5,7 +5,32 @@ from openpype.settings import get_local_settings LOCAL_EXPERIMENTAL_KEY = "experimental_tools" -class ExperimentalTool: +class ExperimentalTool(object): + """Definition of experimental tool. + + Definition is used in local settings. + + Args: + identifier (str): String identifier of tool (unique). + label (str): Label shown in UI. + """ + def __init__(self, identifier, label, tooltip): + self.identifier = identifier + self.label = label + self.tooltip = tooltip + self._enabled = True + + @property + def enabled(self): + """Is tool enabled and button is clickable.""" + return self._enabled + + def set_enabled(self, enabled=True): + """Change if tool is enabled.""" + self._enabled = enabled + + +class ExperimentalHostTool(ExperimentalTool): """Definition of experimental tool. Definition is used in local settings and in experimental tools dialog. @@ -19,12 +44,10 @@ class ExperimentalTool: Some tools may not be available in all hosts. """ def __init__( - self, identifier, label, callback, tooltip, hosts_filter=None + self, identifier, label, tooltip, callback, hosts_filter=None ): - self.identifier = identifier - self.label = label + super(ExperimentalHostTool, self).__init__(identifier, label, tooltip) self.callback = callback - self.tooltip = tooltip self.hosts_filter = hosts_filter self._enabled = True @@ -33,18 +56,9 @@ class ExperimentalTool: return host_name in self.hosts_filter return True - @property - def enabled(self): - """Is tool enabled and button is clickable.""" - return self._enabled - - def set_enabled(self, enabled=True): - """Change if tool is enabled.""" - self._enabled = enabled - - def execute(self): + def execute(self, *args, **kwargs): """Trigger registered callback.""" - self.callback() + self.callback(*args, **kwargs) class ExperimentalTools: @@ -53,57 +67,36 @@ class ExperimentalTools: To add/remove experimental tool just add/remove tool to `experimental_tools` variable in __init__ function. - Args: - parent (QtWidgets.QWidget): Parent widget for tools. - host_name (str): Name of host in which context we're now. Environment - value 'AVALON_APP' is used when not passed. - filter_hosts (bool): Should filter tools. By default is set to 'True' - when 'host_name' is passed. Is always set to 'False' if 'host_name' - is not defined. + --- Example tool (callback will just print on click) --- + def example_callback(*args): + print("Triggered tool") + + experimental_tools = [ + ExperimentalHostTool( + "example", + "Example experimental tool", + example_callback, + "Example tool tooltip." + ) + ] + --- """ - def __init__(self, parent=None, host_name=None, filter_hosts=None): + def __init__(self, parent_widget=None, refresh=True): # Definition of experimental tools experimental_tools = [ - ExperimentalTool( + ExperimentalHostTool( "publisher", "New publisher", - self._show_publisher, - "Combined creation and publishing into one tool." + "Combined creation and publishing into one tool.", + self._show_publisher + ), + ExperimentalTool( + "traypublisher", + "New Standalone Publisher", + "Standalone publisher using new publisher. Requires restart" ) ] - # --- Example tool (callback will just print on click) --- - # def example_callback(*args): - # print("Triggered tool") - # - # experimental_tools = [ - # ExperimentalTool( - # "example", - # "Example experimental tool", - # example_callback, - # "Example tool tooltip." - # ) - # ] - - # Try to get host name from env variable `AVALON_APP` - if not host_name: - host_name = os.environ.get("AVALON_APP") - - # Decide if filtering by host name should happen - if filter_hosts is None: - filter_hosts = host_name is not None - - if filter_hosts and not host_name: - filter_hosts = False - - # Filter tools by host name - if filter_hosts: - experimental_tools = [ - tool - for tool in experimental_tools - if tool.is_available_for_host(host_name) - ] - # Store tools by identifier tools_by_identifier = {} for tool in experimental_tools: @@ -115,10 +108,13 @@ class ExperimentalTools: self._tools_by_identifier = tools_by_identifier self._tools = experimental_tools - self._parent_widget = parent + self._parent_widget = parent_widget self._publisher_tool = None + if refresh: + self.refresh_availability() + @property def tools(self): """Tools in list. @@ -139,6 +135,22 @@ class ExperimentalTools: """ return self._tools_by_identifier + def get(self, tool_identifier): + """Get tool by identifier.""" + return self.tools_by_identifier.get(tool_identifier) + + def get_tools_for_host(self, host_name=None): + if not host_name: + host_name = os.environ.get("AVALON_APP") + tools = [] + for tool in self.tools: + if ( + isinstance(tool, ExperimentalHostTool) + and tool.is_available_for_host(host_name) + ): + tools.append(tool) + return tools + def refresh_availability(self): """Reload local settings and check if any tool changed ability.""" local_settings = get_local_settings() diff --git a/openpype/tools/settings/local_settings/experimental_widget.py b/openpype/tools/settings/local_settings/experimental_widget.py index e863d9afb0..22ef952356 100644 --- a/openpype/tools/settings/local_settings/experimental_widget.py +++ b/openpype/tools/settings/local_settings/experimental_widget.py @@ -28,7 +28,7 @@ class LocalExperimentalToolsWidgets(QtWidgets.QWidget): layout.addRow(empty_label) - experimental_defs = ExperimentalTools(filter_hosts=False) + experimental_defs = ExperimentalTools(refresh=False) checkboxes_by_identifier = {} for tool in experimental_defs.tools: checkbox = QtWidgets.QCheckBox(self) From 93b8366e602a1c02a73885a5b960802d2a4ad4dc Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Feb 2022 11:42:07 +0100 Subject: [PATCH 236/483] Revert "flame: fixing getting already created entity" This reverts commit e0670d34eb9a6779f959f5d908247928806afeea. --- .../openpype_babypublisher/modules/ftrack_lib.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py index 90311a5ac5..c2168016c6 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py @@ -389,17 +389,13 @@ class FtrackEntityOperator: return entity def get_ftrack_entity(self, session, type, name, parent): - query_no_parent = '{} where name is "{}" and project_id is "{}"'.format( + query = '{} where name is "{}" and project_id is "{}"'.format( type, name, self.project_entity["id"]) - query_with_parent = ( - '{} where name is "{}" and project_id is "{}" ' - 'and parent_id is {}').format( - type, name, self.project_entity["id"], parent["id"]) - entity = ( - session.query(query_no_parent).first() or - session.query(query_with_parent).first() - ) + try: + entity = session.query(query).one() + except Exception: + entity = None # if entity doesnt exist then create one if not entity: From 93f64acff0e91eeeeb27551e14a8e9513d79acef Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Feb 2022 11:51:54 +0100 Subject: [PATCH 237/483] flame: code simplification --- .../startup/openpype_babypublisher/modules/ftrack_lib.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py index c2168016c6..0a601a8804 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py @@ -392,10 +392,7 @@ class FtrackEntityOperator: query = '{} where name is "{}" and project_id is "{}"'.format( type, name, self.project_entity["id"]) - try: - entity = session.query(query).one() - except Exception: - entity = None + entity = session.query(query).first() # if entity doesnt exist then create one if not entity: From f4d27e591dea8cc3f63a0f209a65f9411a06000c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Feb 2022 12:10:00 +0100 Subject: [PATCH 238/483] flame: making sure task is created only once --- .../modules/ftrack_lib.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py index 0a601a8804..7bf28ae5a7 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py @@ -363,6 +363,7 @@ class FtrackEntityOperator: def __init__(self, session, project_entity): self.session = session self.project_entity = project_entity + self.existing_tasks = [] def commit(self): try: @@ -427,10 +428,21 @@ class FtrackEntityOperator: return parents def create_task(self, task_type, task_types, parent): - existing_task = [ + _exising_tasks = [ child for child in parent['children'] if child.entity_type.lower() == 'task' - if child['name'].lower() in task_type.lower() + ] + + # add task into existing tasks if they are not already there + for _t in _exising_tasks: + if _t in self.existing_tasks: + continue + self.existing_tasks.append(_t) + + existing_task = [ + task for task in self.existing_tasks + if task['name'].lower() in task_type.lower() + if task['parent'] == parent ] if existing_task: @@ -442,4 +454,5 @@ class FtrackEntityOperator: }) task["type"] = task_types[task_type] + self.existing_tasks.append(task) return task From 3015c998db50bf3540487f9a392a0fee64d09105 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 12:24:16 +0100 Subject: [PATCH 239/483] Changed label of tray action --- openpype/modules/traypublish_action.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/traypublish_action.py b/openpype/modules/traypublish_action.py index 033e24da88..39163b8eb8 100644 --- a/openpype/modules/traypublish_action.py +++ b/openpype/modules/traypublish_action.py @@ -6,7 +6,7 @@ from openpype_interfaces import ITrayAction class TrayPublishAction(OpenPypeModule, ITrayAction): - label = "Tray Publish (beta)" + label = "New Publish (beta)" name = "traypublish_tool" def initialize(self, modules_settings): From f985e58bb9687d19e63c05dce061063fce35e77e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 24 Feb 2022 12:27:13 +0100 Subject: [PATCH 240/483] Removed forgotten lines --- openpype/lib/anatomy.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index 8f2f09a803..3bcd6169e4 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -67,10 +67,7 @@ class Anatomy: " to load data for specific project." )) - from .avalon_context import get_project_code - self.project_name = project_name - self.project_code = get_project_code(project_name) self._data = self._prepare_anatomy_data( get_anatomy_settings(project_name, site_name) From df9a398cc67b8d4a75554c9566688b76f1ab64d1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Feb 2022 13:11:31 +0100 Subject: [PATCH 241/483] flame: existing tasks adding to object variable --- .../flame/startup/openpype_babypublisher/modules/ftrack_lib.py | 3 ++- .../flame/startup/openpype_babypublisher/modules/panel_app.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py index 7bf28ae5a7..0e84a5ef52 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py @@ -360,10 +360,11 @@ class FtrackComponentCreator: class FtrackEntityOperator: + existing_tasks = [] + def __init__(self, session, project_entity): self.session = session self.project_entity = project_entity - self.existing_tasks = [] def commit(self): try: diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py index e087549dc4..1e8011efaa 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py @@ -33,6 +33,7 @@ class MainWindow(QtWidgets.QWidget): self.panel_class.clear_temp_data() self.panel_class.close() clear_inner_modules() + ftrack_lib.FtrackEntityOperator.existing_tasks = [] # now the panel can be closed event.accept() From 8d7f56c9e822cc3b71c9d928cf8da153c68d1804 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 15:11:40 +0100 Subject: [PATCH 242/483] added more methods to StringTemplate --- openpype/lib/path_templates.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py index b51951851f..3b0e9ad3cc 100644 --- a/openpype/lib/path_templates.py +++ b/openpype/lib/path_templates.py @@ -126,6 +126,19 @@ class StringTemplate(object): self._parts = self.find_optional_parts(new_parts) + def __str__(self): + return self.template + + def __repr__(self): + return "<{}> {}".format(self.__class__.__name__, self.template) + + def __contains__(self, other): + return other in self.template + + def replace(self, *args, **kwargs): + self._template = self.template.replace(*args, **kwargs) + return self + @property def template(self): return self._template From 899e59b05919a8ebd3830fd381a81aded412d5fc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 15:12:09 +0100 Subject: [PATCH 243/483] fix which template key is used for getting last workfile --- openpype/lib/applications.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 393c83e9be..f6182c1846 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -28,7 +28,8 @@ from .profiles_filtering import filter_profiles from .local_settings import get_openpype_username from .avalon_context import ( get_workdir_data, - get_workdir_with_workdir_data + get_workdir_with_workdir_data, + get_workfile_template_key ) from .python_module_tools import ( @@ -1587,14 +1588,15 @@ def _prepare_last_workfile(data, workdir): last_workfile_path = data.get("last_workfile_path") or "" if not last_workfile_path: extensions = avalon.api.HOST_WORKFILE_EXTENSIONS.get(app.host_name) - if extensions: anatomy = data["anatomy"] + project_settings = data["project_settings"] + task_type = workdir_data["task"]["type"] + template_key = get_workfile_template_key( + task_type, app.host_name, project_settings=project_settings + ) # Find last workfile - file_template = anatomy.templates["work"]["file"] - # Replace {task} by '{task[name]}' for backward compatibility - if '{task}' in file_template: - file_template = file_template.replace('{task}', '{task[name]}') + file_template = str(anatomy.templates[template_key]["file"]) workdir_data.update({ "version": 1, From 43839e63f131e0bd3fc32d1d91fdb61b1db1e96d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 15:36:15 +0100 Subject: [PATCH 244/483] TemplatesDict does not return objected templates with 'templates' attribute --- openpype/lib/anatomy.py | 61 +++++++++++-------- openpype/lib/path_templates.py | 11 +++- .../action_create_folders.py | 1 - 3 files changed, 46 insertions(+), 27 deletions(-) diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index 3bcd6169e4..3d56c1f1ba 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -402,7 +402,9 @@ class AnatomyTemplates(TemplatesDict): return self.templates.get(key, default) def reset(self): + self._raw_templates = None self._templates = None + self._objected_templates = None @property def project_name(self): @@ -414,13 +416,21 @@ class AnatomyTemplates(TemplatesDict): @property def templates(self): + self._validate_discovery() + return self._templates + + @property + def objected_templates(self): + self._validate_discovery() + return self._objected_templates + + def _validate_discovery(self): if self.project_name != self.loaded_project: - self._templates = None + self.reset() if self._templates is None: self._discover() self.loaded_project = self.project_name - return self._templates def _format_value(self, value, data): if isinstance(value, RootItem): @@ -434,31 +444,34 @@ class AnatomyTemplates(TemplatesDict): def set_templates(self, templates): if not templates: - self._raw_templates = None - self._templates = None - else: - self._raw_templates = copy.deepcopy(templates) - templates = copy.deepcopy(templates) - v_queue = collections.deque() - v_queue.append(templates) - while v_queue: - item = v_queue.popleft() - if not isinstance(item, dict): - continue + self.reset() + return - for key in tuple(item.keys()): - value = item[key] - if isinstance(value, dict): - v_queue.append(value) + self._raw_templates = copy.deepcopy(templates) + templates = copy.deepcopy(templates) + v_queue = collections.deque() + v_queue.append(templates) + while v_queue: + item = v_queue.popleft() + if not isinstance(item, dict): + continue - elif ( - isinstance(value, StringType) - and "{task}" in value - ): - item[key] = value.replace("{task}", "{task[name]}") + for key in tuple(item.keys()): + value = item[key] + if isinstance(value, dict): + v_queue.append(value) - solved_templates = self.solve_template_inner_links(templates) - self._templates = self.create_ojected_templates(solved_templates) + elif ( + isinstance(value, StringType) + and "{task}" in value + ): + item[key] = value.replace("{task}", "{task[name]}") + + solved_templates = self.solve_template_inner_links(templates) + self._templates = solved_templates + self._objected_templates = self.create_ojected_templates( + solved_templates + ) def default_templates(self): """Return default templates data with solved inner keys.""" diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py index 3b0e9ad3cc..370ffdd27c 100644 --- a/openpype/lib/path_templates.py +++ b/openpype/lib/path_templates.py @@ -227,15 +227,18 @@ class TemplatesDict(object): def __init__(self, templates=None): self._raw_templates = None self._templates = None + self._objected_templates = None self.set_templates(templates) def set_templates(self, templates): if templates is None: self._raw_templates = None self._templates = None + self._objected_templates = None elif isinstance(templates, dict): self._raw_templates = copy.deepcopy(templates) - self._templates = self.create_ojected_templates(templates) + self._templates = templates + self._objected_templates = self.create_ojected_templates(templates) else: raise TypeError("<{}> argument must be a dict, not {}.".format( self.__class__.__name__, str(type(templates)) @@ -255,6 +258,10 @@ class TemplatesDict(object): def templates(self): return self._templates + @property + def objected_templates(self): + return self._objected_templates + @classmethod def create_ojected_templates(cls, templates): if not isinstance(templates, dict): @@ -325,7 +332,7 @@ class TemplatesDict(object): if env_key not in data: data[env_key] = val - solved = self._solve_dict(self.templates, data) + solved = self._solve_dict(self.objected_templates, data) output = TemplatesResultDict(solved) output.strict = strict diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py index 8bbef9ad73..d15a865124 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py @@ -97,7 +97,6 @@ class CreateFolders(BaseAction): all_entities = self.get_notask_children(entity) anatomy = Anatomy(project_name) - project_settings = get_project_settings(project_name) work_keys = ["work", "folder"] work_template = anatomy.templates From e9c67e35bc2d294d1aa9a4319f4d67022079d1af Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 17:21:52 +0100 Subject: [PATCH 245/483] fixed used values passed to TemplateResult --- openpype/lib/path_templates.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py index 370ffdd27c..62bfdf774a 100644 --- a/openpype/lib/path_templates.py +++ b/openpype/lib/path_templates.py @@ -171,7 +171,7 @@ class StringTemplate(object): missing_keys |= result.missing_optional_keys solved = result.solved - used_values = result.split_keys_to_subdicts(result.used_values) + used_values = result.get_clean_used_values() return TemplateResult( result.output, @@ -485,7 +485,7 @@ class TemplatePartResult: self._missing_optional_keys = set() self._invalid_optional_types = {} - # Used values stored by key + # Used values stored by key with origin type # - key without any padding or key modifiers # - value from filling data # Example: {"version": 1} @@ -584,6 +584,15 @@ class TemplatePartResult: data[last_key] = value return output + def get_clean_used_values(self): + new_used_values = {} + for key, value in self.used_values.items(): + if isinstance(value, FormatObject): + value = str(value) + new_used_values[key] = value + + return self.split_keys_to_subdicts(new_used_values) + def add_realy_used_value(self, key, value): self._realy_used_values[key] = value @@ -724,7 +733,7 @@ class FormattingPart: formatted_value = self.template.format(**fill_data) result.add_realy_used_value(key, formatted_value) - result.add_used_value(existence_check, value) + result.add_used_value(existence_check, formatted_value) result.add_output(formatted_value) return result From 8c83016910467e6999daa2e186494e9ee256b7e3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 25 Feb 2022 10:01:04 +0100 Subject: [PATCH 246/483] search is not case sensitive --- openpype/tools/settings/settings/search_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/settings/settings/search_dialog.py b/openpype/tools/settings/settings/search_dialog.py index 3f987c0010..c02670c180 100644 --- a/openpype/tools/settings/settings/search_dialog.py +++ b/openpype/tools/settings/settings/search_dialog.py @@ -30,7 +30,7 @@ class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): regex = self.filterRegExp() if not regex.isEmpty() and regex.isValid(): pattern = regex.pattern() - compiled_regex = re.compile(pattern) + compiled_regex = re.compile(pattern, re.IGNORECASE) source_model = self.sourceModel() # Check current index itself in all columns From c7c324c4bb28e92ad05273582a125a994374246f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 25 Feb 2022 10:01:16 +0100 Subject: [PATCH 247/483] text change timer is singleshot --- openpype/tools/settings/settings/search_dialog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/settings/settings/search_dialog.py b/openpype/tools/settings/settings/search_dialog.py index c02670c180..e6538cfe67 100644 --- a/openpype/tools/settings/settings/search_dialog.py +++ b/openpype/tools/settings/settings/search_dialog.py @@ -75,6 +75,7 @@ class SearchEntitiesDialog(QtWidgets.QDialog): filter_changed_timer = QtCore.QTimer() filter_changed_timer.setInterval(200) + filter_changed_timer.setSingleShot(True) view.selectionModel().selectionChanged.connect( self._on_selection_change From 1574a24953aac9fb581329c8a10313d8500b6ffe Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 25 Feb 2022 10:01:47 +0100 Subject: [PATCH 248/483] fix crashed entity creation handling --- openpype/tools/settings/settings/categories.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index 14e25a54d8..663d497c36 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -715,7 +715,12 @@ class SettingsCategoryWidget(QtWidgets.QWidget): self._outdated_version_label, self._require_restart_label, } - if self.entity.require_restart: + if self.is_modifying_defaults or self.entity is None: + require_restart = False + else: + require_restart = self.entity.require_restart + + if require_restart: visible_label = self._require_restart_label elif self._is_loaded_version_outdated: visible_label = self._outdated_version_label From 02d3a5fa5764da970102229052ca6654581ccd0e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 25 Feb 2022 12:30:14 +0100 Subject: [PATCH 249/483] nuke: add reformat settings for baking mov presets publish plugin --- .../defaults/project_settings/nuke.json | 25 ++++++++++++- .../schemas/schema_nuke_publish.json | 35 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 5a819e6904..238d21d43a 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -122,7 +122,30 @@ "viewer_process_override": "", "bake_viewer_process": true, "bake_viewer_input_process": true, - "add_tags": [] + "add_tags": [], + "reformat_node_add": false, + "reformat_node_config": [ + { + "name": "type", + "value": "to format" + }, + { + "name": "format", + "value": "HD_1080" + }, + { + "name": "filter", + "value": "Lanczos6" + }, + { + "name": "black_outside", + "value": "true" + }, + { + "name": "pbb", + "value": "false" + } + ] } } }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json index 39390f355a..81e5d2cc3f 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json @@ -226,6 +226,41 @@ "label": "Add additional tags to representations", "type": "list", "object_type": "text" + }, + { + "type": "separator" + }, + { + "type": "boolean", + "key": "reformat_node_add", + "label": "Add Reformat Node" + }, + { + "type": "collapsible-wrap", + "label": "Reformat Node Knobs", + "collapsible": true, + "collapsed": false, + "children": [ + { + "type": "list", + "key": "reformat_node_config", + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "name", + "label": "Knob Name" + }, + { + "type": "text", + "key": "value", + "label": "Knob Value" + } + ] + } + } + ] } ] } From 15d4047b1fc76acec4a84633ec5621eaec8261c3 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 25 Feb 2022 13:59:08 +0100 Subject: [PATCH 250/483] Nuke: adding reformat to bake mov worfklow procedure --- openpype/hosts/nuke/api/plugin.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index fd754203d4..79413bab6c 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -446,6 +446,8 @@ class ExporterReviewMov(ExporterReview): return path def generate_mov(self, farm=False, **kwargs): + reformat_node_add = kwargs["reformat_node_add"] + reformat_node_config = kwargs["reformat_node_config"] bake_viewer_process = kwargs["bake_viewer_process"] bake_viewer_input_process_node = kwargs[ "bake_viewer_input_process"] @@ -483,6 +485,25 @@ class ExporterReviewMov(ExporterReview): self.previous_node = r_node self.log.debug("Read... `{}`".format(self._temp_nodes[subset])) + # add reformat node + if reformat_node_add: + rf_node = nuke.createNode("Reformat") + for kn_conf in reformat_node_config: + k_name = str(kn_conf["name"]) + k_value = str(kn_conf["value"]) + if k_value == "true": + k_value = True + if k_value == "false": + k_value = False + rf_node[k_name].setValue(k_value) + + # connect + rf_node.setInput(0, self.previous_node) + self._temp_nodes[subset].append(rf_node) + self.previous_node = rf_node + self.log.debug( + "Reformat... `{}`".format(self._temp_nodes[subset])) + # only create colorspace baking if toggled on if bake_viewer_process: if bake_viewer_input_process_node: @@ -555,7 +576,7 @@ class ExporterReviewMov(ExporterReview): self.log.debug("Representation... `{}`".format(self.data)) - self.clean_nodes(subset) + # self.clean_nodes(subset) nuke.scriptSave() return self.data From 546b6cca98c1e58a500a4b52a461f7ec9f58ff5f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 25 Feb 2022 15:34:15 +0100 Subject: [PATCH 251/483] Moved dev pages to Dev Added page for Admins about releases --- website/docs/admin_builds.md | 20 ++++++++++++++++++++ website/docs/dev_introduction.md | 4 ---- website/sidebars.js | 5 +++-- 3 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 website/docs/admin_builds.md diff --git a/website/docs/admin_builds.md b/website/docs/admin_builds.md new file mode 100644 index 0000000000..3a02cd5baf --- /dev/null +++ b/website/docs/admin_builds.md @@ -0,0 +1,20 @@ +--- +id: admin_builds +title: Builds and Releases +sidebar_label: Builds +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Admins might find prepared builds on https://github.com/pypeclub/OpenPype/releases for all major platforms. + +### Currently supported OS versions +- Windows 10+ +- Ubuntu 20 +- Centos 7.9 +- MacOS Mohave + +In case your studio requires build for different OS version, or any specific build, please take a look at +[Requirements](dev_requirements.md) and [Build](dev_build.md) for more details how to create binaries to distribute. + \ No newline at end of file diff --git a/website/docs/dev_introduction.md b/website/docs/dev_introduction.md index af17f30692..5b48635a08 100644 --- a/website/docs/dev_introduction.md +++ b/website/docs/dev_introduction.md @@ -8,7 +8,3 @@ sidebar_label: Introduction Here you should find additional information targeted on developers who would like to contribute or dive deeper into OpenPype platform Currently there are details about automatic testing, in the future this should be location for API definition and documentation - -Check also: -- [Requirements](dev_requirements.md) -- [Build](dev_build.md) \ No newline at end of file diff --git a/website/sidebars.js b/website/sidebars.js index f1b77871f3..16af1e1151 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -46,8 +46,7 @@ module.exports = { type: "category", label: "Getting Started", items: [ - "dev_requirements", - "dev_build", + "admin_builds", "admin_distribute", "admin_use", "admin_openpype_commands", @@ -134,6 +133,8 @@ module.exports = { ], Dev: [ "dev_introduction", + "dev_requirements", + "dev_build", "dev_testing", "dev_contribute" ] From 4d914fe2572e41f13169c2033db19dd8c26bec38 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 25 Feb 2022 16:28:09 +0100 Subject: [PATCH 252/483] Fixed more detailed versions --- website/docs/admin_builds.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/docs/admin_builds.md b/website/docs/admin_builds.md index 3a02cd5baf..a4e0e77242 100644 --- a/website/docs/admin_builds.md +++ b/website/docs/admin_builds.md @@ -9,11 +9,11 @@ import TabItem from '@theme/TabItem'; Admins might find prepared builds on https://github.com/pypeclub/OpenPype/releases for all major platforms. -### Currently supported OS versions -- Windows 10+ -- Ubuntu 20 -- Centos 7.9 -- MacOS Mohave +### Currently built on OS versions +- Windows 10 +- Ubuntu 20.04 +- Centos 7.6 +- MacOS Mohave (10.14.6) In case your studio requires build for different OS version, or any specific build, please take a look at [Requirements](dev_requirements.md) and [Build](dev_build.md) for more details how to create binaries to distribute. From 33cd5af26a897ffc1901617d2955a8af6e03878e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 25 Feb 2022 17:24:51 +0100 Subject: [PATCH 253/483] nuke: reverse clearing disable --- openpype/hosts/nuke/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 79413bab6c..67c5203cda 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -576,7 +576,7 @@ class ExporterReviewMov(ExporterReview): self.log.debug("Representation... `{}`".format(self.data)) - # self.clean_nodes(subset) + self.clean_nodes(subset) nuke.scriptSave() return self.data From 8750bdae707711a64df490485efc63192853b1d2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 25 Feb 2022 17:27:18 +0100 Subject: [PATCH 254/483] global: letter box calculated on output as last process --- openpype/plugins/publish/extract_review.py | 133 ++++++++++++++------- 1 file changed, 88 insertions(+), 45 deletions(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 5f286a53e6..9d7ad26a40 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -972,11 +972,8 @@ class ExtractReview(pyblish.api.InstancePlugin): def get_letterbox_filters( self, letter_box_def, - input_res_ratio, - output_res_ratio, - pixel_aspect, - scale_factor_by_width, - scale_factor_by_height + output_width, + output_height ): output = [] @@ -996,70 +993,119 @@ class ExtractReview(pyblish.api.InstancePlugin): l_red, l_green, l_blue ) line_color_alpha = float(l_alpha) / 255 - - if input_res_ratio == output_res_ratio: - ratio /= pixel_aspect - elif input_res_ratio < output_res_ratio: - ratio /= scale_factor_by_width - else: - ratio /= scale_factor_by_height - + height_letterbox = int(output_height - (output_width * (1 / ratio))) if state == "letterbox": if fill_color_alpha > 0: top_box = ( - "drawbox=0:0:iw:round((ih-(iw*(1/{})))/2):t=fill:c={}@{}" - ).format(ratio, fill_color_hex, fill_color_alpha) + "drawbox=0:0:{widht}:round(" + "({height}-({widht}*(1/{ratio})))/2)" + ":t=fill:c={color}@{alpha}" + ).format( + widht=output_width, + height=output_height, + ratio=ratio, + color=fill_color_hex, + alpha=fill_color_alpha + ) bottom_box = ( - "drawbox=0:ih-round((ih-(iw*(1/{0})))/2)" - ":iw:round((ih-(iw*(1/{0})))/2):t=fill:c={1}@{2}" - ).format(ratio, fill_color_hex, fill_color_alpha) + "drawbox=0:{height}-round(" + "({height}-({widht}*(1/{ratio})))/2)" + ":{widht}:round(({height}-({widht}" + "*(1/{ratio})))/2):t=fill:" + "c={color}@{alpha}" + ).format( + widht=output_width, + height=output_height, + ratio=ratio, + color=fill_color_hex, + alpha=fill_color_alpha + ) - output.extend([top_box, bottom_box]) + if height_letterbox > 0: + output.extend([top_box, bottom_box]) if line_color_alpha > 0 and line_thickness > 0: top_line = ( - "drawbox=0:round((ih-(iw*(1/{0})))/2)-{1}:iw:{1}:" - "t=fill:c={2}@{3}" + "drawbox=0:round(({height}-({widht}" + "*(1/{ratio})))/2)-{l_thick}:{widht}:{l_thick}:" + "t=fill:c={l_color}@{l_alpha}" ).format( - ratio, line_thickness, line_color_hex, line_color_alpha + widht=output_width, + height=output_height, + ratio=ratio, + l_thick=line_thickness, + l_color=line_color_hex, + l_alpha=line_color_alpha ) bottom_line = ( - "drawbox=0:ih-round((ih-(iw*(1/{})))/2)" - ":iw:{}:t=fill:c={}@{}" + "drawbox=0:{height}-round(({height}-({widht}" + "*(1/{ratio})))/2)" + ":{widht}:{l_thick}:t=fill:c={l_color}@{l_alpha}" ).format( - ratio, line_thickness, line_color_hex, line_color_alpha + widht=output_width, + height=output_height, + ratio=ratio, + l_thick=line_thickness, + l_color=line_color_hex, + l_alpha=line_color_alpha ) - output.extend([top_line, bottom_line]) + if height_letterbox > 0: + output.extend([top_line, bottom_line]) elif state == "pillar": if fill_color_alpha > 0: left_box = ( - "drawbox=0:0:round((iw-(ih*{}))/2):ih:t=fill:c={}@{}" - ).format(ratio, fill_color_hex, fill_color_alpha) + "drawbox=0:0:round(({widht}-({height}" + "*{ratio}))/2):{height}:t=fill:c={color}@{alpha}" + ).format( + widht=output_width, + height=output_height, + ratio=ratio, + color=fill_color_hex, + alpha=fill_color_alpha + ) right_box = ( - "drawbox=iw-round((iw-(ih*{0}))/2))" - ":0:round((iw-(ih*{0}))/2):ih:t=fill:c={1}@{2}" - ).format(ratio, fill_color_hex, fill_color_alpha) - - output.extend([left_box, right_box]) + "drawbox={widht}-round(({widht}-({height}*{ratio}))/2))" + ":0:round(({widht}-({height}*{ratio}))/2):{height}" + ":t=fill:c={color}@{alpha}" + ).format( + widht=output_width, + height=output_height, + ratio=ratio, + color=fill_color_hex, + alpha=fill_color_alpha + ) + if height_letterbox > 0: + output.extend([left_box, right_box]) if line_color_alpha > 0 and line_thickness > 0: left_line = ( - "drawbox=round((iw-(ih*{}))/2):0:{}:ih:t=fill:c={}@{}" + "drawbox=round(({widht}-({height}*{ratio}))/2)" + ":0:{l_thick}:{height}:t=fill:c={l_color}@{l_alpha}" ).format( - ratio, line_thickness, line_color_hex, line_color_alpha + widht=output_width, + height=output_height, + ratio=ratio, + l_thick=line_thickness, + l_color=line_color_hex, + l_alpha=line_color_alpha ) right_line = ( - "drawbox=iw-round((iw-(ih*{}))/2))" - ":0:{}:ih:t=fill:c={}@{}" + "drawbox={widht}-round(({widht}-({height}*{ratio}))/2))" + ":0:{l_thick}:{height}:t=fill:c={l_color}@{l_alpha}" ).format( - ratio, line_thickness, line_color_hex, line_color_alpha + widht=output_width, + height=output_height, + ratio=ratio, + l_thick=line_thickness, + l_color=line_color_hex, + l_alpha=line_color_alpha ) - - output.extend([left_line, right_line]) + if height_letterbox > 0: + output.extend([left_line, right_line]) else: raise ValueError( @@ -1259,11 +1305,8 @@ class ExtractReview(pyblish.api.InstancePlugin): filters.extend( self.get_letterbox_filters( letter_box_def, - input_res_ratio, - output_res_ratio, - pixel_aspect, - scale_factor_by_width, - scale_factor_by_height + output_width, + output_height ) ) From d72183e0a0b3936cc87d4dd5cb36e346feb82f58 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Feb 2022 20:09:00 +0100 Subject: [PATCH 255/483] Update openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../openpype_flame_to_ftrack/modules/ftrack_lib.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py index 7a0efe079e..6256265730 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py @@ -139,17 +139,7 @@ class FtrackComponentCreator: if name == "ftrackreview-mp4": duration = data["duration"] - handle_start = data.get("handleStart", None) - handle_end = data.get("handleEnd", None) - if handle_start is not None: - duration += handle_start - if handle_end is not None: - duration += handle_end - if handle_start is None and handle_end is None: - # Backwards compatibility; old style 'handles' - # We multiply by two because old-style handles defined - # both the handle start and handle end - duration += data.get("handles", 0) * 2 + handles = data["handles"] fps = data["fps"] component_data["metadata"] = { From d32f2f946f540a265070b7af12bb1fd32db07d86 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Feb 2022 20:09:05 +0100 Subject: [PATCH 256/483] Update openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../openpype_flame_to_ftrack/modules/ftrack_lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py index 6256265730..7e2ef381a3 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py @@ -145,7 +145,7 @@ class FtrackComponentCreator: component_data["metadata"] = { 'ftr_meta': json.dumps({ 'frameIn': int(0), - 'frameOut': int(duration), + 'frameOut': int(duration + (handles * 2)), 'frameRate': float(fps) }) } From 2501c47ca7e396f8d725b2e0edebf504c9e7d7fa Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Sat, 26 Feb 2022 10:00:30 +0100 Subject: [PATCH 257/483] remove modifications in flame file --- .../openpype_flame_to_ftrack/modules/ftrack_lib.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py index 7e2ef381a3..26b197ee1d 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py @@ -138,9 +138,7 @@ class FtrackComponentCreator: if name == "ftrackreview-mp4": duration = data["duration"] - handles = data["handles"] - fps = data["fps"] component_data["metadata"] = { 'ftr_meta': json.dumps({ From 9ab32bd634382a0f877b45487d7e89676f2f86e6 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 28 Feb 2022 11:39:14 +0100 Subject: [PATCH 258/483] Added possibility to create texture subset from dynamically parsed group names --- .../plugins/publish/collect_texture.py | 81 +++++++++++++------ 1 file changed, 57 insertions(+), 24 deletions(-) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py index 596a8ccfd2..8cf36fd489 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py @@ -147,6 +147,13 @@ class CollectTextures(pyblish.api.ContextPlugin): } resource_files[workfile_subset].append(item) + formatting_data = self._get_parsed_groups( + repre_file, + self.input_naming_patterns["textures"], + self.input_naming_groups["textures"], + self.color_space + ) + if ext in self.texture_extensions: c_space = self._get_color_space( repre_file, @@ -167,13 +174,15 @@ class CollectTextures(pyblish.api.ContextPlugin): self.color_space ) - formatting_data = { + explicit_data = { "color_space": c_space or '', # None throws exception "channel": channel or '', "shader": shader or '', "subset": parsed_subset or '' } + formatting_data.update(explicit_data) + fill_pairs = prepare_template_data(formatting_data) subset = format_template_with_optional_keys( fill_pairs, self.texture_subset_template) @@ -320,13 +329,14 @@ class CollectTextures(pyblish.api.ContextPlugin): """ asset_name = "NOT_AVAIL" - return self._parse(name, input_naming_patterns, input_naming_groups, - color_spaces, 'asset') or asset_name + return (self._parse_key(name, input_naming_patterns, + input_naming_groups, color_spaces, 'asset') or + asset_name) def _get_version(self, name, input_naming_patterns, input_naming_groups, color_spaces): - found = self._parse(name, input_naming_patterns, input_naming_groups, - color_spaces, 'version') + found = self._parse_key(name, input_naming_patterns, + input_naming_groups, color_spaces, 'version') if found: return found.replace('v', '') @@ -336,8 +346,8 @@ class CollectTextures(pyblish.api.ContextPlugin): def _get_udim(self, name, input_naming_patterns, input_naming_groups, color_spaces): """Parses from 'name' udim value.""" - found = self._parse(name, input_naming_patterns, input_naming_groups, - color_spaces, 'udim') + found = self._parse_key(name, input_naming_patterns, + input_naming_groups, color_spaces, 'udim') if found: return found @@ -375,8 +385,8 @@ class CollectTextures(pyblish.api.ContextPlugin): Unknown format of channel name and color spaces >> cs are known list - 'color_space' used as a placeholder """ - found = self._parse(name, input_naming_patterns, input_naming_groups, - color_spaces, 'shader') + found = self._parse_key(name, input_naming_patterns, + input_naming_groups, color_spaces, 'shader') if found: return found @@ -389,15 +399,15 @@ class CollectTextures(pyblish.api.ContextPlugin): Unknown format of channel name and color spaces >> cs are known list - 'color_space' used as a placeholder """ - found = self._parse(name, input_naming_patterns, input_naming_groups, - color_spaces, 'channel') + found = self._parse_key(name, input_naming_patterns, + input_naming_groups, color_spaces, 'channel') if found: return found self.log.warning("Didn't find channel in {}".format(name)) - def _parse(self, name, input_naming_patterns, input_naming_groups, - color_spaces, key): + def _parse_key(self, name, input_naming_patterns, input_naming_groups, + color_spaces, key): """Universal way to parse 'name' with configurable regex groups. Args: @@ -411,23 +421,46 @@ class CollectTextures(pyblish.api.ContextPlugin): Raises: ValueError - if broken 'input_naming_groups' """ + parsed_groups = self._get_parsed_groups(name, + input_naming_patterns, + input_naming_groups, + color_spaces) + + try: + parsed_value = parsed_groups[key] + return parsed_value + except IndexError: + msg = ("input_naming_groups must " + + "have '{}' key".format(key)) + raise ValueError(msg) + + def _get_parsed_groups(self, name, input_naming_patterns, + input_naming_groups, color_spaces): + """Universal way to parse 'name' with configurable regex groups. + + Args: + name (str): workfile name or texture name + input_naming_patterns (list): + [workfile_pattern] or [texture_pattern] + input_naming_groups (list) + ordinal position of regex groups matching to input_naming.. + color_spaces (list) - predefined color spaces + + Returns: + (dict) {group_name:parsed_value} + """ for input_pattern in input_naming_patterns: for cs in color_spaces: pattern = input_pattern.replace('{color_space}', cs) regex_result = re.findall(pattern, name) if regex_result: - idx = list(input_naming_groups).index(key) - if idx < 0: - msg = "input_naming_groups must " +\ - "have '{}' key".format(key) - raise ValueError(msg) + if len(regex_result[0]) == len(input_naming_groups): + return dict(zip(input_naming_groups, regex_result[0])) + else: + self.log.warning("No of parsed groups doesn't match " + "no of group labels") - try: - parsed_value = regex_result[0][idx] - return parsed_value - except IndexError: - self.log.warning("Wrong index, probably " - "wrong name {}".format(name)) + return {} def _update_representations(self, upd_representations): """Frames dont have sense for textures, add collected udims instead.""" From 52a5d13ca74df5626b46197e4616715c5b6eaa99 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 28 Feb 2022 13:50:07 +0100 Subject: [PATCH 259/483] Added possibility to create workfile subset from dynamically parsed group names --- .../plugins/publish/collect_texture.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py index 8cf36fd489..e441218ca7 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py @@ -81,14 +81,10 @@ class CollectTextures(pyblish.api.ContextPlugin): parsed_subset = instance.data["subset"].replace( instance.data["family"], '') - fill_pairs = { + explicit_data = { "subset": parsed_subset } - fill_pairs = prepare_template_data(fill_pairs) - workfile_subset = format_template_with_optional_keys( - fill_pairs, self.workfile_subset_template) - processed_instance = False for repre in instance.data["representations"]: ext = repre["ext"].replace('.', '') @@ -102,6 +98,18 @@ class CollectTextures(pyblish.api.ContextPlugin): if ext in self.main_workfile_extensions or \ ext in self.other_workfile_extensions: + formatting_data = self._get_parsed_groups( + repre_file, + self.input_naming_patterns["workfile"], + self.input_naming_groups["workfile"], + self.color_space + ) + + formatting_data.update(explicit_data) + fill_pairs = prepare_template_data(formatting_data) + workfile_subset = format_template_with_optional_keys( + fill_pairs, self.workfile_subset_template) + asset_build = self._get_asset_build( repre_file, self.input_naming_patterns["workfile"], From 9617d3068bc1cab36128d1c9eebc5861ad5452cf Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 28 Feb 2022 14:35:07 +0100 Subject: [PATCH 260/483] alpha slider is painted correctly --- .../widgets/color_widgets/color_inputs.py | 148 +++++++++++------- 1 file changed, 90 insertions(+), 58 deletions(-) diff --git a/openpype/widgets/color_widgets/color_inputs.py b/openpype/widgets/color_widgets/color_inputs.py index 6f5d4baa02..d6564ca29b 100644 --- a/openpype/widgets/color_widgets/color_inputs.py +++ b/openpype/widgets/color_widgets/color_inputs.py @@ -8,42 +8,56 @@ class AlphaSlider(QtWidgets.QSlider): def __init__(self, *args, **kwargs): super(AlphaSlider, self).__init__(*args, **kwargs) self._mouse_clicked = False + self._handle_size = 0 + self.setSingleStep(1) self.setMinimum(0) self.setMaximum(255) self.setValue(255) - self._checkerboard = None - - def checkerboard(self): - if self._checkerboard is None: - self._checkerboard = draw_checkerboard_tile( - 3, QtGui.QColor(173, 173, 173), QtGui.QColor(27, 27, 27) - ) - return self._checkerboard + self._handle_brush = QtGui.QBrush(QtGui.QColor(127, 127, 127)) def mousePressEvent(self, event): self._mouse_clicked = True if event.button() == QtCore.Qt.LeftButton: - self._set_value_to_pos(event.pos().x()) + self._set_value_to_pos(event.pos()) return event.accept() return super(AlphaSlider, self).mousePressEvent(event) - def _set_value_to_pos(self, pos_x): - value = ( - self.maximum() - self.minimum() - ) * pos_x / self.width() + self.minimum() - self.setValue(value) - def mouseMoveEvent(self, event): if self._mouse_clicked: - self._set_value_to_pos(event.pos().x()) + self._set_value_to_pos(event.pos()) + super(AlphaSlider, self).mouseMoveEvent(event) def mouseReleaseEvent(self, event): self._mouse_clicked = True super(AlphaSlider, self).mouseReleaseEvent(event) + def _set_value_to_pos(self, pos): + if self.orientation() == QtCore.Qt.Horizontal: + self._set_value_to_pos_x(pos.x()) + else: + self._set_value_to_pos_y(pos.y()) + + def _set_value_to_pos_x(self, pos_x): + _range = self.maximum() - self.minimum() + handle_size = self._handle_size + half_handle = handle_size / 2 + pos_x -= half_handle + width = self.width() - handle_size + value = ((_range * pos_x) / width) + self.minimum() + self.setValue(value) + + def _set_value_to_pos_y(self, pos_y): + _range = self.maximum() - self.minimum() + handle_size = self._handle_size + half_handle = handle_size / 2 + pos_y = self.height() - pos_y - half_handle + height = self.height() - handle_size + value = (_range * pos_y / height) + self.minimum() + self.setValue(value) + def paintEvent(self, event): painter = QtGui.QPainter(self) opt = QtWidgets.QStyleOptionSlider() @@ -52,64 +66,82 @@ class AlphaSlider(QtWidgets.QSlider): painter.fillRect(event.rect(), QtCore.Qt.transparent) painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) + + horizontal = self.orientation() == QtCore.Qt.Horizontal + rect = self.style().subControlRect( QtWidgets.QStyle.CC_Slider, opt, QtWidgets.QStyle.SC_SliderGroove, self ) - final_height = 9 - offset_top = 0 - if rect.height() > final_height: - offset_top = int((rect.height() - final_height) / 2) - rect = QtCore.QRect( - rect.x(), - offset_top, - rect.width(), - final_height - ) - pix_rect = QtCore.QRect(event.rect()) - pix_rect.setX(rect.x()) - pix_rect.setWidth(rect.width() - (2 * rect.x())) - pix = QtGui.QPixmap(pix_rect.width(), pix_rect.height()) - pix_painter = QtGui.QPainter(pix) - pix_painter.drawTiledPixmap(pix_rect, self.checkerboard()) + _range = self.maximum() - self.minimum() + _offset = self.value() - self.minimum() + if horizontal: + _handle_half = rect.height() / 2 + _handle_size = _handle_half * 2 + width = rect.width() - _handle_size + pos_x = ((width / _range) * _offset) + pos_y = rect.center().y() - _handle_half + 1 + else: + _handle_half = rect.width() / 2 + _handle_size = _handle_half * 2 + height = rect.height() - _handle_size + pos_x = rect.center().x() - _handle_half + 1 + pos_y = height - ((height / _range) * _offset) + + handle_rect = QtCore.QRect( + pos_x, pos_y, _handle_size, _handle_size + ) + + self._handle_size = _handle_size + _offset = 2 + _size = _handle_size - _offset + if horizontal: + if rect.height() > _size: + new_rect = QtCore.QRect(0, 0, rect.width(), _size) + center_point = QtCore.QPoint( + rect.center().x(), handle_rect.center().y() + ) + new_rect.moveCenter(center_point) + rect = new_rect + + ratio = rect.height() / 2 + + else: + if rect.width() > _size: + new_rect = QtCore.QRect(0, 0, _size, rect.height()) + center_point = QtCore.QPoint( + handle_rect.center().x(), rect.center().y() + ) + new_rect.moveCenter(center_point) + rect = new_rect + + ratio = rect.width() / 2 + + painter.save() + clip_path = QtGui.QPainterPath() + clip_path.addRoundedRect(rect, ratio, ratio) + painter.setClipPath(clip_path) + checker_size = int(_handle_size / 3) + if checker_size == 0: + checker_size = 1 + checkerboard = draw_checkerboard_tile( + checker_size, QtGui.QColor(173, 173, 173), QtGui.QColor(27, 27, 27) + ) + painter.drawTiledPixmap(rect, checkerboard) gradient = QtGui.QLinearGradient(rect.topLeft(), rect.bottomRight()) gradient.setColorAt(0, QtCore.Qt.transparent) gradient.setColorAt(1, QtCore.Qt.white) - pix_painter.fillRect(pix_rect, gradient) - pix_painter.end() - - brush = QtGui.QBrush(pix) - painter.save() painter.setPen(QtCore.Qt.NoPen) - painter.setBrush(brush) - ratio = rect.height() / 2 - painter.drawRoundedRect(rect, ratio, ratio) + painter.fillRect(rect, gradient) painter.restore() - _handle_rect = self.style().subControlRect( - QtWidgets.QStyle.CC_Slider, - opt, - QtWidgets.QStyle.SC_SliderHandle, - self - ) - - handle_rect = QtCore.QRect(rect) - if offset_top > 1: - height = handle_rect.height() - handle_rect.setY(handle_rect.y() - 1) - handle_rect.setHeight(height + 2) - handle_rect.setX(_handle_rect.x()) - handle_rect.setWidth(handle_rect.height()) - painter.save() - painter.setPen(QtCore.Qt.NoPen) - painter.setBrush(QtGui.QColor(127, 127, 127)) + painter.setBrush(self._handle_brush) painter.drawEllipse(handle_rect) - painter.restore() From 5fddb5c17ad66952b06174cbfd589d122b7a2699 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 28 Feb 2022 14:35:47 +0100 Subject: [PATCH 261/483] thickness of circles in triangle and outline are calculated using ceil and not floor --- openpype/widgets/color_widgets/color_triangle.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/widgets/color_widgets/color_triangle.py b/openpype/widgets/color_widgets/color_triangle.py index f4a86c4fa5..e15b9e9f65 100644 --- a/openpype/widgets/color_widgets/color_triangle.py +++ b/openpype/widgets/color_widgets/color_triangle.py @@ -1,5 +1,5 @@ from enum import Enum -from math import floor, sqrt, sin, cos, acos, pi as PI +from math import floor, ceil, sqrt, sin, cos, acos, pi as PI from Qt import QtWidgets, QtCore, QtGui TWOPI = PI * 2 @@ -187,10 +187,10 @@ class QtColorTriangle(QtWidgets.QWidget): self.outer_radius = (size - 1) / 2 self.pen_width = int( - floor(self.outer_radius / self.ellipse_thick_ratio) + ceil(self.outer_radius / self.ellipse_thick_ratio) ) self.ellipse_size = int( - floor(self.outer_radius / self.ellipse_size_ratio) + ceil(self.outer_radius / self.ellipse_size_ratio) ) cx = float(self.contentsRect().center().x()) @@ -542,10 +542,10 @@ class QtColorTriangle(QtWidgets.QWidget): self.outer_radius = (size - 1) / 2 self.pen_width = int( - floor(self.outer_radius / self.ellipse_thick_ratio) + ceil(self.outer_radius / self.ellipse_thick_ratio) ) self.ellipse_size = int( - floor(self.outer_radius / self.ellipse_size_ratio) + ceil(self.outer_radius / self.ellipse_size_ratio) ) cx = float(self.contentsRect().center().x()) From 266ca700c9e6012c861bdc4989c9d2b6aa8954b4 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Mon, 28 Feb 2022 18:07:40 +0100 Subject: [PATCH 262/483] temporary fix for foreign pull request numbers in CI --- tools/ci_tools.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/ci_tools.py b/tools/ci_tools.py index e5ca0c2c28..aeb367af38 100644 --- a/tools/ci_tools.py +++ b/tools/ci_tools.py @@ -19,7 +19,10 @@ def get_release_type_github(Log, github_token): match = re.search("pull request #(\d+)", line) if match: pr_number = match.group(1) - pr = repo.get_pull(int(pr_number)) + try: + pr = repo.get_pull(int(pr_number)) + except: + continue for label in pr.labels: labels.add(label.name) From d0baf4f7009f5d8f61885286d9f8c5f13d6dd48d Mon Sep 17 00:00:00 2001 From: OpenPype Date: Mon, 28 Feb 2022 17:19:09 +0000 Subject: [PATCH 263/483] [Automated] Bump version --- CHANGELOG.md | 107 ++++++++++++-------------------------------- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 31 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3babdceafb..c945569545 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,121 +1,72 @@ # Changelog -## [3.9.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.9.0-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...HEAD) **Deprecated:** -- Loader: Remove default family states for hosts from code [\#2706](https://github.com/pypeclub/OpenPype/pull/2706) +- Houdini: Remove unused code [\#2779](https://github.com/pypeclub/OpenPype/pull/2779) ### 📖 Documentation +- Documentation: fixed broken links [\#2799](https://github.com/pypeclub/OpenPype/pull/2799) +- Documentation: broken link fix [\#2785](https://github.com/pypeclub/OpenPype/pull/2785) +- Documentation: link fixes [\#2772](https://github.com/pypeclub/OpenPype/pull/2772) - Update docusaurus to latest version [\#2760](https://github.com/pypeclub/OpenPype/pull/2760) -- documentation: add example to `repack-version` command [\#2669](https://github.com/pypeclub/OpenPype/pull/2669) - -**🆕 New features** - -- Flame: loading clips to reels [\#2622](https://github.com/pypeclub/OpenPype/pull/2622) +- Various testing updates [\#2726](https://github.com/pypeclub/OpenPype/pull/2726) **🚀 Enhancements** +- General: Set context environments for non host applications [\#2803](https://github.com/pypeclub/OpenPype/pull/2803) +- Tray publisher: New Tray Publisher host \(beta\) [\#2778](https://github.com/pypeclub/OpenPype/pull/2778) +- Houdini: Implement Reset Frame Range [\#2770](https://github.com/pypeclub/OpenPype/pull/2770) - Pyblish Pype: Remove redundant new line in installed fonts printing [\#2758](https://github.com/pypeclub/OpenPype/pull/2758) +- Flame: use Shot Name on segment for asset name [\#2751](https://github.com/pypeclub/OpenPype/pull/2751) - Flame: adding validator source clip [\#2746](https://github.com/pypeclub/OpenPype/pull/2746) - Work Files: Preserve subversion comment of current filename by default [\#2734](https://github.com/pypeclub/OpenPype/pull/2734) -- Ftrack: Disable ftrack module by default [\#2732](https://github.com/pypeclub/OpenPype/pull/2732) -- Project Manager: Disable add task, add asset and save button when not in a project [\#2727](https://github.com/pypeclub/OpenPype/pull/2727) -- dropbox handle big file [\#2718](https://github.com/pypeclub/OpenPype/pull/2718) -- Fusion Move PR: Minor tweaks to Fusion integration [\#2716](https://github.com/pypeclub/OpenPype/pull/2716) -- Nuke: prerender with review knob [\#2691](https://github.com/pypeclub/OpenPype/pull/2691) -- Maya configurable unit validator [\#2680](https://github.com/pypeclub/OpenPype/pull/2680) -- General: Add settings for CleanUpFarm and disable the plugin by default [\#2679](https://github.com/pypeclub/OpenPype/pull/2679) -- Project Manager: Only allow scroll wheel edits when spinbox is active [\#2678](https://github.com/pypeclub/OpenPype/pull/2678) -- Ftrack: Sync description to assets [\#2670](https://github.com/pypeclub/OpenPype/pull/2670) -- General: FFmpeg conversion also check attribute string length [\#2635](https://github.com/pypeclub/OpenPype/pull/2635) -- Global: adding studio name/code to anatomy template formatting data [\#2630](https://github.com/pypeclub/OpenPype/pull/2630) -- Houdini: Load Arnold .ass procedurals into Houdini [\#2606](https://github.com/pypeclub/OpenPype/pull/2606) -- Deadline: Simplify GlobalJobPreLoad logic [\#2605](https://github.com/pypeclub/OpenPype/pull/2605) -- Houdini: Implement Arnold .ass standin extraction from Houdini \(also support .ass.gz\) [\#2603](https://github.com/pypeclub/OpenPype/pull/2603) +- RoyalRender: Minor enhancements [\#2700](https://github.com/pypeclub/OpenPype/pull/2700) **🐛 Bug fixes** +- Settings UI: Search case sensitivity [\#2810](https://github.com/pypeclub/OpenPype/pull/2810) +- Flame Babypublisher optimalization [\#2806](https://github.com/pypeclub/OpenPype/pull/2806) +- resolve: fixing fusion module loading [\#2802](https://github.com/pypeclub/OpenPype/pull/2802) +- Flame: Fix version string in default settings [\#2783](https://github.com/pypeclub/OpenPype/pull/2783) +- After Effects: Fix typo in name `afftereffects` -\> `aftereffects` [\#2768](https://github.com/pypeclub/OpenPype/pull/2768) +- Avoid renaming udim indexes [\#2765](https://github.com/pypeclub/OpenPype/pull/2765) +- Maya: Fix `unique\_namespace` when in an namespace that is empty [\#2759](https://github.com/pypeclub/OpenPype/pull/2759) - Loader UI: Fix right click in representation widget [\#2757](https://github.com/pypeclub/OpenPype/pull/2757) - Aftereffects 2022 and Deadline [\#2748](https://github.com/pypeclub/OpenPype/pull/2748) -- Flame: bunch of bugs [\#2745](https://github.com/pypeclub/OpenPype/pull/2745) - Maya: Save current scene on workfile publish [\#2744](https://github.com/pypeclub/OpenPype/pull/2744) - Version Up: Preserve parts of filename after version number \(like subversion\) on version\_up [\#2741](https://github.com/pypeclub/OpenPype/pull/2741) - Loader UI: Multiple asset selection and underline colors fixed [\#2731](https://github.com/pypeclub/OpenPype/pull/2731) -- General: Fix loading of unused chars in xml format [\#2729](https://github.com/pypeclub/OpenPype/pull/2729) -- TVPaint: Set objectName with members [\#2725](https://github.com/pypeclub/OpenPype/pull/2725) -- General: Don't use 'objectName' from loaded references [\#2715](https://github.com/pypeclub/OpenPype/pull/2715) -- Settings: Studio Project anatomy is queried using right keys [\#2711](https://github.com/pypeclub/OpenPype/pull/2711) -- Local Settings: Additional applications don't break UI [\#2710](https://github.com/pypeclub/OpenPype/pull/2710) -- Houdini: Fix refactor of Houdini host move for CreateArnoldAss [\#2704](https://github.com/pypeclub/OpenPype/pull/2704) -- LookAssigner: Fix imports after moving code to OpenPype repository [\#2701](https://github.com/pypeclub/OpenPype/pull/2701) -- Multiple hosts: unify menu style across hosts [\#2693](https://github.com/pypeclub/OpenPype/pull/2693) -- Maya Redshift fixes [\#2692](https://github.com/pypeclub/OpenPype/pull/2692) -- Maya: fix fps validation popup [\#2685](https://github.com/pypeclub/OpenPype/pull/2685) -- Houdini Explicitly collect correct frame name even in case of single frame render when `frameStart` is provided [\#2676](https://github.com/pypeclub/OpenPype/pull/2676) -- hiero: fix effect collector name and order [\#2673](https://github.com/pypeclub/OpenPype/pull/2673) -- Maya: Fix menu callbacks [\#2671](https://github.com/pypeclub/OpenPype/pull/2671) -- Launcher: Fix access to 'data' attribute on actions [\#2659](https://github.com/pypeclub/OpenPype/pull/2659) -- Houdini: fix usd family in loader and integrators [\#2631](https://github.com/pypeclub/OpenPype/pull/2631) +- Maya: Remove some unused code [\#2709](https://github.com/pypeclub/OpenPype/pull/2709) **Merged pull requests:** +- Ftrack: Unset task ids from asset versions before tasks are removed [\#2800](https://github.com/pypeclub/OpenPype/pull/2800) +- Slack: fail gracefully if slack exception [\#2798](https://github.com/pypeclub/OpenPype/pull/2798) +- Ftrack: Moved module one hierarchy level higher [\#2792](https://github.com/pypeclub/OpenPype/pull/2792) +- SyncServer: Moved module one hierarchy level higher [\#2791](https://github.com/pypeclub/OpenPype/pull/2791) +- Royal render: Move module one hierarchy level higher [\#2790](https://github.com/pypeclub/OpenPype/pull/2790) +- Deadline: Move module one hierarchy level higher [\#2789](https://github.com/pypeclub/OpenPype/pull/2789) +- Houdini: Remove duplicate ValidateOutputNode plug-in [\#2780](https://github.com/pypeclub/OpenPype/pull/2780) +- Slack: Added regex for filtering on subset names [\#2775](https://github.com/pypeclub/OpenPype/pull/2775) +- Houdini: Fix open last workfile [\#2767](https://github.com/pypeclub/OpenPype/pull/2767) - Harmony: Rendering in Deadline didn't work in other machines than submitter [\#2754](https://github.com/pypeclub/OpenPype/pull/2754) +- Houdini: Move Houdini Save Current File to beginning of ExtractorOrder [\#2747](https://github.com/pypeclub/OpenPype/pull/2747) - Maya: set Deadline job/batch name to original source workfile name instead of published workfile [\#2733](https://github.com/pypeclub/OpenPype/pull/2733) - Fusion: Moved implementation into OpenPype [\#2713](https://github.com/pypeclub/OpenPype/pull/2713) -- TVPaint: Plugin build without dependencies [\#2705](https://github.com/pypeclub/OpenPype/pull/2705) -- Webpublisher: Photoshop create a beauty png [\#2689](https://github.com/pypeclub/OpenPype/pull/2689) -- Ftrack: Hierarchical attributes are queried properly [\#2682](https://github.com/pypeclub/OpenPype/pull/2682) -- Maya: Add Validate Frame Range settings [\#2661](https://github.com/pypeclub/OpenPype/pull/2661) -- Harmony: move to Openpype [\#2657](https://github.com/pypeclub/OpenPype/pull/2657) -- General: Show applications without integration in project [\#2656](https://github.com/pypeclub/OpenPype/pull/2656) -- Maya: cleanup duplicate rendersetup code [\#2642](https://github.com/pypeclub/OpenPype/pull/2642) ## [3.8.2](https://github.com/pypeclub/OpenPype/tree/3.8.2) (2022-02-07) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.8.2-nightly.3...3.8.2) -### 📖 Documentation - -- Cosmetics: Fix common typos in openpype/website [\#2617](https://github.com/pypeclub/OpenPype/pull/2617) - -**🚀 Enhancements** - -- General: Project backup tools [\#2629](https://github.com/pypeclub/OpenPype/pull/2629) -- nuke: adding clear button to write nodes [\#2627](https://github.com/pypeclub/OpenPype/pull/2627) -- Ftrack: Family to Asset type mapping is in settings [\#2602](https://github.com/pypeclub/OpenPype/pull/2602) - -**🐛 Bug fixes** - -- Fix pulling of cx\_freeze 6.10 [\#2628](https://github.com/pypeclub/OpenPype/pull/2628) - -**Merged pull requests:** - -- Docker: enhance dockerfiles with metadata, fix pyenv initialization [\#2647](https://github.com/pypeclub/OpenPype/pull/2647) -- WebPublisher: fix instance duplicates [\#2641](https://github.com/pypeclub/OpenPype/pull/2641) -- Fix - safer pulling of task name for webpublishing from PS [\#2613](https://github.com/pypeclub/OpenPype/pull/2613) - ## [3.8.1](https://github.com/pypeclub/OpenPype/tree/3.8.1) (2022-02-01) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.8.1-nightly.3...3.8.1) -**🚀 Enhancements** - -- Webpublisher: Thumbnail extractor [\#2600](https://github.com/pypeclub/OpenPype/pull/2600) - -**🐛 Bug fixes** - -- Release/3.8.0 [\#2619](https://github.com/pypeclub/OpenPype/pull/2619) -- hotfix: OIIO tool path - add extension on windows [\#2618](https://github.com/pypeclub/OpenPype/pull/2618) -- Settings: Enum does not store empty string if has single item to select [\#2615](https://github.com/pypeclub/OpenPype/pull/2615) - -**Merged pull requests:** - -- Bump pillow from 8.4.0 to 9.0.0 [\#2595](https://github.com/pypeclub/OpenPype/pull/2595) - ## [3.8.0](https://github.com/pypeclub/OpenPype/tree/3.8.0) (2022-01-24) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.8.0-nightly.7...3.8.0) diff --git a/openpype/version.py b/openpype/version.py index cb3658a827..0a799462ed 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.9.0-nightly.3" +__version__ = "3.9.0-nightly.4" diff --git a/pyproject.toml b/pyproject.toml index 052ed92bbc..44bc0acbcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.9.0-nightly.3" # OpenPype +version = "3.9.0-nightly.4" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From a6392f131ee69b4b5c6979f1da577fae66dd656c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 28 Feb 2022 18:56:00 +0100 Subject: [PATCH 264/483] use AVALON_APP to get value for "app" key --- openpype/hosts/nuke/api/lib.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 6faf6cd108..dba7ec1b85 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -1,6 +1,5 @@ import os import re -import sys import six import platform import contextlib @@ -679,10 +678,10 @@ def get_render_path(node): } nuke_imageio_writes = get_created_node_imageio_setting(**data_preset) + host_name = os.environ.get("AVALON_APP") - application = lib.get_application(os.environ["AVALON_APP_NAME"]) data.update({ - "application": application, + "app": host_name, "nuke_imageio_writes": nuke_imageio_writes }) @@ -805,18 +804,14 @@ def create_write_node(name, data, input=None, prenodes=None, ''' imageio_writes = get_created_node_imageio_setting(**data) - app_manager = ApplicationManager() - app_name = os.environ.get("AVALON_APP_NAME") - if app_name: - app = app_manager.applications.get(app_name) - for knob in imageio_writes["knobs"]: if knob["name"] == "file_type": representation = knob["value"] + host_name = os.environ.get("AVALON_APP") try: data.update({ - "app": app.host_name, + "app": host_name, "imageio_writes": imageio_writes, "representation": representation, }) From 8eddbab5035f7367bbe6c79107dafc4241a33cdb Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 28 Feb 2022 20:51:47 +0100 Subject: [PATCH 265/483] implement function which looks for executable --- openpype/lib/vendor_bin_utils.py | 53 +++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/openpype/lib/vendor_bin_utils.py b/openpype/lib/vendor_bin_utils.py index 4c2cf93dfa..bfdfd3174d 100644 --- a/openpype/lib/vendor_bin_utils.py +++ b/openpype/lib/vendor_bin_utils.py @@ -5,7 +5,58 @@ import platform import subprocess import distutils -log = logging.getLogger("FFmpeg utils") +log = logging.getLogger("Vendor utils") + + +def find_executable(executable): + """Find full path to executable. + + Also tries additional extensions if passed executable does not contain one. + + Paths where it is looked for executable is defined by 'PATH' environment + variable, 'os.confstr("CS_PATH")' or 'os.defpath'. + + Args: + executable(str): Name of executable with or without extension. Can be + path to file. + + Returns: + str: Full path to executable with extension (is file). + None: When the executable was not found. + """ + if os.path.isfile(executable): + return executable + + low_platform = platform.system().lower() + _, ext = os.path.splitext(executable) + variants = [executable] + if not ext: + if low_platform == "windows": + exts = [".exe", ".ps1", ".bat"] + for ext in os.getenv("PATHEXT", "").split(os.pathsep): + ext = ext.lower() + if ext and ext not in exts: + exts.append(ext) + else: + exts = [".sh"] + + for ext in exts: + variant = executable + ext + if os.path.isfile(variant): + return variant + variants.append(variant) + + path_str = os.environ.get("PATH", None) + if path_str is None: + if hasattr(os, "confstr"): + path_str = os.confstr("CS_PATH") + elif hasattr(os, "defpath"): + path_str = os.defpath + + if not path_str: + return None + + paths = path_str.split(os.pathsep) def get_vendor_bin_path(bin_app): From 77232a07efd1eaefebb313d714d98f6487a27100 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 28 Feb 2022 20:52:21 +0100 Subject: [PATCH 266/483] replace distutils find_executable with custom version --- openpype/lib/__init__.py | 17 +++++++++-------- openpype/lib/applications.py | 8 +++++--- openpype/lib/execute.py | 4 ++-- openpype/lib/vendor_bin_utils.py | 17 +++++++++-------- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 882ff03e61..63173941c5 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -16,6 +16,14 @@ sys.path.insert(0, python_version_dir) site.addsitedir(python_version_dir) +from .vendor_bin_utils import ( + find_executable, + get_vendor_bin_path, + get_oiio_tools_path, + get_ffmpeg_tool_path, + ffprobe_streams, + is_oiio_supported +) from .env_tools import ( env_value_to_bool, get_paths_from_environ, @@ -48,14 +56,6 @@ from .anatomy import ( from .config import get_datetime_data -from .vendor_bin_utils import ( - get_vendor_bin_path, - get_oiio_tools_path, - get_ffmpeg_tool_path, - ffprobe_streams, - is_oiio_supported -) - from .python_module_tools import ( import_filepath, modules_from_path, @@ -184,6 +184,7 @@ from .openpype_version import ( terminal = Terminal __all__ = [ + "find_executable", "get_openpype_execute_args", "get_pype_execute_args", "get_linux_launcher_args", diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 0b51a6629c..5613d8cccf 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -35,8 +35,10 @@ from .python_module_tools import ( modules_from_path, classes_from_module ) -from .execute import get_linux_launcher_args - +from .execute import ( + find_executable, + get_linux_launcher_args +) _logger = None @@ -646,7 +648,7 @@ class ApplicationExecutable: def _realpath(self): """Check if path is valid executable path.""" # Check for executable in PATH - result = distutils.spawn.find_executable(self.executable_path) + result = find_executable(self.executable_path) if result is not None: return result diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index f2eb97c5f5..c3e35772f3 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -4,9 +4,9 @@ import subprocess import platform import json import tempfile -import distutils.spawn from .log import PypeLogger as Logger +from .vendor_bin_utils import find_executable # MSDN process creation flag (Windows only) CREATE_NO_WINDOW = 0x08000000 @@ -341,7 +341,7 @@ def get_linux_launcher_args(*args): os.path.dirname(openpype_executable), filename ) - executable_path = distutils.spawn.find_executable(new_executable) + executable_path = find_executable(new_executable) if executable_path is None: return None launch_args = [executable_path] diff --git a/openpype/lib/vendor_bin_utils.py b/openpype/lib/vendor_bin_utils.py index bfdfd3174d..6571e2f515 100644 --- a/openpype/lib/vendor_bin_utils.py +++ b/openpype/lib/vendor_bin_utils.py @@ -3,7 +3,6 @@ import logging import json import platform import subprocess -import distutils log = logging.getLogger("Vendor utils") @@ -57,6 +56,12 @@ def find_executable(executable): return None paths = path_str.split(os.pathsep) + for path in paths: + for variant in variants: + filepath = os.path.abspath(os.path.join(path, executable)) + if os.path.isfile(filepath): + return filepath + return None def get_vendor_bin_path(bin_app): @@ -92,11 +97,7 @@ def get_oiio_tools_path(tool="oiiotool"): Default is "oiiotool". """ oiio_dir = get_vendor_bin_path("oiio") - if platform.system().lower() == "windows" and not tool.lower().endswith( - ".exe" - ): - tool = "{}.exe".format(tool) - return os.path.join(oiio_dir, tool) + return find_executable(os.path.join(oiio_dir, tool)) def get_ffmpeg_tool_path(tool="ffmpeg"): @@ -112,7 +113,7 @@ def get_ffmpeg_tool_path(tool="ffmpeg"): ffmpeg_dir = get_vendor_bin_path("ffmpeg") if platform.system().lower() == "windows": ffmpeg_dir = os.path.join(ffmpeg_dir, "bin") - return os.path.join(ffmpeg_dir, tool) + return find_executable(os.path.join(ffmpeg_dir, tool)) def ffprobe_streams(path_to_file, logger=None): @@ -173,7 +174,7 @@ def is_oiio_supported(): """ loaded_path = oiio_path = get_oiio_tools_path() if oiio_path: - oiio_path = distutils.spawn.find_executable(oiio_path) + oiio_path = find_executable(oiio_path) if not oiio_path: log.debug("OIIOTool is not configured or not present at {}".format( From 79c1fe27129f64251658685a03d9871b6bfbc5ba Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 1 Mar 2022 11:04:10 +0100 Subject: [PATCH 267/483] replace lambda with custom function callback --- openpype/tools/settings/settings/base.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py index bbfbc58627..d4ad84996c 100644 --- a/openpype/tools/settings/settings/base.py +++ b/openpype/tools/settings/settings/base.py @@ -12,6 +12,22 @@ from .lib import create_deffered_value_change_timer from .constants import DEFAULT_PROJECT_LABEL +class _Callback: + """Callback wrapper which stores it's args and kwargs. + + Using lambda has few issues if local variables are passed to called + functions in loop it may change the value of the variable in already + stored callback. + """ + def __init__(self, func, *args, **kwargs): + self._func = func + self._args = args + self._kwargs = kwargs + + def __call__(self): + self._func(*self._args, **self._kwargs) + + class BaseWidget(QtWidgets.QWidget): allow_actions = True @@ -325,7 +341,11 @@ class BaseWidget(QtWidgets.QWidget): action = QtWidgets.QAction(project_name) submenu.addAction(action) - actions_mapping[action] = lambda: self._apply_values_from_project( + # Use custom callback object instead of lambda + # - project_name value is changed each value so all actions will + # use the same source project + actions_mapping[action] = _Callback( + self._apply_values_from_project, project_name ) menu.addMenu(submenu) From 713b82b19c6fe6c4bae7fcc57bdd3662e30eecc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 1 Mar 2022 11:32:03 +0100 Subject: [PATCH 268/483] renaming integration --- .gitmodules | 5 +- openpype/hosts/unreal/api/__init__.py | 2 +- openpype/hosts/unreal/api/helpers.py | 2 +- openpype/hosts/unreal/api/lib.py | 18 ++--- openpype/hosts/unreal/api/pipeline.py | 74 ++++++++----------- .../unreal/hooks/pre_workfile_preparation.py | 4 +- .../integration/Content/Python/init_unreal.py | 31 ++++---- .../Private/AvalonPublishInstanceFactory.cpp | 20 ----- .../Avalon/Private/AvalonPythonBridge.cpp | 13 ---- .../Source/Avalon/Private/AvalonStyle.cpp | 69 ----------------- .../OpenPype.Build.cs} | 2 +- .../Private/AssetContainer.cpp | 0 .../Private/AssetContainerFactory.cpp | 0 .../Private/OpenPype.cpp} | 46 ++++++------ .../Private/OpenPypeLib.cpp} | 6 +- .../Private/OpenPypePublishInstance.cpp} | 30 ++++---- .../OpenPypePublishInstanceFactory.cpp | 20 +++++ .../OpenPype/Private/OpenPypePythonBridge.cpp | 13 ++++ .../Source/OpenPype/Private/OpenPypeStyle.cpp | 70 ++++++++++++++++++ .../Public/AssetContainer.h | 0 .../Public/AssetContainerFactory.h | 2 +- .../Avalon.h => OpenPype/Public/OpenPype.h} | 2 +- .../Public/OpenPypeLib.h} | 2 +- .../Public/OpenPypePublishInstance.h} | 6 +- .../Public/OpenPypePublishInstanceFactory.h} | 6 +- .../Public/OpenPypePythonBridge.h} | 6 +- .../Public/OpenPypeStyle.h} | 2 +- .../unreal/plugins/create/create_camera.py | 2 +- .../unreal/plugins/create/create_layout.py | 3 +- .../unreal/plugins/create/create_look.py | 14 ++-- .../plugins/create/create_staticmeshfbx.py | 8 +- .../load/load_alembic_geometrycache.py | 22 +++--- .../plugins/load/load_alembic_skeletalmesh.py | 19 ++--- .../plugins/load/load_alembic_staticmesh.py | 21 +++--- .../unreal/plugins/load/load_animation.py | 24 +++--- .../hosts/unreal/plugins/load/load_camera.py | 16 ++-- .../hosts/unreal/plugins/load/load_layout.py | 49 ++++++------ .../hosts/unreal/plugins/load/load_rig.py | 25 ++++--- .../unreal/plugins/load/load_staticmeshfbx.py | 26 ++++--- .../plugins/publish/collect_current_file.py | 9 ++- .../plugins/publish/collect_instances.py | 10 ++- .../unreal/plugins/publish/extract_camera.py | 8 +- .../unreal/plugins/publish/extract_layout.py | 7 +- .../unreal/plugins/publish/extract_look.py | 15 ++-- 44 files changed, 376 insertions(+), 353 deletions(-) delete mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp delete mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp delete mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp rename openpype/hosts/unreal/integration/Source/{Avalon/Avalon.Build.cs => OpenPype/OpenPype.Build.cs} (96%) rename openpype/hosts/unreal/integration/Source/{Avalon => OpenPype}/Private/AssetContainer.cpp (100%) rename openpype/hosts/unreal/integration/Source/{Avalon => OpenPype}/Private/AssetContainerFactory.cpp (100%) rename openpype/hosts/unreal/integration/Source/{Avalon/Private/Avalon.cpp => OpenPype/Private/OpenPype.cpp} (56%) rename openpype/hosts/unreal/integration/Source/{Avalon/Private/AvalonLib.cpp => OpenPype/Private/OpenPypeLib.cpp} (84%) rename openpype/hosts/unreal/integration/Source/{Avalon/Private/AvalonPublishInstance.cpp => OpenPype/Private/OpenPypePublishInstance.cpp} (67%) create mode 100644 openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp create mode 100644 openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp create mode 100644 openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp rename openpype/hosts/unreal/integration/Source/{Avalon => OpenPype}/Public/AssetContainer.h (100%) rename openpype/hosts/unreal/integration/Source/{Avalon => OpenPype}/Public/AssetContainerFactory.h (89%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/Avalon.h => OpenPype/Public/OpenPype.h} (87%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/AvalonLib.h => OpenPype/Public/OpenPypeLib.h} (88%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/AvalonPublishInstance.h => OpenPype/Public/OpenPypePublishInstance.h} (65%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/AvalonPublishInstanceFactory.h => OpenPype/Public/OpenPypePublishInstanceFactory.h} (61%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/AvalonPythonBridge.h => OpenPype/Public/OpenPypePythonBridge.h} (71%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/AvalonStyle.h => OpenPype/Public/OpenPypeStyle.h} (95%) diff --git a/.gitmodules b/.gitmodules index 67b820a247..9920ceaad6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "repos/avalon-core"] path = repos/avalon-core - url = https://github.com/pypeclub/avalon-core.git -[submodule "repos/avalon-unreal-integration"] - path = repos/avalon-unreal-integration - url = https://github.com/pypeclub/avalon-unreal-integration.git \ No newline at end of file + url = https://github.com/pypeclub/avalon-core.git \ No newline at end of file diff --git a/openpype/hosts/unreal/api/__init__.py b/openpype/hosts/unreal/api/__init__.py index 38469e0ddb..df86c09073 100644 --- a/openpype/hosts/unreal/api/__init__.py +++ b/openpype/hosts/unreal/api/__init__.py @@ -16,7 +16,7 @@ INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") def install(): - """Install Unreal configuration for Avalon.""" + """Install Unreal configuration for OpenPype.""" print("-=" * 40) logo = '''. . diff --git a/openpype/hosts/unreal/api/helpers.py b/openpype/hosts/unreal/api/helpers.py index 6fc89cf176..555133eae0 100644 --- a/openpype/hosts/unreal/api/helpers.py +++ b/openpype/hosts/unreal/api/helpers.py @@ -29,7 +29,7 @@ class OpenPypeHelpers(unreal.OpenPypeLib): Example: - AvalonHelpers().set_folder_color( + OpenPypeHelpers().set_folder_color( "/Game/Path", unreal.LinearColor(a=1.0, r=1.0, g=0.5, b=0) ) diff --git a/openpype/hosts/unreal/api/lib.py b/openpype/hosts/unreal/api/lib.py index e04606a333..d4a776e892 100644 --- a/openpype/hosts/unreal/api/lib.py +++ b/openpype/hosts/unreal/api/lib.py @@ -230,18 +230,18 @@ def create_unreal_project(project_name: str, ue_id = "{" + loaded_modules.get("BuildId") + "}" plugins_path = None - if os.path.isdir(env.get("AVALON_UNREAL_PLUGIN", "")): + if os.path.isdir(env.get("OPENPYPE_UNREAL_PLUGIN", "")): # copy plugin to correct path under project plugins_path = pr_dir / "Plugins" - avalon_plugin_path = plugins_path / "Avalon" - if not avalon_plugin_path.is_dir(): - avalon_plugin_path.mkdir(parents=True, exist_ok=True) + openpype_plugin_path = plugins_path / "OpenPype" + if not openpype_plugin_path.is_dir(): + openpype_plugin_path.mkdir(parents=True, exist_ok=True) dir_util._path_created = {} - dir_util.copy_tree(os.environ.get("AVALON_UNREAL_PLUGIN"), - avalon_plugin_path.as_posix()) + dir_util.copy_tree(os.environ.get("OPENPYPE_UNREAL_PLUGIN"), + openpype_plugin_path.as_posix()) - if not (avalon_plugin_path / "Binaries").is_dir() \ - or not (avalon_plugin_path / "Intermediate").is_dir(): + if not (openpype_plugin_path / "Binaries").is_dir() \ + or not (openpype_plugin_path / "Intermediate").is_dir(): dev_mode = True # data for project file @@ -304,7 +304,7 @@ def _prepare_cpp_project(project_file: Path, engine_path: Path) -> None: """Prepare CPP Unreal Project. This function will add source files needed for project to be - rebuild along with the avalon integration plugin. + rebuild along with the OpenPype integration plugin. There seems not to be automated way to do it from command line. But there might be way to create at least those target and build files diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index c255005f31..02c89abadd 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -1,21 +1,17 @@ # -*- coding: utf-8 -*- -import sys import pyblish.api from avalon.pipeline import AVALON_CONTAINER_ID import unreal # noqa from typing import List - from openpype.tools.utils import host_tools - from avalon import api -AVALON_CONTAINERS = "OpenPypeContainers" +OPENPYPE_CONTAINERS = "OpenPypeContainers" def install(): - pyblish.api.register_host("unreal") _register_callbacks() _register_events() @@ -46,7 +42,7 @@ class Creator(api.Creator): def process(self): nodes = list() - with unreal.ScopedEditorTransaction("Avalon Creating Instance"): + with unreal.ScopedEditorTransaction("OpenPype Creating Instance"): if (self.options or {}).get("useSelection"): self.log.info("setting ...") print("settings ...") @@ -63,23 +59,21 @@ class Creator(api.Creator): return instance -class Loader(api.Loader): - hosts = ["unreal"] - - def ls(): - """ - List all containers found in *Content Manager* of Unreal and return + """List all containers. + + List all found in *Content Manager* of Unreal and return metadata from them. Adding `objectName` to set. + """ ar = unreal.AssetRegistryHelpers.get_asset_registry() - avalon_containers = ar.get_assets_by_class("AssetContainer", True) + openpype_containers = ar.get_assets_by_class("AssetContainer", True) # get_asset_by_class returns AssetData. To get all metadata we need to # load asset. get_tag_values() work only on metadata registered in - # Asset Registy Project settings (and there is no way to set it with + # Asset Registry Project settings (and there is no way to set it with # python short of editing ini configuration file). - for asset_data in avalon_containers: + for asset_data in openpype_containers: asset = asset_data.get_asset() data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset) data["objectName"] = asset_data.asset_name @@ -89,8 +83,7 @@ def ls(): def parse_container(container): - """ - To get data from container, AssetContainer must be loaded. + """To get data from container, AssetContainer must be loaded. Args: container(str): path to container @@ -107,20 +100,19 @@ def parse_container(container): def publish(): - """Shorthand to publish from within host""" + """Shorthand to publish from within host.""" import pyblish.util return pyblish.util.publish() def containerise(name, namespace, nodes, context, loader=None, suffix="_CON"): - """Bundles *nodes* (assets) into a *container* and add metadata to it. Unreal doesn't support *groups* of assets that you can add metadata to. But it does support folders that helps to organize asset. Unfortunately those folders are just that - you cannot add any additional information - to them. `Avalon Integration Plugin`_ is providing way out - Implementing + to them. OpenPype Integration Plugin is providing way out - Implementing `AssetContainer` Blueprint class. This class when added to folder can handle metadata on it using standard :func:`unreal.EditorAssetLibrary.set_metadata_tag()` and @@ -129,10 +121,7 @@ def containerise(name, namespace, nodes, context, loader=None, suffix="_CON"): those assets is available as `assets` property. This is list of strings starting with asset type and ending with its path: - `Material /Game/Avalon/Test/TestMaterial.TestMaterial` - - .. _Avalon Integration Plugin: - https://github.com/pypeclub/avalon-unreal-integration + `Material /Game/OpenPype/Test/TestMaterial.TestMaterial` """ # 1 - create directory for container @@ -160,10 +149,11 @@ def containerise(name, namespace, nodes, context, loader=None, suffix="_CON"): def instantiate(root, name, data, assets=None, suffix="_INS"): - """ - Bundles *nodes* into *container* marking it with metadata as publishable - instance. If assets are provided, they are moved to new path where - `AvalonPublishInstance` class asset is created and imprinted with metadata. + """Bundles *nodes* into *container*. + + Marking it with metadata as publishable instance. If assets are provided, + they are moved to new path where `OpenPypePublishInstance` class asset is + created and imprinted with metadata. This can then be collected for publishing by Pyblish for example. @@ -174,6 +164,7 @@ def instantiate(root, name, data, assets=None, suffix="_INS"): assets (list of str): list of asset paths to include in publish instance suffix (str): suffix string to append to instance name + """ container_name = "{}{}".format(name, suffix) @@ -203,7 +194,7 @@ def imprint(node, data): loaded_asset, key, str(value) ) - with unreal.ScopedEditorTransaction("Avalon containerising"): + with unreal.ScopedEditorTransaction("OpenPype containerising"): unreal.EditorAssetLibrary.save_asset(node) @@ -248,7 +239,7 @@ def show_experimental_tools(): def create_folder(root: str, name: str) -> str: - """Create new folder + """Create new folder. If folder exists, append number at the end and try again, incrementing if needed. @@ -281,8 +272,7 @@ def create_folder(root: str, name: str) -> str: def move_assets_to_path(root: str, name: str, assets: List[str]) -> str: - """ - Moving (renaming) list of asset paths to new destination. + """Moving (renaming) list of asset paths to new destination. Args: root (str): root of the path (eg. `/Game`) @@ -316,8 +306,8 @@ def move_assets_to_path(root: str, name: str, assets: List[str]) -> str: def create_container(container: str, path: str) -> unreal.Object: - """ - Helper function to create Asset Container class on given path. + """Helper function to create Asset Container class on given path. + This Asset Class helps to mark given path as Container and enable asset version control on it. @@ -331,7 +321,7 @@ def create_container(container: str, path: str) -> unreal.Object: Example: - create_avalon_container( + create_container( "/Game/modelingFooCharacter_CON", "modelingFooCharacter_CON" ) @@ -345,9 +335,9 @@ def create_container(container: str, path: str) -> unreal.Object: def create_publish_instance(instance: str, path: str) -> unreal.Object: - """ - Helper function to create Avalon Publish Instance on given path. - This behaves similary as :func:`create_avalon_container`. + """Helper function to create OpenPype Publish Instance on given path. + + This behaves similarly as :func:`create_openpype_container`. Args: path (str): Path where to create Publish Instance. @@ -365,13 +355,13 @@ def create_publish_instance(instance: str, path: str) -> unreal.Object: ) """ - factory = unreal.AvalonPublishInstanceFactory() + factory = unreal.OpenPypePublishInstanceFactory() tools = unreal.AssetToolsHelpers().get_asset_tools() asset = tools.create_asset(instance, path, None, factory) return asset -def cast_map_to_str_dict(map) -> dict: +def cast_map_to_str_dict(umap) -> dict: """Cast Unreal Map to dict. Helper function to cast Unreal Map object to plain old python @@ -379,10 +369,10 @@ def cast_map_to_str_dict(map) -> dict: metadata dicts. Args: - map: Unreal Map object + umap: Unreal Map object Returns: dict """ - return {str(key): str(value) for (key, value) in map.items()} + return {str(key): str(value) for (key, value) in umap.items()} diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index 880dba5cfb..6b787f4da7 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -136,9 +136,9 @@ class UnrealPrelaunchHook(PreLaunchHook): f"{self.signature} creating unreal " f"project [ {unreal_project_name} ]" )) - # Set "AVALON_UNREAL_PLUGIN" to current process environment for + # Set "OPENPYPE_UNREAL_PLUGIN" to current process environment for # execution of `create_unreal_project` - env_key = "AVALON_UNREAL_PLUGIN" + env_key = "OPENPYPE_UNREAL_PLUGIN" if self.launch_context.env.get(env_key): os.environ[env_key] = self.launch_context.env[env_key] diff --git a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py index 48e931bb04..4445abb1b0 100644 --- a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py +++ b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py @@ -1,27 +1,32 @@ import unreal -avalon_detected = True +openpype_detected = True try: from avalon import api - from avalon import unreal as avalon_unreal except ImportError as exc: - avalon_detected = False - unreal.log_error("Avalon: cannot load avalon [ {} ]".format(exc)) + openpype_detected = False + unreal.log_error("Avalon: cannot load Avalon [ {} ]".format(exc)) -if avalon_detected: - api.install(avalon_unreal) +try: + from openpype.host.unreal import api as openpype_host +except ImportError as exc: + openpype_detected = False + unreal.log_error("OpenPype: cannot load OpenPype [ {} ]".format(exc)) + +if openpype_detected: + api.install(openpype_host) @unreal.uclass() -class AvalonIntegration(unreal.AvalonPythonBridge): +class OpenPypeIntegration(unreal.OpenPypePythonBridge): @unreal.ufunction(override=True) def RunInPython_Popup(self): - unreal.log_warning("Avalon: showing tools popup") - if avalon_detected: - avalon_unreal.show_tools_popup() + unreal.log_warning("OpenPype: showing tools popup") + if openpype_detected: + openpype_host.show_tools_popup() @unreal.ufunction(override=True) def RunInPython_Dialog(self): - unreal.log_warning("Avalon: showing tools dialog") - if avalon_detected: - avalon_unreal.show_tools_dialog() + unreal.log_warning("OpenPype: showing tools dialog") + if openpype_detected: + openpype_host.show_tools_dialog() diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp deleted file mode 100644 index e14a14f1e5..0000000000 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp +++ /dev/null @@ -1,20 +0,0 @@ -#include "AvalonPublishInstanceFactory.h" -#include "AvalonPublishInstance.h" - -UAvalonPublishInstanceFactory::UAvalonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer) - : UFactory(ObjectInitializer) -{ - SupportedClass = UAvalonPublishInstance::StaticClass(); - bCreateNew = false; - bEditorImport = true; -} - -UObject* UAvalonPublishInstanceFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) -{ - UAvalonPublishInstance* AvalonPublishInstance = NewObject(InParent, Class, Name, Flags); - return AvalonPublishInstance; -} - -bool UAvalonPublishInstanceFactory::ShouldShowInNewMenu() const { - return false; -} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp deleted file mode 100644 index 8642ab6b63..0000000000 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp +++ /dev/null @@ -1,13 +0,0 @@ -#include "AvalonPythonBridge.h" - -UAvalonPythonBridge* UAvalonPythonBridge::Get() -{ - TArray AvalonPythonBridgeClasses; - GetDerivedClasses(UAvalonPythonBridge::StaticClass(), AvalonPythonBridgeClasses); - int32 NumClasses = AvalonPythonBridgeClasses.Num(); - if (NumClasses > 0) - { - return Cast(AvalonPythonBridgeClasses[NumClasses - 1]->GetDefaultObject()); - } - return nullptr; -}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp deleted file mode 100644 index 5b3d1269b0..0000000000 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp +++ /dev/null @@ -1,69 +0,0 @@ -#include "AvalonStyle.h" -#include "Framework/Application/SlateApplication.h" -#include "Styling/SlateStyle.h" -#include "Styling/SlateStyleRegistry.h" - - -TUniquePtr< FSlateStyleSet > FAvalonStyle::AvalonStyleInstance = nullptr; - -void FAvalonStyle::Initialize() -{ - if (!AvalonStyleInstance.IsValid()) - { - AvalonStyleInstance = Create(); - FSlateStyleRegistry::RegisterSlateStyle(*AvalonStyleInstance); - } -} - -void FAvalonStyle::Shutdown() -{ - if (AvalonStyleInstance.IsValid()) - { - FSlateStyleRegistry::UnRegisterSlateStyle(*AvalonStyleInstance); - AvalonStyleInstance.Reset(); - } -} - -FName FAvalonStyle::GetStyleSetName() -{ - static FName StyleSetName(TEXT("AvalonStyle")); - return StyleSetName; -} - -FName FAvalonStyle::GetContextName() -{ - static FName ContextName(TEXT("OpenPype")); - return ContextName; -} - -#define IMAGE_BRUSH(RelativePath, ...) FSlateImageBrush( Style->RootToContentDir( RelativePath, TEXT(".png") ), __VA_ARGS__ ) - -const FVector2D Icon40x40(40.0f, 40.0f); - -TUniquePtr< FSlateStyleSet > FAvalonStyle::Create() -{ - TUniquePtr< FSlateStyleSet > Style = MakeUnique(GetStyleSetName()); - Style->SetContentRoot(FPaths::ProjectPluginsDir() / TEXT("Avalon/Resources")); - - return Style; -} - -void FAvalonStyle::SetIcon(const FString& StyleName, const FString& ResourcePath) -{ - FSlateStyleSet* Style = AvalonStyleInstance.Get(); - - FString Name(GetContextName().ToString()); - Name = Name + "." + StyleName; - Style->Set(*Name, new FSlateImageBrush(Style->RootToContentDir(ResourcePath, TEXT(".png")), Icon40x40)); - - - FSlateApplication::Get().GetRenderer()->ReloadTextureResources(); -} - -#undef IMAGE_BRUSH - -const ISlateStyle& FAvalonStyle::Get() -{ - check(AvalonStyleInstance); - return *AvalonStyleInstance; -} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs b/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs similarity index 96% rename from openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs rename to openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs index 5068e37d80..cf50041aed 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs +++ b/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs @@ -2,7 +2,7 @@ using UnrealBuildTool; -public class Avalon : ModuleRules +public class OpenPype : ModuleRules { public Avalon(ReadOnlyTargetRules Target) : base(Target) { diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainer.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp rename to openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainer.cpp diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainerFactory.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp rename to openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainerFactory.cpp diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp similarity index 56% rename from openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp rename to openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp index ed782f4870..65da780ad6 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp @@ -1,19 +1,19 @@ #include "Avalon.h" #include "LevelEditor.h" -#include "AvalonPythonBridge.h" -#include "AvalonStyle.h" +#include "OpenPypePythonBridge.h" +#include "OpenPypeStyle.h" -static const FName AvalonTabName("Avalon"); +static const FName OpenPypeTabName("OpenPype"); -#define LOCTEXT_NAMESPACE "FAvalonModule" +#define LOCTEXT_NAMESPACE "FOpenPypeModule" // This function is triggered when the plugin is staring up -void FAvalonModule::StartupModule() +void FOpenPypeModule::StartupModule() { - FAvalonStyle::Initialize(); - FAvalonStyle::SetIcon("Logo", "openpype40"); + FOpenPypeStyle::Initialize(); + FOpenPypeStyle::SetIcon("Logo", "openpype40"); // Create the Extender that will add content to the menu FLevelEditorModule& LevelEditorModule = FModuleManager::LoadModuleChecked("LevelEditor"); @@ -25,13 +25,13 @@ void FAvalonModule::StartupModule() "LevelEditor", EExtensionHook::After, NULL, - FMenuExtensionDelegate::CreateRaw(this, &FAvalonModule::AddMenuEntry) + FMenuExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddMenuEntry) ); ToolbarExtender->AddToolBarExtension( "Settings", EExtensionHook::After, NULL, - FToolBarExtensionDelegate::CreateRaw(this, &FAvalonModule::AddToobarEntry)); + FToolBarExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddToobarEntry)); LevelEditorModule.GetMenuExtensibilityManager()->AddExtender(MenuExtender); @@ -39,13 +39,13 @@ void FAvalonModule::StartupModule() } -void FAvalonModule::ShutdownModule() +void FOpenPypeModule::ShutdownModule() { - FAvalonStyle::Shutdown(); + FOpenPypeStyle::Shutdown(); } -void FAvalonModule::AddMenuEntry(FMenuBuilder& MenuBuilder) +void FOpenPypeModule::AddMenuEntry(FMenuBuilder& MenuBuilder) { // Create Section MenuBuilder.BeginSection("OpenPype", TAttribute(FText::FromString("OpenPype"))); @@ -54,22 +54,22 @@ void FAvalonModule::AddMenuEntry(FMenuBuilder& MenuBuilder) MenuBuilder.AddMenuEntry( FText::FromString("Tools..."), FText::FromString("Pipeline tools"), - FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo"), - FUIAction(FExecuteAction::CreateRaw(this, &FAvalonModule::MenuPopup)) + FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo"), + FUIAction(FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuPopup)) ); MenuBuilder.AddMenuEntry( FText::FromString("Tools dialog..."), FText::FromString("Pipeline tools dialog"), - FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo"), - FUIAction(FExecuteAction::CreateRaw(this, &FAvalonModule::MenuDialog)) + FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo"), + FUIAction(FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuDialog)) ); } MenuBuilder.EndSection(); } -void FAvalonModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder) +void FOpenPypeModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder) { ToolbarBuilder.BeginSection(TEXT("OpenPype")); { @@ -83,21 +83,21 @@ void FAvalonModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder) NAME_None, LOCTEXT("OpenPype_label", "OpenPype"), LOCTEXT("OpenPype_tooltip", "OpenPype Tools"), - FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo") + FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo") ); } ToolbarBuilder.EndSection(); } -void FAvalonModule::MenuPopup() { - UAvalonPythonBridge* bridge = UAvalonPythonBridge::Get(); +void FOpenPypeModule::MenuPopup() { + UOpenPypePythonBridge* bridge = UOpenPypePythonBridge::Get(); bridge->RunInPython_Popup(); } -void FAvalonModule::MenuDialog() { - UAvalonPythonBridge* bridge = UAvalonPythonBridge::Get(); +void FOpenPypeModule::MenuDialog() { + UOpenPypePythonBridge* bridge = UOpenPypePythonBridge::Get(); bridge->RunInPython_Dialog(); } -IMPLEMENT_MODULE(FAvalonModule, Avalon) +IMPLEMENT_MODULE(FOpenPypeModule, OpenPype) diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeLib.cpp similarity index 84% rename from openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp rename to openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeLib.cpp index 312656424c..5facab7b8b 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeLib.cpp @@ -1,4 +1,4 @@ -#include "AvalonLib.h" +#include "OpenPypeLib.h" #include "Misc/Paths.h" #include "Misc/ConfigCacheIni.h" #include "UObject/UnrealType.h" @@ -10,7 +10,7 @@ * @warning This color will appear only after Editor restart. Is there a better way? */ -void UAvalonLib::CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd) +void UOpenPypeLib::CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd) { auto SaveColorInternal = [](FString InPath, FLinearColor InFolderColor) { @@ -30,7 +30,7 @@ void UAvalonLib::CSetFolderColor(FString FolderPath, FLinearColor FolderColor, b * @param cls - class * @return TArray of properties */ -TArray UAvalonLib::GetAllProperties(UClass* cls) +TArray UOpenPypeLib::GetAllProperties(UClass* cls) { TArray Ret; if (cls != nullptr) diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstance.cpp similarity index 67% rename from openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp rename to openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstance.cpp index 2bb31a4853..4f1e846c0b 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstance.cpp @@ -1,28 +1,28 @@ #pragma once -#include "AvalonPublishInstance.h" +#include "OpenPypePublishInstance.h" #include "AssetRegistryModule.h" -UAvalonPublishInstance::UAvalonPublishInstance(const FObjectInitializer& ObjectInitializer) +UOpenPypePublishInstance::UOpenPypePublishInstance(const FObjectInitializer& ObjectInitializer) : UObject(ObjectInitializer) { FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); - FString path = UAvalonPublishInstance::GetPathName(); + FString path = UOpenPypePublishInstance::GetPathName(); FARFilter Filter; Filter.PackagePaths.Add(FName(*path)); - AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAvalonPublishInstance::OnAssetAdded); - AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAvalonPublishInstance::OnAssetRemoved); - AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAvalonPublishInstance::OnAssetRenamed); + AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UOpenPypePublishInstance::OnAssetAdded); + AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UOpenPypePublishInstance::OnAssetRemoved); + AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UOpenPypePublishInstance::OnAssetRenamed); } -void UAvalonPublishInstance::OnAssetAdded(const FAssetData& AssetData) +void UOpenPypePublishInstance::OnAssetAdded(const FAssetData& AssetData) { TArray split; // get directory of current container - FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfFullPath = UOpenPypePublishInstance::GetPathName(); FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); // get asset path and class @@ -38,7 +38,7 @@ void UAvalonPublishInstance::OnAssetAdded(const FAssetData& AssetData) if (assetDir.StartsWith(*selfDir)) { // exclude self - if (assetFName != "AvalonPublishInstance") + if (assetFName != "OpenPypePublishInstance") { assets.Add(assetPath); UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir); @@ -46,12 +46,12 @@ void UAvalonPublishInstance::OnAssetAdded(const FAssetData& AssetData) } } -void UAvalonPublishInstance::OnAssetRemoved(const FAssetData& AssetData) +void UOpenPypePublishInstance::OnAssetRemoved(const FAssetData& AssetData) { TArray split; // get directory of current container - FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfFullPath = UOpenPypePublishInstance::GetPathName(); FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); // get asset path and class @@ -64,13 +64,13 @@ void UAvalonPublishInstance::OnAssetRemoved(const FAssetData& AssetData) FString assetDir = FPackageName::GetLongPackagePath(*split[1]); // take interest only in paths starting with path of current container - FString path = UAvalonPublishInstance::GetPathName(); + FString path = UOpenPypePublishInstance::GetPathName(); FString lpp = FPackageName::GetLongPackagePath(*path); if (assetDir.StartsWith(*selfDir)) { // exclude self - if (assetFName != "AvalonPublishInstance") + if (assetFName != "OpenPypePublishInstance") { // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp); assets.Remove(assetPath); @@ -78,12 +78,12 @@ void UAvalonPublishInstance::OnAssetRemoved(const FAssetData& AssetData) } } -void UAvalonPublishInstance::OnAssetRenamed(const FAssetData& AssetData, const FString& str) +void UOpenPypePublishInstance::OnAssetRenamed(const FAssetData& AssetData, const FString& str) { TArray split; // get directory of current container - FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfFullPath = UOpenPypePublishInstance::GetPathName(); FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); // get asset path and class diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp new file mode 100644 index 0000000000..e61964c689 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp @@ -0,0 +1,20 @@ +#include "OpenPypePublishInstanceFactory.h" +#include "OpenPypePublishInstance.h" + +UOpenPypePublishInstanceFactory::UOpenPypePublishInstanceFactory(const FObjectInitializer& ObjectInitializer) + : UFactory(ObjectInitializer) +{ + SupportedClass = UOpenPypePublishInstance::StaticClass(); + bCreateNew = false; + bEditorImport = true; +} + +UObject* UOpenPypePublishInstanceFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) +{ + UOpenPypePublishInstance* OpenPypePublishInstance = NewObject(InParent, Class, Name, Flags); + return OpenPypePublishInstance; +} + +bool UOpenPypePublishInstanceFactory::ShouldShowInNewMenu() const { + return false; +} diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp new file mode 100644 index 0000000000..767f089374 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp @@ -0,0 +1,13 @@ +#include "OpenPypePythonBridge.h" + +UOpenPypePythonBridge* UOpenPypePythonBridge::Get() +{ + TArray OpenPypePythonBridgeClasses; + GetDerivedClasses(UAvalonPythonBridge::StaticClass(), OpenPypePythonBridgeClasses); + int32 NumClasses = OpenPypePythonBridgeClasses.Num(); + if (NumClasses > 0) + { + return Cast(AvalonPythonBridgeClasses[NumClasses - 1]->GetDefaultObject()); + } + return nullptr; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp new file mode 100644 index 0000000000..a51c2d6aa5 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp @@ -0,0 +1,70 @@ +#include "OpenPypeStyle.h" +#include "Framework/Application/SlateApplication.h" +#include "Styling/SlateStyle.h" +#include "Styling/SlateStyleRegistry.h" + + +TUniquePtr< FSlateStyleSet > FOpenPypeStyle::OpenPypeStyleInstance = nullptr; + +void FOpenPypeStyle::Initialize() +{ + if (!OpenPypeStyleInstance.IsValid()) + { + OpenPypeStyleInstance = Create(); + FSlateStyleRegistry::RegisterSlateStyle(*OpenPypeStyleInstance); + } +} + +void FOpenPypeStyle::Shutdown() +{ + if (OpenPypeStyleInstance.IsValid()) + { + FSlateStyleRegistry::UnRegisterSlateStyle(*OpenPypeStyleInstance); + OpenPypeStyleInstance.Reset(); + } +} + +FName FOpenPypeStyle::GetStyleSetName() +{ + static FName StyleSetName(TEXT("OpenPypeStyle")); + return StyleSetName; +} + +FName FOpenPypeStyle::GetContextName() +{ + static FName ContextName(TEXT("OpenPype")); + return ContextName; +} + +#define IMAGE_BRUSH(RelativePath, ...) FSlateImageBrush( Style->RootToContentDir( RelativePath, TEXT(".png") ), __VA_ARGS__ ) + +const FVector2D Icon40x40(40.0f, 40.0f); + +TUniquePtr< FSlateStyleSet > FOpenPypeStyle::Create() +{ + TUniquePtr< FSlateStyleSet > Style = MakeUnique(GetStyleSetName()); + Style->SetContentRoot(FPaths::ProjectPluginsDir() / TEXT("OpenPype/Resources")); + + return Style; +} + +void FOpenPypeStyle::SetIcon(const FString& StyleName, const FString& ResourcePath) +{ + FSlateStyleSet* Style = OpenPypeStyleInstance.Get(); + + FString Name(GetContextName().ToString()); + Name = Name + "." + StyleName; + Style->Set(*Name, new FSlateImageBrush(Style->RootToContentDir(ResourcePath, TEXT(".png")), Icon40x40)); + + + FSlateApplication::Get().GetRenderer()->ReloadTextureResources(); +} + +#undef IMAGE_BRUSH + +const ISlateStyle& FOpenPypeStyle::Get() +{ + check(OpenPypeStyleInstance); + return *OpenPypeStyleInstance; + return *OpenPypeStyleInstance; +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainerFactory.h similarity index 89% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainerFactory.h index 62b6e73640..331ce6bb50 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainerFactory.h @@ -10,7 +10,7 @@ * */ UCLASS() -class AVALON_API UAssetContainerFactory : public UFactory +class OPENPYPE_API UAssetContainerFactory : public UFactory { GENERATED_BODY() diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h similarity index 87% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h index 2dd6a825ab..db3f299354 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h @@ -5,7 +5,7 @@ #include "Engine.h" -class FAvalonModule : public IModuleInterface +class FOpenPypeModule : public IModuleInterface { public: virtual void StartupModule() override; diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h similarity index 88% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h index da3369970c..3b4afe1408 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h @@ -5,7 +5,7 @@ UCLASS(Blueprintable) -class AVALON_API UAvalonLib : public UObject +class OPENPYPE_API UOpenPypeLib : public UObject { GENERATED_BODY() diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstance.h similarity index 65% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstance.h index 7678f78924..0a27a078d7 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstance.h @@ -1,16 +1,16 @@ #pragma once #include "Engine.h" -#include "AvalonPublishInstance.generated.h" +#include "OpenPypePublishInstance.generated.h" UCLASS(Blueprintable) -class AVALON_API UAvalonPublishInstance : public UObject +class OPENPYPE_API UOpenPypePublishInstance : public UObject { GENERATED_BODY() public: - UAvalonPublishInstance(const FObjectInitializer& ObjectInitalizer); + UOpenPypePublishInstance(const FObjectInitializer& ObjectInitalizer); UPROPERTY(EditAnywhere, BlueprintReadOnly) TArray assets; diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h similarity index 61% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h index 79e781c60c..a2b3abe13e 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h @@ -2,18 +2,18 @@ #include "CoreMinimal.h" #include "Factories/Factory.h" -#include "AvalonPublishInstanceFactory.generated.h" +#include "OpenPypePublishInstanceFactory.generated.h" /** * */ UCLASS() -class AVALON_API UAvalonPublishInstanceFactory : public UFactory +class OPENPYPE_API UOpenPypePublishInstanceFactory : public UFactory { GENERATED_BODY() public: - UAvalonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer); + UOpenPypePublishInstanceFactory(const FObjectInitializer& ObjectInitializer); virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; virtual bool ShouldShowInNewMenu() const override; }; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePythonBridge.h similarity index 71% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePythonBridge.h index db4b16d53f..692aab2e5e 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePythonBridge.h @@ -1,15 +1,15 @@ #pragma once #include "Engine.h" -#include "AvalonPythonBridge.generated.h" +#include "OpenPypePythonBridge.generated.h" UCLASS(Blueprintable) -class UAvalonPythonBridge : public UObject +class UOpenPypePythonBridge : public UObject { GENERATED_BODY() public: UFUNCTION(BlueprintCallable, Category = Python) - static UAvalonPythonBridge* Get(); + static UOpenPypePythonBridge* Get(); UFUNCTION(BlueprintImplementableEvent, Category = Python) void RunInPython_Popup() const; diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h similarity index 95% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h index ffb2bc7aa4..0e9400406a 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h @@ -5,7 +5,7 @@ class FSlateStyleSet; class ISlateStyle; -class FAvalonStyle +class FOpenPypeStyle { public: static void Initialize(); diff --git a/openpype/hosts/unreal/plugins/create/create_camera.py b/openpype/hosts/unreal/plugins/create/create_camera.py index eda2b52be3..c2905fb6dd 100644 --- a/openpype/hosts/unreal/plugins/create/create_camera.py +++ b/openpype/hosts/unreal/plugins/create/create_camera.py @@ -16,7 +16,7 @@ class CreateCamera(Creator): family = "camera" icon = "cubes" - root = "/Game/Avalon/Instances" + root = "/Game/OpenPype/Instances" suffix = "_INS" def __init__(self, *args, **kwargs): diff --git a/openpype/hosts/unreal/plugins/create/create_layout.py b/openpype/hosts/unreal/plugins/create/create_layout.py index 239b72787b..00e83cf433 100644 --- a/openpype/hosts/unreal/plugins/create/create_layout.py +++ b/openpype/hosts/unreal/plugins/create/create_layout.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from unreal import EditorLevelLibrary as ell from openpype.hosts.unreal.api.plugin import Creator from avalon.unreal import ( @@ -6,7 +7,7 @@ from avalon.unreal import ( class CreateLayout(Creator): - """Layout output for character rigs""" + """Layout output for character rigs.""" name = "layoutMain" label = "Layout" diff --git a/openpype/hosts/unreal/plugins/create/create_look.py b/openpype/hosts/unreal/plugins/create/create_look.py index 7d3913b883..59c40d3e74 100644 --- a/openpype/hosts/unreal/plugins/create/create_look.py +++ b/openpype/hosts/unreal/plugins/create/create_look.py @@ -1,10 +1,12 @@ -import unreal +# -*- coding: utf-8 -*- +"""Create look in Unreal.""" +import unreal # noqa from openpype.hosts.unreal.api.plugin import Creator -from avalon.unreal import pipeline +from openpype.hosts.unreal.api import pipeline class CreateLook(Creator): - """Shader connections defining shape look""" + """Shader connections defining shape look.""" name = "unrealLook" label = "Unreal - Look" @@ -49,14 +51,14 @@ class CreateLook(Creator): for material in materials: name = material.get_editor_property('material_slot_name') object_path = f"{full_path}/{name}.{name}" - object = unreal.EditorAssetLibrary.duplicate_loaded_asset( + unreal_object = unreal.EditorAssetLibrary.duplicate_loaded_asset( cube.get_asset(), object_path ) # Remove the default material of the cube object - object.get_editor_property('static_materials').pop() + unreal_object.get_editor_property('static_materials').pop() - object.add_material( + unreal_object.add_material( material.get_editor_property('material_interface')) self.data["members"].append(object_path) diff --git a/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py b/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py index 4cc67e0f1f..700eac7366 100644 --- a/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py @@ -1,12 +1,14 @@ -import unreal +# -*- coding: utf-8 -*- +"""Create Static Meshes as FBX geometry.""" +import unreal # noqa from openpype.hosts.unreal.api.plugin import Creator -from avalon.unreal import ( +from openpype.hosts.unreal.api.pipeline import ( instantiate, ) class CreateStaticMeshFBX(Creator): - """Static FBX geometry""" + """Static FBX geometry.""" name = "unrealStaticMeshMain" label = "Unreal - Static Mesh" diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py index e2023e8b47..a0cd69326f 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py @@ -1,12 +1,15 @@ +# -*- coding: utf-8 -*- +"""Loader for published alembics.""" import os from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline + +import unreal # noqa -class PointCacheAlembicLoader(api.Loader): +class PointCacheAlembicLoader(plugin.Loader): """Load Point Cache from Alembic""" families = ["model", "pointcache"] @@ -56,8 +59,7 @@ class PointCacheAlembicLoader(api.Loader): return task def load(self, context, name, namespace, data): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -76,10 +78,10 @@ class PointCacheAlembicLoader(api.Loader): Returns: list(str): list of container content - """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + """ + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -109,7 +111,7 @@ class PointCacheAlembicLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py index b652af0b89..0236bab138 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py @@ -1,12 +1,14 @@ +# -*- coding: utf-8 -*- +"""Load Skeletal Mesh alembics.""" import os from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class SkeletalMeshAlembicLoader(api.Loader): +class SkeletalMeshAlembicLoader(plugin.Loader): """Load Unreal SkeletalMesh from Alembic""" families = ["pointcache"] @@ -16,8 +18,7 @@ class SkeletalMeshAlembicLoader(api.Loader): color = "orange" def load(self, context, name, namespace, data): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -38,8 +39,8 @@ class SkeletalMeshAlembicLoader(api.Loader): list(str): list of container content """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + # Create directory for asset and openpype container + root = "/Game/OpenPype/Assets" asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -74,7 +75,7 @@ class SkeletalMeshAlembicLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py index ccec31b832..aec8b45041 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py @@ -1,12 +1,14 @@ +# -*- coding: utf-8 -*- +"""Loader for Static Mesh alembics.""" import os from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class StaticMeshAlembicLoader(api.Loader): +class StaticMeshAlembicLoader(plugin.Loader): """Load Unreal StaticMesh from Alembic""" families = ["model"] @@ -49,8 +51,7 @@ class StaticMeshAlembicLoader(api.Loader): return task def load(self, context, name, namespace, data): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -69,10 +70,10 @@ class StaticMeshAlembicLoader(api.Loader): Returns: list(str): list of container content - """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + """ + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -93,7 +94,7 @@ class StaticMeshAlembicLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py index 20baa30847..63c734b969 100644 --- a/openpype/hosts/unreal/plugins/load/load_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_animation.py @@ -1,14 +1,16 @@ +# -*- coding: utf-8 -*- +"""Load FBX with animations.""" import os import json from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class AnimationFBXLoader(api.Loader): - """Load Unreal SkeletalMesh from FBX""" +class AnimationFBXLoader(plugin.Loader): + """Load Unreal SkeletalMesh from FBX.""" families = ["animation"] label = "Import FBX Animation" @@ -37,10 +39,10 @@ class AnimationFBXLoader(api.Loader): Returns: list(str): list of container content - """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + """ + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -62,9 +64,9 @@ class AnimationFBXLoader(api.Loader): task = unreal.AssetImportTask() task.options = unreal.FbxImportUI() - libpath = self.fname.replace("fbx", "json") + lib_path = self.fname.replace("fbx", "json") - with open(libpath, "r") as fp: + with open(lib_path, "r") as fp: data = json.load(fp) instance_name = data.get("instance_name") @@ -127,7 +129,7 @@ class AnimationFBXLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index b2b25eec73..c6bcfa08a9 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -1,12 +1,14 @@ +# -*- coding: utf-8 -*- +"""Load camera from FBX.""" import os from avalon import api, io, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class CameraLoader(api.Loader): +class CameraLoader(plugin.Loader): """Load Unreal StaticMesh from FBX""" families = ["camera"] @@ -38,8 +40,8 @@ class CameraLoader(api.Loader): list(str): list of container content """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -109,7 +111,7 @@ class CameraLoader(api.Loader): ) # Create Asset Container - lib.create_avalon_container(container=container_name, path=asset_dir) + unreal_pipeline.create_container(container=container_name, path=asset_dir) data = { "schema": "openpype:container-2.0", diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 19d0b74e3e..a5e93a009f 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +"""Loader for layouts.""" import os import json from pathlib import Path @@ -10,11 +12,11 @@ from unreal import FBXImportType from unreal import MathLibrary as umath from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline -class LayoutLoader(api.Loader): +class LayoutLoader(plugin.Loader): """Load Layout from a JSON file""" families = ["layout"] @@ -23,6 +25,7 @@ class LayoutLoader(api.Loader): label = "Load Layout" icon = "code-fork" color = "orange" + ASSET_ROOT = "/Game/OpenPype/Assets" def _get_asset_containers(self, path): ar = unreal.AssetRegistryHelpers.get_asset_registry() @@ -40,7 +43,8 @@ class LayoutLoader(api.Loader): return asset_containers - def _get_fbx_loader(self, loaders, family): + @staticmethod + def _get_fbx_loader(loaders, family): name = "" if family == 'rig': name = "SkeletalMeshFBXLoader" @@ -58,7 +62,8 @@ class LayoutLoader(api.Loader): return None - def _get_abc_loader(self, loaders, family): + @staticmethod + def _get_abc_loader(loaders, family): name = "" if family == 'rig': name = "SkeletalMeshAlembicLoader" @@ -74,14 +79,15 @@ class LayoutLoader(api.Loader): return None - def _process_family(self, assets, classname, transform, inst_name=None): + @staticmethod + def _process_family(assets, class_name, transform, inst_name=None): ar = unreal.AssetRegistryHelpers.get_asset_registry() actors = [] for asset in assets: obj = ar.get_asset_by_object_path(asset).get_asset() - if obj.get_class().get_name() == classname: + if obj.get_class().get_name() == class_name: actor = EditorLevelLibrary.spawn_actor_from_object( obj, transform.get('translation') @@ -111,8 +117,9 @@ class LayoutLoader(api.Loader): return actors + @staticmethod def _import_animation( - self, asset_dir, path, instance_name, skeleton, actors_dict, + asset_dir, path, instance_name, skeleton, actors_dict, animation_file): anim_file = Path(animation_file) anim_file_name = anim_file.with_suffix('') @@ -192,10 +199,10 @@ class LayoutLoader(api.Loader): actor.skeletal_mesh_component.animation_data.set_editor_property( 'anim_to_play', animation) - def _process(self, libpath, asset_dir, loaded=None): + def _process(self, lib_path, asset_dir, loaded=None): ar = unreal.AssetRegistryHelpers.get_asset_registry() - with open(libpath, "r") as fp: + with open(lib_path, "r") as fp: data = json.load(fp) all_loaders = api.discover(api.Loader) @@ -203,7 +210,7 @@ class LayoutLoader(api.Loader): if not loaded: loaded = [] - path = Path(libpath) + path = Path(lib_path) skeleton_dict = {} actors_dict = {} @@ -292,17 +299,18 @@ class LayoutLoader(api.Loader): asset_dir, path, instance_name, skeleton, actors_dict, animation_file) - def _remove_family(self, assets, components, classname, propname): + @staticmethod + def _remove_family(assets, components, class_name, prop_name): ar = unreal.AssetRegistryHelpers.get_asset_registry() objects = [] for a in assets: obj = ar.get_asset_by_object_path(a) - if obj.get_asset().get_class().get_name() == classname: + if obj.get_asset().get_class().get_name() == class_name: objects.append(obj) for obj in objects: for comp in components: - if comp.get_editor_property(propname) == obj.get_asset(): + if comp.get_editor_property(prop_name) == obj.get_asset(): comp.get_owner().destroy_actor() def _remove_actors(self, path): @@ -334,8 +342,7 @@ class LayoutLoader(api.Loader): assets, skel_meshes_comp, 'SkeletalMesh', 'skeletal_mesh') def load(self, context, name, namespace, options): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -349,14 +356,14 @@ class LayoutLoader(api.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - data (dict): Those would be data to be imprinted. This is not used + options (dict): Those would be data to be imprinted. This is not used now, data are imprinted by `containerise()`. Returns: list(str): list of container content """ # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + root = self.ASSET_ROOT asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -375,7 +382,7 @@ class LayoutLoader(api.Loader): self._process(self.fname, asset_dir) # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { @@ -406,7 +413,7 @@ class LayoutLoader(api.Loader): source_path = api.get_representation_path(representation) destination_path = container["namespace"] - libpath = Path(api.get_representation_path(representation)) + lib_path = Path(api.get_representation_path(representation)) self._remove_actors(destination_path) @@ -502,7 +509,7 @@ class LayoutLoader(api.Loader): if animation_file and skeleton: self._import_animation( - destination_path, libpath, + destination_path, lib_path, instance_name, skeleton, actors_dict, animation_file) diff --git a/openpype/hosts/unreal/plugins/load/load_rig.py b/openpype/hosts/unreal/plugins/load/load_rig.py index c7d095aa21..1503477ec7 100644 --- a/openpype/hosts/unreal/plugins/load/load_rig.py +++ b/openpype/hosts/unreal/plugins/load/load_rig.py @@ -1,13 +1,15 @@ +# -*- coding: utf-8 -*- +"""Load Skeletal Meshes form FBX.""" import os from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class SkeletalMeshFBXLoader(api.Loader): - """Load Unreal SkeletalMesh from FBX""" +class SkeletalMeshFBXLoader(plugin.Loader): + """Load Unreal SkeletalMesh from FBX.""" families = ["rig"] label = "Import FBX Skeletal Mesh" @@ -16,8 +18,7 @@ class SkeletalMeshFBXLoader(api.Loader): color = "orange" def load(self, context, name, namespace, options): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -31,15 +32,15 @@ class SkeletalMeshFBXLoader(api.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - data (dict): Those would be data to be imprinted. This is not used + options (dict): Those would be data to be imprinted. This is not used now, data are imprinted by `containerise()`. Returns: list(str): list of container content - """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + """ + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" if options and options.get("asset_dir"): root = options["asset_dir"] asset = context.get('asset').get('name') @@ -94,7 +95,7 @@ class SkeletalMeshFBXLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py index 510c4331ad..14ca39c728 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py @@ -1,13 +1,15 @@ +# -*- coding: utf-8 -*- +"""Load Static meshes form FBX.""" import os from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class StaticMeshFBXLoader(api.Loader): - """Load Unreal StaticMesh from FBX""" +class StaticMeshFBXLoader(plugin.Loader): + """Load Unreal StaticMesh from FBX.""" families = ["model", "unrealStaticMesh"] label = "Import FBX Static Mesh" @@ -15,7 +17,8 @@ class StaticMeshFBXLoader(api.Loader): icon = "cube" color = "orange" - def get_task(self, filename, asset_dir, asset_name, replace): + @staticmethod + def get_task(filename, asset_dir, asset_name, replace): task = unreal.AssetImportTask() options = unreal.FbxImportUI() import_data = unreal.FbxStaticMeshImportData() @@ -41,8 +44,7 @@ class StaticMeshFBXLoader(api.Loader): return task def load(self, context, name, namespace, options): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -56,15 +58,15 @@ class StaticMeshFBXLoader(api.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - data (dict): Those would be data to be imprinted. This is not used + options (dict): Those would be data to be imprinted. This is not used now, data are imprinted by `containerise()`. Returns: list(str): list of container content """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" if options and options.get("asset_dir"): root = options["asset_dir"] asset = context.get('asset').get('name') @@ -87,7 +89,7 @@ class StaticMeshFBXLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/publish/collect_current_file.py b/openpype/hosts/unreal/plugins/publish/collect_current_file.py index 4e828933bb..acd4c5c8d2 100644 --- a/openpype/hosts/unreal/plugins/publish/collect_current_file.py +++ b/openpype/hosts/unreal/plugins/publish/collect_current_file.py @@ -1,17 +1,18 @@ -import unreal - +# -*- coding: utf-8 -*- +"""Collect current project path.""" +import unreal # noqa import pyblish.api class CollectUnrealCurrentFile(pyblish.api.ContextPlugin): - """Inject the current working file into context""" + """Inject the current working file into context.""" order = pyblish.api.CollectorOrder - 0.5 label = "Unreal Current File" hosts = ['unreal'] def process(self, context): - """Inject the current working file""" + """Inject the current working file.""" current_file = unreal.Paths.get_project_file_path() context.data['currentFile'] = current_file diff --git a/openpype/hosts/unreal/plugins/publish/collect_instances.py b/openpype/hosts/unreal/plugins/publish/collect_instances.py index 62676f9938..94e732d728 100644 --- a/openpype/hosts/unreal/plugins/publish/collect_instances.py +++ b/openpype/hosts/unreal/plugins/publish/collect_instances.py @@ -1,12 +1,14 @@ +# -*- coding: utf-8 -*- +"""Collect publishable instances in Unreal.""" import ast -import unreal +import unreal # noqa import pyblish.api class CollectInstances(pyblish.api.ContextPlugin): - """Gather instances by AvalonPublishInstance class + """Gather instances by OpenPypePublishInstance class - This collector finds all paths containing `AvalonPublishInstance` class + This collector finds all paths containing `OpenPypePublishInstance` class asset Identifier: @@ -22,7 +24,7 @@ class CollectInstances(pyblish.api.ContextPlugin): ar = unreal.AssetRegistryHelpers.get_asset_registry() instance_containers = ar.get_assets_by_class( - "AvalonPublishInstance", True) + "OpenPypePublishInstance", True) for container_data in instance_containers: asset = container_data.get_asset() diff --git a/openpype/hosts/unreal/plugins/publish/extract_camera.py b/openpype/hosts/unreal/plugins/publish/extract_camera.py index 10862fc0ef..ce53824563 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_camera.py +++ b/openpype/hosts/unreal/plugins/publish/extract_camera.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +"""Extract camera from Unreal.""" import os import unreal @@ -17,7 +19,7 @@ class ExtractCamera(openpype.api.Extractor): def process(self, instance): # Define extract output file path - stagingdir = self.staging_dir(instance) + staging_dir = self.staging_dir(instance) fbx_filename = "{}.fbx".format(instance.name) # Perform extraction @@ -38,7 +40,7 @@ class ExtractCamera(openpype.api.Extractor): sequence, sequence.get_bindings(), unreal.FbxExportOption(), - os.path.join(stagingdir, fbx_filename) + os.path.join(staging_dir, fbx_filename) ) break @@ -49,6 +51,6 @@ class ExtractCamera(openpype.api.Extractor): 'name': 'fbx', 'ext': 'fbx', 'files': fbx_filename, - "stagingDir": stagingdir, + "stagingDir": staging_dir, } instance.data["representations"].append(fbx_representation) diff --git a/openpype/hosts/unreal/plugins/publish/extract_layout.py b/openpype/hosts/unreal/plugins/publish/extract_layout.py index a47187cf47..2d09b0e7bd 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_layout.py +++ b/openpype/hosts/unreal/plugins/publish/extract_layout.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import os import json import math @@ -20,7 +21,7 @@ class ExtractLayout(openpype.api.Extractor): def process(self, instance): # Define extract output file path - stagingdir = self.staging_dir(instance) + staging_dir = self.staging_dir(instance) # Perform extraction self.log.info("Performing extraction..") @@ -96,7 +97,7 @@ class ExtractLayout(openpype.api.Extractor): json_data.append(json_element) json_filename = "{}.json".format(instance.name) - json_path = os.path.join(stagingdir, json_filename) + json_path = os.path.join(staging_dir, json_filename) with open(json_path, "w+") as file: json.dump(json_data, fp=file, indent=2) @@ -108,6 +109,6 @@ class ExtractLayout(openpype.api.Extractor): 'name': 'json', 'ext': 'json', 'files': json_filename, - "stagingDir": stagingdir, + "stagingDir": staging_dir, } instance.data["representations"].append(json_representation) diff --git a/openpype/hosts/unreal/plugins/publish/extract_look.py b/openpype/hosts/unreal/plugins/publish/extract_look.py index 0f1539a7d5..ea39949417 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_look.py +++ b/openpype/hosts/unreal/plugins/publish/extract_look.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import json import os @@ -17,7 +18,7 @@ class ExtractLook(openpype.api.Extractor): def process(self, instance): # Define extract output file path - stagingdir = self.staging_dir(instance) + staging_dir = self.staging_dir(instance) resources_dir = instance.data["resourcesDir"] ar = unreal.AssetRegistryHelpers.get_asset_registry() @@ -57,7 +58,7 @@ class ExtractLook(openpype.api.Extractor): tga_export_task.set_editor_property('automated', True) tga_export_task.set_editor_property('object', texture) tga_export_task.set_editor_property( - 'filename', f"{stagingdir}/{tga_filename}") + 'filename', f"{staging_dir}/{tga_filename}") tga_export_task.set_editor_property('prompt', False) tga_export_task.set_editor_property('selected', False) @@ -66,7 +67,7 @@ class ExtractLook(openpype.api.Extractor): json_element['tga_filename'] = tga_filename transfers.append(( - f"{stagingdir}/{tga_filename}", + f"{staging_dir}/{tga_filename}", f"{resources_dir}/{tga_filename}")) fbx_filename = f"{instance.name}_{name}.fbx" @@ -84,7 +85,7 @@ class ExtractLook(openpype.api.Extractor): task.set_editor_property('automated', True) task.set_editor_property('object', object) task.set_editor_property( - 'filename', f"{stagingdir}/{fbx_filename}") + 'filename', f"{staging_dir}/{fbx_filename}") task.set_editor_property('prompt', False) task.set_editor_property('selected', False) @@ -93,13 +94,13 @@ class ExtractLook(openpype.api.Extractor): json_element['fbx_filename'] = fbx_filename transfers.append(( - f"{stagingdir}/{fbx_filename}", + f"{staging_dir}/{fbx_filename}", f"{resources_dir}/{fbx_filename}")) json_data.append(json_element) json_filename = f"{instance.name}.json" - json_path = os.path.join(stagingdir, json_filename) + json_path = os.path.join(staging_dir, json_filename) with open(json_path, "w+") as file: json.dump(json_data, fp=file, indent=2) @@ -113,7 +114,7 @@ class ExtractLook(openpype.api.Extractor): 'name': 'json', 'ext': 'json', 'files': json_filename, - "stagingDir": stagingdir, + "stagingDir": staging_dir, } instance.data["representations"].append(json_representation) From f04ea594e1d86a86234cd3e39ab4c533956380db Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 1 Mar 2022 11:48:25 +0100 Subject: [PATCH 269/483] removed duplicated code --- .../tools/publisher/widgets/validations_widget.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index 0db30acd6e..798c1f9d92 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -203,17 +203,6 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): self._title_frame.setProperty("selected", value) self._title_frame.style().polish(self._title_frame) - def current_desctiption_text(self): - if self._context_validation: - return self._help_text_by_instance_id[None] - index = self._instances_view.currentIndex() - # TODO make sure instance is selected - if not index.isValid(): - index = self._instances_model.index(0, 0) - - indence_id = index.data(INSTANCE_ID_ROLE) - return self._help_text_by_instance_id[indence_id] - def set_selected(self, selected=None): """Change selected state of widget.""" if selected is None: @@ -557,9 +546,6 @@ class ValidationsWidget(QtWidgets.QWidget): self._previous_select = self._title_widgets[index] error_item = self._error_info[index] - self._actions_widget.set_plugin(error_item["plugin"]) - - self._update_description() self._actions_widget.set_plugin(error_item["plugin"]) From de8e1f821859def926995381403504368f6b3ba9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 1 Mar 2022 11:54:05 +0100 Subject: [PATCH 270/483] globa: fix host name retrieving from running session --- openpype/lib/avalon_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 3ce205c499..1e8d21852b 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -952,7 +952,7 @@ class BuildWorkfile: Returns: (dict): preset per entered task name """ - host_name = avalon.api.registered_host().__name__.rsplit(".", 1)[-1] + host_name = os.environ["AVALON_APP"] project_settings = get_project_settings( avalon.io.Session["AVALON_PROJECT"] ) From 863753705680abde97b6f0795521dd8cdfa527ba Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 1 Mar 2022 12:20:19 +0100 Subject: [PATCH 271/483] remove adding of exe to maketx --- openpype/hosts/maya/plugins/publish/extract_look.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index a9a2a7b60c..a8893072d0 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -4,7 +4,6 @@ import os import sys import json import tempfile -import platform import contextlib import subprocess from collections import OrderedDict @@ -64,10 +63,6 @@ def maketx(source, destination, *args): maketx_path = get_oiio_tools_path("maketx") - if platform.system().lower() == "windows": - # Ensure .exe extension - maketx_path += ".exe" - if not os.path.exists(maketx_path): print( "OIIO tool not found in {}".format(maketx_path)) From 06783daf8eee247170250cf09daff858bd8ebf43 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 1 Mar 2022 12:22:07 +0100 Subject: [PATCH 272/483] fix usage of variables --- openpype/lib/vendor_bin_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/lib/vendor_bin_utils.py b/openpype/lib/vendor_bin_utils.py index 6571e2f515..742023a0d7 100644 --- a/openpype/lib/vendor_bin_utils.py +++ b/openpype/lib/vendor_bin_utils.py @@ -58,7 +58,7 @@ def find_executable(executable): paths = path_str.split(os.pathsep) for path in paths: for variant in variants: - filepath = os.path.abspath(os.path.join(path, executable)) + filepath = os.path.abspath(os.path.join(path, variant)) if os.path.isfile(filepath): return filepath return None From c999dd5a918448a1c395a41d2370dd21f90b0b7c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 1 Mar 2022 12:24:27 +0100 Subject: [PATCH 273/483] added few comments --- openpype/lib/vendor_bin_utils.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/openpype/lib/vendor_bin_utils.py b/openpype/lib/vendor_bin_utils.py index 742023a0d7..5698ede16a 100644 --- a/openpype/lib/vendor_bin_utils.py +++ b/openpype/lib/vendor_bin_utils.py @@ -23,12 +23,16 @@ def find_executable(executable): str: Full path to executable with extension (is file). None: When the executable was not found. """ + # Skip if passed path is file if os.path.isfile(executable): return executable low_platform = platform.system().lower() _, ext = os.path.splitext(executable) + + # Prepare variants for which it will be looked variants = [executable] + # Add other extension variants only if passed executable does not have one if not ext: if low_platform == "windows": exts = [".exe", ".ps1", ".bat"] @@ -45,6 +49,7 @@ def find_executable(executable): return variant variants.append(variant) + # Get paths where to look for executable path_str = os.environ.get("PATH", None) if path_str is None: if hasattr(os, "confstr"): @@ -52,15 +57,13 @@ def find_executable(executable): elif hasattr(os, "defpath"): path_str = os.defpath - if not path_str: - return None - - paths = path_str.split(os.pathsep) - for path in paths: - for variant in variants: - filepath = os.path.abspath(os.path.join(path, variant)) - if os.path.isfile(filepath): - return filepath + if path_str: + paths = path_str.split(os.pathsep) + for path in paths: + for variant in variants: + filepath = os.path.abspath(os.path.join(path, variant)) + if os.path.isfile(filepath): + return filepath return None From 3c9501ac3ceb471f98f602b52baf2aa73d2b2434 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 13:32:21 +0100 Subject: [PATCH 274/483] remove submodule, minor fixes --- openpype/hosts/unreal/api/__init__.py | 40 +++++++++++++++++++ .../integration/Content/Python/init_unreal.py | 4 +- .../Source/OpenPype/OpenPype.Build.cs | 2 +- .../Source/OpenPype/Private/OpenPype.cpp | 4 +- .../OpenPype/Private/OpenPypePythonBridge.cpp | 4 +- .../Source/OpenPype/Public/AssetContainer.h | 2 +- .../Source/OpenPype/Public/OpenPypeLib.h | 2 +- .../Source/OpenPype/Public/OpenPypeStyle.h | 2 +- repos/avalon-unreal-integration | 1 - 9 files changed, 51 insertions(+), 10 deletions(-) delete mode 160000 repos/avalon-unreal-integration diff --git a/openpype/hosts/unreal/api/__init__.py b/openpype/hosts/unreal/api/__init__.py index df86c09073..e70749004b 100644 --- a/openpype/hosts/unreal/api/__init__.py +++ b/openpype/hosts/unreal/api/__init__.py @@ -4,6 +4,26 @@ import logging from avalon import api as avalon from pyblish import api as pyblish import openpype.hosts.unreal +from .plugin import( + Loader, + Creator +) +from .pipeline import ( + install, + uninstall, + ls, + publish, + containerise, + show_creator, + show_loader, + show_publisher, + show_manager, + show_experimental_tools, + show_tools_dialog, + show_tools_popup, + instantiate, +) + logger = logging.getLogger("openpype.hosts.unreal") @@ -15,6 +35,26 @@ CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") +__all__ = [ + "install", + "uninstall", + "Creator", + "Loader", + "ls", + "publish", + "containerise", + "show_creator", + "show_loader", + "show_publisher", + "show_manager", + "show_experimental_tools", + "show_tools_dialog", + "show_tools_popup", + "instantiate" +] + + + def install(): """Install Unreal configuration for OpenPype.""" print("-=" * 40) diff --git a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py index 4445abb1b0..2ecd301c25 100644 --- a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py +++ b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py @@ -4,12 +4,14 @@ openpype_detected = True try: from avalon import api except ImportError as exc: + api = None openpype_detected = False unreal.log_error("Avalon: cannot load Avalon [ {} ]".format(exc)) try: - from openpype.host.unreal import api as openpype_host + from openpype.hosts.unreal import api as openpype_host except ImportError as exc: + openpype_host = None openpype_detected = False unreal.log_error("OpenPype: cannot load OpenPype [ {} ]".format(exc)) diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs b/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs index cf50041aed..c30835b63d 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs +++ b/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs @@ -4,7 +4,7 @@ using UnrealBuildTool; public class OpenPype : ModuleRules { - public Avalon(ReadOnlyTargetRules Target) : base(Target) + public OpenPype(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp index 65da780ad6..15c46b3862 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp @@ -1,4 +1,4 @@ -#include "Avalon.h" +#include "OpenPype.h" #include "LevelEditor.h" #include "OpenPypePythonBridge.h" #include "OpenPypeStyle.h" @@ -75,7 +75,7 @@ void FOpenPypeModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder) { ToolbarBuilder.AddToolBarButton( FUIAction( - FExecuteAction::CreateRaw(this, &FAvalonModule::MenuPopup), + FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuPopup), NULL, FIsActionChecked() diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp index 767f089374..8113231503 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp @@ -3,11 +3,11 @@ UOpenPypePythonBridge* UOpenPypePythonBridge::Get() { TArray OpenPypePythonBridgeClasses; - GetDerivedClasses(UAvalonPythonBridge::StaticClass(), OpenPypePythonBridgeClasses); + GetDerivedClasses(UOpenPypePythonBridge::StaticClass(), OpenPypePythonBridgeClasses); int32 NumClasses = OpenPypePythonBridgeClasses.Num(); if (NumClasses > 0) { - return Cast(AvalonPythonBridgeClasses[NumClasses - 1]->GetDefaultObject()); + return Cast(OpenPypePythonBridgeClasses[NumClasses - 1]->GetDefaultObject()); } return nullptr; }; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h index 1195f95cba..3c2a360c78 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h @@ -12,7 +12,7 @@ * */ UCLASS(Blueprintable) -class AVALON_API UAssetContainer : public UAssetUserData +class OPENPYPE_API UAssetContainer : public UAssetUserData { GENERATED_BODY() diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h index 3b4afe1408..59e9c8bd76 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h @@ -1,7 +1,7 @@ #pragma once #include "Engine.h" -#include "AvalonLib.generated.h" +#include "OpenPypeLib.generated.h" UCLASS(Blueprintable) diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h index 0e9400406a..fbc8bcdd5b 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h @@ -18,5 +18,5 @@ public: private: static TUniquePtr< FSlateStyleSet > Create(); - static TUniquePtr< FSlateStyleSet > AvalonStyleInstance; + static TUniquePtr< FSlateStyleSet > OpenPypeStyleInstance; }; \ No newline at end of file diff --git a/repos/avalon-unreal-integration b/repos/avalon-unreal-integration deleted file mode 160000 index 43f6ea9439..0000000000 --- a/repos/avalon-unreal-integration +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 43f6ea943980b29c02a170942b566ae11f2b7080 From 4fbf8f90319ce91b88025f39d1bee5125402139d Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 13:42:28 +0100 Subject: [PATCH 275/483] =?UTF-8?q?fix=20=F0=9F=90=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openpype/hosts/unreal/api/__init__.py | 2 +- openpype/hosts/unreal/api/helpers.py | 2 +- .../hosts/unreal/plugins/load/load_alembic_geometrycache.py | 2 +- openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py | 2 +- openpype/hosts/unreal/plugins/load/load_camera.py | 3 ++- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/unreal/api/__init__.py b/openpype/hosts/unreal/api/__init__.py index e70749004b..1aad704c56 100644 --- a/openpype/hosts/unreal/api/__init__.py +++ b/openpype/hosts/unreal/api/__init__.py @@ -4,7 +4,7 @@ import logging from avalon import api as avalon from pyblish import api as pyblish import openpype.hosts.unreal -from .plugin import( +from .plugin import ( Loader, Creator ) diff --git a/openpype/hosts/unreal/api/helpers.py b/openpype/hosts/unreal/api/helpers.py index 555133eae0..0b6f07f52f 100644 --- a/openpype/hosts/unreal/api/helpers.py +++ b/openpype/hosts/unreal/api/helpers.py @@ -15,7 +15,7 @@ class OpenPypeHelpers(unreal.OpenPypeLib): """ @unreal.ufunction(params=[str, unreal.LinearColor, bool]) - def set_folder_color(self, path: str, color: unreal.LinearColor) -> Bool: + def set_folder_color(self, path: str, color: unreal.LinearColor) -> None: """Set color on folder in Content Browser. This method sets color on folder in Content Browser. Unfortunately diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py index a0cd69326f..027e9f4cd3 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py @@ -3,7 +3,7 @@ import os from avalon import api, pipeline -from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py index aec8b45041..3bcc8b476f 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py @@ -3,7 +3,7 @@ import os from avalon import api, pipeline -from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index c6bcfa08a9..34999faa23 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -111,7 +111,8 @@ class CameraLoader(plugin.Loader): ) # Create Asset Container - unreal_pipeline.create_container(container=container_name, path=asset_dir) + unreal_pipeline.create_container( + container=container_name, path=asset_dir) data = { "schema": "openpype:container-2.0", From c654353c73b26c8a18273f9f98cbcc7f7b186fda Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 13:46:10 +0100 Subject: [PATCH 276/483] =?UTF-8?q?fix=20=F0=9F=90=95=E2=80=8D=F0=9F=A6=BA?= =?UTF-8?q?=20round=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openpype/hosts/unreal/plugins/load/load_camera.py | 4 ++-- openpype/hosts/unreal/plugins/load/load_layout.py | 6 +++--- openpype/hosts/unreal/plugins/load/load_rig.py | 2 +- openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index 34999faa23..0de9470ef9 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -2,8 +2,8 @@ """Load camera from FBX.""" import os -from avalon import api, io, pipeline -from openpype.hosts.unreal.api import lib, plugin +from avalon import io, pipeline +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index a5e93a009f..b802f5940a 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -12,7 +12,7 @@ from unreal import FBXImportType from unreal import MathLibrary as umath from avalon import api, pipeline -from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline @@ -356,8 +356,8 @@ class LayoutLoader(plugin.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - options (dict): Those would be data to be imprinted. This is not used - now, data are imprinted by `containerise()`. + options (dict): Those would be data to be imprinted. This is not + used now, data are imprinted by `containerise()`. Returns: list(str): list of container content diff --git a/openpype/hosts/unreal/plugins/load/load_rig.py b/openpype/hosts/unreal/plugins/load/load_rig.py index 1503477ec7..009d6bc656 100644 --- a/openpype/hosts/unreal/plugins/load/load_rig.py +++ b/openpype/hosts/unreal/plugins/load/load_rig.py @@ -3,7 +3,7 @@ import os from avalon import api, pipeline -from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa diff --git a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py index 14ca39c728..573e5bd7e6 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py @@ -3,7 +3,7 @@ import os from avalon import api, pipeline -from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa From 5479a26b216507230694405156a6466e53bf5209 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 13:47:51 +0100 Subject: [PATCH 277/483] =?UTF-8?q?fix=20=F0=9F=90=A9=20round=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openpype/hosts/unreal/plugins/load/load_rig.py | 4 ++-- openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_rig.py b/openpype/hosts/unreal/plugins/load/load_rig.py index 009d6bc656..a7ecb0ef7d 100644 --- a/openpype/hosts/unreal/plugins/load/load_rig.py +++ b/openpype/hosts/unreal/plugins/load/load_rig.py @@ -32,8 +32,8 @@ class SkeletalMeshFBXLoader(plugin.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - options (dict): Those would be data to be imprinted. This is not used - now, data are imprinted by `containerise()`. + options (dict): Those would be data to be imprinted. This is not + used now, data are imprinted by `containerise()`. Returns: list(str): list of container content diff --git a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py index 573e5bd7e6..c8a6964ffb 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py @@ -58,8 +58,8 @@ class StaticMeshFBXLoader(plugin.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - options (dict): Those would be data to be imprinted. This is not used - now, data are imprinted by `containerise()`. + options (dict): Those would be data to be imprinted. This is not + used now, data are imprinted by `containerise()`. Returns: list(str): list of container content From 5bf2bd2efad7a1ae0e68aeb55ecbc89aed881607 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 1 Mar 2022 14:08:56 +0100 Subject: [PATCH 278/483] removed unused import --- openpype/lib/applications.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 5613d8cccf..89b016922d 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -7,7 +7,6 @@ import platform import collections import inspect import subprocess -import distutils.spawn from abc import ABCMeta, abstractmethod import six From 6a463bfbb455321b90413b5795263f18e9d7c9b0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 1 Mar 2022 14:35:35 +0100 Subject: [PATCH 279/483] OL-2799 - more detailed temp file name for environment json for Deadline Previous implementation probably wasn't detailed enough. --- .../repository/custom/plugins/GlobalJobPreLoad.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index ee137a2ee3..82c2494e7a 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- import os import tempfile -import time +from datetime import datetime import subprocess import json import platform +import uuid from Deadline.Scripting import RepositoryUtils, FileUtils @@ -36,9 +37,11 @@ def inject_openpype_environment(deadlinePlugin): print("--- OpenPype executable: {}".format(openpype_app)) # tempfile.TemporaryFile cannot be used because of locking - export_url = os.path.join(tempfile.gettempdir(), - time.strftime('%Y%m%d%H%M%S'), - 'env.json') # add HHMMSS + delete later + temp_file_name = "{}_{}.json".format( + datetime.utcnow().strftime('%Y%m%d%H%M%S%f'), + str(uuid.uuid1()) + ) + export_url = os.path.join(tempfile.gettempdir(), temp_file_name) print(">>> Temporary path: {}".format(export_url)) args = [ From 5d896b97de964aab19b279f579222f0483192a2e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 1 Mar 2022 15:00:13 +0100 Subject: [PATCH 280/483] make sure that result of '_get_versions_order_doc' is dictionary --- openpype/settings/handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/handlers.py b/openpype/settings/handlers.py index 9f2b46d758..2109b53b09 100644 --- a/openpype/settings/handlers.py +++ b/openpype/settings/handlers.py @@ -694,7 +694,7 @@ class MongoSettingsHandler(SettingsHandler): return self.collection.find_one( {"type": self._version_order_key}, projection - ) + ) or {} def _check_version_order(self): """This method will work only in OpenPype process. From be1ae4a99c5fa95b78714e33c5aba0e3ea58fd02 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 1 Mar 2022 15:01:25 +0100 Subject: [PATCH 281/483] replace _Callback with functools.partial --- openpype/tools/settings/settings/base.py | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py index d4ad84996c..706e2fdcf0 100644 --- a/openpype/tools/settings/settings/base.py +++ b/openpype/tools/settings/settings/base.py @@ -1,6 +1,7 @@ import sys import json import traceback +import functools from Qt import QtWidgets, QtGui, QtCore @@ -12,22 +13,6 @@ from .lib import create_deffered_value_change_timer from .constants import DEFAULT_PROJECT_LABEL -class _Callback: - """Callback wrapper which stores it's args and kwargs. - - Using lambda has few issues if local variables are passed to called - functions in loop it may change the value of the variable in already - stored callback. - """ - def __init__(self, func, *args, **kwargs): - self._func = func - self._args = args - self._kwargs = kwargs - - def __call__(self): - self._func(*self._args, **self._kwargs) - - class BaseWidget(QtWidgets.QWidget): allow_actions = True @@ -341,10 +326,7 @@ class BaseWidget(QtWidgets.QWidget): action = QtWidgets.QAction(project_name) submenu.addAction(action) - # Use custom callback object instead of lambda - # - project_name value is changed each value so all actions will - # use the same source project - actions_mapping[action] = _Callback( + actions_mapping[action] = functools.partial( self._apply_values_from_project, project_name ) From ed526947ebb717d830da9f876e57b78ec6c6bed3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 1 Mar 2022 15:32:46 +0100 Subject: [PATCH 282/483] fix adding 'root' key to format data --- openpype/lib/anatomy.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index 3d56c1f1ba..3fbc05ee88 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -726,10 +726,11 @@ class AnatomyTemplates(TemplatesDict): return output def format(self, data, strict=True): + copy_data = copy.deepcopy(data) roots = self.roots if roots: - data["root"] = roots - result = super(AnatomyTemplates, self).format(data) + copy_data["root"] = roots + result = super(AnatomyTemplates, self).format(copy_data) result.strict = strict return result From 32963fb56d9b0d4234f8fcc20488cbe2a0ece391 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 15:43:09 +0100 Subject: [PATCH 283/483] move lib out of host implementation --- openpype/hosts/unreal/hooks/pre_workfile_preparation.py | 2 +- openpype/hosts/unreal/{api => }/lib.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename openpype/hosts/unreal/{api => }/lib.py (100%) diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index 6b787f4da7..f07e96551c 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -10,7 +10,7 @@ from openpype.lib import ( get_workdir_data, get_workfile_template_key ) -from openpype.hosts.unreal.api import lib as unreal_lib +import openpype.hosts.unreal.lib as unreal_lib class UnrealPrelaunchHook(PreLaunchHook): diff --git a/openpype/hosts/unreal/api/lib.py b/openpype/hosts/unreal/lib.py similarity index 100% rename from openpype/hosts/unreal/api/lib.py rename to openpype/hosts/unreal/lib.py From 306eddd5493986d702ee6423b0b14e1b41629223 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 15:43:55 +0100 Subject: [PATCH 284/483] move install/uninstall to pipeline --- openpype/hosts/unreal/api/__init__.py | 49 +---------------------- openpype/hosts/unreal/api/pipeline.py | 56 ++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 57 deletions(-) diff --git a/openpype/hosts/unreal/api/__init__.py b/openpype/hosts/unreal/api/__init__.py index 1aad704c56..ede71aa218 100644 --- a/openpype/hosts/unreal/api/__init__.py +++ b/openpype/hosts/unreal/api/__init__.py @@ -1,9 +1,6 @@ -import os -import logging +# -*- coding: utf-8 -*- +"""Unreal Editor OpenPype host API.""" -from avalon import api as avalon -from pyblish import api as pyblish -import openpype.hosts.unreal from .plugin import ( Loader, Creator @@ -24,17 +21,6 @@ from .pipeline import ( instantiate, ) - -logger = logging.getLogger("openpype.hosts.unreal") - -HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.unreal.__file__)) -PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") -PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") -LOAD_PATH = os.path.join(PLUGINS_DIR, "load") -CREATE_PATH = os.path.join(PLUGINS_DIR, "create") -INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") - - __all__ = [ "install", "uninstall", @@ -52,34 +38,3 @@ __all__ = [ "show_tools_popup", "instantiate" ] - - - -def install(): - """Install Unreal configuration for OpenPype.""" - print("-=" * 40) - logo = '''. -. - ____________ - / \\ __ \\ - \\ \\ \\/_\\ \\ - \\ \\ _____/ ______ - \\ \\ \\___// \\ \\ - \\ \\____\\ \\ \\_____\\ - \\/_____/ \\/______/ PYPE Club . -. -''' - print(logo) - print("installing OpenPype for Unreal ...") - print("-=" * 40) - logger.info("installing OpenPype for Unreal") - pyblish.register_plugin_path(str(PUBLISH_PATH)) - avalon.register_plugin_path(avalon.Loader, str(LOAD_PATH)) - avalon.register_plugin_path(avalon.Creator, str(CREATE_PATH)) - - -def uninstall(): - """Uninstall Unreal configuration for Avalon.""" - pyblish.deregister_plugin_path(str(PUBLISH_PATH)) - avalon.deregister_plugin_path(avalon.Loader, str(LOAD_PATH)) - avalon.deregister_plugin_path(avalon.Creator, str(CREATE_PATH)) diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 02c89abadd..5a93709ada 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -1,22 +1,62 @@ # -*- coding: utf-8 -*- -import pyblish.api -from avalon.pipeline import AVALON_CONTAINER_ID - -import unreal # noqa +import os +import logging from typing import List -from openpype.tools.utils import host_tools + +import pyblish.api +import avalon +from avalon.pipeline import AVALON_CONTAINER_ID from avalon import api +from openpype.tools.utils import host_tools +import openpype.hosts.unreal +import unreal # noqa + + +logger = logging.getLogger("openpype.hosts.unreal") OPENPYPE_CONTAINERS = "OpenPypeContainers" +HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.unreal.__file__)) +PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") +INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") + def install(): - pyblish.api.register_host("unreal") + """Install Unreal configuration for OpenPype.""" + print("-=" * 40) + logo = '''. +. + ____________ + / \\ __ \\ + \\ \\ \\/_\\ \\ + \\ \\ _____/ ______ + \\ \\ \\___// \\ \\ + \\ \\____\\ \\ \\_____\\ + \\/_____/ \\/______/ PYPE Club . +. +''' + print(logo) + print("installing OpenPype for Unreal ...") + print("-=" * 40) + logger.info("installing OpenPype for Unreal") + pyblish.api.register_plugin_path(str(PUBLISH_PATH)) + api.register_plugin_path(api.Loader, str(LOAD_PATH)) + api.register_plugin_path(api.Creator, str(CREATE_PATH)) _register_callbacks() _register_events() +def uninstall(): + """Uninstall Unreal configuration for Avalon.""" + pyblish.api.deregister_plugin_path(str(PUBLISH_PATH)) + api.deregister_plugin_path(api.Loader, str(LOAD_PATH)) + api.deregister_plugin_path(api.Creator, str(CREATE_PATH)) + + def _register_callbacks(): """ TODO: Implement callbacks if supported by UE4 @@ -31,10 +71,6 @@ def _register_events(): pass -def uninstall(): - pyblish.api.deregister_host("unreal") - - class Creator(api.Creator): hosts = ["unreal"] asset_types = [] From 11b3d2cbaf0cb1a3201dae7e30a3958f51b848e8 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 15:44:08 +0100 Subject: [PATCH 285/483] module relative path --- openpype/hosts/unreal/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py index e6ca1e833d..533f315df3 100644 --- a/openpype/hosts/unreal/__init__.py +++ b/openpype/hosts/unreal/__init__.py @@ -1,11 +1,12 @@ import os +import openpype.hosts def add_implementation_envs(env, _app): """Modify environments to contain all required for implementation.""" # Set OPENPYPE_UNREAL_PLUGIN required for Unreal implementation unreal_plugin_path = os.path.join( - os.environ["OPENPYPE_ROOT"], "openpype", "hosts", + os.path.dirname(os.path.abspath(openpype.hosts.__file__)), "unreal", "integration" ) env["OPENPYPE_UNREAL_PLUGIN"] = unreal_plugin_path From 765ba59358e595aca45128914d8c40223fd5060c Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 15:46:28 +0100 Subject: [PATCH 286/483] remove unused import --- openpype/hosts/unreal/api/pipeline.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 5a93709ada..ad64d56e9e 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -4,7 +4,6 @@ import logging from typing import List import pyblish.api -import avalon from avalon.pipeline import AVALON_CONTAINER_ID from avalon import api From e0e26a5d1cf7774c32b0d83b0d7800a48892a681 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 1 Mar 2022 16:42:03 +0100 Subject: [PATCH 287/483] general: removing obsolete way of nuke bake farm publishing --- .../deadline/plugins/publish/submit_publish_job.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index c7a14791e4..1de1c37575 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -516,7 +516,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): """ representations = [] collections, remainders = clique.assemble(exp_files) - bake_renders = instance.get("bakingNukeScripts", []) # create representation for every collected sequento ce for collection in collections: @@ -534,9 +533,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): preview = True break - if bake_renders: - preview = False - # toggle preview on if multipart is on if instance.get("multipartExr", False): preview = True @@ -610,16 +606,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): }) self._solve_families(instance, True) - if (bake_renders - and remainder in bake_renders[0]["bakeRenderPath"]): - rep.update({ - "fps": instance.get("fps"), - "tags": ["review", "delete"] - }) - # solve families with `preview` attributes - self._solve_families(instance, True) - representations.append(rep) - return representations def _solve_families(self, instance, preview=False): From d8a3ffe5125cebdc3ac13d44d8629bc2ce4c353c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 1 Mar 2022 16:44:30 +0100 Subject: [PATCH 288/483] nuke: including representation even it is from farm --- .../hosts/nuke/plugins/publish/extract_review_data_mov.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index 5bbc88266a..d8c94dfdec 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -113,9 +113,11 @@ class ExtractReviewDataMov(openpype.api.Extractor): }) else: data = exporter.generate_mov(**o_data) - generated_repres.extend(data["representations"]) - self.log.info(generated_repres) + # add representation generated by exporter + generated_repres.extend(data["representations"]) + self.log.debug( + "__ generated_repres: {}".format(generated_repres)) if generated_repres: # assign to representations From c82ee012ab7f9566e4b2971c3fb7089abd1d556c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 1 Mar 2022 16:48:28 +0100 Subject: [PATCH 289/483] nuke: baking generator returning representation even on farm --- openpype/hosts/nuke/api/plugin.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index fd754203d4..32b69d4604 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -152,6 +152,7 @@ class ExporterReview(object): """ data = None + publish_on_farm = False def __init__(self, klass, @@ -210,6 +211,9 @@ class ExporterReview(object): if self.multiple_presets: repre["outputName"] = self.name + if self.publish_on_farm: + repre["tags"].append("publish_on_farm") + self.data["representations"].append(repre) def get_view_input_process_node(self): @@ -446,6 +450,7 @@ class ExporterReviewMov(ExporterReview): return path def generate_mov(self, farm=False, **kwargs): + self.publish_on_farm = farm bake_viewer_process = kwargs["bake_viewer_process"] bake_viewer_input_process_node = kwargs[ "bake_viewer_input_process"] @@ -537,7 +542,7 @@ class ExporterReviewMov(ExporterReview): # ---------- end nodes creation # ---------- render or save to nk - if farm: + if self.publish_on_farm: nuke.scriptSave() path_nk = self.save_file() self.data.update({ @@ -547,11 +552,12 @@ class ExporterReviewMov(ExporterReview): }) else: self.render(write_node.name()) - # ---------- generate representation data - self.get_representation_data( - tags=["review", "delete"] + add_tags, - range=True - ) + + # ---------- generate representation data + self.get_representation_data( + tags=["review", "delete"] + add_tags, + range=True + ) self.log.debug("Representation... `{}`".format(self.data)) From 0568a8061881300aefd6e3746e093ded4076a4b7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 1 Mar 2022 17:00:08 +0100 Subject: [PATCH 290/483] added 'is_file_executable' to check if file can be executed --- openpype/lib/vendor_bin_utils.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/openpype/lib/vendor_bin_utils.py b/openpype/lib/vendor_bin_utils.py index 5698ede16a..4be016f656 100644 --- a/openpype/lib/vendor_bin_utils.py +++ b/openpype/lib/vendor_bin_utils.py @@ -7,6 +7,25 @@ import subprocess log = logging.getLogger("Vendor utils") +def is_file_executable(filepath): + """Filepath lead to executable file. + + Args: + filepath(str): Full path to file. + """ + if not filepath: + return False + + if os.path.isfile(filepath): + if os.access(filepath, os.X_OK): + return True + + log.info( + "Filepath is not available for execution \"{}\"".format(filepath) + ) + return False + + def find_executable(executable): """Find full path to executable. @@ -24,7 +43,7 @@ def find_executable(executable): None: When the executable was not found. """ # Skip if passed path is file - if os.path.isfile(executable): + if is_file_executable(executable): return executable low_platform = platform.system().lower() @@ -45,7 +64,7 @@ def find_executable(executable): for ext in exts: variant = executable + ext - if os.path.isfile(variant): + if is_file_executable(variant): return variant variants.append(variant) @@ -62,7 +81,7 @@ def find_executable(executable): for path in paths: for variant in variants: filepath = os.path.abspath(os.path.join(path, variant)) - if os.path.isfile(filepath): + if is_file_executable(filepath): return filepath return None From 0f8c297f85604de31d6b7c9dfddee47da1577548 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Wed, 2 Mar 2022 03:36:52 +0000 Subject: [PATCH 291/483] [Automated] Bump version --- CHANGELOG.md | 14 +++++++++----- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c945569545..348f7dc1b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.9.0-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.9.0-nightly.5](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...HEAD) @@ -14,21 +14,23 @@ - Documentation: broken link fix [\#2785](https://github.com/pypeclub/OpenPype/pull/2785) - Documentation: link fixes [\#2772](https://github.com/pypeclub/OpenPype/pull/2772) - Update docusaurus to latest version [\#2760](https://github.com/pypeclub/OpenPype/pull/2760) -- Various testing updates [\#2726](https://github.com/pypeclub/OpenPype/pull/2726) **🚀 Enhancements** +- General: Color dialog UI fixes [\#2817](https://github.com/pypeclub/OpenPype/pull/2817) - General: Set context environments for non host applications [\#2803](https://github.com/pypeclub/OpenPype/pull/2803) - Tray publisher: New Tray Publisher host \(beta\) [\#2778](https://github.com/pypeclub/OpenPype/pull/2778) - Houdini: Implement Reset Frame Range [\#2770](https://github.com/pypeclub/OpenPype/pull/2770) - Pyblish Pype: Remove redundant new line in installed fonts printing [\#2758](https://github.com/pypeclub/OpenPype/pull/2758) - Flame: use Shot Name on segment for asset name [\#2751](https://github.com/pypeclub/OpenPype/pull/2751) - Flame: adding validator source clip [\#2746](https://github.com/pypeclub/OpenPype/pull/2746) -- Work Files: Preserve subversion comment of current filename by default [\#2734](https://github.com/pypeclub/OpenPype/pull/2734) +- Ftrack: Disable ftrack module by default [\#2732](https://github.com/pypeclub/OpenPype/pull/2732) - RoyalRender: Minor enhancements [\#2700](https://github.com/pypeclub/OpenPype/pull/2700) **🐛 Bug fixes** +- Settings: Missing document with OP versions may break start of OpenPype [\#2825](https://github.com/pypeclub/OpenPype/pull/2825) +- Settings UI: Fix "Apply from" action [\#2820](https://github.com/pypeclub/OpenPype/pull/2820) - Settings UI: Search case sensitivity [\#2810](https://github.com/pypeclub/OpenPype/pull/2810) - Flame Babypublisher optimalization [\#2806](https://github.com/pypeclub/OpenPype/pull/2806) - resolve: fixing fusion module loading [\#2802](https://github.com/pypeclub/OpenPype/pull/2802) @@ -38,13 +40,15 @@ - Maya: Fix `unique\_namespace` when in an namespace that is empty [\#2759](https://github.com/pypeclub/OpenPype/pull/2759) - Loader UI: Fix right click in representation widget [\#2757](https://github.com/pypeclub/OpenPype/pull/2757) - Aftereffects 2022 and Deadline [\#2748](https://github.com/pypeclub/OpenPype/pull/2748) +- Flame: bunch of bugs [\#2745](https://github.com/pypeclub/OpenPype/pull/2745) - Maya: Save current scene on workfile publish [\#2744](https://github.com/pypeclub/OpenPype/pull/2744) - Version Up: Preserve parts of filename after version number \(like subversion\) on version\_up [\#2741](https://github.com/pypeclub/OpenPype/pull/2741) -- Loader UI: Multiple asset selection and underline colors fixed [\#2731](https://github.com/pypeclub/OpenPype/pull/2731) - Maya: Remove some unused code [\#2709](https://github.com/pypeclub/OpenPype/pull/2709) **Merged pull requests:** +- Move Unreal Implementation to OpenPype [\#2823](https://github.com/pypeclub/OpenPype/pull/2823) +- Ftrack: Job killer with missing user [\#2819](https://github.com/pypeclub/OpenPype/pull/2819) - Ftrack: Unset task ids from asset versions before tasks are removed [\#2800](https://github.com/pypeclub/OpenPype/pull/2800) - Slack: fail gracefully if slack exception [\#2798](https://github.com/pypeclub/OpenPype/pull/2798) - Ftrack: Moved module one hierarchy level higher [\#2792](https://github.com/pypeclub/OpenPype/pull/2792) @@ -54,10 +58,10 @@ - Houdini: Remove duplicate ValidateOutputNode plug-in [\#2780](https://github.com/pypeclub/OpenPype/pull/2780) - Slack: Added regex for filtering on subset names [\#2775](https://github.com/pypeclub/OpenPype/pull/2775) - Houdini: Fix open last workfile [\#2767](https://github.com/pypeclub/OpenPype/pull/2767) +- General: Extract template formatting from anatomy [\#2766](https://github.com/pypeclub/OpenPype/pull/2766) - Harmony: Rendering in Deadline didn't work in other machines than submitter [\#2754](https://github.com/pypeclub/OpenPype/pull/2754) - Houdini: Move Houdini Save Current File to beginning of ExtractorOrder [\#2747](https://github.com/pypeclub/OpenPype/pull/2747) - Maya: set Deadline job/batch name to original source workfile name instead of published workfile [\#2733](https://github.com/pypeclub/OpenPype/pull/2733) -- Fusion: Moved implementation into OpenPype [\#2713](https://github.com/pypeclub/OpenPype/pull/2713) ## [3.8.2](https://github.com/pypeclub/OpenPype/tree/3.8.2) (2022-02-07) diff --git a/openpype/version.py b/openpype/version.py index 0a799462ed..b41951a34c 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.9.0-nightly.4" +__version__ = "3.9.0-nightly.5" diff --git a/pyproject.toml b/pyproject.toml index 44bc0acbcc..851bf3f735 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.9.0-nightly.4" # OpenPype +version = "3.9.0-nightly.5" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From a252865b9ee3ecebf57806178e592b06eb748077 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 11:25:10 +0100 Subject: [PATCH 292/483] added missing deadline events folder --- .../custom/events/OpenPype/OpenPype.param | 37 ++++ .../custom/events/OpenPype/OpenPype.py | 191 ++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param create mode 100644 openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py diff --git a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param new file mode 100644 index 0000000000..871ce47467 --- /dev/null +++ b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param @@ -0,0 +1,37 @@ +[State] +Type=Enum +Items=Global Enabled;Opt-In;Disabled +Category=Options +CategoryOrder=0 +CategoryIndex=0 +Label=State +Default=Global Enabled +Description=How this event plug-in should respond to events. If Global, all jobs and slaves will trigger the events for this plugin. If Opt-In, jobs and slaves can choose to trigger the events for this plugin. If Disabled, no events are triggered for this plugin. + +[PythonSearchPaths] +Type=MultiLineMultiFolder +Label=Additional Python Search Paths +Category=Options +CategoryOrder=0 +CategoryIndex=1 +Default= +Description=The list of paths to append to the PYTHONPATH environment variable. This allows the Python job to find custom modules in non-standard locations. + +[LoggingLevel] +Type=Enum +Label=Logging Level +Category=Options +CategoryOrder=0 +CategoryIndex=2 +Items=DEBUG;INFO;WARNING;ERROR +Default=DEBUG +Description=Logging level where printing will start. + +[OpenPypeExecutable] +Type=multilinemultifilename +Label=Path to OpenPype executable +Category=Job Plugins +CategoryOrder=1 +CategoryIndex=1 +Default= +Description= \ No newline at end of file diff --git a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py new file mode 100644 index 0000000000..e5e2cf52a8 --- /dev/null +++ b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py @@ -0,0 +1,191 @@ +import Deadline.Events +import Deadline.Scripting + + +def GetDeadlineEventListener(): + return OpenPypeEventListener() + + +def CleanupDeadlineEventListener(eventListener): + eventListener.Cleanup() + + +class OpenPypeEventListener(Deadline.Events.DeadlineEventListener): + """ + Called on every Deadline plugin event, used for injecting OpenPype + environment variables into rendering process. + + Expects that job already contains env vars: + AVALON_PROJECT + AVALON_ASSET + AVALON_TASK + AVALON_APP_NAME + Without these only global environment would be pulled from OpenPype + + Configure 'Path to OpenPype executable dir' in Deadlines + 'Tools > Configure Events > openpype ' + Only directory path is needed. + + """ + def __init__(self): + self.OnJobSubmittedCallback += self.OnJobSubmitted + self.OnJobStartedCallback += self.OnJobStarted + self.OnJobFinishedCallback += self.OnJobFinished + self.OnJobRequeuedCallback += self.OnJobRequeued + self.OnJobFailedCallback += self.OnJobFailed + self.OnJobSuspendedCallback += self.OnJobSuspended + self.OnJobResumedCallback += self.OnJobResumed + self.OnJobPendedCallback += self.OnJobPended + self.OnJobReleasedCallback += self.OnJobReleased + self.OnJobDeletedCallback += self.OnJobDeleted + self.OnJobErrorCallback += self.OnJobError + self.OnJobPurgedCallback += self.OnJobPurged + + self.OnHouseCleaningCallback += self.OnHouseCleaning + self.OnRepositoryRepairCallback += self.OnRepositoryRepair + + self.OnSlaveStartedCallback += self.OnSlaveStarted + self.OnSlaveStoppedCallback += self.OnSlaveStopped + self.OnSlaveIdleCallback += self.OnSlaveIdle + self.OnSlaveRenderingCallback += self.OnSlaveRendering + self.OnSlaveStartingJobCallback += self.OnSlaveStartingJob + self.OnSlaveStalledCallback += self.OnSlaveStalled + + self.OnIdleShutdownCallback += self.OnIdleShutdown + self.OnMachineStartupCallback += self.OnMachineStartup + self.OnThermalShutdownCallback += self.OnThermalShutdown + self.OnMachineRestartCallback += self.OnMachineRestart + + def Cleanup(self): + del self.OnJobSubmittedCallback + del self.OnJobStartedCallback + del self.OnJobFinishedCallback + del self.OnJobRequeuedCallback + del self.OnJobFailedCallback + del self.OnJobSuspendedCallback + del self.OnJobResumedCallback + del self.OnJobPendedCallback + del self.OnJobReleasedCallback + del self.OnJobDeletedCallback + del self.OnJobErrorCallback + del self.OnJobPurgedCallback + + del self.OnHouseCleaningCallback + del self.OnRepositoryRepairCallback + + del self.OnSlaveStartedCallback + del self.OnSlaveStoppedCallback + del self.OnSlaveIdleCallback + del self.OnSlaveRenderingCallback + del self.OnSlaveStartingJobCallback + del self.OnSlaveStalledCallback + + del self.OnIdleShutdownCallback + del self.OnMachineStartupCallback + del self.OnThermalShutdownCallback + del self.OnMachineRestartCallback + + def set_openpype_executable_path(self, job): + """ + Sets configurable OpenPypeExecutable value to job extra infos. + + GlobalJobPreLoad takes this value, pulls env vars for each task + from specific worker itself. GlobalJobPreLoad is not easily + configured, so we are configuring Event itself. + """ + openpype_execs = self.GetConfigEntryWithDefault("OpenPypeExecutable", + "") + job.SetJobExtraInfoKeyValue("openpype_executables", openpype_execs) + + Deadline.Scripting.RepositoryUtils.SaveJob(job) + + def updateFtrackStatus(self, job, statusName, createIfMissing=False): + """Updates version status on ftrack""" + pass + + def OnJobSubmitted(self, job): + # self.LogInfo("OnJobSubmitted LOGGING") + # for 1st time submit + self.set_openpype_executable_path(job) + self.updateFtrackStatus(job, "Render Queued") + + def OnJobStarted(self, job): + # self.LogInfo("OnJobStarted") + self.set_openpype_executable_path(job) + self.updateFtrackStatus(job, "Rendering") + + def OnJobFinished(self, job): + # self.LogInfo("OnJobFinished") + self.updateFtrackStatus(job, "Artist Review") + + def OnJobRequeued(self, job): + # self.LogInfo("OnJobRequeued LOGGING") + self.set_openpype_executable_path(job) + + def OnJobFailed(self, job): + pass + + def OnJobSuspended(self, job): + # self.LogInfo("OnJobSuspended LOGGING") + self.updateFtrackStatus(job, "Render Queued") + + def OnJobResumed(self, job): + # self.LogInfo("OnJobResumed LOGGING") + self.set_openpype_executable_path(job) + self.updateFtrackStatus(job, "Rendering") + + def OnJobPended(self, job): + # self.LogInfo("OnJobPended LOGGING") + pass + + def OnJobReleased(self, job): + pass + + def OnJobDeleted(self, job): + pass + + def OnJobError(self, job, task, report): + # self.LogInfo("OnJobError LOGGING") + pass + + def OnJobPurged(self, job): + pass + + def OnHouseCleaning(self): + pass + + def OnRepositoryRepair(self, job, *args): + pass + + def OnSlaveStarted(self, job): + # self.LogInfo("OnSlaveStarted LOGGING") + pass + + def OnSlaveStopped(self, job): + pass + + def OnSlaveIdle(self, job): + pass + + def OnSlaveRendering(self, host_name, job): + # self.LogInfo("OnSlaveRendering LOGGING") + pass + + def OnSlaveStartingJob(self, host_name, job): + # self.LogInfo("OnSlaveStartingJob LOGGING") + self.set_openpype_executable_path(job) + + def OnSlaveStalled(self, job): + pass + + def OnIdleShutdown(self, job): + pass + + def OnMachineStartup(self, job): + pass + + def OnThermalShutdown(self, job): + pass + + def OnMachineRestart(self, job): + pass From 4b1bbe668f6143bb822d14ef8869c277274903a1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 11:59:08 +0100 Subject: [PATCH 293/483] removed event that should be removed --- .../custom/events/OpenPype/OpenPype.param | 37 ---- .../custom/events/OpenPype/OpenPype.py | 191 ------------------ 2 files changed, 228 deletions(-) delete mode 100644 openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param delete mode 100644 openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py diff --git a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param deleted file mode 100644 index 871ce47467..0000000000 --- a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param +++ /dev/null @@ -1,37 +0,0 @@ -[State] -Type=Enum -Items=Global Enabled;Opt-In;Disabled -Category=Options -CategoryOrder=0 -CategoryIndex=0 -Label=State -Default=Global Enabled -Description=How this event plug-in should respond to events. If Global, all jobs and slaves will trigger the events for this plugin. If Opt-In, jobs and slaves can choose to trigger the events for this plugin. If Disabled, no events are triggered for this plugin. - -[PythonSearchPaths] -Type=MultiLineMultiFolder -Label=Additional Python Search Paths -Category=Options -CategoryOrder=0 -CategoryIndex=1 -Default= -Description=The list of paths to append to the PYTHONPATH environment variable. This allows the Python job to find custom modules in non-standard locations. - -[LoggingLevel] -Type=Enum -Label=Logging Level -Category=Options -CategoryOrder=0 -CategoryIndex=2 -Items=DEBUG;INFO;WARNING;ERROR -Default=DEBUG -Description=Logging level where printing will start. - -[OpenPypeExecutable] -Type=multilinemultifilename -Label=Path to OpenPype executable -Category=Job Plugins -CategoryOrder=1 -CategoryIndex=1 -Default= -Description= \ No newline at end of file diff --git a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py deleted file mode 100644 index e5e2cf52a8..0000000000 --- a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py +++ /dev/null @@ -1,191 +0,0 @@ -import Deadline.Events -import Deadline.Scripting - - -def GetDeadlineEventListener(): - return OpenPypeEventListener() - - -def CleanupDeadlineEventListener(eventListener): - eventListener.Cleanup() - - -class OpenPypeEventListener(Deadline.Events.DeadlineEventListener): - """ - Called on every Deadline plugin event, used for injecting OpenPype - environment variables into rendering process. - - Expects that job already contains env vars: - AVALON_PROJECT - AVALON_ASSET - AVALON_TASK - AVALON_APP_NAME - Without these only global environment would be pulled from OpenPype - - Configure 'Path to OpenPype executable dir' in Deadlines - 'Tools > Configure Events > openpype ' - Only directory path is needed. - - """ - def __init__(self): - self.OnJobSubmittedCallback += self.OnJobSubmitted - self.OnJobStartedCallback += self.OnJobStarted - self.OnJobFinishedCallback += self.OnJobFinished - self.OnJobRequeuedCallback += self.OnJobRequeued - self.OnJobFailedCallback += self.OnJobFailed - self.OnJobSuspendedCallback += self.OnJobSuspended - self.OnJobResumedCallback += self.OnJobResumed - self.OnJobPendedCallback += self.OnJobPended - self.OnJobReleasedCallback += self.OnJobReleased - self.OnJobDeletedCallback += self.OnJobDeleted - self.OnJobErrorCallback += self.OnJobError - self.OnJobPurgedCallback += self.OnJobPurged - - self.OnHouseCleaningCallback += self.OnHouseCleaning - self.OnRepositoryRepairCallback += self.OnRepositoryRepair - - self.OnSlaveStartedCallback += self.OnSlaveStarted - self.OnSlaveStoppedCallback += self.OnSlaveStopped - self.OnSlaveIdleCallback += self.OnSlaveIdle - self.OnSlaveRenderingCallback += self.OnSlaveRendering - self.OnSlaveStartingJobCallback += self.OnSlaveStartingJob - self.OnSlaveStalledCallback += self.OnSlaveStalled - - self.OnIdleShutdownCallback += self.OnIdleShutdown - self.OnMachineStartupCallback += self.OnMachineStartup - self.OnThermalShutdownCallback += self.OnThermalShutdown - self.OnMachineRestartCallback += self.OnMachineRestart - - def Cleanup(self): - del self.OnJobSubmittedCallback - del self.OnJobStartedCallback - del self.OnJobFinishedCallback - del self.OnJobRequeuedCallback - del self.OnJobFailedCallback - del self.OnJobSuspendedCallback - del self.OnJobResumedCallback - del self.OnJobPendedCallback - del self.OnJobReleasedCallback - del self.OnJobDeletedCallback - del self.OnJobErrorCallback - del self.OnJobPurgedCallback - - del self.OnHouseCleaningCallback - del self.OnRepositoryRepairCallback - - del self.OnSlaveStartedCallback - del self.OnSlaveStoppedCallback - del self.OnSlaveIdleCallback - del self.OnSlaveRenderingCallback - del self.OnSlaveStartingJobCallback - del self.OnSlaveStalledCallback - - del self.OnIdleShutdownCallback - del self.OnMachineStartupCallback - del self.OnThermalShutdownCallback - del self.OnMachineRestartCallback - - def set_openpype_executable_path(self, job): - """ - Sets configurable OpenPypeExecutable value to job extra infos. - - GlobalJobPreLoad takes this value, pulls env vars for each task - from specific worker itself. GlobalJobPreLoad is not easily - configured, so we are configuring Event itself. - """ - openpype_execs = self.GetConfigEntryWithDefault("OpenPypeExecutable", - "") - job.SetJobExtraInfoKeyValue("openpype_executables", openpype_execs) - - Deadline.Scripting.RepositoryUtils.SaveJob(job) - - def updateFtrackStatus(self, job, statusName, createIfMissing=False): - """Updates version status on ftrack""" - pass - - def OnJobSubmitted(self, job): - # self.LogInfo("OnJobSubmitted LOGGING") - # for 1st time submit - self.set_openpype_executable_path(job) - self.updateFtrackStatus(job, "Render Queued") - - def OnJobStarted(self, job): - # self.LogInfo("OnJobStarted") - self.set_openpype_executable_path(job) - self.updateFtrackStatus(job, "Rendering") - - def OnJobFinished(self, job): - # self.LogInfo("OnJobFinished") - self.updateFtrackStatus(job, "Artist Review") - - def OnJobRequeued(self, job): - # self.LogInfo("OnJobRequeued LOGGING") - self.set_openpype_executable_path(job) - - def OnJobFailed(self, job): - pass - - def OnJobSuspended(self, job): - # self.LogInfo("OnJobSuspended LOGGING") - self.updateFtrackStatus(job, "Render Queued") - - def OnJobResumed(self, job): - # self.LogInfo("OnJobResumed LOGGING") - self.set_openpype_executable_path(job) - self.updateFtrackStatus(job, "Rendering") - - def OnJobPended(self, job): - # self.LogInfo("OnJobPended LOGGING") - pass - - def OnJobReleased(self, job): - pass - - def OnJobDeleted(self, job): - pass - - def OnJobError(self, job, task, report): - # self.LogInfo("OnJobError LOGGING") - pass - - def OnJobPurged(self, job): - pass - - def OnHouseCleaning(self): - pass - - def OnRepositoryRepair(self, job, *args): - pass - - def OnSlaveStarted(self, job): - # self.LogInfo("OnSlaveStarted LOGGING") - pass - - def OnSlaveStopped(self, job): - pass - - def OnSlaveIdle(self, job): - pass - - def OnSlaveRendering(self, host_name, job): - # self.LogInfo("OnSlaveRendering LOGGING") - pass - - def OnSlaveStartingJob(self, host_name, job): - # self.LogInfo("OnSlaveStartingJob LOGGING") - self.set_openpype_executable_path(job) - - def OnSlaveStalled(self, job): - pass - - def OnIdleShutdown(self, job): - pass - - def OnMachineStartup(self, job): - pass - - def OnThermalShutdown(self, job): - pass - - def OnMachineRestart(self, job): - pass From d90c83a6b8b5cd7a4765a91ff47d773ffda7384f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 12:26:33 +0100 Subject: [PATCH 294/483] move pyblish ui logic into host_tools --- openpype/tools/utils/host_tools.py | 36 ++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index a7ad8fef3b..f9e38c0dee 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -3,8 +3,9 @@ It is possible to create `HostToolsHelper` in host implementation or use singleton approach with global functions (using helper anyway). """ - +import os import avalon.api +import pyblish.api from .lib import qt_app_context @@ -196,10 +197,29 @@ class HostToolsHelper: library_loader_tool.refresh() def show_publish(self, parent=None): - """Publish UI.""" - from avalon.tools import publish + """Try showing the most desirable publish GUI - publish.show(parent) + This function cycles through the currently registered + graphical user interfaces, if any, and presents it to + the user. + """ + + pyblish_show = self._discover_pyblish_gui() + return pyblish_show(parent) + + def _discover_pyblish_gui(): + """Return the most desirable of the currently registered GUIs""" + # Prefer last registered + guis = list(reversed(pyblish.api.registered_guis())) + for gui in guis: + try: + gui = __import__(gui).show + except (ImportError, AttributeError): + continue + else: + return gui + + raise ImportError("No Pyblish GUI found") def get_look_assigner_tool(self, parent): """Create, cache and return look assigner tool window.""" @@ -394,3 +414,11 @@ def show_publish(parent=None): def show_experimental_tools_dialog(parent=None): _SingletonPoint.show_tool_by_name("experimental_tools", parent) + + +def get_pyblish_icon(): + pyblish_dir = os.path.abspath(os.path.dirname(pyblish.api.__file__)) + icon_path = os.path.join(pyblish_dir, "icons", "logo-32x32.svg") + if os.path.exists(icon_path): + return icon_path + return None From b22a3c9217230aff377fd083517647f326fd35da Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 12:26:55 +0100 Subject: [PATCH 295/483] import qt_app_context in utils init file --- openpype/tools/utils/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index b4b0af106e..c15e9f8139 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -15,6 +15,7 @@ from .lib import ( get_warning_pixmap, set_style_property, DynamicQThread, + qt_app_context, ) from .models import ( @@ -39,6 +40,7 @@ __all__ = ( "get_warning_pixmap", "set_style_property", "DynamicQThread", + "qt_app_context", "RecursiveSortFilterProxyModel", ) From d2ee9c023f795abdac88a420511fce4ea20c89ee Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 2 Mar 2022 13:23:57 +0100 Subject: [PATCH 296/483] Fix validate properly expected files without any frames Applicable for .mov or other formats like that. --- .../validate_expected_and_rendered_files.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index d49e314179..c2426e0d78 100644 --- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -107,6 +107,10 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): explicitly and manually changed the frame list on the Deadline job. """ + # no frames in file name at all, eg 'renderCompositingMain.withLut.mov' + if not frame_placeholder: + return set([file_name_template]) + real_expected_rendered = set() src_padding_exp = "%0{}d".format(len(frame_placeholder)) for frames in frame_list: @@ -130,14 +134,13 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): # There might be cases where clique was unable to collect # collections in `collect_frames` - thus we capture that case - if frame is None: - self.log.warning("Unable to detect frame from filename: " - "{}".format(file_name)) - continue + if frame is not None: + frame_placeholder = "#" * len(frame) - frame_placeholder = "#" * len(frame) - file_name_template = os.path.basename( - file_name.replace(frame, frame_placeholder)) + file_name_template = os.path.basename( + file_name.replace(frame, frame_placeholder)) + else: + file_name_template = file_name break return file_name_template, frame_placeholder From 4f0001c4f3ea709c02b15cc6a62ad0f4e5df4f7e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 13:48:47 +0100 Subject: [PATCH 297/483] replace usages of avalon.tools with use classes from openpype.tools --- openpype/hosts/blender/api/pipeline.py | 11 ++++------- openpype/hosts/maya/api/commands.py | 4 ++-- openpype/hosts/maya/api/menu.py | 4 ++-- openpype/tools/mayalookassigner/widgets.py | 15 +++++++++------ openpype/tools/sceneinventory/model.py | 2 +- openpype/tools/sceneinventory/view.py | 12 ++++++++---- openpype/tools/standalonepublish/publish.py | 4 ++-- openpype/tools/workfiles/model.py | 4 ++-- 8 files changed, 30 insertions(+), 26 deletions(-) diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 0e5104fea9..6da0ba3dcb 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -202,13 +202,10 @@ def reload_pipeline(*args): avalon.api.uninstall() for module in ( - "avalon.io", - "avalon.lib", - "avalon.pipeline", - "avalon.tools.creator.app", - "avalon.tools.manager.app", - "avalon.api", - "avalon.tools", + "avalon.io", + "avalon.lib", + "avalon.pipeline", + "avalon.api", ): module = importlib.import_module(module) importlib.reload(module) diff --git a/openpype/hosts/maya/api/commands.py b/openpype/hosts/maya/api/commands.py index c774afcc12..a1e0be2cfe 100644 --- a/openpype/hosts/maya/api/commands.py +++ b/openpype/hosts/maya/api/commands.py @@ -37,17 +37,17 @@ class ToolWindows: def edit_shader_definitions(): - from avalon.tools import lib from Qt import QtWidgets from openpype.hosts.maya.api.shader_definition_editor import ( ShaderDefinitionsEditor ) + from openpype.tools.utils import qt_app_context top_level_widgets = QtWidgets.QApplication.topLevelWidgets() main_window = next(widget for widget in top_level_widgets if widget.objectName() == "MayaWindow") - with lib.application(): + with qt_app_context(): window = ToolWindows.get_window("shader_definition_editor") if not window: window = ShaderDefinitionsEditor(parent=main_window) diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index b1934c757d..5f0fc39bf3 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -36,7 +36,7 @@ def install(): return def deferred(): - from avalon.tools import publish + pyblish_icon = host_tools.get_pyblish_icon() parent_widget = get_main_window() cmds.menu( MENU_NAME, @@ -80,7 +80,7 @@ def install(): command=lambda *args: host_tools.show_publish( parent=parent_widget ), - image=publish.ICON + image=pyblish_icon ) cmds.menuItem( diff --git a/openpype/tools/mayalookassigner/widgets.py b/openpype/tools/mayalookassigner/widgets.py index d575e647ce..e5a9968b01 100644 --- a/openpype/tools/mayalookassigner/widgets.py +++ b/openpype/tools/mayalookassigner/widgets.py @@ -4,8 +4,11 @@ from collections import defaultdict from Qt import QtWidgets, QtCore # TODO: expose this better in avalon core -from avalon.tools import lib -from avalon.tools.models import TreeModel +from openpype.tools.utils.models import TreeModel +from openpype.tools.utils.lib import ( + preserve_expanded_rows, + preserve_selection, +) from .models import ( AssetModel, @@ -88,8 +91,8 @@ class AssetOutliner(QtWidgets.QWidget): """Add all items from the current scene""" items = [] - with lib.preserve_expanded_rows(self.view): - with lib.preserve_selection(self.view): + with preserve_expanded_rows(self.view): + with preserve_selection(self.view): self.clear() nodes = commands.get_all_asset_nodes() items = commands.create_items_from_nodes(nodes) @@ -100,8 +103,8 @@ class AssetOutliner(QtWidgets.QWidget): def get_selected_assets(self): """Add all selected items from the current scene""" - with lib.preserve_expanded_rows(self.view): - with lib.preserve_selection(self.view): + with preserve_expanded_rows(self.view): + with preserve_selection(self.view): self.clear() nodes = commands.get_selected_nodes() items = commands.create_items_from_nodes(nodes) diff --git a/openpype/tools/sceneinventory/model.py b/openpype/tools/sceneinventory/model.py index d2b7f8b70f..6435e5c488 100644 --- a/openpype/tools/sceneinventory/model.py +++ b/openpype/tools/sceneinventory/model.py @@ -8,7 +8,7 @@ from avalon import api, io, style, schema from avalon.vendor import qtawesome from avalon.lib import HeroVersionType -from avalon.tools.models import TreeModel, Item +from openpype.tools.utils.models import TreeModel, Item from .lib import ( get_site_icons, diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py index 80f26a881d..f55a68df95 100644 --- a/openpype/tools/sceneinventory/view.py +++ b/openpype/tools/sceneinventory/view.py @@ -7,9 +7,13 @@ from Qt import QtWidgets, QtCore from avalon import io, api, style from avalon.vendor import qtawesome from avalon.lib import HeroVersionType -from avalon.tools import lib as tools_lib from openpype.modules import ModulesManager +from openpype.tools.utils.lib import ( + get_progress_for_repre, + iter_model_rows, + format_version +) from .switch_dialog import SwitchAssetDialog from .model import InventoryModel @@ -373,7 +377,7 @@ class SceneInvetoryView(QtWidgets.QTreeView): if not repre_doc: continue - progress = tools_lib.get_progress_for_repre( + progress = get_progress_for_repre( repre_doc, active_site, remote_site @@ -544,7 +548,7 @@ class SceneInvetoryView(QtWidgets.QTreeView): "toggle": selection_model.Toggle, }[options.get("mode", "select")] - for item in tools_lib.iter_model_rows(model, 0): + for item in iter_model_rows(model, 0): item = item.data(InventoryModel.ItemRole) if item.get("isGroupNode"): continue @@ -704,7 +708,7 @@ class SceneInvetoryView(QtWidgets.QTreeView): labels = [] for version in all_versions: is_hero = version["type"] == "hero_version" - label = tools_lib.format_version(version["name"], is_hero) + label = format_version(version["name"], is_hero) labels.append(label) versions_by_label[label] = version["name"] diff --git a/openpype/tools/standalonepublish/publish.py b/openpype/tools/standalonepublish/publish.py index af269c4381..582e7eccf8 100644 --- a/openpype/tools/standalonepublish/publish.py +++ b/openpype/tools/standalonepublish/publish.py @@ -3,10 +3,10 @@ import sys import openpype import pyblish.api +from openpype.tools.utils.host_tools import show_publish def main(env): - from avalon.tools import publish # Registers pype's Global pyblish plugins openpype.install() @@ -19,7 +19,7 @@ def main(env): continue pyblish.api.register_plugin_path(path) - return publish.show() + return show_publish() if __name__ == "__main__": diff --git a/openpype/tools/workfiles/model.py b/openpype/tools/workfiles/model.py index 583f495606..3425cc3df0 100644 --- a/openpype/tools/workfiles/model.py +++ b/openpype/tools/workfiles/model.py @@ -1,11 +1,11 @@ import os import logging -from Qt import QtCore, QtGui +from Qt import QtCore from avalon import style from avalon.vendor import qtawesome -from avalon.tools.models import TreeModel, Item +from openpype.tools.utils.models import TreeModel, Item log = logging.getLogger(__name__) From 9bd774593e870e842e4889d0d198dcacdb1c4326 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 14:07:42 +0100 Subject: [PATCH 298/483] fix method arguments --- openpype/tools/utils/host_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index f9e38c0dee..6ce9e818d9 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -207,7 +207,7 @@ class HostToolsHelper: pyblish_show = self._discover_pyblish_gui() return pyblish_show(parent) - def _discover_pyblish_gui(): + def _discover_pyblish_gui(self): """Return the most desirable of the currently registered GUIs""" # Prefer last registered guis = list(reversed(pyblish.api.registered_guis())) From 171ddd66766f4e81165e605101ef160434c35909 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 2 Mar 2022 15:22:28 +0100 Subject: [PATCH 299/483] Update openpype/tools/mayalookassigner/widgets.py --- openpype/tools/mayalookassigner/widgets.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/tools/mayalookassigner/widgets.py b/openpype/tools/mayalookassigner/widgets.py index e5a9968b01..e546ee705d 100644 --- a/openpype/tools/mayalookassigner/widgets.py +++ b/openpype/tools/mayalookassigner/widgets.py @@ -3,7 +3,6 @@ from collections import defaultdict from Qt import QtWidgets, QtCore -# TODO: expose this better in avalon core from openpype.tools.utils.models import TreeModel from openpype.tools.utils.lib import ( preserve_expanded_rows, From 4740616310b90bfbbb78ac4360648a8e41571794 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 2 Mar 2022 15:51:07 +0100 Subject: [PATCH 300/483] nuke: rework reformat knob defining --- .../defaults/project_settings/nuke.json | 9 +- .../schemas/schema_nuke_publish.json | 92 +++++++++++++++++-- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 238d21d43a..e30296d0ad 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -126,24 +126,29 @@ "reformat_node_add": false, "reformat_node_config": [ { + "type": "string", "name": "type", "value": "to format" }, { + "type": "string", "name": "format", "value": "HD_1080" }, { + "type": "string", "name": "filter", "value": "Lanczos6" }, { + "type": "bool", "name": "black_outside", - "value": "true" + "value": true }, { + "type": "bool", "name": "pbb", - "value": "false" + "value": false } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json index 81e5d2cc3f..f53c53c2f8 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json @@ -245,17 +245,93 @@ "type": "list", "key": "reformat_node_config", "object_type": { - "type": "dict", - "children": [ + "type": "dict-conditional", + "enum_key": "type", + "enum_label": "Type", + "enum_children": [ { - "type": "text", - "key": "name", - "label": "Knob Name" + "key": "string", + "label": "String", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "text", + "key": "value", + "label": "Value" + } + ] }, { - "type": "text", - "key": "value", - "label": "Knob Value" + "key": "bool", + "label": "Boolean", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "boolean", + "key": "value", + "label": "Value" + } + ] + }, + { + "key": "number", + "label": "Number", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "list-strict", + "key": "value", + "label": "Value", + "object_types": [ + { + "type": "number", + "key": "number", + "decimal": 4 + } + ] + } + + ] + }, + { + "key": "list_numbers", + "label": "2 Numbers", + "children": [ + { + "type": "text", + "key": "name", + "label": "Name" + }, + { + "type": "list-strict", + "key": "value", + "label": "Value", + "object_types": [ + { + "type": "number", + "key": "x", + "decimal": 4 + }, + { + "type": "number", + "key": "y", + "decimal": 4 + } + ] + } + ] } ] } From c70015cbe675453107edb846f4f257cdb3ff1761 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 2 Mar 2022 16:34:13 +0100 Subject: [PATCH 301/483] nuke: connect api to new reformat config settings --- openpype/hosts/nuke/api/plugin.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 67c5203cda..5cc1db41c7 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -489,12 +489,14 @@ class ExporterReviewMov(ExporterReview): if reformat_node_add: rf_node = nuke.createNode("Reformat") for kn_conf in reformat_node_config: + _type = kn_conf["type"] k_name = str(kn_conf["name"]) - k_value = str(kn_conf["value"]) - if k_value == "true": - k_value = True - if k_value == "false": - k_value = False + k_value = kn_conf["value"] + + # to remove unicode as nuke doesn't like it + if _type == "string": + k_value = str(kn_conf["value"]) + rf_node[k_name].setValue(k_value) # connect From 17eaec1f5be0d8841da88403db5bcf6355f7fd84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 2 Mar 2022 17:12:46 +0100 Subject: [PATCH 302/483] quick fix crypto --- openpype/plugins/publish/extract_jpeg_exr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/extract_jpeg_exr.py b/openpype/plugins/publish/extract_jpeg_exr.py index d80b7bb9c3..99feadcc0b 100644 --- a/openpype/plugins/publish/extract_jpeg_exr.py +++ b/openpype/plugins/publish/extract_jpeg_exr.py @@ -34,7 +34,7 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): self.log.info("subset {}".format(instance.data['subset'])) # skip crypto passes. - if 'crypto' in instance.data['subset']: + if 'crypto' in instance.data['subset'].lower(): self.log.info("Skipping crypto passes.") return From bdd4e088b73c12988a48a7db916f81914c321346 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 2 Mar 2022 22:24:23 +0100 Subject: [PATCH 303/483] added todo --- openpype/plugins/publish/extract_jpeg_exr.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/plugins/publish/extract_jpeg_exr.py b/openpype/plugins/publish/extract_jpeg_exr.py index 99feadcc0b..468ed96199 100644 --- a/openpype/plugins/publish/extract_jpeg_exr.py +++ b/openpype/plugins/publish/extract_jpeg_exr.py @@ -34,6 +34,11 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): self.log.info("subset {}".format(instance.data['subset'])) # skip crypto passes. + # TODO: This is just a quick fix and has its own side-effects - it is + # affecting every subset name with `crypto` in its name. + # This must be solved properly, maybe using tags on + # representation that can be determined much earlier and + # with better precision. if 'crypto' in instance.data['subset'].lower(): self.log.info("Skipping crypto passes.") return From cc4894d899696d9f3ef80e6b9e5c21473ae610b9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 3 Mar 2022 11:19:28 +0100 Subject: [PATCH 304/483] fix value changes --- openpype/settings/entities/dict_conditional.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py index 963fd406ed..19f326aea7 100644 --- a/openpype/settings/entities/dict_conditional.py +++ b/openpype/settings/entities/dict_conditional.py @@ -584,8 +584,9 @@ class DictConditionalEntity(ItemEntity): self.enum_entity.update_default_value(enum_value) for children_by_key in self.non_gui_children.values(): + value_copy = copy.deepcopy(value) for key, child_obj in children_by_key.items(): - child_value = value.get(key, NOT_SET) + child_value = value_copy.get(key, NOT_SET) child_obj.update_default_value(child_value) def update_studio_value(self, value): @@ -620,8 +621,9 @@ class DictConditionalEntity(ItemEntity): self.enum_entity.update_studio_value(enum_value) for children_by_key in self.non_gui_children.values(): + value_copy = copy.deepcopy(value) for key, child_obj in children_by_key.items(): - child_value = value.get(key, NOT_SET) + child_value = value_copy.get(key, NOT_SET) child_obj.update_studio_value(child_value) def update_project_value(self, value): @@ -656,8 +658,9 @@ class DictConditionalEntity(ItemEntity): self.enum_entity.update_project_value(enum_value) for children_by_key in self.non_gui_children.values(): + value_copy = copy.deepcopy(value) for key, child_obj in children_by_key.items(): - child_value = value.get(key, NOT_SET) + child_value = value_copy.get(key, NOT_SET) child_obj.update_project_value(child_value) def _discard_changes(self, on_change_trigger): From d7b704d6e5a3eebaa5153beca41c8427be231ca2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 3 Mar 2022 11:47:01 +0100 Subject: [PATCH 305/483] removed module_name logic from harmony --- openpype/hosts/harmony/api/lib.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/openpype/hosts/harmony/api/lib.py b/openpype/hosts/harmony/api/lib.py index 134f670dc4..66eeac1e3a 100644 --- a/openpype/hosts/harmony/api/lib.py +++ b/openpype/hosts/harmony/api/lib.py @@ -361,7 +361,7 @@ def zip_and_move(source, destination): log.debug(f"Saved '{source}' to '{destination}'") -def show(module_name): +def show(tool_name): """Call show on "module_name". This allows to make a QApplication ahead of time and always "exec_" to @@ -375,13 +375,6 @@ def show(module_name): # requests to be received properly. time.sleep(1) - # Get tool name from module name - # TODO this is for backwards compatibility not sure if `TB_sceneOpened.js` - # is automatically updated. - # Previous javascript sent 'module_name' which contained whole tool import - # string e.g. "avalon.tools.workfiles" now it should be only "workfiles" - tool_name = module_name.split(".")[-1] - kwargs = {} if tool_name == "loader": kwargs["use_context"] = True From 8f92f392586b9e43d8c27317b0ab118dd9185582 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 3 Mar 2022 12:57:33 +0100 Subject: [PATCH 306/483] general: improving letter/pillar box ratio exception --- openpype/plugins/publish/extract_review.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 9d7ad26a40..ae96f668f2 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -993,8 +993,13 @@ class ExtractReview(pyblish.api.InstancePlugin): l_red, l_green, l_blue ) line_color_alpha = float(l_alpha) / 255 - height_letterbox = int(output_height - (output_width * (1 / ratio))) - if state == "letterbox": + test_ratio_width = int( + (output_height - (output_width * (1 / ratio))) / 2 + ) + test_ratio_height = int( + (output_width - (output_height * ratio)) / 2 + ) + if state == "letterbox" and test_ratio_width: if fill_color_alpha > 0: top_box = ( "drawbox=0:0:{widht}:round(" @@ -1022,8 +1027,7 @@ class ExtractReview(pyblish.api.InstancePlugin): alpha=fill_color_alpha ) - if height_letterbox > 0: - output.extend([top_box, bottom_box]) + output.extend([top_box, bottom_box]) if line_color_alpha > 0 and line_thickness > 0: top_line = ( @@ -1050,10 +1054,10 @@ class ExtractReview(pyblish.api.InstancePlugin): l_color=line_color_hex, l_alpha=line_color_alpha ) - if height_letterbox > 0: - output.extend([top_line, bottom_line]) - elif state == "pillar": + output.extend([top_line, bottom_line]) + + elif state == "pillar" and test_ratio_height: if fill_color_alpha > 0: left_box = ( "drawbox=0:0:round(({widht}-({height}" From 62695b36c4c559cb461a6d4438a754e251e53112 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 3 Mar 2022 13:00:33 +0100 Subject: [PATCH 307/483] hound catches --- openpype/plugins/publish/extract_review.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index ae96f668f2..c75eea4e06 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -1026,7 +1026,6 @@ class ExtractReview(pyblish.api.InstancePlugin): color=fill_color_hex, alpha=fill_color_alpha ) - output.extend([top_box, bottom_box]) if line_color_alpha > 0 and line_thickness > 0: @@ -1054,7 +1053,6 @@ class ExtractReview(pyblish.api.InstancePlugin): l_color=line_color_hex, l_alpha=line_color_alpha ) - output.extend([top_line, bottom_line]) elif state == "pillar" and test_ratio_height: @@ -1081,8 +1079,7 @@ class ExtractReview(pyblish.api.InstancePlugin): color=fill_color_hex, alpha=fill_color_alpha ) - if height_letterbox > 0: - output.extend([left_box, right_box]) + output.extend([left_box, right_box]) if line_color_alpha > 0 and line_thickness > 0: left_line = ( @@ -1108,8 +1105,7 @@ class ExtractReview(pyblish.api.InstancePlugin): l_color=line_color_hex, l_alpha=line_color_alpha ) - if height_letterbox > 0: - output.extend([left_line, right_line]) + output.extend([left_line, right_line]) else: raise ValueError( From 6a6ce4d5c5976038bf4f296183603883d38d9f92 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 3 Mar 2022 14:52:24 +0100 Subject: [PATCH 308/483] added funciton to convert string fpx into float --- openpype/modules/ftrack/lib/avalon_sync.py | 106 +++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index db7c592c9b..11478925d6 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -2,6 +2,9 @@ import re import json import collections import copy +import numbers + +import six from avalon.api import AvalonMongoDB @@ -32,6 +35,109 @@ CURRENT_DOC_SCHEMAS = { "config": "openpype:config-2.0" } +FPS_KEYS = { + "fps", + # For development purposes + "fps_string" +} + + +class InvalidFpsValue(Exception): + pass + + +def is_string_number(value): + """Can string value be converted to number (float).""" + if not isinstance(value, six.string_types): + raise TypeError("Expected {} got {}".format( + ", ".join(str(t) for t in six.string_types), str(type(value)) + )) + if value == ".": + return False + + if value.startswith("."): + value = "0" + value + elif value.endswith("."): + value = value + "0" + + if re.match(r"^\d+(\.\d+)?$", value) is None: + return False + return True + + +def convert_to_fps(source_value): + """Convert value into fps value. + + Non string values are kept untouched. String is tried to convert. + Valid values: + "1000" + "1000.05" + "1000,05" + ",05" + ".05" + "1000," + "1000." + "1000/1000" + "1000.05/1000" + "1000/1000.05" + "1000.05/1000.05" + "1000,05/1000" + "1000/1000,05" + "1000,05/1000,05" + + Invalid values: + "/" + "/1000" + "1000/" + "," + "." + ...any other string + + Returns: + float: Converted value. + + Raises: + InvalidFpsValue: When value can't be converted to float. + """ + if not isinstance(source_value, six.string_types): + if isinstance(source_value, numbers.Number): + return float(source_value) + return source_value + + value = source_value.strip().replace(",", ".") + if not value: + raise InvalidFpsValue("Got empty value") + + subs = value.split("/") + if len(subs) == 1: + str_value = subs[0] + if not is_string_number(str_value): + raise InvalidFpsValue( + "Value \"{}\" can't be converted to number.".format(value) + ) + return float(str_value) + + elif len(subs) == 2: + divident, divisor = subs + if not divident or not is_string_number(divident): + raise InvalidFpsValue( + "Divident value \"{}\" can't be converted to number".format( + divident + ) + ) + + if not divisor or not is_string_number(divisor): + raise InvalidFpsValue( + "Divisor value \"{}\" can't be converted to number".format( + divident + ) + ) + return float(divident) / float(divisor) + + raise InvalidFpsValue( + "Value can't be converted to number \"{}\"".format(source_value) + ) + def create_chunks(iterable, chunk_size=None): """Separate iterable into multiple chunks by size. From f88bf7b5be19280ba9ea2088a46ffe579644d564 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 3 Mar 2022 14:52:41 +0100 Subject: [PATCH 309/483] use fps conversion function during synchronization --- .../event_sync_to_avalon.py | 49 +++++++++++++++++++ openpype/modules/ftrack/lib/avalon_sync.py | 44 ++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py index 9f85000dbb..76f4be1419 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py +++ b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py @@ -25,6 +25,11 @@ from openpype_modules.ftrack.lib import ( BaseEvent ) +from openpype_modules.ftrack.lib.avalon_sync import ( + convert_to_fps, + InvalidFpsValue, + FPS_KEYS +) from openpype.lib import CURRENT_DOC_SCHEMAS @@ -1149,12 +1154,31 @@ class SyncToAvalonEvent(BaseEvent): "description": ftrack_ent["description"] } } + invalid_fps_items = [] cust_attrs = self.get_cust_attr_values(ftrack_ent) for key, val in cust_attrs.items(): if key.startswith("avalon_"): continue + + if key in FPS_KEYS: + try: + val = convert_to_fps(val) + except InvalidFpsValue: + invalid_fps_items.append((ftrack_ent["id"], val)) + continue + final_entity["data"][key] = val + if invalid_fps_items: + fps_msg = ( + "These entities have invalid fps value in custom attributes" + ) + items = [] + for entity_id, value in invalid_fps_items: + ent_path = self.get_ent_path(entity_id) + items.append("{} - \"{}\"".format(ent_path, value)) + self.report_items["error"][fps_msg] = items + _mongo_id_str = cust_attrs.get(CUST_ATTR_ID_KEY) if _mongo_id_str: try: @@ -2155,11 +2179,19 @@ class SyncToAvalonEvent(BaseEvent): ) convert_types_by_id[attr_id] = convert_type + default_value = attr["default"] + if key in FPS_KEYS: + try: + default_value = convert_to_fps(default_value) + except InvalidFpsValue: + pass + entities_dict[ftrack_project_id]["hier_attrs"][key] = ( attr["default"] ) # PREPARE DATA BEFORE THIS + invalid_fps_items = [] avalon_hier = [] for item in values: value = item["value"] @@ -2173,8 +2205,25 @@ class SyncToAvalonEvent(BaseEvent): if convert_type: value = convert_type(value) + + if key in FPS_KEYS: + try: + value = convert_to_fps(value) + except InvalidFpsValue: + invalid_fps_items.append((entity_id, value)) + continue entities_dict[entity_id]["hier_attrs"][key] = value + if invalid_fps_items: + fps_msg = ( + "These entities have invalid fps value in custom attributes" + ) + items = [] + for entity_id, value in invalid_fps_items: + ent_path = self.get_ent_path(entity_id) + items.append("{} - \"{}\"".format(ent_path, value)) + self.report_items["error"][fps_msg] = items + # Get dictionary with not None hierarchical values to pull to childs project_values = {} for key, value in ( diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 11478925d6..07b974d84f 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -1086,6 +1086,7 @@ class SyncEntitiesFactory: sync_ids ) + invalid_fps_items = [] for item in items: entity_id = item["entity_id"] attr_id = item["configuration_id"] @@ -1098,8 +1099,24 @@ class SyncEntitiesFactory: value = item["value"] if convert_type: value = convert_type(value) + + if key in FPS_KEYS: + try: + value = convert_to_fps(value) + except InvalidFpsValue: + invalid_fps_items.append((entity_id, value)) self.entities_dict[entity_id][store_key][key] = value + if invalid_fps_items: + fps_msg = ( + "These entities have invalid fps value in custom attributes" + ) + items = [] + for entity_id, value in invalid_fps_items: + ent_path = self.get_ent_path(entity_id) + items.append("{} - \"{}\"".format(ent_path, value)) + self.report_items["error"][fps_msg] = items + # process hierarchical attributes self.set_hierarchical_attribute( hier_attrs, sync_ids, cust_attr_type_name_by_id @@ -1132,8 +1149,15 @@ class SyncEntitiesFactory: if key.startswith("avalon_"): store_key = "avalon_attrs" + default_value = attr["default"] + if key in FPS_KEYS: + try: + default_value = convert_to_fps(default_value) + except InvalidFpsValue: + pass + self.entities_dict[self.ft_project_id][store_key][key] = ( - attr["default"] + default_value ) # Add attribute ids to entities dictionary @@ -1175,6 +1199,7 @@ class SyncEntitiesFactory: True ) + invalid_fps_items = [] avalon_hier = [] for item in items: value = item["value"] @@ -1194,6 +1219,13 @@ class SyncEntitiesFactory: entity_id = item["entity_id"] key = attribute_key_by_id[attr_id] + if key in FPS_KEYS: + try: + value = convert_to_fps(value) + except InvalidFpsValue: + invalid_fps_items.append((entity_id, value)) + continue + if key.startswith("avalon_"): store_key = "avalon_attrs" avalon_hier.append(key) @@ -1201,6 +1233,16 @@ class SyncEntitiesFactory: store_key = "hier_attrs" self.entities_dict[entity_id][store_key][key] = value + if invalid_fps_items: + fps_msg = ( + "These entities have invalid fps value in custom attributes" + ) + items = [] + for entity_id, value in invalid_fps_items: + ent_path = self.get_ent_path(entity_id) + items.append("{} - \"{}\"".format(ent_path, value)) + self.report_items["error"][fps_msg] = items + # Get dictionary with not None hierarchical values to pull to childs top_id = self.ft_project_id project_values = {} From 630c8193366edaceacaadd59bb47c5f0fe47ee70 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 3 Mar 2022 15:29:30 +0100 Subject: [PATCH 310/483] moved FPS_KEYS to constants --- .../ftrack/event_handlers_server/event_sync_to_avalon.py | 4 ++-- openpype/modules/ftrack/lib/__init__.py | 5 ++++- openpype/modules/ftrack/lib/avalon_sync.py | 8 +------- openpype/modules/ftrack/lib/constants.py | 6 ++++++ 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py index 76f4be1419..eea6436b53 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py +++ b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py @@ -20,6 +20,7 @@ from openpype_modules.ftrack.lib import ( query_custom_attributes, CUST_ATTR_ID_KEY, CUST_ATTR_AUTO_SYNC, + FPS_KEYS, avalon_sync, @@ -27,8 +28,7 @@ from openpype_modules.ftrack.lib import ( ) from openpype_modules.ftrack.lib.avalon_sync import ( convert_to_fps, - InvalidFpsValue, - FPS_KEYS + InvalidFpsValue ) from openpype.lib import CURRENT_DOC_SCHEMAS diff --git a/openpype/modules/ftrack/lib/__init__.py b/openpype/modules/ftrack/lib/__init__.py index 80b4db9dd6..7fc2bc99eb 100644 --- a/openpype/modules/ftrack/lib/__init__.py +++ b/openpype/modules/ftrack/lib/__init__.py @@ -4,7 +4,8 @@ from .constants import ( CUST_ATTR_GROUP, CUST_ATTR_TOOLS, CUST_ATTR_APPLICATIONS, - CUST_ATTR_INTENT + CUST_ATTR_INTENT, + FPS_KEYS ) from .settings import ( get_ftrack_event_mongo_info @@ -30,6 +31,8 @@ __all__ = ( "CUST_ATTR_GROUP", "CUST_ATTR_TOOLS", "CUST_ATTR_APPLICATIONS", + "CUST_ATTR_INTENT", + "FPS_KEYS", "get_ftrack_event_mongo_info", diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 07b974d84f..5a0c3c1574 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -17,7 +17,7 @@ from openpype.api import ( ) from openpype.lib import ApplicationManager -from .constants import CUST_ATTR_ID_KEY +from .constants import CUST_ATTR_ID_KEY, FPS_KEYS from .custom_attributes import get_openpype_attr, query_custom_attributes from bson.objectid import ObjectId @@ -35,12 +35,6 @@ CURRENT_DOC_SCHEMAS = { "config": "openpype:config-2.0" } -FPS_KEYS = { - "fps", - # For development purposes - "fps_string" -} - class InvalidFpsValue(Exception): pass diff --git a/openpype/modules/ftrack/lib/constants.py b/openpype/modules/ftrack/lib/constants.py index e6e2013d2b..636dcfbc3d 100644 --- a/openpype/modules/ftrack/lib/constants.py +++ b/openpype/modules/ftrack/lib/constants.py @@ -12,3 +12,9 @@ CUST_ATTR_APPLICATIONS = "applications" CUST_ATTR_TOOLS = "tools_env" # Intent custom attribute name CUST_ATTR_INTENT = "intent" + +FPS_KEYS = { + "fps", + # For development purposes + "fps_string" +} From c237434ad682f4477791df3751c05835b9a99551 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 3 Mar 2022 15:30:15 +0100 Subject: [PATCH 311/483] create custom attributes action does not replace text fps custom attribute --- .../action_create_cust_attrs.py | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py index cb5b88ad50..88dc8213bd 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py +++ b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py @@ -11,6 +11,7 @@ from openpype_modules.ftrack.lib import ( CUST_ATTR_TOOLS, CUST_ATTR_APPLICATIONS, CUST_ATTR_INTENT, + FPS_KEYS, default_custom_attributes_definition, app_definitions_from_app_manager, @@ -519,20 +520,28 @@ class CustomAttributes(BaseAction): self.show_message(event, msg) def process_attribute(self, data): - existing_attrs = self.session.query( - "CustomAttributeConfiguration" - ).all() + existing_attrs = self.session.query(( + "select is_hierarchical, key, type, entity_type, object_type_id" + " from CustomAttributeConfiguration" + )).all() matching = [] + is_hierarchical = data.get("is_hierarchical", False) for attr in existing_attrs: if ( - attr["key"] != data["key"] or - attr["type"]["name"] != data["type"]["name"] + is_hierarchical != attr["is_hierarchical"] + or attr["key"] != data["key"] ): continue - if data.get("is_hierarchical") is True: - if attr["is_hierarchical"] is True: - matching.append(attr) + if attr["type"]["name"] != data["type"]["name"]: + if data["key"] in FPS_KEYS and attr["type"]["name"] == "text": + self.log.info("Kept 'fps' as text custom attribute.") + return + continue + + if is_hierarchical: + matching.append(attr) + elif "object_type_id" in data: if ( attr["entity_type"] == data["entity_type"] and From 522770a1605297be693856d50bf6ef4ae1060c49 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 3 Mar 2022 16:07:22 +0100 Subject: [PATCH 312/483] nuke: adding `reformated` tag to differentiate repre for extract review --- openpype/hosts/nuke/api/plugin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 5cc1db41c7..3e61caedf9 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -487,6 +487,9 @@ class ExporterReviewMov(ExporterReview): # add reformat node if reformat_node_add: + # append reformated tag + add_tags.append("reformated") + rf_node = nuke.createNode("Reformat") for kn_conf in reformat_node_config: _type = kn_conf["type"] From ff6fecdc5dbc04f583c11d5735eb4ea7116752dd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 3 Mar 2022 16:07:58 +0100 Subject: [PATCH 313/483] global: adding `reformated` tag exception into extract review --- openpype/plugins/publish/extract_review.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 5f286a53e6..b4a5117959 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -1171,6 +1171,9 @@ class ExtractReview(pyblish.api.InstancePlugin): self.log.debug("input_width: `{}`".format(input_width)) self.log.debug("input_height: `{}`".format(input_height)) + reformat_in_baking = bool("reformated" in new_repre["tags"]) + self.log.debug("reformat_in_baking: `{}`".format(reformat_in_baking)) + # Use instance resolution if output definition has not set it. if output_width is None or output_height is None: output_width = temp_data["resolution_width"] @@ -1182,6 +1185,17 @@ class ExtractReview(pyblish.api.InstancePlugin): output_width = input_width output_height = input_height + if reformat_in_baking: + self.log.debug(( + "Using resolution from input. It is already " + "reformated from baking process" + )) + output_width = input_width + output_height = input_height + pixel_aspect = 1 + new_repre["resolutionWidth"] = input_width + new_repre["resolutionHeight"] = input_height + output_width = int(output_width) output_height = int(output_height) From 1aecfef38a5ab0220f9e0856984ff13961e12fdd Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 3 Mar 2022 17:02:51 +0100 Subject: [PATCH 314/483] add loaded containers to published instance --- .../hosts/maya/plugins/load/load_reference.py | 21 +++++++++ .../plugins/publish/extract_maya_scene_raw.py | 45 ++++++++++++++++++- .../defaults/project_settings/maya.json | 6 +++ .../schemas/schema_maya_publish.json | 24 ++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 0565b0b95c..859e1d339a 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -1,9 +1,11 @@ import os from maya import cmds from avalon import api +from avalon.pipeline import AVALON_CONTAINER_ID from openpype.api import get_project_settings from openpype.lib import get_creator_by_name import openpype.hosts.maya.api.plugin +from openpype.hosts.maya.api.pipeline import AVALON_CONTAINERS from openpype.hosts.maya.api.lib import maintained_selection @@ -125,6 +127,11 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): return new_nodes + def load(self, context, name=None, namespace=None, options=None): + super(ReferenceLoader, self).load(context, name, namespace, options) + # clean containers if present to AVALON_CONTAINERS + self._organize_containers(self[:]) + def switch(self, container, representation): self.update(container, representation) @@ -158,3 +165,17 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): options={"useSelection": True}, data={"dependencies": dependency} ) + + @staticmethod + def _organize_containers(nodes): + # type: (list) -> None + for node in nodes: + id_attr = "{}.id".format(node) + if not cmds.attributeQuery("id", node=node, exists=True): + print("-" * 80) + print("skipping {}".format(node)) + continue + if cmds.getAttr(id_attr) == AVALON_CONTAINER_ID: + print("=" * 80) + print("moving {}".format(node)) + cmds.sets(node, forceElement=AVALON_CONTAINERS) diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py index 9c432cbc67..591789917e 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py @@ -6,6 +6,7 @@ from maya import cmds import openpype.api from openpype.hosts.maya.api.lib import maintained_selection +from avalon.pipeline import AVALON_CONTAINER_ID class ExtractMayaSceneRaw(openpype.api.Extractor): @@ -57,10 +58,22 @@ class ExtractMayaSceneRaw(openpype.api.Extractor): else: members = instance[:] + loaded_containers = None + if {f.lower() for f in self.add_for_families}.intersection( + {f.lower() for f in instance.data.get("families")}, + {instance.data.get("family").lower()}, + ): + loaded_containers = self._add_loaded_containers(members) + + selection = members + if loaded_containers: + self.log.info(loaded_containers) + selection += loaded_containers + # Perform extraction self.log.info("Performing extraction ...") with maintained_selection(): - cmds.select(members, noExpand=True) + cmds.select(selection, noExpand=True) cmds.file(path, force=True, typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 @@ -83,3 +96,33 @@ class ExtractMayaSceneRaw(openpype.api.Extractor): instance.data["representations"].append(representation) self.log.info("Extracted instance '%s' to: %s" % (instance.name, path)) + + @staticmethod + def _add_loaded_containers(members): + # type: (list) -> list + refs_to_include = [ + cmds.referenceQuery(ref, referenceNode=True) + for ref in members + if cmds.referenceQuery(ref, isNodeReferenced=True) + ] + + refs_to_include = set(refs_to_include) + + obj_sets = cmds.ls("*.id", long=True, type="objectSet", recursive=True, + objectsOnly=True) + + loaded_containers = [] + for obj_set in obj_sets: + + if not cmds.attributeQuery("id", node=obj_set, exists=True): + continue + + id_attr = "{}.id".format(obj_set) + if cmds.getAttr(id_attr) != AVALON_CONTAINER_ID: + continue + + set_content = set(cmds.sets(obj_set, query=True)) + if set_content.intersection(refs_to_include): + loaded_containers.append(obj_set) + + return loaded_containers diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index c25f416562..74ecf502d1 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -502,6 +502,12 @@ } } }, + "ExtractMayaSceneRaw": { + "enabled": true, + "add_for_families": [ + "layout" + ] + }, "ExtractCameraAlembic": { "enabled": true, "optional": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index bf7b8a22e7..5c17e3db2c 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -547,6 +547,30 @@ "type": "schema", "name": "schema_maya_capture" }, + { + "type": "dict", + "collapsible": true, + "key": "ExtractMayaSceneRaw", + "label": "Maya Scene (Raw)", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "label", + "label": "Add loaded instances to those published families:" + }, + { + "key": "add_for_families", + "label": "Families", + "type": "list", + "object_type": "text" + } + ] + }, { "type": "dict", "collapsible": true, From cec7adab1c1163f1ae15fbd4deeb57ea4b4a2924 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 3 Mar 2022 17:53:14 +0100 Subject: [PATCH 315/483] fix zero division error --- openpype/modules/ftrack/lib/avalon_sync.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 5a0c3c1574..5301ec568e 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -126,7 +126,10 @@ def convert_to_fps(source_value): divident ) ) - return float(divident) / float(divisor) + divisor_float = float(divisor) + if divisor_float == 0.0: + raise InvalidFpsValue("Can't divide by zero") + return float(divident) / divisor_float raise InvalidFpsValue( "Value can't be converted to number \"{}\"".format(source_value) From 3697fba45313594fa9ebdf6599916e8c174ffab3 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 4 Mar 2022 00:59:22 +0100 Subject: [PATCH 316/483] remove debug prints --- openpype/hosts/maya/plugins/load/load_reference.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 859e1d339a..0a0ce4536f 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -172,10 +172,6 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): for node in nodes: id_attr = "{}.id".format(node) if not cmds.attributeQuery("id", node=node, exists=True): - print("-" * 80) - print("skipping {}".format(node)) continue if cmds.getAttr(id_attr) == AVALON_CONTAINER_ID: - print("=" * 80) - print("moving {}".format(node)) cmds.sets(node, forceElement=AVALON_CONTAINERS) From 2b470aeacca5dcab37ecc5aa92e455437b165970 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 4 Mar 2022 11:01:13 +0100 Subject: [PATCH 317/483] copied functions related to change of context --- openpype/lib/avalon_context.py | 155 +++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 1e8d21852b..9f6a9f9cdc 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -644,6 +644,161 @@ def get_workdir( ) +def template_data_from_session(session): + """ Return dictionary with template from session keys. + + Args: + session (dict, Optional): The Session to use. If not provided use the + currently active global Session. + Returns: + dict: All available data from session. + """ + from avalon import io + + if session is None: + session = avalon.api.Session + + project_name = session["AVALON_PROJECT"] + project_doc = io._database[project_name].find_one({"type": "project"}) + asset_doc = io._database[project_name].find_one({ + "type": "asset", + "name": session["AVALON_ASSET"] + }) + task_name = session["AVALON_TASK"] + host_name = session["AVALON_APP"] + return get_workdir_data(project_doc, asset_doc, task_name, host_name) + + +def compute_session_changes( + session, task=None, asset=None, app=None, template_key=None +): + """Compute the changes for a Session object on asset, task or app switch + + This does *NOT* update the Session object, but returns the changes + required for a valid update of the Session. + + Args: + session (dict): The initial session to compute changes to. + This is required for computing the full Work Directory, as that + also depends on the values that haven't changed. + task (str, Optional): Name of task to switch to. + asset (str or dict, Optional): Name of asset to switch to. + You can also directly provide the Asset dictionary as returned + from the database to avoid an additional query. (optimization) + app (str, Optional): Name of app to switch to. + + Returns: + dict: The required changes in the Session dictionary. + + """ + changes = dict() + + # If no changes, return directly + if not any([task, asset, app]): + return changes + + # Get asset document and asset + asset_document = None + asset_tasks = None + if isinstance(asset, dict): + # Assume asset database document + asset_document = asset + asset_tasks = asset_document.get("data", {}).get("tasks") + asset = asset["name"] + + if not asset_document or not asset_tasks: + from avalon import io + + # Assume asset name + asset_document = io.find_one( + { + "name": asset, + "type": "asset" + }, + {"data.tasks": True} + ) + assert asset_document, "Asset must exist" + + # Detect any changes compared session + mapping = { + "AVALON_ASSET": asset, + "AVALON_TASK": task, + "AVALON_APP": app, + } + changes = { + key: value + for key, value in mapping.items() + if value and value != session.get(key) + } + if not changes: + return changes + + # Compute work directory (with the temporary changed session so far) + _session = session.copy() + _session.update(changes) + + changes["AVALON_WORKDIR"] = get_workdir_from_session(_session) + + return changes + + +def get_workdir_from_session(session, template_key=None): + project_name = session["AVALON_PROJECT"] + host_name = session["AVALON_APP"] + anatomy = Anatomy(project_name) + template_data = template_data_from_session(session) + anatomy_filled = anatomy.format(template_data) + + if not template_key: + task_type = template_data["task"]["type"] + template_key = get_workfile_template_key( + task_type, + host_name, + project_name=project_name + ) + return anatomy_filled[template_key]["folder"] + + +def update_current_task(task=None, asset=None, app=None, template_key=None): + """Update active Session to a new task work area. + + This updates the live Session to a different `asset`, `task` or `app`. + + Args: + task (str): The task to set. + asset (str): The asset to set. + app (str): The app to set. + + Returns: + dict: The changed key, values in the current Session. + + """ + import avalon.api + from avalon.pipeline import emit + + changes = compute_session_changes( + avalon.api.Session, + task=task, + asset=asset, + app=app, + template_key=template_key + ) + + # Update the Session and environments. Pop from environments all keys with + # value set to None. + for key, value in changes.items(): + avalon.api.Session[key] = value + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + # Emit session change + emit("taskChanged", changes.copy()) + + return changes + + @with_avalon def get_workfile_doc(asset_id, task_name, filename, dbcon=None): """Return workfile document for entered context. From 81d8e4d4ccd3668fca5f3a4f54e932131691d96d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 4 Mar 2022 11:01:30 +0100 Subject: [PATCH 318/483] use change context function in workfiles tool --- openpype/tools/workfiles/app.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index 3a772a038c..aece7bfb4f 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -29,6 +29,10 @@ from openpype.lib import ( create_workdir_extra_folders, get_system_general_anatomy_data ) +from openpype.lib.avalon_context import ( + update_current_task, + compute_session_changes +) from .model import FilesModel from .view import FilesView @@ -667,7 +671,7 @@ class FilesWidget(QtWidgets.QWidget): session["AVALON_APP"], project_name=session["AVALON_PROJECT"] ) - changes = pipeline.compute_session_changes( + changes = compute_session_changes( session, asset=self._get_asset_doc(), task=self._task_name, @@ -681,7 +685,7 @@ class FilesWidget(QtWidgets.QWidget): """Enter the asset and task session currently selected""" session = api.Session.copy() - changes = pipeline.compute_session_changes( + changes = compute_session_changes( session, asset=self._get_asset_doc(), task=self._task_name, @@ -692,7 +696,7 @@ class FilesWidget(QtWidgets.QWidget): # to avoid any unwanted Task Changed callbacks to be triggered. return - api.update_current_task( + update_current_task( asset=self._get_asset_doc(), task=self._task_name, template_key=self.template_key From 8f88e7b9250d541c2428e479150f9a78db664125 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 4 Mar 2022 11:01:42 +0100 Subject: [PATCH 319/483] modifid assetcreator imports --- openpype/tools/assetcreator/app.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openpype/tools/assetcreator/app.py b/openpype/tools/assetcreator/app.py index 1d332d647e..60ef31e859 100644 --- a/openpype/tools/assetcreator/app.py +++ b/openpype/tools/assetcreator/app.py @@ -4,9 +4,11 @@ from subprocess import Popen import ftrack_api from Qt import QtWidgets, QtCore +from openpype import style from openpype.api import get_current_project_settings +from openpype.lib.avalon_context import update_current_task from openpype.tools.utils.lib import qt_app_context -from avalon import io, api, style, schema +from avalon import io, api, schema from . import widget, model module = sys.modules[__name__] @@ -463,12 +465,12 @@ class Window(QtWidgets.QDialog): return task_name = task_model.itemData(index)[0] try: - api.update_current_task(task=task_name, asset=asset_name) + update_current_task(task=task_name, asset=asset_name) self.open_app() finally: if origin_task is not None and origin_asset is not None: - api.update_current_task( + update_current_task( task=origin_task, asset=origin_asset ) From 59c5c464ccd3960dbf2e5260024d42c2c58436eb Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 4 Mar 2022 11:02:19 +0100 Subject: [PATCH 320/483] change how fusion calcuates workdir path --- openpype/hosts/fusion/scripts/fusion_switch_shot.py | 9 +++------ openpype/hosts/fusion/utility_scripts/switch_ui.py | 13 +++---------- openpype/scripts/fusion_switch_shot.py | 9 ++++----- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/openpype/hosts/fusion/scripts/fusion_switch_shot.py b/openpype/hosts/fusion/scripts/fusion_switch_shot.py index 9dd8a351e4..ed6a06bb34 100644 --- a/openpype/hosts/fusion/scripts/fusion_switch_shot.py +++ b/openpype/hosts/fusion/scripts/fusion_switch_shot.py @@ -5,11 +5,12 @@ import logging # Pipeline imports import avalon.api -from avalon import io, pipeline +from avalon import io from openpype.lib import version_up from openpype.hosts.fusion import api from openpype.hosts.fusion.api import lib +from openpype.lib.avalon_context import get_workdir_from_session log = logging.getLogger("Update Slap Comp") @@ -46,12 +47,8 @@ def _format_version_folder(folder): def _get_work_folder(session): """Convenience function to get the work folder path of the current asset""" - # Get new filename, create path based on asset and work template - template_work = self._project["config"]["template"]["work"] - work_path = pipeline._format_work_template(template_work, session) - - return os.path.normpath(work_path) + return get_workdir_from_session(session) def _get_fusion_instance(): diff --git a/openpype/hosts/fusion/utility_scripts/switch_ui.py b/openpype/hosts/fusion/utility_scripts/switch_ui.py index fe324d9a41..854c2fd415 100644 --- a/openpype/hosts/fusion/utility_scripts/switch_ui.py +++ b/openpype/hosts/fusion/utility_scripts/switch_ui.py @@ -5,11 +5,12 @@ import logging from Qt import QtWidgets, QtCore import avalon.api -from avalon import io, pipeline +from avalon import io from avalon.vendor import qtawesome as qta from openpype import style from openpype.hosts.fusion import api +from openpype.lib.avalon_context import get_workdir_from_session log = logging.getLogger("Fusion Switch Shot") @@ -158,15 +159,7 @@ class App(QtWidgets.QWidget): switch_shot.switch(asset_name=asset, filepath=file_name, new=True) def _get_context_directory(self): - - project = io.find_one({"type": "project", - "name": avalon.api.Session["AVALON_PROJECT"]}, - projection={"config": True}) - - template = project["config"]["template"]["work"] - dir = pipeline._format_work_template(template, avalon.api.Session) - - return dir + return get_workdir_from_session(avalon.api.Session) def collect_slap_comps(self, directory): items = glob.glob("{}/*.comp".format(directory)) diff --git a/openpype/scripts/fusion_switch_shot.py b/openpype/scripts/fusion_switch_shot.py index 26f5356336..a8ac6812b5 100644 --- a/openpype/scripts/fusion_switch_shot.py +++ b/openpype/scripts/fusion_switch_shot.py @@ -4,13 +4,15 @@ import sys import logging # Pipeline imports -from avalon import api, io, pipeline +from avalon import api, io import avalon.fusion # Config imports import openpype.lib as pype import openpype.hosts.fusion.lib as fusion_lib +from openpype.lib.avalon_context import get_workdir_from_session + log = logging.getLogger("Update Slap Comp") self = sys.modules[__name__] @@ -48,10 +50,7 @@ def _get_work_folder(session): """Convenience function to get the work folder path of the current asset""" # Get new filename, create path based on asset and work template - template_work = self._project["config"]["template"]["work"] - work_path = pipeline._format_work_template(template_work, session) - - return os.path.normpath(work_path) + return get_workdir_from_session(session) def _get_fusion_instance(): From 598882113129e80e1619f7d1c9b256b27847b64e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 4 Mar 2022 11:26:26 +0100 Subject: [PATCH 321/483] reduced functions in fusion --- openpype/hosts/fusion/scripts/fusion_switch_shot.py | 10 ++-------- openpype/hosts/fusion/utility_scripts/switch_ui.py | 5 +---- openpype/lib/avalon_context.py | 9 +++++++-- openpype/scripts/fusion_switch_shot.py | 11 ++--------- 4 files changed, 12 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/fusion/scripts/fusion_switch_shot.py b/openpype/hosts/fusion/scripts/fusion_switch_shot.py index ed6a06bb34..ca7efb9136 100644 --- a/openpype/hosts/fusion/scripts/fusion_switch_shot.py +++ b/openpype/hosts/fusion/scripts/fusion_switch_shot.py @@ -45,12 +45,6 @@ def _format_version_folder(folder): return version_folder -def _get_work_folder(session): - """Convenience function to get the work folder path of the current asset""" - # Get new filename, create path based on asset and work template - return get_workdir_from_session(session) - - def _get_fusion_instance(): fusion = getattr(sys.modules["__main__"], "fusion", None) if fusion is None: @@ -69,7 +63,7 @@ def _format_filepath(session): asset = session["AVALON_ASSET"] # Save updated slap comp - work_path = _get_work_folder(session) + work_path = get_workdir_from_session(session) walk_to_dir = os.path.join(work_path, "scenes", "slapcomp") slapcomp_dir = os.path.abspath(walk_to_dir) @@ -109,7 +103,7 @@ def _update_savers(comp, session): None """ - new_work = _get_work_folder(session) + new_work = get_workdir_from_session(session) renders = os.path.join(new_work, "renders") version_folder = _format_version_folder(renders) renders_version = os.path.join(renders, version_folder) diff --git a/openpype/hosts/fusion/utility_scripts/switch_ui.py b/openpype/hosts/fusion/utility_scripts/switch_ui.py index 854c2fd415..afb39f7041 100644 --- a/openpype/hosts/fusion/utility_scripts/switch_ui.py +++ b/openpype/hosts/fusion/utility_scripts/switch_ui.py @@ -124,7 +124,7 @@ class App(QtWidgets.QWidget): def _on_open_from_dir(self): - start_dir = self._get_context_directory() + start_dir = get_workdir_from_session() comp_file, _ = QtWidgets.QFileDialog.getOpenFileName( self, "Choose comp", start_dir) @@ -158,9 +158,6 @@ class App(QtWidgets.QWidget): import colorbleed.scripts.fusion_switch_shot as switch_shot switch_shot.switch(asset_name=asset, filepath=file_name, new=True) - def _get_context_directory(self): - return get_workdir_from_session(avalon.api.Session) - def collect_slap_comps(self, directory): items = glob.glob("{}/*.comp".format(directory)) return items diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 9f6a9f9cdc..0bfd3f6de0 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -644,7 +644,7 @@ def get_workdir( ) -def template_data_from_session(session): +def template_data_from_session(session=None): """ Return dictionary with template from session keys. Args: @@ -654,6 +654,7 @@ def template_data_from_session(session): dict: All available data from session. """ from avalon import io + import avalon.api if session is None: session = avalon.api.Session @@ -742,7 +743,11 @@ def compute_session_changes( return changes -def get_workdir_from_session(session, template_key=None): +def get_workdir_from_session(session=None, template_key=None): + import avalon.api + + if session is None: + session = avalon.api.Session project_name = session["AVALON_PROJECT"] host_name = session["AVALON_APP"] anatomy = Anatomy(project_name) diff --git a/openpype/scripts/fusion_switch_shot.py b/openpype/scripts/fusion_switch_shot.py index a8ac6812b5..6db8ff36a8 100644 --- a/openpype/scripts/fusion_switch_shot.py +++ b/openpype/scripts/fusion_switch_shot.py @@ -46,13 +46,6 @@ def _format_version_folder(folder): return version_folder -def _get_work_folder(session): - """Convenience function to get the work folder path of the current asset""" - - # Get new filename, create path based on asset and work template - return get_workdir_from_session(session) - - def _get_fusion_instance(): fusion = getattr(sys.modules["__main__"], "fusion", None) if fusion is None: @@ -71,7 +64,7 @@ def _format_filepath(session): asset = session["AVALON_ASSET"] # Save updated slap comp - work_path = _get_work_folder(session) + work_path = get_workdir_from_session(session) walk_to_dir = os.path.join(work_path, "scenes", "slapcomp") slapcomp_dir = os.path.abspath(walk_to_dir) @@ -102,7 +95,7 @@ def _update_savers(comp, session): None """ - new_work = _get_work_folder(session) + new_work = get_workdir_from_session(session) renders = os.path.join(new_work, "renders") version_folder = _format_version_folder(renders) renders_version = os.path.join(renders, version_folder) From 37cba59fb2172ba14e101c3eacbc028c2026f203 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 4 Mar 2022 11:46:35 +0100 Subject: [PATCH 322/483] nuke: settings adding default states --- .../projects_schema/schemas/schema_nuke_publish.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json index f53c53c2f8..4c94801796 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json @@ -233,7 +233,8 @@ { "type": "boolean", "key": "reformat_node_add", - "label": "Add Reformat Node" + "label": "Add Reformat Node", + "default": false }, { "type": "collapsible-wrap", @@ -298,6 +299,7 @@ { "type": "number", "key": "number", + "default": 1, "decimal": 4 } ] @@ -322,11 +324,13 @@ { "type": "number", "key": "x", + "default": 1, "decimal": 4 }, { "type": "number", "key": "y", + "default": 1, "decimal": 4 } ] From 882a17b04a164c833de3bdbca06a1af5505eaf78 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 4 Mar 2022 11:50:36 +0100 Subject: [PATCH 323/483] Move update all logic to from window to view --- openpype/tools/sceneinventory/view.py | 37 +++++++++++++++++++++++++ openpype/tools/sceneinventory/window.py | 37 +------------------------ 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py index 1ed3c9fcb6..ec48b10e47 100644 --- a/openpype/tools/sceneinventory/view.py +++ b/openpype/tools/sceneinventory/view.py @@ -796,3 +796,40 @@ class SceneInventoryView(QtWidgets.QTreeView): ).format(version_str) dialog.setText(msg) dialog.exec_() + + def update_all(self): + """Update all items that are currently 'outdated' in the view""" + # Get the source model through the proxy model + model = self.model().sourceModel() + + # Get all items from outdated groups + outdated_items = [] + for index in iter_model_rows(model, + column=0, + include_root=False): + item = index.data(model.ItemRole) + + if not item.get("isGroupNode"): + continue + + # Only the group nodes contain the "highest_version" data and as + # such we find only the groups and take its children. + if not model.outdated(item): + continue + + # Collect all children which we want to update + children = item.children() + outdated_items.extend(children) + + if not outdated_items: + log.info("Nothing to update.") + return + + # Trigger update to latest + for item in outdated_items: + try: + api.update(item, -1) + except AssertionError: + self._show_version_error_dialog(None, [item]) + log.warning("Update failed", exc_info=True) + self.data_changed.emit() diff --git a/openpype/tools/sceneinventory/window.py b/openpype/tools/sceneinventory/window.py index d9d34dbb08..b23c45c0f4 100644 --- a/openpype/tools/sceneinventory/window.py +++ b/openpype/tools/sceneinventory/window.py @@ -21,8 +21,6 @@ from .model import ( ) from .view import SceneInventoryView -from ..utils.lib import iter_model_rows - log = logging.getLogger(__name__) module = sys.modules[__name__] @@ -172,40 +170,7 @@ class SceneInventoryWindow(QtWidgets.QDialog): ) def _on_update_all(self): - """Update all items that are currently 'outdated' in the view""" - - # Get all items from outdated groups - outdated_items = [] - for index in iter_model_rows(self._model, - column=0, - include_root=False): - item = index.data(self._model.ItemRole) - - if not item.get("isGroupNode"): - continue - - # Only the group nodes contain the "highest_version" data and as - # such we find only the groups and take its children. - if not self._model.outdated(item): - continue - - # Collect all children which we want to update - children = item.children() - outdated_items.extend(children) - - if not outdated_items: - log.info("Nothing to update.") - return - - # Trigger update to latest - # Logic copied from SceneInventoryView._build_item_menu_for_selection - for item in outdated_items: - try: - api.update(item, -1) - except AssertionError: - self._show_version_error_dialog(None, [item]) - log.warning("Update failed", exc_info=True) - self._view.data_changed.emit() + self._view.update_all() def show(root=None, debug=False, parent=None, items=None): From b24ea2cecd2e7cb4d909d52310ce70b4f829dac4 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 4 Mar 2022 11:07:27 +0000 Subject: [PATCH 324/483] Fixed parameters for FBX export of the camera --- openpype/hosts/blender/plugins/publish/extract_camera.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/hosts/blender/plugins/publish/extract_camera.py b/openpype/hosts/blender/plugins/publish/extract_camera.py index 597dcecd21..b2c7611b58 100644 --- a/openpype/hosts/blender/plugins/publish/extract_camera.py +++ b/openpype/hosts/blender/plugins/publish/extract_camera.py @@ -50,6 +50,10 @@ class ExtractCamera(api.Extractor): filepath=filepath, use_active_collection=False, use_selection=True, + bake_anim_use_nla_strips=False, + bake_anim_use_all_actions=False, + add_leaf_bones=False, + armature_nodetype='ROOT', object_types={'CAMERA'}, bake_anim_simplify_factor=0.0 ) From f65b202ae34d58981df916ac6ca7897ba303b69a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Mar 2022 13:23:09 +0100 Subject: [PATCH 325/483] Added more log messages --- .../plugins/publish/collect_texture.py | 65 +++++++++++++------ 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py index e441218ca7..ea0b6cdf41 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py @@ -104,6 +104,9 @@ class CollectTextures(pyblish.api.ContextPlugin): self.input_naming_groups["workfile"], self.color_space ) + self.log.info("Parsed groups from workfile " + "name '{}': {}".format(repre_file, + formatting_data)) formatting_data.update(explicit_data) fill_pairs = prepare_template_data(formatting_data) @@ -155,19 +158,24 @@ class CollectTextures(pyblish.api.ContextPlugin): } resource_files[workfile_subset].append(item) - formatting_data = self._get_parsed_groups( - repre_file, - self.input_naming_patterns["textures"], - self.input_naming_groups["textures"], - self.color_space - ) - if ext in self.texture_extensions: + formatting_data = self._get_parsed_groups( + repre_file, + self.input_naming_patterns["textures"], + self.input_naming_groups["textures"], + self.color_space + ) + + self.log.info("Parsed groups from texture " + "name '{}': {}".format(repre_file, + formatting_data)) + c_space = self._get_color_space( repre_file, self.color_space ) + # optional value channel = self._get_channel_name( repre_file, self.input_naming_patterns["textures"], @@ -175,6 +183,7 @@ class CollectTextures(pyblish.api.ContextPlugin): self.color_space ) + # optional value shader = self._get_shader_name( repre_file, self.input_naming_patterns["textures"], @@ -260,6 +269,13 @@ class CollectTextures(pyblish.api.ContextPlugin): for asset_build, version, subset, family in asset_builds: if not main_version: main_version = version + + try: + version_int = int(version or main_version or 1) + except ValueError: + self.log.error("Parsed version {} is not " + "an number".format(version)) + new_instance = context.create_instance(subset) new_instance.data.update( { @@ -268,7 +284,7 @@ class CollectTextures(pyblish.api.ContextPlugin): "label": subset, "name": subset, "family": family, - "version": int(version or main_version or 1), + "version": version_int, "asset_build": asset_build # remove in validator } ) @@ -393,12 +409,15 @@ class CollectTextures(pyblish.api.ContextPlugin): Unknown format of channel name and color spaces >> cs are known list - 'color_space' used as a placeholder """ - found = self._parse_key(name, input_naming_patterns, - input_naming_groups, color_spaces, 'shader') - if found: - return found + found = None + try: + found = self._parse_key(name, input_naming_patterns, + input_naming_groups, color_spaces, + 'shader') + except ValueError: + self.log.warning("Didn't find shader in {}".format(name)) - self.log.warning("Didn't find shader in {}".format(name)) + return found def _get_channel_name(self, name, input_naming_patterns, input_naming_groups, color_spaces): @@ -407,12 +426,15 @@ class CollectTextures(pyblish.api.ContextPlugin): Unknown format of channel name and color spaces >> cs are known list - 'color_space' used as a placeholder """ - found = self._parse_key(name, input_naming_patterns, - input_naming_groups, color_spaces, 'channel') - if found: - return found + found = None + try: + found = self._parse_key(name, input_naming_patterns, + input_naming_groups, color_spaces, + 'channel') + except ValueError: + self.log.warning("Didn't find channel in {}".format(name)) - self.log.warning("Didn't find channel in {}".format(name)) + return found def _parse_key(self, name, input_naming_patterns, input_naming_groups, color_spaces, key): @@ -437,8 +459,8 @@ class CollectTextures(pyblish.api.ContextPlugin): try: parsed_value = parsed_groups[key] return parsed_value - except IndexError: - msg = ("input_naming_groups must " + + except (IndexError, KeyError): + msg = ("'Textures group positions' must " + "have '{}' key".format(key)) raise ValueError(msg) @@ -468,7 +490,8 @@ class CollectTextures(pyblish.api.ContextPlugin): self.log.warning("No of parsed groups doesn't match " "no of group labels") - return {} + raise ValueError("Name '{}' cannot be parsed by any " + "'{}' patterns".format(name, input_naming_patterns)) def _update_representations(self, upd_representations): """Frames dont have sense for textures, add collected udims instead.""" From 69b0012fd9427c554df753f7c2fdc43fc1c60bea Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 4 Mar 2022 13:46:32 +0100 Subject: [PATCH 326/483] nuke: subset filtering on baking presets --- .../publish/extract_review_data_mov.py | 26 ++++++++++++++++++- .../defaults/project_settings/nuke.json | 3 ++- .../schemas/schema_nuke_publish.json | 6 +++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index 5bbc88266a..1071834497 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -1,4 +1,5 @@ import os +import re import pyblish.api import openpype from openpype.hosts.nuke.api import plugin @@ -25,6 +26,7 @@ class ExtractReviewDataMov(openpype.api.Extractor): def process(self, instance): families = instance.data["families"] task_type = instance.context.data["taskType"] + subset = instance.data["subset"] self.log.info("Creating staging dir...") if "representations" not in instance.data: @@ -46,6 +48,7 @@ class ExtractReviewDataMov(openpype.api.Extractor): for o_name, o_data in self.outputs.items(): f_families = o_data["filter"]["families"] f_task_types = o_data["filter"]["task_types"] + f_subsets = o_data["filter"]["sebsets"] # test if family found in context test_families = any([ @@ -69,11 +72,25 @@ class ExtractReviewDataMov(openpype.api.Extractor): bool(not f_task_types) ]) + # test subsets from filter + test_subsets = any([ + # check if any of subset filter inputs + # converted to regex patern is not found in subset + # we keep strict case sensitivity + bool(next(( + s for s in f_subsets + if re.search(re.compile(s), subset) + ), None)), + # but if no subsets were set then make this acuntable too + bool(not f_subsets) + ]) + # we need all filters to be positive for this # preset to be activated test_all = all([ test_families, - test_task_types + test_task_types, + test_subsets ]) # if it is not positive then skip this preset @@ -120,6 +137,13 @@ class ExtractReviewDataMov(openpype.api.Extractor): if generated_repres: # assign to representations instance.data["representations"] += generated_repres + else: + instance.data["families"].remove("review") + self.log.info(( + "Removing `review` from families. " + "Not available baking profile." + )) + self.log.debug(instance.data["families"]) self.log.debug( "_ representations: {}".format( diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index e30296d0ad..6992fb6e3e 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -116,7 +116,8 @@ "baking": { "filter": { "task_types": [], - "families": [] + "families": [], + "sebsets": [] }, "extension": "mov", "viewer_process_override": "", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json index 4c94801796..1636a8d700 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json @@ -195,6 +195,12 @@ "label": "Families", "type": "list", "object_type": "text" + }, + { + "key": "sebsets", + "label": "Subsets", + "type": "list", + "object_type": "text" } ] }, From a0cd2870d6f3bd0b969f1e62b94a0fe5076eec9b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 4 Mar 2022 14:18:17 +0100 Subject: [PATCH 327/483] recreate removed classes and functions --- openpype/pipeline/__init__.py | 2 + openpype/pipeline/publish/__init__.py | 12 +++-- openpype/pipeline/publish/lib.py | 56 ++++++++++++++++++++ openpype/pipeline/publish/publish_plugins.py | 26 ++++++++- 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index e968df4011..79d6ce4d54 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -9,6 +9,7 @@ from .create import ( from .publish import ( PublishValidationError, + PublishXmlValidationError, KnownPublishError, OpenPypePyblishPluginMixin ) @@ -23,6 +24,7 @@ __all__ = ( "CreatedInstance", "PublishValidationError", + "PublishXmlValidationError", "KnownPublishError", "OpenPypePyblishPluginMixin" ) diff --git a/openpype/pipeline/publish/__init__.py b/openpype/pipeline/publish/__init__.py index ca958816fe..c2729a46ce 100644 --- a/openpype/pipeline/publish/__init__.py +++ b/openpype/pipeline/publish/__init__.py @@ -1,20 +1,26 @@ from .publish_plugins import ( PublishValidationError, + PublishXmlValidationError, KnownPublishError, - OpenPypePyblishPluginMixin + OpenPypePyblishPluginMixin, ) from .lib import ( DiscoverResult, - publish_plugins_discover + publish_plugins_discover, + load_help_content_from_plugin, + load_help_content_from_filepath, ) __all__ = ( "PublishValidationError", + "PublishXmlValidationError", "KnownPublishError", "OpenPypePyblishPluginMixin", "DiscoverResult", - "publish_plugins_discover" + "publish_plugins_discover", + "load_help_content_from_plugin", + "load_help_content_from_filepath", ) diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index d3e4ec8a02..739b2c8806 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -1,6 +1,8 @@ import os import sys import types +import inspect +import xml.etree.ElementTree import six import pyblish.plugin @@ -28,6 +30,60 @@ class DiscoverResult: self.plugins[item] = value +class HelpContent: + def __init__(self, title, description, detail=None): + self.title = title + self.description = description + self.detail = detail + + +def load_help_content_from_filepath(filepath): + """Load help content from xml file. + Xml file may containt errors and warnings. + """ + errors = {} + warnings = {} + output = { + "errors": errors, + "warnings": warnings + } + if not os.path.exists(filepath): + return output + tree = xml.etree.ElementTree.parse(filepath) + root = tree.getroot() + for child in root: + child_id = child.attrib.get("id") + if child_id is None: + continue + + # Make sure ID is string + child_id = str(child_id) + + title = child.find("title").text + description = child.find("description").text + detail_node = child.find("detail") + detail = None + if detail_node is not None: + detail = detail_node.text + if child.tag == "error": + errors[child_id] = HelpContent(title, description, detail) + elif child.tag == "warning": + warnings[child_id] = HelpContent(title, description, detail) + return output + + +def load_help_content_from_plugin(plugin): + cls = plugin + if not inspect.isclass(plugin): + cls = plugin.__class__ + plugin_filepath = inspect.getfile(cls) + plugin_dir = os.path.dirname(plugin_filepath) + basename = os.path.splitext(os.path.basename(plugin_filepath))[0] + filename = basename + ".xml" + filepath = os.path.join(plugin_dir, "help", filename) + return load_help_content_from_filepath(filepath) + + def publish_plugins_discover(paths=None): """Find and return available pyblish plug-ins diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py index b60b9f43a7..bce64ec709 100644 --- a/openpype/pipeline/publish/publish_plugins.py +++ b/openpype/pipeline/publish/publish_plugins.py @@ -1,3 +1,6 @@ +from .lib import load_help_content_from_plugin + + class PublishValidationError(Exception): """Validation error happened during publishing. @@ -12,13 +15,34 @@ class PublishValidationError(Exception): description(str): Detailed description of an error. It is possible to use Markdown syntax. """ - def __init__(self, message, title=None, description=None): + def __init__(self, message, title=None, description=None, detail=None): self.message = message self.title = title or "< Missing title >" self.description = description or message + self.detail = detail super(PublishValidationError, self).__init__(message) +class PublishXmlValidationError(PublishValidationError): + def __init__( + self, plugin, message, key=None, formatting_data=None + ): + if key is None: + key = "main" + + if not formatting_data: + formatting_data = {} + result = load_help_content_from_plugin(plugin) + content_obj = result["errors"][key] + description = content_obj.description.format(**formatting_data) + detail = content_obj.detail + if detail: + detail = detail.format(**formatting_data) + super(PublishXmlValidationError, self).__init__( + message, content_obj.title, description, detail + ) + + class KnownPublishError(Exception): """Publishing crashed because of known error. From bb01f66ba708f810ec11fcd85fbc2a550f53ed57 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 4 Mar 2022 14:28:38 +0100 Subject: [PATCH 328/483] hound fixes --- .../hosts/houdini/plugins/publish/validate_vdb_output_node.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py index f6e54f3ae2..f672e78b5f 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py @@ -51,7 +51,6 @@ class ValidateVDBOutputNode(pyblish.api.InstancePlugin): key="wrongSOP", formatting_data=data ) - return [node.path()] invalid = self.get_invalid(instance) @@ -71,7 +70,8 @@ class ValidateVDBOutputNode(pyblish.api.InstancePlugin): frame = instance.data.get("frameStart", 0) geometry = output_node.geometryAtFrame(frame) if geometry is None: - # No geometry data on this output_node, maybe the node hasn't cooked? + # No geometry data on this output_node + # - maybe the node hasn't cooked? cls.log.debug( "SOP node has no geometry data. " "Is it cooked? %s" % output_node.path() From 0260e4f269e4b58932df08622bb1d3ac2c90e0a8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 4 Mar 2022 14:30:47 +0100 Subject: [PATCH 329/483] add 2 spaces --- .../hosts/houdini/plugins/publish/validate_vdb_output_node.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py index f672e78b5f..0345f27d72 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py @@ -2,6 +2,8 @@ import pyblish.api import openpype.api from openpype.pipeline import PublishXmlValidationError import hou + + class ValidateVDBOutputNode(pyblish.api.InstancePlugin): """Validate that the node connected to the output node is of type VDB. From 10fb4f68b9fae26820b4033cdeed73639eee70bc Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 4 Mar 2022 14:59:29 +0100 Subject: [PATCH 330/483] Remove log Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/tools/sceneinventory/window.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/tools/sceneinventory/window.py b/openpype/tools/sceneinventory/window.py index b23c45c0f4..7dee32e90b 100644 --- a/openpype/tools/sceneinventory/window.py +++ b/openpype/tools/sceneinventory/window.py @@ -21,7 +21,6 @@ from .model import ( ) from .view import SceneInventoryView -log = logging.getLogger(__name__) module = sys.modules[__name__] module.window = None From e27896f4bbae2d9bcf02abdea39b534e1ebe6ef2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 4 Mar 2022 15:00:17 +0100 Subject: [PATCH 331/483] Remove unused import --- openpype/tools/sceneinventory/window.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/tools/sceneinventory/window.py b/openpype/tools/sceneinventory/window.py index 7dee32e90b..095d30cac0 100644 --- a/openpype/tools/sceneinventory/window.py +++ b/openpype/tools/sceneinventory/window.py @@ -1,6 +1,5 @@ import os import sys -import logging from Qt import QtWidgets, QtCore from avalon.vendor import qtawesome From 0cce15d7450769941167e15c99e594d7267a840d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 4 Mar 2022 15:19:17 +0100 Subject: [PATCH 332/483] Removed submodule repos/avalon-unreal-integration --- repos/avalon-unreal-integration | 1 - 1 file changed, 1 deletion(-) delete mode 160000 repos/avalon-unreal-integration diff --git a/repos/avalon-unreal-integration b/repos/avalon-unreal-integration deleted file mode 160000 index 43f6ea9439..0000000000 --- a/repos/avalon-unreal-integration +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 43f6ea943980b29c02a170942b566ae11f2b7080 From 1e0883cd0f1f63027c3ce4986c7be0bdb3e13534 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Fri, 4 Mar 2022 15:26:30 +0100 Subject: [PATCH 333/483] Revert "Merge pull request #2438 from pypeclub/feature/validations_exceptions_houdini" This reverts commit f1693e20d710abeaa2007710a8d59b2d576a3c22. --- .../help/validate_abc_primitive_to_detail.xml | 15 ---- .../help/validate_alembic_face_sets.xml | 22 ------ .../help/validate_alembic_input_node.xml | 21 ----- .../publish/help/validate_frame_token.xml | 31 -------- .../publish/help/validate_vdb_output_node.xml | 48 ------------ .../plugins/publish/valiate_vdb_input_node.py | 47 +++++++++++ .../publish/validate_animation_settings.py | 51 ++++++++++++ .../plugins/publish/validate_frame_token.py | 17 ++-- .../plugins/publish/validate_output_node.py | 77 +++++++++++++++++++ .../publish/validate_sop_output_node.py | 2 +- .../publish/validate_vdb_input_node.py | 47 +++++++++++ .../publish/validate_vdb_output_node.py | 64 ++++----------- .../publish/validate_context_with_error.py | 1 - 13 files changed, 246 insertions(+), 197 deletions(-) delete mode 100644 openpype/hosts/houdini/plugins/publish/help/validate_abc_primitive_to_detail.xml delete mode 100644 openpype/hosts/houdini/plugins/publish/help/validate_alembic_face_sets.xml delete mode 100644 openpype/hosts/houdini/plugins/publish/help/validate_alembic_input_node.xml delete mode 100644 openpype/hosts/houdini/plugins/publish/help/validate_frame_token.xml delete mode 100644 openpype/hosts/houdini/plugins/publish/help/validate_vdb_output_node.xml create mode 100644 openpype/hosts/houdini/plugins/publish/valiate_vdb_input_node.py create mode 100644 openpype/hosts/houdini/plugins/publish/validate_animation_settings.py create mode 100644 openpype/hosts/houdini/plugins/publish/validate_output_node.py create mode 100644 openpype/hosts/houdini/plugins/publish/validate_vdb_input_node.py diff --git a/openpype/hosts/houdini/plugins/publish/help/validate_abc_primitive_to_detail.xml b/openpype/hosts/houdini/plugins/publish/help/validate_abc_primitive_to_detail.xml deleted file mode 100644 index 0e2aa6c1f4..0000000000 --- a/openpype/hosts/houdini/plugins/publish/help/validate_abc_primitive_to_detail.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - -Primitive to Detail -## Invalid Primitive to Detail Attributes - -Primitives with inconsistent primitive to detail attributes were found. - -{message} - - - - - - \ No newline at end of file diff --git a/openpype/hosts/houdini/plugins/publish/help/validate_alembic_face_sets.xml b/openpype/hosts/houdini/plugins/publish/help/validate_alembic_face_sets.xml deleted file mode 100644 index 7bc149d7c3..0000000000 --- a/openpype/hosts/houdini/plugins/publish/help/validate_alembic_face_sets.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - -Alembic ROP Face Sets -## Invalid Alembic ROP Face Sets - -When groups are saved as Face Sets with the Alembic these show up -as shadingEngine connections in Maya - however, with animated groups -these connections in Maya won't work as expected, it won't update per -frame. Additionally, it can break shader assignments in some cases -where it requires to first break this connection to allow a shader to -be assigned. - -It is allowed to include Face Sets, so only an issue is logged to -identify that it could introduce issues down the pipeline. - - - - - - - \ No newline at end of file diff --git a/openpype/hosts/houdini/plugins/publish/help/validate_alembic_input_node.xml b/openpype/hosts/houdini/plugins/publish/help/validate_alembic_input_node.xml deleted file mode 100644 index 5be722ccb2..0000000000 --- a/openpype/hosts/houdini/plugins/publish/help/validate_alembic_input_node.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - -Alembic input -## Invalid Alembic input - -The node connected to the output is incorrect. -It contains primitive types that are not supported for alembic output. - -Problematic primitive is of type {primitive_type} - - - - - -The connected node cannot be of the following types for Alembic: - - VDB - - Volume - - - \ No newline at end of file diff --git a/openpype/hosts/houdini/plugins/publish/help/validate_frame_token.xml b/openpype/hosts/houdini/plugins/publish/help/validate_frame_token.xml deleted file mode 100644 index 925113362a..0000000000 --- a/openpype/hosts/houdini/plugins/publish/help/validate_frame_token.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - -Output frame token -## Output path is missing frame token - -This validator will check the output parameter of the node if -the Valid Frame Range is not set to 'Render Current Frame' - -No frame token found in: **{nodepath}** - -### How to repair? - -You need to add `$F4` or similar frame based token to your path. - -**Example:** - Good: 'my_vbd_cache.$F4.vdb' - Bad: 'my_vbd_cache.vdb' - - - - -If you render out a frame range it is mandatory to have the -frame token - '$F4' or similar - to ensure that each frame gets -written. If this is not the case you will override the same file -every time a frame is written out. - - - - - \ No newline at end of file diff --git a/openpype/hosts/houdini/plugins/publish/help/validate_vdb_output_node.xml b/openpype/hosts/houdini/plugins/publish/help/validate_vdb_output_node.xml deleted file mode 100644 index 822d1836c1..0000000000 --- a/openpype/hosts/houdini/plugins/publish/help/validate_vdb_output_node.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - -VDB output node -## Invalid VDB output nodes - -Validate that the node connected to the output node is of type VDB. - -Regardless of the amount of VDBs created the output will need to have an -equal amount of VDBs, points, primitives and vertices - -A VDB is an inherited type of Prim, holds the following data: - -- Primitives: 1 -- Points: 1 -- Vertices: 1 -- VDBs: 1 - - - - - - - -No SOP path -## No SOP Path in output node - -SOP Output node in '{node}' does not exist. Ensure a valid SOP output path is set. - - - - - - - -Wrong SOP path -## Wrong SOP Path in output node - -Output node {nodepath} is not a SOP node. -SOP Path must point to a SOP node, -instead found category type: {categoryname} - - - - - - - \ No newline at end of file diff --git a/openpype/hosts/houdini/plugins/publish/valiate_vdb_input_node.py b/openpype/hosts/houdini/plugins/publish/valiate_vdb_input_node.py new file mode 100644 index 0000000000..0ae1bc94eb --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/valiate_vdb_input_node.py @@ -0,0 +1,47 @@ +import pyblish.api +import openpype.api + + +class ValidateVDBInputNode(pyblish.api.InstancePlugin): + """Validate that the node connected to the output node is of type VDB. + + Regardless of the amount of VDBs create the output will need to have an + equal amount of VDBs, points, primitives and vertices + + A VDB is an inherited type of Prim, holds the following data: + - Primitives: 1 + - Points: 1 + - Vertices: 1 + - VDBs: 1 + + """ + + order = openpype.api.ValidateContentsOrder + 0.1 + families = ["vdbcache"] + hosts = ["houdini"] + label = "Validate Input Node (VDB)" + + def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + raise RuntimeError( + "Node connected to the output node is not" "of type VDB!" + ) + + @classmethod + def get_invalid(cls, instance): + + node = instance.data["output_node"] + + prims = node.geometry().prims() + nr_of_prims = len(prims) + + nr_of_points = len(node.geometry().points()) + if nr_of_points != nr_of_prims: + cls.log.error("The number of primitives and points do not match") + return [instance] + + for prim in prims: + if prim.numVertices() != 1: + cls.log.error("Found primitive with more than 1 vertex!") + return [instance] diff --git a/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py b/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py new file mode 100644 index 0000000000..5eb8f93d03 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py @@ -0,0 +1,51 @@ +import pyblish.api + +from openpype.hosts.houdini.api import lib + + +class ValidateAnimationSettings(pyblish.api.InstancePlugin): + """Validate if the unexpanded string contains the frame ('$F') token + + This validator will only check the output parameter of the node if + the Valid Frame Range is not set to 'Render Current Frame' + + Rules: + If you render out a frame range it is mandatory to have the + frame token - '$F4' or similar - to ensure that each frame gets + written. If this is not the case you will override the same file + every time a frame is written out. + + Examples: + Good: 'my_vbd_cache.$F4.vdb' + Bad: 'my_vbd_cache.vdb' + + """ + + order = pyblish.api.ValidatorOrder + label = "Validate Frame Settings" + families = ["vdbcache"] + + def process(self, instance): + + invalid = self.get_invalid(instance) + if invalid: + raise RuntimeError( + "Output settings do no match for '%s'" % instance + ) + + @classmethod + def get_invalid(cls, instance): + + node = instance[0] + + # Check trange parm, 0 means Render Current Frame + frame_range = node.evalParm("trange") + if frame_range == 0: + return [] + + output_parm = lib.get_output_parameter(node) + unexpanded_str = output_parm.unexpandedString() + + if "$F" not in unexpanded_str: + cls.log.error("No frame token found in '%s'" % node.path()) + return [instance] diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_token.py b/openpype/hosts/houdini/plugins/publish/validate_frame_token.py index f66238f159..76b5910576 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_token.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_token.py @@ -1,12 +1,12 @@ import pyblish.api from openpype.hosts.houdini.api import lib -from openpype.pipeline import PublishXmlValidationError + class ValidateFrameToken(pyblish.api.InstancePlugin): - """Validate if the unexpanded string contains the frame ('$F') token + """Validate if the unexpanded string contains the frame ('$F') token. - This validator will only check the output parameter of the node if + This validator will *only* check the output parameter of the node if the Valid Frame Range is not set to 'Render Current Frame' Rules: @@ -28,14 +28,9 @@ class ValidateFrameToken(pyblish.api.InstancePlugin): def process(self, instance): invalid = self.get_invalid(instance) - data = { - "nodepath": instance - } if invalid: - raise PublishXmlValidationError( - self, - "Output path for '%s' is missing $F4 token" % instance, - formatting_data=data + raise RuntimeError( + "Output settings do no match for '%s'" % instance ) @classmethod @@ -52,5 +47,5 @@ class ValidateFrameToken(pyblish.api.InstancePlugin): unexpanded_str = output_parm.unexpandedString() if "$F" not in unexpanded_str: - # cls.log.info("No frame token found in '%s'" % node.path()) + cls.log.error("No frame token found in '%s'" % node.path()) return [instance] diff --git a/openpype/hosts/houdini/plugins/publish/validate_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_output_node.py new file mode 100644 index 0000000000..0b60ab5c48 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_output_node.py @@ -0,0 +1,77 @@ +import pyblish.api + + +class ValidateOutputNode(pyblish.api.InstancePlugin): + """Validate the instance SOP Output Node. + + This will ensure: + - The SOP Path is set. + - The SOP Path refers to an existing object. + - The SOP Path node is a SOP node. + - The SOP Path node has at least one input connection (has an input) + - The SOP Path has geometry data. + + """ + + order = pyblish.api.ValidatorOrder + families = ["pointcache", "vdbcache"] + hosts = ["houdini"] + label = "Validate Output Node" + + def process(self, instance): + + invalid = self.get_invalid(instance) + if invalid: + raise RuntimeError( + "Output node(s) `%s` are incorrect. " + "See plug-in log for details." % invalid + ) + + @classmethod + def get_invalid(cls, instance): + + import hou + + output_node = instance.data["output_node"] + + if output_node is None: + node = instance[0] + cls.log.error( + "SOP Output node in '%s' does not exist. " + "Ensure a valid SOP output path is set." % node.path() + ) + + return [node.path()] + + # Output node must be a Sop node. + if not isinstance(output_node, hou.SopNode): + cls.log.error( + "Output node %s is not a SOP node. " + "SOP Path must point to a SOP node, " + "instead found category type: %s" + % (output_node.path(), output_node.type().category().name()) + ) + return [output_node.path()] + + # For the sake of completeness also assert the category type + # is Sop to avoid potential edge case scenarios even though + # the isinstance check above should be stricter than this category + assert output_node.type().category().name() == "Sop", ( + "Output node %s is not of category Sop. This is a bug.." + % output_node.path() + ) + + # Check if output node has incoming connections + if not output_node.inputConnections(): + cls.log.error( + "Output node `%s` has no incoming connections" + % output_node.path() + ) + return [output_node.path()] + + # Ensure the output node has at least Geometry data + if not output_node.geometry(): + cls.log.error( + "Output node `%s` has no geometry data." % output_node.path() + ) + return [output_node.path()] diff --git a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py index a37d376919..a5a07b1b1a 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py @@ -14,7 +14,7 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin): """ order = pyblish.api.ValidatorOrder - families = ["pointcache"] + families = ["pointcache", "vdbcache"] hosts = ["houdini"] label = "Validate Output Node" diff --git a/openpype/hosts/houdini/plugins/publish/validate_vdb_input_node.py b/openpype/hosts/houdini/plugins/publish/validate_vdb_input_node.py new file mode 100644 index 0000000000..0ae1bc94eb --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_vdb_input_node.py @@ -0,0 +1,47 @@ +import pyblish.api +import openpype.api + + +class ValidateVDBInputNode(pyblish.api.InstancePlugin): + """Validate that the node connected to the output node is of type VDB. + + Regardless of the amount of VDBs create the output will need to have an + equal amount of VDBs, points, primitives and vertices + + A VDB is an inherited type of Prim, holds the following data: + - Primitives: 1 + - Points: 1 + - Vertices: 1 + - VDBs: 1 + + """ + + order = openpype.api.ValidateContentsOrder + 0.1 + families = ["vdbcache"] + hosts = ["houdini"] + label = "Validate Input Node (VDB)" + + def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + raise RuntimeError( + "Node connected to the output node is not" "of type VDB!" + ) + + @classmethod + def get_invalid(cls, instance): + + node = instance.data["output_node"] + + prims = node.geometry().prims() + nr_of_prims = len(prims) + + nr_of_points = len(node.geometry().points()) + if nr_of_points != nr_of_prims: + cls.log.error("The number of primitives and points do not match") + return [instance] + + for prim in prims: + if prim.numVertices() != 1: + cls.log.error("Found primitive with more than 1 vertex!") + return [instance] diff --git a/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py index 0345f27d72..1ba840b71d 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py @@ -1,6 +1,5 @@ import pyblish.api import openpype.api -from openpype.pipeline import PublishXmlValidationError import hou @@ -24,61 +23,32 @@ class ValidateVDBOutputNode(pyblish.api.InstancePlugin): label = "Validate Output Node (VDB)" def process(self, instance): - - data = { - "node": instance - } - - output_node = instance.data["output_node"] - if output_node is None: - raise PublishXmlValidationError( - self, - "SOP Output node in '{node}' does not exist. Ensure a valid " - "SOP output path is set.".format(**data), - key="noSOP", - formatting_data=data - ) - - # Output node must be a Sop node. - if not isinstance(output_node, hou.SopNode): - data = { - "nodepath": output_node.path(), - "categoryname": output_node.type().category().name() - } - raise PublishXmlValidationError( - self, - "Output node {nodepath} is not a SOP node. SOP Path must" - "point to a SOP node, instead found category" - "type: {categoryname}".format(**data), - key="wrongSOP", - formatting_data=data - ) - invalid = self.get_invalid(instance) - if invalid: - raise PublishXmlValidationError( - self, - "Output node(s) `{}` are incorrect. See plug-in" - "log for details.".format(invalid), - formatting_data=data + raise RuntimeError( + "Node connected to the output node is not" " of type VDB!" ) @classmethod def get_invalid(cls, instance): - output_node = instance.data["output_node"] + node = instance.data["output_node"] + if node is None: + cls.log.error( + "SOP path is not correctly set on " + "ROP node '%s'." % instance[0].path() + ) + return [instance] frame = instance.data.get("frameStart", 0) - geometry = output_node.geometryAtFrame(frame) + geometry = node.geometryAtFrame(frame) if geometry is None: - # No geometry data on this output_node - # - maybe the node hasn't cooked? - cls.log.debug( + # No geometry data on this node, maybe the node hasn't cooked? + cls.log.error( "SOP node has no geometry data. " - "Is it cooked? %s" % output_node.path() + "Is it cooked? %s" % node.path() ) - return [output_node] + return [node] prims = geometry.prims() nr_of_prims = len(prims) @@ -87,17 +57,17 @@ class ValidateVDBOutputNode(pyblish.api.InstancePlugin): invalid_prim = False for prim in prims: if not isinstance(prim, hou.VDB): - cls.log.debug("Found non-VDB primitive: %s" % prim) + cls.log.error("Found non-VDB primitive: %s" % prim) invalid_prim = True if invalid_prim: return [instance] nr_of_points = len(geometry.points()) if nr_of_points != nr_of_prims: - cls.log.debug("The number of primitives and points do not match") + cls.log.error("The number of primitives and points do not match") return [instance] for prim in prims: if prim.numVertices() != 1: - cls.log.debug("Found primitive with more than 1 vertex!") + cls.log.error("Found primitive with more than 1 vertex!") return [instance] diff --git a/openpype/hosts/testhost/plugins/publish/validate_context_with_error.py b/openpype/hosts/testhost/plugins/publish/validate_context_with_error.py index 20fb47513e..46e996a569 100644 --- a/openpype/hosts/testhost/plugins/publish/validate_context_with_error.py +++ b/openpype/hosts/testhost/plugins/publish/validate_context_with_error.py @@ -2,7 +2,6 @@ import pyblish.api from openpype.pipeline import PublishValidationError - class ValidateInstanceAssetRepair(pyblish.api.Action): """Repair the instance asset.""" From 4dd520bea922ea540da1aa5ea42005665ae775ee Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Fri, 4 Mar 2022 15:28:19 +0100 Subject: [PATCH 334/483] remove extra validator --- .../plugins/publish/validate_output_node.py | 77 ------------------- 1 file changed, 77 deletions(-) delete mode 100644 openpype/hosts/houdini/plugins/publish/validate_output_node.py diff --git a/openpype/hosts/houdini/plugins/publish/validate_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_output_node.py deleted file mode 100644 index 0b60ab5c48..0000000000 --- a/openpype/hosts/houdini/plugins/publish/validate_output_node.py +++ /dev/null @@ -1,77 +0,0 @@ -import pyblish.api - - -class ValidateOutputNode(pyblish.api.InstancePlugin): - """Validate the instance SOP Output Node. - - This will ensure: - - The SOP Path is set. - - The SOP Path refers to an existing object. - - The SOP Path node is a SOP node. - - The SOP Path node has at least one input connection (has an input) - - The SOP Path has geometry data. - - """ - - order = pyblish.api.ValidatorOrder - families = ["pointcache", "vdbcache"] - hosts = ["houdini"] - label = "Validate Output Node" - - def process(self, instance): - - invalid = self.get_invalid(instance) - if invalid: - raise RuntimeError( - "Output node(s) `%s` are incorrect. " - "See plug-in log for details." % invalid - ) - - @classmethod - def get_invalid(cls, instance): - - import hou - - output_node = instance.data["output_node"] - - if output_node is None: - node = instance[0] - cls.log.error( - "SOP Output node in '%s' does not exist. " - "Ensure a valid SOP output path is set." % node.path() - ) - - return [node.path()] - - # Output node must be a Sop node. - if not isinstance(output_node, hou.SopNode): - cls.log.error( - "Output node %s is not a SOP node. " - "SOP Path must point to a SOP node, " - "instead found category type: %s" - % (output_node.path(), output_node.type().category().name()) - ) - return [output_node.path()] - - # For the sake of completeness also assert the category type - # is Sop to avoid potential edge case scenarios even though - # the isinstance check above should be stricter than this category - assert output_node.type().category().name() == "Sop", ( - "Output node %s is not of category Sop. This is a bug.." - % output_node.path() - ) - - # Check if output node has incoming connections - if not output_node.inputConnections(): - cls.log.error( - "Output node `%s` has no incoming connections" - % output_node.path() - ) - return [output_node.path()] - - # Ensure the output node has at least Geometry data - if not output_node.geometry(): - cls.log.error( - "Output node `%s` has no geometry data." % output_node.path() - ) - return [output_node.path()] From ee53add80fb8af7147508c1864f7fab7aae232db Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 4 Mar 2022 17:13:03 +0100 Subject: [PATCH 335/483] don't set version if is not available --- openpype/pipeline/create/context.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index e11d32091f..706279fd72 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -399,15 +399,6 @@ class CreatedInstance: self._data["active"] = data.get("active", True) self._data["creator_identifier"] = creator.identifier - # QUESTION handle version of instance here or in creator? - version = None - if not new: - version = data.get("version") - - if version is None: - version = 1 - self._data["version"] = version - # Pop from source data all keys that are defined in `_data` before # this moment and through their values away # - they should be the same and if are not then should not change From 93956497fcdac75e83162700b705dcfcc30e9854 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 4 Mar 2022 17:55:43 +0100 Subject: [PATCH 336/483] removed deprecated and unused assetcreator tool --- openpype/tools/assetcreator/__init__.py | 10 - openpype/tools/assetcreator/__main__.py | 5 - openpype/tools/assetcreator/app.py | 654 ------------------------ openpype/tools/assetcreator/model.py | 310 ----------- openpype/tools/assetcreator/widget.py | 448 ---------------- 5 files changed, 1427 deletions(-) delete mode 100644 openpype/tools/assetcreator/__init__.py delete mode 100644 openpype/tools/assetcreator/__main__.py delete mode 100644 openpype/tools/assetcreator/app.py delete mode 100644 openpype/tools/assetcreator/model.py delete mode 100644 openpype/tools/assetcreator/widget.py diff --git a/openpype/tools/assetcreator/__init__.py b/openpype/tools/assetcreator/__init__.py deleted file mode 100644 index 3b88ebe984..0000000000 --- a/openpype/tools/assetcreator/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ - -from .app import ( - show, - cli -) - -__all__ = [ - "show", - "cli", -] diff --git a/openpype/tools/assetcreator/__main__.py b/openpype/tools/assetcreator/__main__.py deleted file mode 100644 index d77bc585c5..0000000000 --- a/openpype/tools/assetcreator/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -from . import cli - -if __name__ == '__main__': - import sys - sys.exit(cli(sys.argv[1:])) diff --git a/openpype/tools/assetcreator/app.py b/openpype/tools/assetcreator/app.py deleted file mode 100644 index 60ef31e859..0000000000 --- a/openpype/tools/assetcreator/app.py +++ /dev/null @@ -1,654 +0,0 @@ -import os -import sys -from subprocess import Popen - -import ftrack_api -from Qt import QtWidgets, QtCore -from openpype import style -from openpype.api import get_current_project_settings -from openpype.lib.avalon_context import update_current_task -from openpype.tools.utils.lib import qt_app_context -from avalon import io, api, schema -from . import widget, model - -module = sys.modules[__name__] -module.window = None - - -class Window(QtWidgets.QDialog): - """Asset creator interface - - """ - - def __init__(self, parent=None, context=None): - super(Window, self).__init__(parent) - self.context = context - project_name = io.active_project() - self.setWindowTitle("Asset creator ({0})".format(project_name)) - self.setFocusPolicy(QtCore.Qt.StrongFocus) - self.setAttribute(QtCore.Qt.WA_DeleteOnClose) - - # Validators - self.valid_parent = False - - self.session = None - - # assets widget - assets_widget = QtWidgets.QWidget() - assets_widget.setContentsMargins(0, 0, 0, 0) - assets_layout = QtWidgets.QVBoxLayout(assets_widget) - assets = widget.AssetWidget() - assets.view.setSelectionMode(assets.view.ExtendedSelection) - assets_layout.addWidget(assets) - - # Outlink - label_outlink = QtWidgets.QLabel("Outlink:") - input_outlink = QtWidgets.QLineEdit() - input_outlink.setReadOnly(True) - input_outlink.setStyleSheet("background-color: #333333;") - checkbox_outlink = QtWidgets.QCheckBox("Use outlink") - # Parent - label_parent = QtWidgets.QLabel("*Parent:") - input_parent = QtWidgets.QLineEdit() - input_parent.setReadOnly(True) - input_parent.setStyleSheet("background-color: #333333;") - - # Name - label_name = QtWidgets.QLabel("*Name:") - input_name = QtWidgets.QLineEdit() - input_name.setPlaceholderText("") - - # Asset Build - label_assetbuild = QtWidgets.QLabel("Asset Build:") - combo_assetbuilt = QtWidgets.QComboBox() - - # Task template - label_task_template = QtWidgets.QLabel("Task template:") - combo_task_template = QtWidgets.QComboBox() - - # Info widget - info_widget = QtWidgets.QWidget() - info_widget.setContentsMargins(10, 10, 10, 10) - info_layout = QtWidgets.QVBoxLayout(info_widget) - - # Inputs widget - inputs_widget = QtWidgets.QWidget() - inputs_widget.setContentsMargins(0, 0, 0, 0) - - inputs_layout = QtWidgets.QFormLayout(inputs_widget) - inputs_layout.addRow(label_outlink, input_outlink) - inputs_layout.addRow(None, checkbox_outlink) - inputs_layout.addRow(label_parent, input_parent) - inputs_layout.addRow(label_name, input_name) - inputs_layout.addRow(label_assetbuild, combo_assetbuilt) - inputs_layout.addRow(label_task_template, combo_task_template) - - # Add button - btns_widget = QtWidgets.QWidget() - btns_widget.setContentsMargins(0, 0, 0, 0) - btn_layout = QtWidgets.QHBoxLayout(btns_widget) - btn_create_asset = QtWidgets.QPushButton("Create asset") - btn_create_asset.setToolTip( - "Creates all necessary components for asset" - ) - checkbox_app = None - if self.context is not None: - checkbox_app = QtWidgets.QCheckBox("Open {}".format( - self.context.capitalize()) - ) - btn_layout.addWidget(checkbox_app) - btn_layout.addWidget(btn_create_asset) - - task_view = QtWidgets.QTreeView() - task_view.setIndentation(0) - task_model = model.TasksModel() - task_view.setModel(task_model) - - info_layout.addWidget(inputs_widget) - info_layout.addWidget(task_view) - info_layout.addWidget(btns_widget) - - # Body - body = QtWidgets.QSplitter() - body.setContentsMargins(0, 0, 0, 0) - body.setSizePolicy(QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.Expanding) - body.setOrientation(QtCore.Qt.Horizontal) - body.addWidget(assets_widget) - body.addWidget(info_widget) - body.setStretchFactor(0, 100) - body.setStretchFactor(1, 150) - - # statusbar - message = QtWidgets.QLabel() - message.setFixedHeight(20) - - statusbar = QtWidgets.QWidget() - layout = QtWidgets.QHBoxLayout(statusbar) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(message) - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(body) - layout.addWidget(statusbar) - - self.data = { - "label": { - "message": message, - }, - "view": { - "tasks": task_view - }, - "model": { - "assets": assets, - "tasks": task_model - }, - "inputs": { - "outlink": input_outlink, - "outlink_cb": checkbox_outlink, - "parent": input_parent, - "name": input_name, - "assetbuild": combo_assetbuilt, - "tasktemplate": combo_task_template, - "open_app": checkbox_app - }, - "buttons": { - "create_asset": btn_create_asset - } - } - - # signals - btn_create_asset.clicked.connect(self.create_asset) - assets.selection_changed.connect(self.on_asset_changed) - input_name.textChanged.connect(self.on_asset_name_change) - checkbox_outlink.toggled.connect(self.on_outlink_checkbox_change) - combo_task_template.currentTextChanged.connect( - self.on_task_template_changed - ) - if self.context is not None: - checkbox_app.toggled.connect(self.on_app_checkbox_change) - # on start - self.on_start() - - self.resize(600, 500) - - self.echo("Connected to project: {0}".format(project_name)) - - def open_app(self): - if self.context == 'maya': - Popen("maya") - else: - message = QtWidgets.QMessageBox(self) - message.setWindowTitle("App is not set") - message.setIcon(QtWidgets.QMessageBox.Critical) - message.show() - - def on_start(self): - project_name = io.Session['AVALON_PROJECT'] - project_query = 'Project where full_name is "{}"'.format(project_name) - if self.session is None: - session = ftrack_api.Session() - self.session = session - else: - session = self.session - ft_project = session.query(project_query).one() - schema_name = ft_project['project_schema']['name'] - # Load config - schemas_items = get_current_project_settings().get('ftrack', {}).get( - 'project_schemas', {} - ) - # Get info if it is silo project - self.silos = io.distinct("silo") - if self.silos and None in self.silos: - self.silos = None - - key = "default" - if schema_name in schemas_items: - key = schema_name - - self.config_data = schemas_items[key] - - # set outlink - input_outlink = self.data['inputs']['outlink'] - checkbox_outlink = self.data['inputs']['outlink_cb'] - outlink_text = io.Session.get('AVALON_ASSET', '') - checkbox_outlink.setChecked(True) - if outlink_text == '': - outlink_text = '< No context >' - checkbox_outlink.setChecked(False) - checkbox_outlink.hide() - input_outlink.setText(outlink_text) - - # load asset build types - self.load_assetbuild_types() - - # Load task templates - self.load_task_templates() - self.data["model"]["assets"].refresh() - self.on_asset_changed() - - def create_asset(self): - name_input = self.data['inputs']['name'] - name = name_input.text() - test_name = name.replace(' ', '') - error_message = None - message = QtWidgets.QMessageBox(self) - message.setWindowTitle("Some errors have occurred") - message.setIcon(QtWidgets.QMessageBox.Critical) - # TODO: show error messages on any error - if self.valid_parent is not True and test_name == '': - error_message = "Name is not set and Parent is not selected" - elif self.valid_parent is not True: - error_message = "Parent is not selected" - elif test_name == '': - error_message = "Name is not set" - - if error_message is not None: - message.setText(error_message) - message.show() - return - - test_name_exists = io.find({ - 'type': 'asset', - 'name': name - }) - existing_assets = [x for x in test_name_exists] - if len(existing_assets) > 0: - message.setText("Entered Asset name is occupied") - message.show() - return - - checkbox_app = self.data['inputs']['open_app'] - if checkbox_app is not None and checkbox_app.isChecked() is True: - task_view = self.data["view"]["tasks"] - task_model = self.data["model"]["tasks"] - try: - index = task_view.selectedIndexes()[0] - task_name = task_model.itemData(index)[0] - except Exception: - message.setText("Please select task") - message.show() - return - - # Get ftrack session - if self.session is None: - session = ftrack_api.Session() - self.session = session - else: - session = self.session - - # Get Ftrack project entity - project_name = io.Session['AVALON_PROJECT'] - project_query = 'Project where full_name is "{}"'.format(project_name) - try: - ft_project = session.query(project_query).one() - except Exception: - message.setText("Ftrack project was not found") - message.show() - return - - # Get Ftrack entity of parent - ft_parent = None - assets_model = self.data["model"]["assets"] - selected = assets_model.get_selected_assets() - parent = io.find_one({"_id": selected[0], "type": "asset"}) - asset_id = parent.get('data', {}).get('ftrackId', None) - asset_entity_type = parent.get('data', {}).get('entityType', None) - asset_query = '{} where id is "{}"' - if asset_id is not None and asset_entity_type is not None: - try: - ft_parent = session.query(asset_query.format( - asset_entity_type, asset_id) - ).one() - except Exception: - ft_parent = None - - if ft_parent is None: - ft_parent = self.get_ftrack_asset(parent, ft_project) - - if ft_parent is None: - message.setText("Parent's Ftrack entity was not found") - message.show() - return - - asset_build_combo = self.data['inputs']['assetbuild'] - asset_type_name = asset_build_combo.currentText() - asset_type_query = 'Type where name is "{}"'.format(asset_type_name) - try: - asset_type = session.query(asset_type_query).one() - except Exception: - message.setText("Selected Asset Build type does not exists") - message.show() - return - - for children in ft_parent['children']: - if children['name'] == name: - message.setText("Entered Asset name is occupied") - message.show() - return - - task_template_combo = self.data['inputs']['tasktemplate'] - task_template = task_template_combo.currentText() - tasks = [] - for template in self.config_data['task_templates']: - if template['name'] == task_template: - tasks = template['task_types'] - break - - available_task_types = [] - task_types = ft_project['project_schema']['_task_type_schema'] - for task_type in task_types['types']: - available_task_types.append(task_type['name']) - - not_possible_tasks = [] - for task in tasks: - if task not in available_task_types: - not_possible_tasks.append(task) - - if len(not_possible_tasks) != 0: - message.setText(( - "These Task types weren't found" - " in Ftrack project schema:\n{}").format( - ', '.join(not_possible_tasks)) - ) - message.show() - return - - # Create asset build - asset_build_data = { - 'name': name, - 'project_id': ft_project['id'], - 'parent_id': ft_parent['id'], - 'type': asset_type - } - - new_entity = session.create('AssetBuild', asset_build_data) - - task_data = { - 'project_id': ft_project['id'], - 'parent_id': new_entity['id'] - } - - for task in tasks: - type = session.query('Type where name is "{}"'.format(task)).one() - - task_data['type_id'] = type['id'] - task_data['name'] = task - session.create('Task', task_data) - - av_project = io.find_one({'type': 'project'}) - - hiearchy_items = [] - hiearchy_items.extend(self.get_avalon_parent(parent)) - hiearchy_items.append(parent['name']) - - hierarchy = os.path.sep.join(hiearchy_items) - new_asset_data = { - 'ftrackId': new_entity['id'], - 'entityType': new_entity.entity_type, - 'visualParent': parent['_id'], - 'tasks': tasks, - 'parents': hiearchy_items, - 'hierarchy': hierarchy - } - new_asset_info = { - 'parent': av_project['_id'], - 'name': name, - 'schema': "openpype:asset-3.0", - 'type': 'asset', - 'data': new_asset_data - } - - # Backwards compatibility (add silo from parent if is silo project) - if self.silos: - new_asset_info["silo"] = parent["silo"] - - try: - schema.validate(new_asset_info) - except Exception: - message.setText(( - 'Asset information are not valid' - ' to create asset in avalon database' - )) - message.show() - session.rollback() - return - io.insert_one(new_asset_info) - session.commit() - - outlink_cb = self.data['inputs']['outlink_cb'] - if outlink_cb.isChecked() is True: - outlink_input = self.data['inputs']['outlink'] - outlink_name = outlink_input.text() - outlink_asset = io.find_one({ - 'type': 'asset', - 'name': outlink_name - }) - outlink_ft_id = outlink_asset.get('data', {}).get('ftrackId', None) - outlink_entity_type = outlink_asset.get( - 'data', {} - ).get('entityType', None) - if outlink_ft_id is not None and outlink_entity_type is not None: - try: - outlink_entity = session.query(asset_query.format()).one() - except Exception: - outlink_entity = None - - if outlink_entity is None: - outlink_entity = self.get_ftrack_asset( - outlink_asset, ft_project - ) - - if outlink_entity is None: - message.setText("Outlink's Ftrack entity was not found") - message.show() - return - - link_data = { - 'from_id': new_entity['id'], - 'to_id': outlink_entity['id'] - } - session.create('TypedContextLink', link_data) - session.commit() - - if checkbox_app is not None and checkbox_app.isChecked() is True: - origin_asset = api.Session.get('AVALON_ASSET', None) - origin_task = api.Session.get('AVALON_TASK', None) - asset_name = name - task_view = self.data["view"]["tasks"] - task_model = self.data["model"]["tasks"] - try: - index = task_view.selectedIndexes()[0] - except Exception: - message.setText("No task is selected. App won't be launched") - message.show() - return - task_name = task_model.itemData(index)[0] - try: - update_current_task(task=task_name, asset=asset_name) - self.open_app() - - finally: - if origin_task is not None and origin_asset is not None: - update_current_task( - task=origin_task, asset=origin_asset - ) - - message.setWindowTitle("Asset Created") - message.setText("Asset Created successfully") - message.setIcon(QtWidgets.QMessageBox.Information) - message.show() - - def get_ftrack_asset(self, asset, ft_project): - parenthood = [] - parenthood.extend(self.get_avalon_parent(asset)) - parenthood.append(asset['name']) - parenthood = list(reversed(parenthood)) - output_entity = None - ft_entity = ft_project - index = len(parenthood) - 1 - while True: - name = parenthood[index] - found = False - for children in ft_entity['children']: - if children['name'] == name: - ft_entity = children - found = True - break - if found is False: - return None - if index == 0: - output_entity = ft_entity - break - index -= 1 - - return output_entity - - def get_avalon_parent(self, entity): - parent_id = entity['data']['visualParent'] - parents = [] - if parent_id is not None: - parent = io.find_one({'_id': parent_id}) - parents.extend(self.get_avalon_parent(parent)) - parents.append(parent['name']) - return parents - - def echo(self, message): - widget = self.data["label"]["message"] - widget.setText(str(message)) - - QtCore.QTimer.singleShot(5000, lambda: widget.setText("")) - - print(message) - - def load_task_templates(self): - templates = self.config_data.get('task_templates', []) - all_names = [] - for template in templates: - all_names.append(template['name']) - - tt_combobox = self.data['inputs']['tasktemplate'] - tt_combobox.clear() - tt_combobox.addItems(all_names) - - def load_assetbuild_types(self): - types = [] - schemas = self.config_data.get('schemas', []) - for _schema in schemas: - if _schema['object_type'] == 'Asset Build': - types = _schema['task_types'] - break - ab_combobox = self.data['inputs']['assetbuild'] - ab_combobox.clear() - ab_combobox.addItems(types) - - def on_app_checkbox_change(self): - task_model = self.data['model']['tasks'] - app_checkbox = self.data['inputs']['open_app'] - if app_checkbox.isChecked() is True: - task_model.selectable = True - else: - task_model.selectable = False - - def on_outlink_checkbox_change(self): - checkbox_outlink = self.data['inputs']['outlink_cb'] - outlink_input = self.data['inputs']['outlink'] - if checkbox_outlink.isChecked() is True: - outlink_text = io.Session['AVALON_ASSET'] - else: - outlink_text = '< Outlinks won\'t be set >' - - outlink_input.setText(outlink_text) - - def on_task_template_changed(self): - combobox = self.data['inputs']['tasktemplate'] - task_model = self.data['model']['tasks'] - name = combobox.currentText() - tasks = [] - for template in self.config_data['task_templates']: - if template['name'] == name: - tasks = template['task_types'] - break - task_model.set_tasks(tasks) - - def on_asset_changed(self): - """Callback on asset selection changed - - This updates the task view. - - """ - assets_model = self.data["model"]["assets"] - parent_input = self.data['inputs']['parent'] - selected = assets_model.get_selected_assets() - - self.valid_parent = False - if len(selected) > 1: - parent_input.setText('< Please select only one asset! >') - elif len(selected) == 1: - if isinstance(selected[0], io.ObjectId): - self.valid_parent = True - asset = io.find_one({"_id": selected[0], "type": "asset"}) - parent_input.setText(asset['name']) - else: - parent_input.setText('< Selected invalid parent(silo) >') - else: - parent_input.setText('< Nothing is selected >') - - self.creatability_check() - - def on_asset_name_change(self): - self.creatability_check() - - def creatability_check(self): - name_input = self.data['inputs']['name'] - name = str(name_input.text()).strip() - creatable = False - if name and self.valid_parent: - creatable = True - - self.data["buttons"]["create_asset"].setEnabled(creatable) - - - -def show(parent=None, debug=False, context=None): - """Display Loader GUI - - Arguments: - debug (bool, optional): Run loader in debug-mode, - defaults to False - - """ - - try: - module.window.close() - del module.window - except (RuntimeError, AttributeError): - pass - - if debug is True: - io.install() - - with qt_app_context(): - window = Window(parent, context) - window.setStyleSheet(style.load_stylesheet()) - window.show() - - module.window = window - - -def cli(args): - import argparse - parser = argparse.ArgumentParser() - parser.add_argument("project") - parser.add_argument("asset") - - args = parser.parse_args(args) - project = args.project - asset = args.asset - io.install() - - api.Session["AVALON_PROJECT"] = project - if asset != '': - api.Session["AVALON_ASSET"] = asset - - show() diff --git a/openpype/tools/assetcreator/model.py b/openpype/tools/assetcreator/model.py deleted file mode 100644 index f84541ca2a..0000000000 --- a/openpype/tools/assetcreator/model.py +++ /dev/null @@ -1,310 +0,0 @@ -import re -import logging - -from Qt import QtCore, QtWidgets -from avalon.vendor import qtawesome -from avalon import io -from avalon import style - -log = logging.getLogger(__name__) - - -class Item(dict): - """An item that can be represented in a tree view using `TreeModel`. - - The item can store data just like a regular dictionary. - - >>> data = {"name": "John", "score": 10} - >>> item = Item(data) - >>> assert item["name"] == "John" - - """ - - def __init__(self, data=None): - super(Item, self).__init__() - - self._children = list() - self._parent = None - - if data is not None: - assert isinstance(data, dict) - self.update(data) - - def childCount(self): - return len(self._children) - - def child(self, row): - - if row >= len(self._children): - log.warning("Invalid row as child: {0}".format(row)) - return - - return self._children[row] - - def children(self): - return self._children - - def parent(self): - return self._parent - - def row(self): - """ - Returns: - int: Index of this item under parent""" - if self._parent is not None: - siblings = self.parent().children() - return siblings.index(self) - - def add_child(self, child): - """Add a child to this item""" - child._parent = self - self._children.append(child) - - -class TreeModel(QtCore.QAbstractItemModel): - - Columns = list() - ItemRole = QtCore.Qt.UserRole + 1 - - def __init__(self, parent=None): - super(TreeModel, self).__init__(parent) - self._root_item = Item() - - def rowCount(self, parent): - if parent.isValid(): - item = parent.internalPointer() - else: - item = self._root_item - - return item.childCount() - - def columnCount(self, parent): - return len(self.Columns) - - def data(self, index, role): - - if not index.isValid(): - return None - - if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole: - - item = index.internalPointer() - column = index.column() - - key = self.Columns[column] - return item.get(key, None) - - if role == self.ItemRole: - return index.internalPointer() - - def setData(self, index, value, role=QtCore.Qt.EditRole): - """Change the data on the items. - - Returns: - bool: Whether the edit was successful - """ - - if index.isValid(): - if role == QtCore.Qt.EditRole: - - item = index.internalPointer() - column = index.column() - key = self.Columns[column] - item[key] = value - - # passing `list()` for PyQt5 (see PYSIDE-462) - self.dataChanged.emit(index, index, list()) - - # must return true if successful - return True - - return False - - def setColumns(self, keys): - assert isinstance(keys, (list, tuple)) - self.Columns = keys - - def headerData(self, section, orientation, role): - - if role == QtCore.Qt.DisplayRole: - if section < len(self.Columns): - return self.Columns[section] - - super(TreeModel, self).headerData(section, orientation, role) - - def flags(self, index): - flags = QtCore.Qt.ItemIsEnabled - - item = index.internalPointer() - if item.get("enabled", True): - flags |= QtCore.Qt.ItemIsSelectable - - return flags - - def parent(self, index): - - item = index.internalPointer() - parent_item = item.parent() - - # If it has no parents we return invalid - if parent_item == self._root_item or not parent_item: - return QtCore.QModelIndex() - - return self.createIndex(parent_item.row(), 0, parent_item) - - def index(self, row, column, parent): - """Return index for row/column under parent""" - - if not parent.isValid(): - parent_item = self._root_item - else: - parent_item = parent.internalPointer() - - child_item = parent_item.child(row) - if child_item: - return self.createIndex(row, column, child_item) - else: - return QtCore.QModelIndex() - - def add_child(self, item, parent=None): - if parent is None: - parent = self._root_item - - parent.add_child(item) - - def column_name(self, column): - """Return column key by index""" - - if column < len(self.Columns): - return self.Columns[column] - - def clear(self): - self.beginResetModel() - self._root_item = Item() - self.endResetModel() - - -class TasksModel(TreeModel): - """A model listing the tasks combined for a list of assets""" - - Columns = ["Tasks"] - - def __init__(self): - super(TasksModel, self).__init__() - self._num_assets = 0 - self._icons = { - "__default__": qtawesome.icon("fa.male", - color=style.colors.default), - "__no_task__": qtawesome.icon("fa.exclamation-circle", - color=style.colors.mid) - } - - self._get_task_icons() - - def _get_task_icons(self): - # Get the project configured icons from database - project = io.find_one({"type": "project"}) - tasks = project["config"].get("tasks", []) - for task in tasks: - icon_name = task.get("icon", None) - if icon_name: - icon = qtawesome.icon("fa.{}".format(icon_name), - color=style.colors.default) - self._icons[task["name"]] = icon - - def set_tasks(self, tasks): - """Set assets to track by their database id - - Arguments: - asset_ids (list): List of asset ids. - - """ - - self.clear() - - # let cleared task view if no tasks are available - if len(tasks) == 0: - return - - self.beginResetModel() - - icon = self._icons["__default__"] - for task in tasks: - item = Item({ - "Tasks": task, - "icon": icon - }) - - self.add_child(item) - - self.endResetModel() - - def flags(self, index): - return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable - - def headerData(self, section, orientation, role): - - # Override header for count column to show amount of assets - # it is listing the tasks for - if role == QtCore.Qt.DisplayRole: - if orientation == QtCore.Qt.Horizontal: - if section == 1: # count column - return "count ({0})".format(self._num_assets) - - return super(TasksModel, self).headerData(section, orientation, role) - - def data(self, index, role): - - if not index.isValid(): - return - - # Add icon to the first column - if role == QtCore.Qt.DecorationRole: - if index.column() == 0: - return index.internalPointer()["icon"] - - return super(TasksModel, self).data(index, role) - - -class DeselectableTreeView(QtWidgets.QTreeView): - """A tree view that deselects on clicking on an empty area in the view""" - - def mousePressEvent(self, event): - - index = self.indexAt(event.pos()) - if not index.isValid(): - # clear the selection - self.clearSelection() - # clear the current index - self.setCurrentIndex(QtCore.QModelIndex()) - - QtWidgets.QTreeView.mousePressEvent(self, event) - - -class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): - """Filters to the regex if any of the children matches allow parent""" - def filterAcceptsRow(self, row, parent): - - regex = self.filterRegExp() - if not regex.isEmpty(): - pattern = regex.pattern() - model = self.sourceModel() - source_index = model.index(row, self.filterKeyColumn(), parent) - if source_index.isValid(): - - # Check current index itself - key = model.data(source_index, self.filterRole()) - if re.search(pattern, key, re.IGNORECASE): - return True - - # Check children - rows = model.rowCount(source_index) - for i in range(rows): - if self.filterAcceptsRow(i, source_index): - return True - - # Otherwise filter it - return False - - return super(RecursiveSortFilterProxyModel, - self).filterAcceptsRow(row, parent) diff --git a/openpype/tools/assetcreator/widget.py b/openpype/tools/assetcreator/widget.py deleted file mode 100644 index fd0f438e68..0000000000 --- a/openpype/tools/assetcreator/widget.py +++ /dev/null @@ -1,448 +0,0 @@ -import logging -import contextlib -import collections - -from avalon.vendor import qtawesome -from Qt import QtWidgets, QtCore, QtGui -from avalon import style, io - -from .model import ( - TreeModel, - Item, - RecursiveSortFilterProxyModel, - DeselectableTreeView -) - -log = logging.getLogger(__name__) - - -def _iter_model_rows(model, - column, - include_root=False): - """Iterate over all row indices in a model""" - indices = [QtCore.QModelIndex()] # start iteration at root - - for index in indices: - - # Add children to the iterations - child_rows = model.rowCount(index) - for child_row in range(child_rows): - child_index = model.index(child_row, column, index) - indices.append(child_index) - - if not include_root and not index.isValid(): - continue - - yield index - - -@contextlib.contextmanager -def preserve_expanded_rows(tree_view, - column=0, - role=QtCore.Qt.DisplayRole): - """Preserves expanded row in QTreeView by column's data role. - - This function is created to maintain the expand vs collapse status of - the model items. When refresh is triggered the items which are expanded - will stay expanded and vice versa. - - Arguments: - tree_view (QWidgets.QTreeView): the tree view which is - nested in the application - column (int): the column to retrieve the data from - role (int): the role which dictates what will be returned - - Returns: - None - - """ - - model = tree_view.model() - - expanded = set() - - for index in _iter_model_rows(model, - column=column, - include_root=False): - if tree_view.isExpanded(index): - value = index.data(role) - expanded.add(value) - - try: - yield - finally: - if not expanded: - return - - for index in _iter_model_rows(model, - column=column, - include_root=False): - value = index.data(role) - state = value in expanded - if state: - tree_view.expand(index) - else: - tree_view.collapse(index) - - -@contextlib.contextmanager -def preserve_selection(tree_view, - column=0, - role=QtCore.Qt.DisplayRole, - current_index=True): - """Preserves row selection in QTreeView by column's data role. - - This function is created to maintain the selection status of - the model items. When refresh is triggered the items which are expanded - will stay expanded and vice versa. - - tree_view (QWidgets.QTreeView): the tree view nested in the application - column (int): the column to retrieve the data from - role (int): the role which dictates what will be returned - - Returns: - None - - """ - - model = tree_view.model() - selection_model = tree_view.selectionModel() - flags = selection_model.Select | selection_model.Rows - - if current_index: - current_index_value = tree_view.currentIndex().data(role) - else: - current_index_value = None - - selected_rows = selection_model.selectedRows() - if not selected_rows: - yield - return - - selected = set(row.data(role) for row in selected_rows) - try: - yield - finally: - if not selected: - return - - # Go through all indices, select the ones with similar data - for index in _iter_model_rows(model, - column=column, - include_root=False): - - value = index.data(role) - state = value in selected - if state: - tree_view.scrollTo(index) # Ensure item is visible - selection_model.select(index, flags) - - if current_index_value and value == current_index_value: - tree_view.setCurrentIndex(index) - - -class AssetModel(TreeModel): - """A model listing assets in the silo in the active project. - - The assets are displayed in a treeview, they are visually parented by - a `visualParent` field in the database containing an `_id` to a parent - asset. - - """ - - Columns = ["label"] - Name = 0 - Deprecated = 2 - ObjectId = 3 - - DocumentRole = QtCore.Qt.UserRole + 2 - ObjectIdRole = QtCore.Qt.UserRole + 3 - - def __init__(self, parent=None): - super(AssetModel, self).__init__(parent=parent) - self.refresh() - - def _add_hierarchy(self, assets, parent=None, silos=None): - """Add the assets that are related to the parent as children items. - - This method does *not* query the database. These instead are queried - in a single batch upfront as an optimization to reduce database - queries. Resulting in up to 10x speed increase. - - Args: - assets (dict): All assets in the currently active silo stored - by key/value - - Returns: - None - - """ - if silos: - # WARNING: Silo item "_id" is set to silo value - # mainly because GUI issue with preserve selection and expanded row - # and because of easier hierarchy parenting (in "assets") - for silo in silos: - item = Item({ - "_id": silo, - "name": silo, - "label": silo, - "type": "silo" - }) - self.add_child(item, parent=parent) - self._add_hierarchy(assets, parent=item) - - parent_id = parent["_id"] if parent else None - current_assets = assets.get(parent_id, list()) - - for asset in current_assets: - # get label from data, otherwise use name - data = asset.get("data", {}) - label = data.get("label", asset["name"]) - tags = data.get("tags", []) - - # store for the asset for optimization - deprecated = "deprecated" in tags - - item = Item({ - "_id": asset["_id"], - "name": asset["name"], - "label": label, - "type": asset["type"], - "tags": ", ".join(tags), - "deprecated": deprecated, - "_document": asset - }) - self.add_child(item, parent=parent) - - # Add asset's children recursively if it has children - if asset["_id"] in assets: - self._add_hierarchy(assets, parent=item) - - def refresh(self): - """Refresh the data for the model.""" - - self.clear() - self.beginResetModel() - - # Get all assets in current silo sorted by name - db_assets = io.find({"type": "asset"}).sort("name", 1) - silos = db_assets.distinct("silo") or None - # if any silo is set to None then it's expected it should not be used - if silos and None in silos: - silos = None - - # Group the assets by their visual parent's id - assets_by_parent = collections.defaultdict(list) - for asset in db_assets: - parent_id = ( - asset.get("data", {}).get("visualParent") or - asset.get("silo") - ) - assets_by_parent[parent_id].append(asset) - - # Build the hierarchical tree items recursively - self._add_hierarchy( - assets_by_parent, - parent=None, - silos=silos - ) - - self.endResetModel() - - def flags(self, index): - return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable - - def data(self, index, role): - - if not index.isValid(): - return - - item = index.internalPointer() - if role == QtCore.Qt.DecorationRole: # icon - - column = index.column() - if column == self.Name: - - # Allow a custom icon and custom icon color to be defined - data = item.get("_document", {}).get("data", {}) - icon = data.get("icon", None) - if icon is None and item.get("type") == "silo": - icon = "database" - color = data.get("color", style.colors.default) - - if icon is None: - # Use default icons if no custom one is specified. - # If it has children show a full folder, otherwise - # show an open folder - has_children = self.rowCount(index) > 0 - icon = "folder" if has_children else "folder-o" - - # Make the color darker when the asset is deprecated - if item.get("deprecated", False): - color = QtGui.QColor(color).darker(250) - - try: - key = "fa.{0}".format(icon) # font-awesome key - icon = qtawesome.icon(key, color=color) - return icon - except Exception as exception: - # Log an error message instead of erroring out completely - # when the icon couldn't be created (e.g. invalid name) - log.error(exception) - - return - - if role == QtCore.Qt.ForegroundRole: # font color - if "deprecated" in item.get("tags", []): - return QtGui.QColor(style.colors.light).darker(250) - - if role == self.ObjectIdRole: - return item.get("_id", None) - - if role == self.DocumentRole: - return item.get("_document", None) - - return super(AssetModel, self).data(index, role) - - -class AssetWidget(QtWidgets.QWidget): - """A Widget to display a tree of assets with filter - - To list the assets of the active project: - >>> # widget = AssetWidget() - >>> # widget.refresh() - >>> # widget.show() - - """ - - assets_refreshed = QtCore.Signal() # on model refresh - selection_changed = QtCore.Signal() # on view selection change - current_changed = QtCore.Signal() # on view current index change - - def __init__(self, parent=None): - super(AssetWidget, self).__init__(parent=parent) - self.setContentsMargins(0, 0, 0, 0) - - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(4) - - # Tree View - model = AssetModel(self) - proxy = RecursiveSortFilterProxyModel() - proxy.setSourceModel(model) - proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) - - view = DeselectableTreeView() - view.setIndentation(15) - view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - view.setHeaderHidden(True) - view.setModel(proxy) - - # Header - header = QtWidgets.QHBoxLayout() - - icon = qtawesome.icon("fa.refresh", color=style.colors.light) - refresh = QtWidgets.QPushButton(icon, "") - refresh.setToolTip("Refresh items") - - filter = QtWidgets.QLineEdit() - filter.textChanged.connect(proxy.setFilterFixedString) - filter.setPlaceholderText("Filter assets..") - - header.addWidget(filter) - header.addWidget(refresh) - - # Layout - layout.addLayout(header) - layout.addWidget(view) - - # Signals/Slots - selection = view.selectionModel() - selection.selectionChanged.connect(self.selection_changed) - selection.currentChanged.connect(self.current_changed) - refresh.clicked.connect(self.refresh) - - self.refreshButton = refresh - self.model = model - self.proxy = proxy - self.view = view - - def _refresh_model(self): - with preserve_expanded_rows( - self.view, column=0, role=self.model.ObjectIdRole - ): - with preserve_selection( - self.view, column=0, role=self.model.ObjectIdRole - ): - self.model.refresh() - - self.assets_refreshed.emit() - - def refresh(self): - self._refresh_model() - - def get_active_asset(self): - """Return the asset id the current asset.""" - current = self.view.currentIndex() - return current.data(self.model.ItemRole) - - def get_active_index(self): - return self.view.currentIndex() - - def get_selected_assets(self): - """Return the assets' ids that are selected.""" - selection = self.view.selectionModel() - rows = selection.selectedRows() - return [row.data(self.model.ObjectIdRole) for row in rows] - - def select_assets(self, assets, expand=True, key="name"): - """Select assets by name. - - Args: - assets (list): List of asset names - expand (bool): Whether to also expand to the asset in the view - - Returns: - None - - """ - # TODO: Instead of individual selection optimize for many assets - - if not isinstance(assets, (tuple, list)): - assets = [assets] - assert isinstance( - assets, (tuple, list) - ), "Assets must be list or tuple" - - # convert to list - tuple cant be modified - assets = list(assets) - - # Clear selection - selection_model = self.view.selectionModel() - selection_model.clearSelection() - - # Select - mode = selection_model.Select | selection_model.Rows - for index in iter_model_rows( - self.proxy, column=0, include_root=False - ): - # stop iteration if there are no assets to process - if not assets: - break - - value = index.data(self.model.ItemRole).get(key) - if value not in assets: - continue - - # Remove processed asset - assets.pop(assets.index(value)) - - selection_model.select(index, mode) - - if expand: - # Expand parent index - self.view.expand(self.proxy.parent(index)) - - # Set the currently active index - self.view.setCurrentIndex(index) From e629e40b4f95718e94de5c63180c38251c3c8e54 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 4 Mar 2022 18:58:33 +0100 Subject: [PATCH 337/483] created base of event system --- openpype/pipeline/__init__.py | 8 ++ openpype/pipeline/events.py | 221 ++++++++++++++++++++++++++++++ openpype/pipeline/lib/__init__.py | 8 -- openpype/pipeline/lib/events.py | 51 ------- 4 files changed, 229 insertions(+), 59 deletions(-) create mode 100644 openpype/pipeline/events.py delete mode 100644 openpype/pipeline/lib/events.py diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index e968df4011..673608bded 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -1,5 +1,10 @@ from .lib import attribute_definitions +from .events import ( + emit_event, + register_event_callback +) + from .create import ( BaseCreator, Creator, @@ -17,6 +22,9 @@ from .publish import ( __all__ = ( "attribute_definitions", + "emit_event", + "register_event_callback", + "BaseCreator", "Creator", "AutoCreator", diff --git a/openpype/pipeline/events.py b/openpype/pipeline/events.py new file mode 100644 index 0000000000..cae8b250f7 --- /dev/null +++ b/openpype/pipeline/events.py @@ -0,0 +1,221 @@ +"""Events holding data about specific event.""" +import os +import re +import inspect +import logging +import weakref +from uuid import uuid4 +try: + from weakref import WeakMethod +except Exception: + from .python_2_comp import WeakMethod + + +class EventCallback(object): + def __init__(self, topic, func_ref, func_name, func_path): + self._topic = topic + # Replace '*' with any character regex and escape rest of text + # - when callback is registered for '*' topic it will receive all + # events + # - it is possible to register to a partial topis 'my.event.*' + # - it will receive all matching event topics + # e.g. 'my.event.start' and 'my.event.end' + topic_regex_str = "^{}$".format( + ".+".join( + re.escape(part) + for part in topic.split("*") + ) + ) + topic_regex = re.compile(topic_regex_str) + self._topic_regex = topic_regex + self._func_ref = func_ref + self._func_name = func_name + self._func_path = func_path + self._ref_valid = True + self._enabled = True + + self._log = None + + def __repr__(self): + return "< {} - {} > {}".format( + self.__class__.__name__, self._func_name, self._func_path + ) + + @property + def log(self): + if self._log is None: + self._log = logging.getLogger(self.__class__.__name__) + return self._log + + @property + def is_ref_valid(self): + return self._ref_valid + + def validate_ref(self): + if not self._ref_valid: + return + + callback = self._func_ref() + if not callback: + self._ref_valid = False + + @property + def enabled(self): + """Is callback enabled.""" + return self._enabled + + def set_enabled(self, enabled): + """Change if callback is enabled.""" + self._enabled = enabled + + def deregister(self): + """Calling this funcion will cause that callback will be removed.""" + # Fake reference + self._ref_valid = False + + def topic_matches(self, topic): + """Check if event topic matches callback's topic.""" + return self._topic_regex.match(topic) + + def process_event(self, event): + """Process event. + + Args: + event(Event): Event that was triggered. + """ + # Skip if callback is not enabled or has invalid reference + if not self._ref_valid or not self._enabled: + return + + # Get reference + callback = self._func_ref() + # Check if reference is valid or callback's topic matches the event + if not callback: + # Change state if is invalid so the callback is removed + self._ref_valid = False + + elif self.topic_matches(event.topic): + # Try execute callback + sig = inspect.signature(callback) + try: + if len(sig.parameters) == 0: + callback() + else: + callback(event) + except Exception: + self.log.warning( + "Failed to execute event callback {}".format( + str(repr(self)) + ), + exc_info=True + ) + + +# Inherit from 'object' for Python 2 hosts +class Event(object): + """Base event object. + + Can be used to anything because data are not much specific. Only required + argument is topic which defines why event is happening and may be used for + filtering. + + Arg: + topic (str): Identifier of event. + data (Any): Data specific for event. Dictionary is recommended. + """ + _data = {} + + def __init__(self, topic, data=None, source=None): + self._id = str(uuid4()) + self._topic = topic + if data is None: + data = {} + self._data = data + self._source = source + + def __getitem__(self, key): + return self._data[key] + + def get(self, key, *args, **kwargs): + return self._data.get(key, *args, **kwargs) + + @property + def id(self): + return self._id + + @property + def source(self): + return self._source + + @property + def data(self): + return self._data + + @property + def topic(self): + return self._topic + + def emit(self): + """Emit event and trigger callbacks.""" + StoredCallbacks.emit_event(self) + + +class StoredCallbacks: + _registered_callbacks = [] + + @classmethod + def add_callback(cls, topic, callback): + # Convert callback into references + # - deleted functions won't cause crashes + if inspect.ismethod(callback): + ref = WeakMethod(callback) + elif callable(callback): + ref = weakref.ref(callback) + else: + # TODO add logs + return + + function_name = callback.__name__ + function_path = os.path.abspath(inspect.getfile(callback)) + callback = EventCallback(topic, ref, function_name, function_path) + cls._registered_callbacks.append(callback) + return callback + + @classmethod + def validate(cls): + invalid_callbacks = [] + for callbacks in cls._registered_callbacks: + for callback in tuple(callbacks): + callback.validate_ref() + if not callback.is_ref_valid: + invalid_callbacks.append(callback) + + for callback in invalid_callbacks: + cls._registered_callbacks.remove(callback) + + @classmethod + def emit_event(cls, event): + invalid_callbacks = [] + for callback in cls._registered_callbacks: + callback.process_event() + if not callback.is_ref_valid: + invalid_callbacks.append(callback) + + for callback in invalid_callbacks: + cls._registered_callbacks.remove(callback) + + +def register_event_callback(topic, callback): + """Add callback that will be executed on specific topic.""" + return StoredCallbacks.add_callback(topic, callback) + + +def emit_event(topic, data=None, source=None): + """Emit event with topic and data. + + Returns: + Event: Object of event that was emitted. + """ + event = Event(topic, data, source) + event.emit() + return event diff --git a/openpype/pipeline/lib/__init__.py b/openpype/pipeline/lib/__init__.py index ed38889c66..f762c4205d 100644 --- a/openpype/pipeline/lib/__init__.py +++ b/openpype/pipeline/lib/__init__.py @@ -1,8 +1,3 @@ -from .events import ( - BaseEvent, - BeforeWorkfileSave -) - from .attribute_definitions import ( AbtractAttrDef, @@ -20,9 +15,6 @@ from .attribute_definitions import ( __all__ = ( - "BaseEvent", - "BeforeWorkfileSave", - "AbtractAttrDef", "UIDef", diff --git a/openpype/pipeline/lib/events.py b/openpype/pipeline/lib/events.py deleted file mode 100644 index 05dea20e8c..0000000000 --- a/openpype/pipeline/lib/events.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Events holding data about specific event.""" - - -# Inherit from 'object' for Python 2 hosts -class BaseEvent(object): - """Base event object. - - Can be used to anything because data are not much specific. Only required - argument is topic which defines why event is happening and may be used for - filtering. - - Arg: - topic (str): Identifier of event. - data (Any): Data specific for event. Dictionary is recommended. - """ - _data = {} - - def __init__(self, topic, data=None): - self._topic = topic - if data is None: - data = {} - self._data = data - - @property - def data(self): - return self._data - - @property - def topic(self): - return self._topic - - @classmethod - def emit(cls, *args, **kwargs): - """Create object of event and emit. - - Args: - Same args as '__init__' expects which may be class specific. - """ - from avalon import pipeline - - obj = cls(*args, **kwargs) - pipeline.emit(obj.topic, [obj]) - return obj - - -class BeforeWorkfileSave(BaseEvent): - """Before workfile changes event data.""" - def __init__(self, filename, workdir): - super(BeforeWorkfileSave, self).__init__("before.workfile.save") - self.filename = filename - self.workdir_path = workdir From 9c111fa9d448b9371807f7812c03d21c4aa5b6f8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 4 Mar 2022 19:01:31 +0100 Subject: [PATCH 338/483] use new event system in openpype --- openpype/__init__.py | 7 +-- openpype/hosts/aftereffects/api/pipeline.py | 3 +- openpype/hosts/blender/api/pipeline.py | 25 ++++++---- openpype/hosts/harmony/api/pipeline.py | 5 +- openpype/hosts/hiero/api/events.py | 4 +- openpype/hosts/hiero/api/menu.py | 2 +- openpype/hosts/houdini/api/pipeline.py | 30 +++++++----- openpype/hosts/maya/api/pipeline.py | 49 ++++++++++--------- openpype/hosts/nuke/api/pipeline.py | 7 +-- openpype/hosts/photoshop/api/pipeline.py | 4 +- .../hosts/tvpaint/api/communication_server.py | 6 +-- openpype/hosts/tvpaint/api/pipeline.py | 5 +- openpype/hosts/webpublisher/api/__init__.py | 5 -- openpype/tools/loader/app.py | 5 +- openpype/tools/workfiles/app.py | 16 ++++-- 15 files changed, 97 insertions(+), 76 deletions(-) diff --git a/openpype/__init__.py b/openpype/__init__.py index 11b563ebfe..9175727a54 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -10,8 +10,9 @@ from .lib import ( Anatomy, filter_pyblish_plugins, set_plugin_attributes_from_settings, - change_timer_to_current_context + change_timer_to_current_context, ) +from .pipeline import register_event_callback pyblish = avalon = _original_discover = None @@ -122,10 +123,10 @@ def install(): avalon.discover = patched_discover pipeline.discover = patched_discover - avalon.on("taskChanged", _on_task_change) + register_event_callback("taskChanged", _on_task_change) -def _on_task_change(*args): +def _on_task_change(): change_timer_to_current_context() diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 94f1e3d105..6f7cd8c46d 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -10,6 +10,7 @@ from avalon import io, pipeline from openpype import lib from openpype.api import Logger import openpype.hosts.aftereffects +from openpype.pipeline import register_event_callback from .launch_logic import get_stub @@ -73,7 +74,7 @@ def install(): "instanceToggled", on_pyblish_instance_toggled ) - avalon.api.on("application.launched", application_launch) + register_event_callback("application.launched", application_launch) def uninstall(): diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 6da0ba3dcb..38312316cc 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -15,6 +15,10 @@ from avalon import io, schema from avalon.pipeline import AVALON_CONTAINER_ID from openpype.api import Logger +from openpype.pipeline import ( + register_event_callback, + emit_event +) import openpype.hosts.blender HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.blender.__file__)) @@ -50,8 +54,9 @@ def install(): lib.append_user_scripts() - avalon.api.on("new", on_new) - avalon.api.on("open", on_open) + register_event_callback("new", on_new) + register_event_callback("open", on_open) + _register_callbacks() _register_events() @@ -113,22 +118,22 @@ def set_start_end_frames(): scene.render.resolution_y = resolution_y -def on_new(arg1, arg2): +def on_new(): set_start_end_frames() -def on_open(arg1, arg2): +def on_open(): set_start_end_frames() @bpy.app.handlers.persistent def _on_save_pre(*args): - avalon.api.emit("before_save", args) + emit_event("before.save") @bpy.app.handlers.persistent def _on_save_post(*args): - avalon.api.emit("save", args) + emit_event("save") @bpy.app.handlers.persistent @@ -136,9 +141,9 @@ def _on_load_post(*args): # Detect new file or opening an existing file if bpy.data.filepath: # Likely this was an open operation since it has a filepath - avalon.api.emit("open", args) + emit_event("open") else: - avalon.api.emit("new", args) + emit_event("new") ops.OpenFileCacher.post_load() @@ -169,7 +174,7 @@ def _register_callbacks(): log.info("Installed event handler _on_load_post...") -def _on_task_changed(*args): +def _on_task_changed(): """Callback for when the task in the context is changed.""" # TODO (jasper): Blender has no concept of projects or workspace. @@ -186,7 +191,7 @@ def _on_task_changed(*args): def _register_events(): """Install callbacks for specific events.""" - avalon.api.on("taskChanged", _on_task_changed) + register_event_callback("taskChanged", _on_task_changed) log.info("Installed event callback for 'taskChanged'...") diff --git a/openpype/hosts/harmony/api/pipeline.py b/openpype/hosts/harmony/api/pipeline.py index 17d2870876..a94d30210e 100644 --- a/openpype/hosts/harmony/api/pipeline.py +++ b/openpype/hosts/harmony/api/pipeline.py @@ -9,6 +9,7 @@ import avalon.api from avalon.pipeline import AVALON_CONTAINER_ID from openpype import lib +from openpype.pipeline import register_event_callback import openpype.hosts.harmony import openpype.hosts.harmony.api as harmony @@ -129,7 +130,7 @@ def check_inventory(): harmony.send({"function": "PypeHarmony.message", "args": msg}) -def application_launch(): +def application_launch(event): """Event that is executed after Harmony is launched.""" # FIXME: This is breaking server <-> client communication. # It is now moved so it it manually called. @@ -187,7 +188,7 @@ def install(): "instanceToggled", on_pyblish_instance_toggled ) - avalon.api.on("application.launched", application_launch) + register_event_callback("application.launched", application_launch) def uninstall(): diff --git a/openpype/hosts/hiero/api/events.py b/openpype/hosts/hiero/api/events.py index 7563503593..6e2580ed8c 100644 --- a/openpype/hosts/hiero/api/events.py +++ b/openpype/hosts/hiero/api/events.py @@ -1,7 +1,7 @@ import os import hiero.core.events -import avalon.api as avalon from openpype.api import Logger +from openpype.pipeline import register_event_callback from .lib import ( sync_avalon_data_to_workfile, launch_workfiles_app, @@ -126,5 +126,5 @@ def register_events(): """ # if task changed then change notext of hiero - avalon.on("taskChanged", update_menu_task_label) + register_event_callback("taskChanged", update_menu_task_label) log.info("Installed event callback for 'taskChanged'..") diff --git a/openpype/hosts/hiero/api/menu.py b/openpype/hosts/hiero/api/menu.py index 306bef87ca..de20b86f30 100644 --- a/openpype/hosts/hiero/api/menu.py +++ b/openpype/hosts/hiero/api/menu.py @@ -14,7 +14,7 @@ self = sys.modules[__name__] self._change_context_menu = None -def update_menu_task_label(*args): +def update_menu_task_label(): """Update the task label in Avalon menu to current session""" object_name = self._change_context_menu diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 1c08e72d65..86c85ad3a1 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -11,6 +11,10 @@ import avalon.api from avalon.pipeline import AVALON_CONTAINER_ID from avalon.lib import find_submodule +from openpype.pipeline import ( + register_event_callback, + emit_event +) import openpype.hosts.houdini from openpype.hosts.houdini.api import lib @@ -51,11 +55,11 @@ def install(): avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) log.info("Installing callbacks ... ") - # avalon.on("init", on_init) - avalon.api.before("save", before_save) - avalon.api.on("save", on_save) - avalon.api.on("open", on_open) - avalon.api.on("new", on_new) + # register_event_callback("init", on_init) + register_event_callback("before.save", before_save) + register_event_callback("save", on_save) + register_event_callback("open", on_open) + register_event_callback("new", on_new) pyblish.api.register_callback( "instanceToggled", on_pyblish_instance_toggled @@ -101,13 +105,13 @@ def _register_callbacks(): def on_file_event_callback(event): if event == hou.hipFileEventType.AfterLoad: - avalon.api.emit("open", [event]) + emit_event("open") elif event == hou.hipFileEventType.AfterSave: - avalon.api.emit("save", [event]) + emit_event("save") elif event == hou.hipFileEventType.BeforeSave: - avalon.api.emit("before_save", [event]) + emit_event("before_save") elif event == hou.hipFileEventType.AfterClear: - avalon.api.emit("new", [event]) + emit_event("new") def get_main_window(): @@ -229,11 +233,11 @@ def ls(): yield data -def before_save(*args): +def before_save(): return lib.validate_fps() -def on_save(*args): +def on_save(): log.info("Running callback on save..") @@ -242,7 +246,7 @@ def on_save(*args): lib.set_id(node, new_id, overwrite=False) -def on_open(*args): +def on_open(): if not hou.isUIAvailable(): log.debug("Batch mode detected, ignoring `on_open` callbacks..") @@ -279,7 +283,7 @@ def on_open(*args): dialog.show() -def on_new(_): +def on_new(): """Set project resolution and fps when create a new file""" if hou.hipFile.isLoadingHipFile(): diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 1b3bb9feb3..05db1b7b26 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -2,7 +2,6 @@ import os import sys import errno import logging -import contextlib from maya import utils, cmds, OpenMaya import maya.api.OpenMaya as om @@ -16,6 +15,10 @@ from avalon.pipeline import AVALON_CONTAINER_ID import openpype.hosts.maya from openpype.tools.utils import host_tools from openpype.lib import any_outdated +from openpype.pipeline import ( + register_event_callback, + emit_event +) from openpype.lib.path_tools import HostDirmap from openpype.hosts.maya.lib import copy_workspace_mel from . import menu, lib @@ -55,7 +58,7 @@ def install(): log.info(PUBLISH_PATH) log.info("Installing callbacks ... ") - avalon.api.on("init", on_init) + register_event_callback("init", on_init) # Callbacks below are not required for headless mode, the `init` however # is important to load referenced Alembics correctly at rendertime. @@ -69,12 +72,12 @@ def install(): menu.install() - avalon.api.on("save", on_save) - avalon.api.on("open", on_open) - avalon.api.on("new", on_new) - avalon.api.before("save", on_before_save) - avalon.api.on("taskChanged", on_task_changed) - avalon.api.on("before.workfile.save", before_workfile_save) + register_event_callback("save", on_save) + register_event_callback("open", on_open) + register_event_callback("new", on_new) + register_event_callback("before.save", on_before_save) + register_event_callback("taskChanged", on_task_changed) + register_event_callback("before.workfile.save", before_workfile_save) def _set_project(): @@ -137,7 +140,7 @@ def _register_callbacks(): def _on_maya_initialized(*args): - avalon.api.emit("init", args) + emit_event("init") if cmds.about(batch=True): log.warning("Running batch mode ...") @@ -147,16 +150,16 @@ def _on_maya_initialized(*args): lib.get_main_window() -def _on_scene_new(*args): - avalon.api.emit("new", args) +def _on_scene_new(): + emit_event("new") -def _on_scene_save(*args): - avalon.api.emit("save", args) +def _on_scene_save(): + emit_event("save") -def _on_scene_open(*args): - avalon.api.emit("open", args) +def _on_scene_open(): + emit_event("open") def _before_scene_save(return_code, client_data): @@ -166,7 +169,7 @@ def _before_scene_save(return_code, client_data): # in order to block the operation. OpenMaya.MScriptUtil.setBool(return_code, True) - avalon.api.emit("before_save", [return_code, client_data]) + emit_event("before.save") def uninstall(): @@ -343,7 +346,7 @@ def containerise(name, return container -def on_init(_): +def on_init(): log.info("Running callback on init..") def safe_deferred(fn): @@ -384,12 +387,12 @@ def on_init(_): safe_deferred(override_toolbox_ui) -def on_before_save(return_code, _): +def on_before_save(): """Run validation for scene's FPS prior to saving""" return lib.validate_fps() -def on_save(_): +def on_save(): """Automatically add IDs to new nodes Any transform of a mesh, without an existing ID, is given one @@ -407,7 +410,7 @@ def on_save(_): lib.set_id(node, new_id, overwrite=False) -def on_open(_): +def on_open(): """On scene open let's assume the containers have changed.""" from Qt import QtWidgets @@ -455,7 +458,7 @@ def on_open(_): dialog.show() -def on_new(_): +def on_new(): """Set project resolution and fps when create a new file""" log.info("Running callback on new..") with lib.suspended_refresh(): @@ -471,7 +474,7 @@ def on_new(_): lib.set_context_settings() -def on_task_changed(*args): +def on_task_changed(): """Wrapped function of app initialize and maya's on task changed""" # Run menu.update_menu_task_label() @@ -509,7 +512,7 @@ def on_task_changed(*args): def before_workfile_save(event): - workdir_path = event.workdir_path + workdir_path = event["workdir_path"] if workdir_path: copy_workspace_mel(workdir_path) diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index 8c6c9ca55b..1a5116c9ea 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -14,6 +14,7 @@ from openpype.api import ( BuildWorkfile, get_current_project_settings ) +from openpype.pipeline import register_event_callback from openpype.tools.utils import host_tools from .command import viewer_update_and_undo_stop @@ -102,8 +103,8 @@ def install(): avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) # Register Avalon event for workfiles loading. - avalon.api.on("workio.open_file", check_inventory_versions) - avalon.api.on("taskChanged", change_context_label) + register_event_callback("workio.open_file", check_inventory_versions) + register_event_callback("taskChanged", change_context_label) pyblish.api.register_callback( "instanceToggled", on_pyblish_instance_toggled) @@ -226,7 +227,7 @@ def _uninstall_menu(): menu.removeItem(item.name()) -def change_context_label(*args): +def change_context_label(): menubar = nuke.menu("Nuke") menu = menubar.findItem(MENU_LABEL) diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index 25983f2471..2aade59812 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -1,5 +1,4 @@ import os -import sys from Qt import QtWidgets import pyblish.api @@ -7,6 +6,7 @@ import avalon.api from avalon import pipeline, io from openpype.api import Logger +from openpype.pipeline import register_event_callback import openpype.hosts.photoshop from . import lib @@ -75,7 +75,7 @@ def install(): "instanceToggled", on_pyblish_instance_toggled ) - avalon.api.on("application.launched", on_application_launch) + register_event_callback("application.launched", on_application_launch) def uninstall(): diff --git a/openpype/hosts/tvpaint/api/communication_server.py b/openpype/hosts/tvpaint/api/communication_server.py index e9c5f4c73e..b001b84203 100644 --- a/openpype/hosts/tvpaint/api/communication_server.py +++ b/openpype/hosts/tvpaint/api/communication_server.py @@ -21,7 +21,7 @@ from aiohttp_json_rpc.protocol import ( ) from aiohttp_json_rpc.exceptions import RpcError -from avalon import api +from openpype.pipeline import emit_event from openpype.hosts.tvpaint.tvpaint_plugin import get_plugin_files_path log = logging.getLogger(__name__) @@ -754,7 +754,7 @@ class BaseCommunicator: self._on_client_connect() - api.emit("application.launched") + emit_event("application.launched") def _on_client_connect(self): self._initial_textfile_write() @@ -938,5 +938,5 @@ class QtCommunicator(BaseCommunicator): def _exit(self, *args, **kwargs): super()._exit(*args, **kwargs) - api.emit("application.exit") + emit_event("application.exit") self.qt_app.exit(self.exit_code) diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index 74eb41892c..b999478fb1 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -14,6 +14,7 @@ from avalon.pipeline import AVALON_CONTAINER_ID from openpype.hosts import tvpaint from openpype.api import get_current_project_settings +from openpype.pipeline import register_event_callback from .lib import ( execute_george, @@ -84,8 +85,8 @@ def install(): if on_instance_toggle not in registered_callbacks: pyblish.api.register_callback("instanceToggled", on_instance_toggle) - avalon.api.on("application.launched", initial_launch) - avalon.api.on("application.exit", application_exit) + register_event_callback("application.launched", initial_launch) + register_event_callback("application.exit", application_exit) def uninstall(): diff --git a/openpype/hosts/webpublisher/api/__init__.py b/openpype/hosts/webpublisher/api/__init__.py index e40d46d662..f338c92a5e 100644 --- a/openpype/hosts/webpublisher/api/__init__.py +++ b/openpype/hosts/webpublisher/api/__init__.py @@ -16,10 +16,6 @@ LOAD_PATH = os.path.join(PLUGINS_DIR, "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "create") -def application_launch(): - pass - - def install(): print("Installing Pype config...") @@ -29,7 +25,6 @@ def install(): log.info(PUBLISH_PATH) io.install() - avalon.on("application.launched", application_launch) def uninstall(): diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py index aa743b05fe..afb94bf8fc 100644 --- a/openpype/tools/loader/app.py +++ b/openpype/tools/loader/app.py @@ -1,9 +1,10 @@ import sys from Qt import QtWidgets, QtCore -from avalon import api, io, pipeline +from avalon import api, io from openpype import style +from openpype.pipeline import register_event_callback from openpype.tools.utils import ( lib, PlaceholderLineEdit @@ -33,7 +34,7 @@ def on_context_task_change(*args, **kwargs): module.window.on_context_task_change(*args, **kwargs) -pipeline.on("taskChanged", on_context_task_change) +register_event_callback("taskChanged", on_context_task_change) class LoaderWindow(QtWidgets.QDialog): diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index aece7bfb4f..280fe2d8a2 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -9,10 +9,10 @@ import datetime import Qt from Qt import QtWidgets, QtCore -from avalon import io, api, pipeline +from avalon import io, api from openpype import style -from openpype.pipeline.lib import BeforeWorkfileSave +from openpype.pipeline import emit_event from openpype.tools.utils.lib import ( qt_app_context ) @@ -823,7 +823,11 @@ class FilesWidget(QtWidgets.QWidget): return # Trigger before save event - BeforeWorkfileSave.emit(work_filename, self._workdir_path) + emit_event( + "before.workfile.save", + {"filename": work_filename, "workdir_path": self._workdir_path}, + source="workfiles.tool" + ) # Make sure workfiles root is updated # - this triggers 'workio.work_root(...)' which may change value of @@ -853,7 +857,11 @@ class FilesWidget(QtWidgets.QWidget): api.Session["AVALON_PROJECT"] ) # Trigger after save events - pipeline.emit("after.workfile.save", [filepath]) + emit_event( + "after.workfile.save", + {"filepath": filepath}, + source="workfiles.tool" + ) self.workfile_created.emit(filepath) # Refresh files model From bb33d63526b342377b6c5228e36c72d140d6421e Mon Sep 17 00:00:00 2001 From: OpenPype Date: Sat, 5 Mar 2022 03:36:36 +0000 Subject: [PATCH 339/483] [Automated] Bump version --- CHANGELOG.md | 44 ++++++++++++++++++++++---------------------- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 348f7dc1b8..711517e6c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.9.0-nightly.5](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.9.0-nightly.6](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...HEAD) @@ -12,56 +12,56 @@ - Documentation: fixed broken links [\#2799](https://github.com/pypeclub/OpenPype/pull/2799) - Documentation: broken link fix [\#2785](https://github.com/pypeclub/OpenPype/pull/2785) -- Documentation: link fixes [\#2772](https://github.com/pypeclub/OpenPype/pull/2772) -- Update docusaurus to latest version [\#2760](https://github.com/pypeclub/OpenPype/pull/2760) +- Various testing updates [\#2726](https://github.com/pypeclub/OpenPype/pull/2726) **🚀 Enhancements** +- Ftrack: Can sync fps as string [\#2836](https://github.com/pypeclub/OpenPype/pull/2836) - General: Color dialog UI fixes [\#2817](https://github.com/pypeclub/OpenPype/pull/2817) +- Nuke: adding Reformat to baking mov plugin [\#2811](https://github.com/pypeclub/OpenPype/pull/2811) +- Manager: Update all to latest button [\#2805](https://github.com/pypeclub/OpenPype/pull/2805) - General: Set context environments for non host applications [\#2803](https://github.com/pypeclub/OpenPype/pull/2803) +- Houdini: Remove duplicate ValidateOutputNode plug-in [\#2780](https://github.com/pypeclub/OpenPype/pull/2780) - Tray publisher: New Tray Publisher host \(beta\) [\#2778](https://github.com/pypeclub/OpenPype/pull/2778) +- Slack: Added regex for filtering on subset names [\#2775](https://github.com/pypeclub/OpenPype/pull/2775) - Houdini: Implement Reset Frame Range [\#2770](https://github.com/pypeclub/OpenPype/pull/2770) -- Pyblish Pype: Remove redundant new line in installed fonts printing [\#2758](https://github.com/pypeclub/OpenPype/pull/2758) - Flame: use Shot Name on segment for asset name [\#2751](https://github.com/pypeclub/OpenPype/pull/2751) -- Flame: adding validator source clip [\#2746](https://github.com/pypeclub/OpenPype/pull/2746) -- Ftrack: Disable ftrack module by default [\#2732](https://github.com/pypeclub/OpenPype/pull/2732) +- Houdini: Move Houdini Save Current File to beginning of ExtractorOrder [\#2747](https://github.com/pypeclub/OpenPype/pull/2747) - RoyalRender: Minor enhancements [\#2700](https://github.com/pypeclub/OpenPype/pull/2700) **🐛 Bug fixes** +- Maya: Stop creation of reviews for Cryptomattes [\#2832](https://github.com/pypeclub/OpenPype/pull/2832) +- Deadline: Remove recreated event [\#2828](https://github.com/pypeclub/OpenPype/pull/2828) +- Deadline: Added missing events folder [\#2827](https://github.com/pypeclub/OpenPype/pull/2827) - Settings: Missing document with OP versions may break start of OpenPype [\#2825](https://github.com/pypeclub/OpenPype/pull/2825) +- Deadline: more detailed temp file name for environment json [\#2824](https://github.com/pypeclub/OpenPype/pull/2824) +- General: Host name was formed from obsolete code [\#2821](https://github.com/pypeclub/OpenPype/pull/2821) - Settings UI: Fix "Apply from" action [\#2820](https://github.com/pypeclub/OpenPype/pull/2820) +- Ftrack: Job killer with missing user [\#2819](https://github.com/pypeclub/OpenPype/pull/2819) +- StandalonePublisher: use dynamic groups in subset names [\#2816](https://github.com/pypeclub/OpenPype/pull/2816) - Settings UI: Search case sensitivity [\#2810](https://github.com/pypeclub/OpenPype/pull/2810) - Flame Babypublisher optimalization [\#2806](https://github.com/pypeclub/OpenPype/pull/2806) - resolve: fixing fusion module loading [\#2802](https://github.com/pypeclub/OpenPype/pull/2802) +- Ftrack: Unset task ids from asset versions before tasks are removed [\#2800](https://github.com/pypeclub/OpenPype/pull/2800) +- Slack: fail gracefully if slack exception [\#2798](https://github.com/pypeclub/OpenPype/pull/2798) - Flame: Fix version string in default settings [\#2783](https://github.com/pypeclub/OpenPype/pull/2783) -- After Effects: Fix typo in name `afftereffects` -\> `aftereffects` [\#2768](https://github.com/pypeclub/OpenPype/pull/2768) -- Avoid renaming udim indexes [\#2765](https://github.com/pypeclub/OpenPype/pull/2765) +- Houdini: Fix open last workfile [\#2767](https://github.com/pypeclub/OpenPype/pull/2767) - Maya: Fix `unique\_namespace` when in an namespace that is empty [\#2759](https://github.com/pypeclub/OpenPype/pull/2759) -- Loader UI: Fix right click in representation widget [\#2757](https://github.com/pypeclub/OpenPype/pull/2757) -- Aftereffects 2022 and Deadline [\#2748](https://github.com/pypeclub/OpenPype/pull/2748) -- Flame: bunch of bugs [\#2745](https://github.com/pypeclub/OpenPype/pull/2745) -- Maya: Save current scene on workfile publish [\#2744](https://github.com/pypeclub/OpenPype/pull/2744) -- Version Up: Preserve parts of filename after version number \(like subversion\) on version\_up [\#2741](https://github.com/pypeclub/OpenPype/pull/2741) - Maya: Remove some unused code [\#2709](https://github.com/pypeclub/OpenPype/pull/2709) +- Multiple hosts: unify menu style across hosts [\#2693](https://github.com/pypeclub/OpenPype/pull/2693) **Merged pull requests:** +- General: Move change context functions [\#2839](https://github.com/pypeclub/OpenPype/pull/2839) +- Tools: Don't use avalon tools code [\#2829](https://github.com/pypeclub/OpenPype/pull/2829) - Move Unreal Implementation to OpenPype [\#2823](https://github.com/pypeclub/OpenPype/pull/2823) -- Ftrack: Job killer with missing user [\#2819](https://github.com/pypeclub/OpenPype/pull/2819) -- Ftrack: Unset task ids from asset versions before tasks are removed [\#2800](https://github.com/pypeclub/OpenPype/pull/2800) -- Slack: fail gracefully if slack exception [\#2798](https://github.com/pypeclub/OpenPype/pull/2798) +- Nuke: Use AVALON\_APP to get value for "app" key [\#2818](https://github.com/pypeclub/OpenPype/pull/2818) - Ftrack: Moved module one hierarchy level higher [\#2792](https://github.com/pypeclub/OpenPype/pull/2792) - SyncServer: Moved module one hierarchy level higher [\#2791](https://github.com/pypeclub/OpenPype/pull/2791) - Royal render: Move module one hierarchy level higher [\#2790](https://github.com/pypeclub/OpenPype/pull/2790) - Deadline: Move module one hierarchy level higher [\#2789](https://github.com/pypeclub/OpenPype/pull/2789) -- Houdini: Remove duplicate ValidateOutputNode plug-in [\#2780](https://github.com/pypeclub/OpenPype/pull/2780) -- Slack: Added regex for filtering on subset names [\#2775](https://github.com/pypeclub/OpenPype/pull/2775) -- Houdini: Fix open last workfile [\#2767](https://github.com/pypeclub/OpenPype/pull/2767) - General: Extract template formatting from anatomy [\#2766](https://github.com/pypeclub/OpenPype/pull/2766) -- Harmony: Rendering in Deadline didn't work in other machines than submitter [\#2754](https://github.com/pypeclub/OpenPype/pull/2754) -- Houdini: Move Houdini Save Current File to beginning of ExtractorOrder [\#2747](https://github.com/pypeclub/OpenPype/pull/2747) -- Maya: set Deadline job/batch name to original source workfile name instead of published workfile [\#2733](https://github.com/pypeclub/OpenPype/pull/2733) ## [3.8.2](https://github.com/pypeclub/OpenPype/tree/3.8.2) (2022-02-07) diff --git a/openpype/version.py b/openpype/version.py index b41951a34c..d977e87243 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.9.0-nightly.5" +__version__ = "3.9.0-nightly.6" diff --git a/pyproject.toml b/pyproject.toml index 851bf3f735..2469cb76a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.9.0-nightly.5" # OpenPype +version = "3.9.0-nightly.6" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 502785021e5fcf364bb1b01217c1dc522114d17e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Sat, 5 Mar 2022 08:30:29 +0100 Subject: [PATCH 340/483] moved events to openpype.lib --- openpype/__init__.py | 2 +- openpype/hosts/aftereffects/api/pipeline.py | 2 +- openpype/hosts/blender/api/pipeline.py | 2 +- openpype/hosts/harmony/api/pipeline.py | 2 +- openpype/hosts/hiero/api/events.py | 4 ++-- openpype/hosts/houdini/api/pipeline.py | 8 +++----- openpype/hosts/maya/api/pipeline.py | 10 +++++----- openpype/hosts/nuke/api/pipeline.py | 2 +- openpype/hosts/photoshop/api/pipeline.py | 2 +- openpype/hosts/tvpaint/api/communication_server.py | 2 +- openpype/hosts/tvpaint/api/pipeline.py | 2 +- openpype/lib/__init__.py | 7 +++++++ openpype/{pipeline => lib}/events.py | 11 ++++++++--- openpype/pipeline/__init__.py | 8 -------- openpype/tools/loader/app.py | 2 +- openpype/tools/workfiles/app.py | 2 +- 16 files changed, 35 insertions(+), 33 deletions(-) rename openpype/{pipeline => lib}/events.py (94%) diff --git a/openpype/__init__.py b/openpype/__init__.py index 9175727a54..63ad81d3ca 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -11,8 +11,8 @@ from .lib import ( filter_pyblish_plugins, set_plugin_attributes_from_settings, change_timer_to_current_context, + register_event_callback, ) -from .pipeline import register_event_callback pyblish = avalon = _original_discover = None diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 6f7cd8c46d..96d04aad14 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -10,7 +10,7 @@ from avalon import io, pipeline from openpype import lib from openpype.api import Logger import openpype.hosts.aftereffects -from openpype.pipeline import register_event_callback +from openpype.lib import register_event_callback from .launch_logic import get_stub diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 38312316cc..d1e7df3a93 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -15,7 +15,7 @@ from avalon import io, schema from avalon.pipeline import AVALON_CONTAINER_ID from openpype.api import Logger -from openpype.pipeline import ( +from openpype.lib import ( register_event_callback, emit_event ) diff --git a/openpype/hosts/harmony/api/pipeline.py b/openpype/hosts/harmony/api/pipeline.py index a94d30210e..8d7ac19eb9 100644 --- a/openpype/hosts/harmony/api/pipeline.py +++ b/openpype/hosts/harmony/api/pipeline.py @@ -9,7 +9,7 @@ import avalon.api from avalon.pipeline import AVALON_CONTAINER_ID from openpype import lib -from openpype.pipeline import register_event_callback +from openpype.lib import register_event_callback import openpype.hosts.harmony import openpype.hosts.harmony.api as harmony diff --git a/openpype/hosts/hiero/api/events.py b/openpype/hosts/hiero/api/events.py index 6e2580ed8c..9439199933 100644 --- a/openpype/hosts/hiero/api/events.py +++ b/openpype/hosts/hiero/api/events.py @@ -1,12 +1,12 @@ import os import hiero.core.events from openpype.api import Logger -from openpype.pipeline import register_event_callback from .lib import ( sync_avalon_data_to_workfile, launch_workfiles_app, selection_changed_timeline, - before_project_save + before_project_save, + register_event_callback ) from .tags import add_tags_to_workfile from .menu import update_menu_task_label diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 86c85ad3a1..bbb7a7c512 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -11,15 +11,13 @@ import avalon.api from avalon.pipeline import AVALON_CONTAINER_ID from avalon.lib import find_submodule -from openpype.pipeline import ( - register_event_callback, - emit_event -) import openpype.hosts.houdini from openpype.hosts.houdini.api import lib from openpype.lib import ( - any_outdated + register_event_callback, + emit_event, + any_outdated, ) from .lib import get_asset_fps diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 05db1b7b26..4945a9ba56 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -14,8 +14,8 @@ from avalon.pipeline import AVALON_CONTAINER_ID import openpype.hosts.maya from openpype.tools.utils import host_tools -from openpype.lib import any_outdated -from openpype.pipeline import ( +from openpype.lib import ( + any_outdated, register_event_callback, emit_event ) @@ -150,15 +150,15 @@ def _on_maya_initialized(*args): lib.get_main_window() -def _on_scene_new(): +def _on_scene_new(*args): emit_event("new") -def _on_scene_save(): +def _on_scene_save(*args): emit_event("save") -def _on_scene_open(): +def _on_scene_open(*args): emit_event("open") diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index 1a5116c9ea..419cfb1ac2 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -14,7 +14,7 @@ from openpype.api import ( BuildWorkfile, get_current_project_settings ) -from openpype.pipeline import register_event_callback +from openpype.lib import register_event_callback from openpype.tools.utils import host_tools from .command import viewer_update_and_undo_stop diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index 2aade59812..e424400465 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -6,7 +6,7 @@ import avalon.api from avalon import pipeline, io from openpype.api import Logger -from openpype.pipeline import register_event_callback +from openpype.lib import register_event_callback import openpype.hosts.photoshop from . import lib diff --git a/openpype/hosts/tvpaint/api/communication_server.py b/openpype/hosts/tvpaint/api/communication_server.py index b001b84203..65cb9aa2f3 100644 --- a/openpype/hosts/tvpaint/api/communication_server.py +++ b/openpype/hosts/tvpaint/api/communication_server.py @@ -21,7 +21,7 @@ from aiohttp_json_rpc.protocol import ( ) from aiohttp_json_rpc.exceptions import RpcError -from openpype.pipeline import emit_event +from openpype.lib import emit_event from openpype.hosts.tvpaint.tvpaint_plugin import get_plugin_files_path log = logging.getLogger(__name__) diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index b999478fb1..381c2c62f0 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -14,7 +14,7 @@ from avalon.pipeline import AVALON_CONTAINER_ID from openpype.hosts import tvpaint from openpype.api import get_current_project_settings -from openpype.pipeline import register_event_callback +from openpype.lib import register_event_callback from .lib import ( execute_george, diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 6a24f30455..1ee9129fa7 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -16,6 +16,10 @@ sys.path.insert(0, python_version_dir) site.addsitedir(python_version_dir) +from .events import ( + emit_event, + register_event_callback +) from .env_tools import ( env_value_to_bool, get_paths_from_environ, @@ -193,6 +197,9 @@ from .openpype_version import ( terminal = Terminal __all__ = [ + "emit_event", + "register_event_callback", + "get_openpype_execute_args", "get_pype_execute_args", "get_linux_launcher_args", diff --git a/openpype/pipeline/events.py b/openpype/lib/events.py similarity index 94% rename from openpype/pipeline/events.py rename to openpype/lib/events.py index cae8b250f7..62c480d8e0 100644 --- a/openpype/pipeline/events.py +++ b/openpype/lib/events.py @@ -8,7 +8,7 @@ from uuid import uuid4 try: from weakref import WeakMethod except Exception: - from .python_2_comp import WeakMethod + from openpype.lib.python_2_comp import WeakMethod class EventCallback(object): @@ -83,6 +83,7 @@ class EventCallback(object): Args: event(Event): Event that was triggered. """ + self.log.info("Processing event {}".format(event.topic)) # Skip if callback is not enabled or has invalid reference if not self._ref_valid or not self._enabled: return @@ -93,9 +94,11 @@ class EventCallback(object): if not callback: # Change state if is invalid so the callback is removed self._ref_valid = False + self.log.info("Invalid reference") elif self.topic_matches(event.topic): # Try execute callback + self.log.info("Triggering callback") sig = inspect.signature(callback) try: if len(sig.parameters) == 0: @@ -109,6 +112,8 @@ class EventCallback(object): ), exc_info=True ) + else: + self.log.info("Not matchin callback") # Inherit from 'object' for Python 2 hosts @@ -172,7 +177,7 @@ class StoredCallbacks: elif callable(callback): ref = weakref.ref(callback) else: - # TODO add logs + print("Invalid callback") return function_name = callback.__name__ @@ -197,7 +202,7 @@ class StoredCallbacks: def emit_event(cls, event): invalid_callbacks = [] for callback in cls._registered_callbacks: - callback.process_event() + callback.process_event(event) if not callback.is_ref_valid: invalid_callbacks.append(callback) diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index 673608bded..e968df4011 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -1,10 +1,5 @@ from .lib import attribute_definitions -from .events import ( - emit_event, - register_event_callback -) - from .create import ( BaseCreator, Creator, @@ -22,9 +17,6 @@ from .publish import ( __all__ = ( "attribute_definitions", - "emit_event", - "register_event_callback", - "BaseCreator", "Creator", "AutoCreator", diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py index afb94bf8fc..ec8f56e74b 100644 --- a/openpype/tools/loader/app.py +++ b/openpype/tools/loader/app.py @@ -4,7 +4,7 @@ from Qt import QtWidgets, QtCore from avalon import api, io from openpype import style -from openpype.pipeline import register_event_callback +from openpype.lib import register_event_callback from openpype.tools.utils import ( lib, PlaceholderLineEdit diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index 280fe2d8a2..87e1492a20 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -12,7 +12,6 @@ from Qt import QtWidgets, QtCore from avalon import io, api from openpype import style -from openpype.pipeline import emit_event from openpype.tools.utils.lib import ( qt_app_context ) @@ -21,6 +20,7 @@ from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget from openpype.tools.utils.tasks_widget import TasksWidget from openpype.tools.utils.delegates import PrettyTimeDelegate from openpype.lib import ( + emit_event, Anatomy, get_workfile_doc, create_workfile_doc, From 70cf30041aa581f65fd5227cae508e42b7aeac88 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Sat, 5 Mar 2022 09:45:36 +0100 Subject: [PATCH 341/483] fix python 2 compatibility --- openpype/lib/events.py | 89 ++++++++++++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 26 deletions(-) diff --git a/openpype/lib/events.py b/openpype/lib/events.py index 62c480d8e0..f71cb74c10 100644 --- a/openpype/lib/events.py +++ b/openpype/lib/events.py @@ -12,7 +12,32 @@ except Exception: class EventCallback(object): - def __init__(self, topic, func_ref, func_name, func_path): + """Callback registered to a topic. + + The callback function is registered to a topic. Topic is a string which + may contain '*' that will be handled as "any characters". + + # Examples: + - "workfile.save" Callback will be triggered if the event topic is exactly + "workfile.save" . + - "workfile.*" Callback will be triggered an event topic starts with + "workfile." so "workfile.save" and "workfile.open" + will trigger the callback. + - "*" Callback will listen to all events. + + Callback can be function or method. In both cases it should expect one + or none arguments. When 1 argument is expected then the processed 'Event' + object is passed in. + + The registered callbacks don't keep function in memory so it is not + possible to store lambda function as callback. + + Args: + topic(str): Topic which will be listened. + func(func): Callback to a topic. + """ + def __init__(self, topic, func): + self._log = None self._topic = topic # Replace '*' with any character regex and escape rest of text # - when callback is registered for '*' topic it will receive all @@ -28,14 +53,42 @@ class EventCallback(object): ) topic_regex = re.compile(topic_regex_str) self._topic_regex = topic_regex + + # Convert callback into references + # - deleted functions won't cause crashes + if inspect.ismethod(func): + func_ref = WeakMethod(func) + elif callable(func): + func_ref = weakref.ref(func) + else: + func_ref = None + self.log.warning(( + "Registered callback is not callable. \"{}\"" + ).format(str(func))) + + func_name = None + func_path = None + expect_args = False + # Collect additional data about function + # - name + # - path + # - if expect argument or not + if func_ref is not None: + func_name = func.__name__ + func_path = os.path.abspath(inspect.getfile(func)) + if hasattr(inspect, "signature"): + sig = inspect.signature(func) + expect_args = len(sig.parameters) > 0 + else: + expect_args = len(inspect.getargspec(func)[0]) > 0 + self._func_ref = func_ref self._func_name = func_name self._func_path = func_path - self._ref_valid = True + self._expect_args = expect_args + self._ref_valid = func_ref is not None self._enabled = True - self._log = None - def __repr__(self): return "< {} - {} > {}".format( self.__class__.__name__, self._func_name, self._func_path @@ -83,7 +136,7 @@ class EventCallback(object): Args: event(Event): Event that was triggered. """ - self.log.info("Processing event {}".format(event.topic)) + # Skip if callback is not enabled or has invalid reference if not self._ref_valid or not self._enabled: return @@ -94,17 +147,15 @@ class EventCallback(object): if not callback: # Change state if is invalid so the callback is removed self._ref_valid = False - self.log.info("Invalid reference") elif self.topic_matches(event.topic): # Try execute callback - self.log.info("Triggering callback") - sig = inspect.signature(callback) try: - if len(sig.parameters) == 0: - callback() - else: + if self._expect_args: callback(event) + else: + callback() + except Exception: self.log.warning( "Failed to execute event callback {}".format( @@ -112,8 +163,6 @@ class EventCallback(object): ), exc_info=True ) - else: - self.log.info("Not matchin callback") # Inherit from 'object' for Python 2 hosts @@ -170,19 +219,7 @@ class StoredCallbacks: @classmethod def add_callback(cls, topic, callback): - # Convert callback into references - # - deleted functions won't cause crashes - if inspect.ismethod(callback): - ref = WeakMethod(callback) - elif callable(callback): - ref = weakref.ref(callback) - else: - print("Invalid callback") - return - - function_name = callback.__name__ - function_path = os.path.abspath(inspect.getfile(callback)) - callback = EventCallback(topic, ref, function_name, function_path) + callback = EventCallback(topic, callback) cls._registered_callbacks.append(callback) return callback From 17c88141c154003736e59e93f75bc405fc604845 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 7 Mar 2022 12:20:50 +0100 Subject: [PATCH 342/483] fix emit of taskChanged topic --- openpype/lib/avalon_context.py | 4 ++-- openpype/lib/events.py | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 0bfd3f6de0..67a5515100 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -15,6 +15,7 @@ from openpype.settings import ( ) from .anatomy import Anatomy from .profiles_filtering import filter_profiles +from .events import emit_event # avalon module is not imported at the top # - may not be in path at the time of pype.lib initialization @@ -779,7 +780,6 @@ def update_current_task(task=None, asset=None, app=None, template_key=None): """ import avalon.api - from avalon.pipeline import emit changes = compute_session_changes( avalon.api.Session, @@ -799,7 +799,7 @@ def update_current_task(task=None, asset=None, app=None, template_key=None): os.environ[key] = value # Emit session change - emit("taskChanged", changes.copy()) + emit_event("taskChanged", changes.copy()) return changes diff --git a/openpype/lib/events.py b/openpype/lib/events.py index f71cb74c10..9496d71ca8 100644 --- a/openpype/lib/events.py +++ b/openpype/lib/events.py @@ -248,13 +248,30 @@ class StoredCallbacks: def register_event_callback(topic, callback): - """Add callback that will be executed on specific topic.""" + """Add callback that will be executed on specific topic. + + Args: + topic(str): Topic on which will callback be triggered. + callback(function): Callback that will be triggered when a topic + is triggered. Callback should expect none or 1 argument where + `Event` object is passed. + + Returns: + EventCallback: Object wrapping the callback. It can be used to + enable/disable listening to a topic or remove the callback from + the topic completely. + """ return StoredCallbacks.add_callback(topic, callback) def emit_event(topic, data=None, source=None): """Emit event with topic and data. + Arg: + topic(str): Event's topic. + data(dict): Event's additional data. Optional. + source(str): Who emitted the topic. Optional. + Returns: Event: Object of event that was emitted. """ From 38b6ad8042647c490c18fc8624e435b9218b83d4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 7 Mar 2022 12:21:53 +0100 Subject: [PATCH 343/483] Loader UI is using registering callback from init --- openpype/tools/loader/app.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py index ec8f56e74b..d73a977ac6 100644 --- a/openpype/tools/loader/app.py +++ b/openpype/tools/loader/app.py @@ -26,17 +26,6 @@ module = sys.modules[__name__] module.window = None -# Register callback on task change -# - callback can't be defined in Window as it is weak reference callback -# so `WeakSet` will remove it immediately -def on_context_task_change(*args, **kwargs): - if module.window: - module.window.on_context_task_change(*args, **kwargs) - - -register_event_callback("taskChanged", on_context_task_change) - - class LoaderWindow(QtWidgets.QDialog): """Asset loader interface""" @@ -195,6 +184,8 @@ class LoaderWindow(QtWidgets.QDialog): self._first_show = True + register_event_callback("taskChanged", self.on_context_task_change) + def resizeEvent(self, event): super(LoaderWindow, self).resizeEvent(event) self._overlay_frame.resize(self.size()) From e0395fd0afadb39cd789e46cd5ae12eed7863ddc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 7 Mar 2022 12:42:43 +0100 Subject: [PATCH 344/483] hound fix --- openpype/lib/events.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/lib/events.py b/openpype/lib/events.py index 9496d71ca8..9cb80b2a6e 100644 --- a/openpype/lib/events.py +++ b/openpype/lib/events.py @@ -18,8 +18,8 @@ class EventCallback(object): may contain '*' that will be handled as "any characters". # Examples: - - "workfile.save" Callback will be triggered if the event topic is exactly - "workfile.save" . + - "workfile.save" Callback will be triggered if the event topic is + exactly "workfile.save" . - "workfile.*" Callback will be triggered an event topic starts with "workfile." so "workfile.save" and "workfile.open" will trigger the callback. From 8088534caa55bacecdf0edd5c9825cd9b80b1908 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 7 Mar 2022 14:49:55 +0100 Subject: [PATCH 345/483] implemented 'create_hard_link' function in openpype lib --- openpype/lib/__init__.py | 2 ++ openpype/lib/path_tools.py | 1 - openpype/lib/vendor_bin_utils.py | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 6a24f30455..e1006303db 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -58,6 +58,7 @@ from .anatomy import ( from .config import get_datetime_data from .vendor_bin_utils import ( + create_hard_link, get_vendor_bin_path, get_oiio_tools_path, get_ffmpeg_tool_path, @@ -208,6 +209,7 @@ __all__ = [ "get_paths_from_environ", "get_global_environments", + "create_hard_link", "get_vendor_bin_path", "get_oiio_tools_path", "get_ffmpeg_tool_path", diff --git a/openpype/lib/path_tools.py b/openpype/lib/path_tools.py index d6c32ad9e8..71fc0fe25c 100644 --- a/openpype/lib/path_tools.py +++ b/openpype/lib/path_tools.py @@ -6,7 +6,6 @@ import logging import six from openpype.settings import get_project_settings -from openpype.settings.lib import get_site_local_overrides from .anatomy import Anatomy from .profiles_filtering import filter_profiles diff --git a/openpype/lib/vendor_bin_utils.py b/openpype/lib/vendor_bin_utils.py index 4c2cf93dfa..fcc15a31f0 100644 --- a/openpype/lib/vendor_bin_utils.py +++ b/openpype/lib/vendor_bin_utils.py @@ -8,6 +8,41 @@ import distutils log = logging.getLogger("FFmpeg utils") +def create_hard_link(src_path, dst_path): + """Create hardlink of file. + + Args: + src_path(str): Full path to a file which is used as source for + hardlink. + dst_path(str): Full path to a file where a link of source will be + added. + """ + # Use `os.link` if is available + # - should be for all platforms with newer python versions + if hasattr(os, "link"): + os.link(src_path, dst_path) + return + + # Windows implementation of hardlinks + # - used in Python 2 + if platform.system().lower() == "windows": + import ctypes + from ctypes.wintypes import BOOL + CreateHardLink = ctypes.windll.kernel32.CreateHardLinkW + CreateHardLink.argtypes = [ + ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_void_p + ] + CreateHardLink.restype = BOOL + + res = CreateHardLink(dst_path, src_path, None) + if res == 0: + raise ctypes.WinError() + # Raises not implemented error if gets here + raise NotImplementedError( + "Implementation of hardlink for current environment is missing." + ) + + def get_vendor_bin_path(bin_app): """Path to OpenPype vendorized binaries. From c57fc0391677b0b48a06ba4c723936cf46071c5f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 7 Mar 2022 14:50:14 +0100 Subject: [PATCH 346/483] use create_hard_link instead of filelink --- openpype/lib/delivery.py | 7 +++---- openpype/plugins/publish/integrate_hero_version.py | 4 ++-- openpype/plugins/publish/integrate_new.py | 8 +++++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/openpype/lib/delivery.py b/openpype/lib/delivery.py index a61603fa05..9fc65aae8e 100644 --- a/openpype/lib/delivery.py +++ b/openpype/lib/delivery.py @@ -71,15 +71,14 @@ def path_from_representation(representation, anatomy): def copy_file(src_path, dst_path): """Hardlink file if possible(to save space), copy if not""" - from avalon.vendor import filelink # safer importing + from openpype.lib import create_hard_link # safer importing if os.path.exists(dst_path): return try: - filelink.create( + create_hard_link( src_path, - dst_path, - filelink.HARDLINK + dst_path ) except OSError: shutil.copyfile(src_path, dst_path) diff --git a/openpype/plugins/publish/integrate_hero_version.py b/openpype/plugins/publish/integrate_hero_version.py index ec836954e8..60245314f4 100644 --- a/openpype/plugins/publish/integrate_hero_version.py +++ b/openpype/plugins/publish/integrate_hero_version.py @@ -7,7 +7,7 @@ import shutil from pymongo import InsertOne, ReplaceOne import pyblish.api from avalon import api, io, schema -from avalon.vendor import filelink +from openpype.lib import create_hard_link class IntegrateHeroVersion(pyblish.api.InstancePlugin): @@ -518,7 +518,7 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): # First try hardlink and copy if paths are cross drive try: - filelink.create(src_path, dst_path, filelink.HARDLINK) + create_hard_link(src_path, dst_path) # Return when successful return diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 6e0940d459..f9ab46b6fd 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -13,12 +13,14 @@ from pymongo import DeleteOne, InsertOne import pyblish.api from avalon import io from avalon.api import format_template_with_optional_keys -from avalon.vendor import filelink import openpype.api from datetime import datetime # from pype.modules import ModulesManager from openpype.lib.profiles_filtering import filter_profiles -from openpype.lib import prepare_template_data +from openpype.lib import ( + prepare_template_data, + create_hard_link +) # this is needed until speedcopy for linux is fixed if sys.platform == "win32": @@ -730,7 +732,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): self.log.critical("An unexpected error occurred.") six.reraise(*sys.exc_info()) - filelink.create(src, dst, filelink.HARDLINK) + create_hard_link(src, dst) def get_subset(self, asset, instance): subset_name = instance.data["subset"] From 4c305b16772e56c4d3e7e4dce6e22a73040f0435 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 7 Mar 2022 16:04:21 +0100 Subject: [PATCH 347/483] global: settings removing pillar/letter box enumerator --- .../settings/defaults/project_settings/global.json | 1 - .../schemas/schema_global_publish.json | 13 ------------- 2 files changed, 14 deletions(-) diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index f08bee8b2d..9c44d9bc86 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -107,7 +107,6 @@ "letter_box": { "enabled": false, "ratio": 0.0, - "state": "letterbox", "fill_color": [ 0, 0, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json index e608e9ff63..3eea7ccb30 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json @@ -366,19 +366,6 @@ "minimum": 0, "maximum": 10000 }, - { - "key": "state", - "label": "Type", - "type": "enum", - "enum_items": [ - { - "letterbox": "Letterbox" - }, - { - "pillar": "Pillar" - } - ] - }, { "type": "color", "label": "Fill Color", From e1dd41274344533eee44759672a17175f9fadd1b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 7 Mar 2022 16:05:16 +0100 Subject: [PATCH 348/483] global: extract review with dynamic letter/pillar box switch --- openpype/plugins/publish/extract_review.py | 48 +++++++++++----------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index a76c0fa450..b70b81e18d 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -978,7 +978,6 @@ class ExtractReview(pyblish.api.InstancePlugin): output = [] ratio = letter_box_def["ratio"] - state = letter_box_def["state"] fill_color = letter_box_def["fill_color"] f_red, f_green, f_blue, f_alpha = fill_color fill_color_hex = "{0:0>2X}{1:0>2X}{2:0>2X}".format( @@ -993,13 +992,13 @@ class ExtractReview(pyblish.api.InstancePlugin): l_red, l_green, l_blue ) line_color_alpha = float(l_alpha) / 255 - test_ratio_width = int( - (output_height - (output_width * (1 / ratio))) / 2 - ) - test_ratio_height = int( - (output_width - (output_height * ratio)) / 2 - ) - if state == "letterbox" and test_ratio_width: + + # test ratios and define if pillar or letter boxes + output_ratio = output_width / output_height + pillar = output_ratio > ratio + need_mask = format(output_ratio, ".3f") != format(ratio, ".3f") + + if need_mask and not pillar: if fill_color_alpha > 0: top_box = ( "drawbox=0:0:{widht}:round(" @@ -1055,7 +1054,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ) output.extend([top_line, bottom_line]) - elif state == "pillar" and test_ratio_height: + elif need_mask and pillar: if fill_color_alpha > 0: left_box = ( "drawbox=0:0:round(({widht}-({height}" @@ -1308,21 +1307,6 @@ class ExtractReview(pyblish.api.InstancePlugin): "scale_factor_by_height: `{}`".format(scale_factor_by_height) ) - # letter_box - if letter_box_enabled: - filters.extend([ - "scale={}x{}:flags=lanczos".format( - output_width, output_height - ), - "setsar=1" - ]) - filters.extend( - self.get_letterbox_filters( - letter_box_def, - output_width, - output_height - ) - ) # scaling none square pixels and 1920 width if ( @@ -1362,6 +1346,22 @@ class ExtractReview(pyblish.api.InstancePlugin): "setsar=1" ]) + # letter_box + if letter_box_enabled: + filters.extend([ + "scale={}x{}:flags=lanczos".format( + output_width, output_height + ), + "setsar=1" + ]) + filters.extend( + self.get_letterbox_filters( + letter_box_def, + output_width, + output_height + ) + ) + new_repre["resolutionWidth"] = output_width new_repre["resolutionHeight"] = output_height From f566779531e83df670f474fcc2652f84408f0234 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 7 Mar 2022 16:30:01 +0100 Subject: [PATCH 349/483] global: shifting order for `reformated` tag processing --- openpype/plugins/publish/extract_review.py | 33 +++++++++++----------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index b70b81e18d..fedeee6f08 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -1124,6 +1124,9 @@ class ExtractReview(pyblish.api.InstancePlugin): """ filters = [] + # Get instance data + pixel_aspect = temp_data["pixel_aspect"] + # NOTE Skipped using instance's resolution full_input_path_single_file = temp_data["full_input_path_single_file"] try: @@ -1158,6 +1161,19 @@ class ExtractReview(pyblish.api.InstancePlugin): output_width = output_def.get("width") or None output_height = output_def.get("height") or None + # if nuke baking profile was having set reformat node + reformat_in_baking = bool("reformated" in new_repre["tags"]) + self.log.debug("reformat_in_baking: `{}`".format(reformat_in_baking)) + + if reformat_in_baking: + self.log.debug(( + "Using resolution from input. It is already " + "reformated from baking process" + )) + output_width = output_width or input_width + output_height = output_height or input_height + pixel_aspect = 1 + # Overscal color overscan_color_value = "black" overscan_color = output_def.get("overscan_color") @@ -1189,9 +1205,6 @@ class ExtractReview(pyblish.api.InstancePlugin): letter_box_def = output_def["letter_box"] letter_box_enabled = letter_box_def["enabled"] - # Get instance data - pixel_aspect = temp_data["pixel_aspect"] - # Make sure input width and height is not an odd number input_width_is_odd = bool(input_width % 2 != 0) input_height_is_odd = bool(input_height % 2 != 0) @@ -1216,9 +1229,6 @@ class ExtractReview(pyblish.api.InstancePlugin): self.log.debug("input_width: `{}`".format(input_width)) self.log.debug("input_height: `{}`".format(input_height)) - reformat_in_baking = bool("reformated" in new_repre["tags"]) - self.log.debug("reformat_in_baking: `{}`".format(reformat_in_baking)) - # Use instance resolution if output definition has not set it. if output_width is None or output_height is None: output_width = temp_data["resolution_width"] @@ -1230,17 +1240,6 @@ class ExtractReview(pyblish.api.InstancePlugin): output_width = input_width output_height = input_height - if reformat_in_baking: - self.log.debug(( - "Using resolution from input. It is already " - "reformated from baking process" - )) - output_width = input_width - output_height = input_height - pixel_aspect = 1 - new_repre["resolutionWidth"] = input_width - new_repre["resolutionHeight"] = input_height - output_width = int(output_width) output_height = int(output_height) From bde7d988a21fdf1df8ac95ff863089f25dc31612 Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Mon, 7 Mar 2022 16:59:50 +0100 Subject: [PATCH 350/483] Fix family test in validate_write_legacy to work with stillImage --- openpype/hosts/nuke/plugins/publish/validate_write_legacy.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py b/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py index a73bed8edd..91e7dacc6e 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py +++ b/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py @@ -34,9 +34,8 @@ class ValidateWriteLegacy(pyblish.api.InstancePlugin): # test if render in family test knob # and only one item should be available assert len(family_test) == 1, msg + " > More avalon attributes" - assert "render" in node[family_test[0]].value(), msg + \ + assert "render" in node[family_test[0]].value() or "still" in node[family_test[0]].value(), msg + \ " > Not correct family" - # test if `file` knob in node, this way old # non-group-node write could be detected assert "file" not in node.knobs(), msg + \ @@ -74,6 +73,8 @@ class ValidateWriteLegacy(pyblish.api.InstancePlugin): Create_name = "CreateWriteRender" elif family == "prerender": Create_name = "CreateWritePrerender" + elif family == "still": + Create_name = "CreateWriteStill" # get appropriate plugin class creator_plugin = None From 9f39bba2d3bdc97f0005ed3793134f66ecad834b Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Mon, 7 Mar 2022 17:06:23 +0100 Subject: [PATCH 351/483] fix hound --- openpype/hosts/nuke/plugins/publish/validate_write_legacy.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py b/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py index 91e7dacc6e..08f09f8097 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py +++ b/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py @@ -34,7 +34,8 @@ class ValidateWriteLegacy(pyblish.api.InstancePlugin): # test if render in family test knob # and only one item should be available assert len(family_test) == 1, msg + " > More avalon attributes" - assert "render" in node[family_test[0]].value() or "still" in node[family_test[0]].value(), msg + \ + assert "render" in node[family_test[0]].value() \ + or "still" in node[family_test[0]].value(), msg + \ " > Not correct family" # test if `file` knob in node, this way old # non-group-node write could be detected From e48af4585734f2354ea0133d7fafc9c0922aefca Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 7 Mar 2022 17:22:51 +0100 Subject: [PATCH 352/483] moved qargparse into openpype vendor --- openpype/hosts/flame/api/plugin.py | 2 +- openpype/hosts/hiero/api/plugin.py | 2 +- openpype/hosts/maya/api/plugin.py | 3 +- openpype/hosts/nuke/plugins/load/load_clip.py | 2 +- .../hosts/nuke/plugins/load/load_image.py | 3 +- .../plugins/load/load_image_from_sequence.py | 3 +- openpype/hosts/resolve/api/plugin.py | 6 +- .../hosts/tvpaint/plugins/load/load_image.py | 2 +- .../plugins/load/load_reference_image.py | 2 +- openpype/plugins/load/delete_old_versions.py | 2 +- openpype/tools/utils/widgets.py | 4 +- openpype/vendor/python/common/qargparse.py | 817 ++++++++++++++++++ 12 files changed, 833 insertions(+), 15 deletions(-) create mode 100644 openpype/vendor/python/common/qargparse.py diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index ec49db1601..0850faf98d 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -2,9 +2,9 @@ import os import re import shutil import sys -from avalon.vendor import qargparse from xml.etree import ElementTree as ET import six +import qargparse from Qt import QtWidgets, QtCore import openpype.api as openpype from openpype import style diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index 3506af2d6a..3d7bdeab68 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -2,7 +2,7 @@ import re import os import hiero from Qt import QtWidgets, QtCore -from avalon.vendor import qargparse +import qargparse import avalon.api as avalon import openpype.api as openpype from . import lib diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index bdb8fcf13a..547b125eb4 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -2,8 +2,9 @@ import os from maya import cmds +import qargparse + from avalon import api -from avalon.vendor import qargparse from openpype.api import PypeCreatorMixin from .pipeline import containerise diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py index 21b7a6a816..a253ba4a9d 100644 --- a/openpype/hosts/nuke/plugins/load/load_clip.py +++ b/openpype/hosts/nuke/plugins/load/load_clip.py @@ -1,5 +1,5 @@ import nuke -from avalon.vendor import qargparse +import qargparse from avalon import api, io from openpype.hosts.nuke.api.lib import ( diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index d36226b139..27c634ec57 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -1,7 +1,6 @@ -import re import nuke -from avalon.vendor import qargparse +import qargparse from avalon import api, io from openpype.hosts.nuke.api.lib import ( diff --git a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py index 6627aded51..12e0503dfc 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py +++ b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py @@ -1,7 +1,7 @@ import os +import qargparse from avalon.pipeline import get_representation_path_from_context -from avalon.vendor import qargparse from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.api import get_unique_layer_name @@ -92,4 +92,3 @@ class ImageFromSequenceLoader(photoshop.PhotoshopLoader): def remove(self, container): """No update possible, not containerized.""" pass - diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 8612cf82ec..3f4476e18e 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -1,12 +1,14 @@ import re import uuid + +import qargparse +from Qt import QtWidgets, QtCore + from avalon import api import openpype.api as pype from openpype.hosts import resolve -from avalon.vendor import qargparse from . import lib -from Qt import QtWidgets, QtCore class CreatorWidget(QtWidgets.QDialog): diff --git a/openpype/hosts/tvpaint/plugins/load/load_image.py b/openpype/hosts/tvpaint/plugins/load/load_image.py index 7dba1e3619..f861d0119e 100644 --- a/openpype/hosts/tvpaint/plugins/load/load_image.py +++ b/openpype/hosts/tvpaint/plugins/load/load_image.py @@ -1,4 +1,4 @@ -from avalon.vendor import qargparse +import qargparse from openpype.hosts.tvpaint.api import lib, plugin diff --git a/openpype/hosts/tvpaint/plugins/load/load_reference_image.py b/openpype/hosts/tvpaint/plugins/load/load_reference_image.py index 0a85e5dc76..5e4e3965d2 100644 --- a/openpype/hosts/tvpaint/plugins/load/load_reference_image.py +++ b/openpype/hosts/tvpaint/plugins/load/load_reference_image.py @@ -1,6 +1,6 @@ import collections +import qargparse from avalon.pipeline import get_representation_context -from avalon.vendor import qargparse from openpype.hosts.tvpaint.api import lib, pipeline, plugin diff --git a/openpype/plugins/load/delete_old_versions.py b/openpype/plugins/load/delete_old_versions.py index b2f2c88975..e8612745fb 100644 --- a/openpype/plugins/load/delete_old_versions.py +++ b/openpype/plugins/load/delete_old_versions.py @@ -5,10 +5,10 @@ import uuid import clique from pymongo import UpdateOne import ftrack_api +import qargparse from Qt import QtWidgets, QtCore from avalon import api, style -from avalon.vendor import qargparse from avalon.api import AvalonMongoDB import avalon.pipeline from openpype.api import Anatomy diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index a4e172ea5c..783736a9ca 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -1,8 +1,8 @@ import logging from Qt import QtWidgets, QtCore, QtGui - -from avalon.vendor import qtawesome, qargparse +import qargparse +from avalon.vendor import qtawesome from openpype.style import ( get_objected_colors, get_style_image_path diff --git a/openpype/vendor/python/common/qargparse.py b/openpype/vendor/python/common/qargparse.py new file mode 100644 index 0000000000..ebde9ae76d --- /dev/null +++ b/openpype/vendor/python/common/qargparse.py @@ -0,0 +1,817 @@ +""" +NOTE: The required `Qt` module has changed to use the one that vendorized. + Remember to change to relative import when updating this. +""" + +import re +import logging + +from collections import OrderedDict as odict +from Qt import QtCore, QtWidgets, QtGui +import qtawesome + +__version__ = "0.5.2" +_log = logging.getLogger(__name__) +_type = type # used as argument + +try: + # Python 2 + _basestring = basestring +except NameError: + _basestring = str + + +class QArgumentParser(QtWidgets.QWidget): + """User interface arguments + + Arguments: + arguments (list, optional): Instances of QArgument + description (str, optional): Long-form text of what this parser is for + storage (QSettings, optional): Persistence to disk, providing + value() and setValue() methods + + """ + + changed = QtCore.Signal(QtCore.QObject) # A QArgument + + def __init__(self, + arguments=None, + description=None, + storage=None, + parent=None): + super(QArgumentParser, self).__init__(parent) + self.setAttribute(QtCore.Qt.WA_StyledBackground) + + # Create internal settings + if storage is True: + storage = QtCore.QSettings( + QtCore.QSettings.IniFormat, + QtCore.QSettings.UserScope, + __name__, "QArgparse", + ) + + if storage is not None: + _log.info("Storing settings @ %s" % storage.fileName()) + + arguments = arguments or [] + + assert hasattr(arguments, "__iter__"), "arguments must be iterable" + assert isinstance(storage, (type(None), QtCore.QSettings)), ( + "storage must be of type QSettings" + ) + + layout = QtWidgets.QGridLayout(self) + layout.setRowStretch(999, 1) + + if description: + layout.addWidget(QtWidgets.QLabel(description), 0, 0, 1, 2) + + self._row = 1 + self._storage = storage + self._arguments = odict() + self._desciption = description + + for arg in arguments or []: + self._addArgument(arg) + + self.setStyleSheet(style) + + def setDescription(self, text): + self._desciption.setText(text) + + def addArgument(self, name, type=None, default=None, **kwargs): + # Infer type from default + if type is None and default is not None: + type = _type(default) + + # Default to string + type = type or str + + Argument = { + None: String, + int: Integer, + float: Float, + bool: Boolean, + str: String, + list: Enum, + tuple: Enum, + }.get(type, type) + + arg = Argument(name, default=default, **kwargs) + self._addArgument(arg) + return arg + + def _addArgument(self, arg): + if arg["name"] in self._arguments: + raise ValueError("Duplicate argument '%s'" % arg["name"]) + + if self._storage is not None: + default = self._storage.value(arg["name"]) + + if default: + if isinstance(arg, Boolean): + default = bool({ + None: QtCore.Qt.Unchecked, + + 0: QtCore.Qt.Unchecked, + 1: QtCore.Qt.Checked, + 2: QtCore.Qt.Checked, + + "0": QtCore.Qt.Unchecked, + "1": QtCore.Qt.Checked, + "2": QtCore.Qt.Checked, + + # May be stored as string, if used with IniFormat + "false": QtCore.Qt.Unchecked, + "true": QtCore.Qt.Checked, + }.get(default)) + + arg["default"] = default + + arg.changed.connect(lambda: self.on_changed(arg)) + + label = ( + QtWidgets.QLabel(arg["label"]) + if arg.label + else QtWidgets.QLabel() + ) + widget = arg.create() + icon = qtawesome.icon("fa.refresh", color="white") + reset = QtWidgets.QPushButton(icon, "") # default + reset.setToolTip("Reset") + reset.setProperty("type", "reset") + reset.clicked.connect(lambda: self.on_reset(arg)) + + # Shown on edit + reset.hide() + + for widget in (label, widget): + widget.setToolTip(arg["help"]) + widget.setObjectName(arg["name"]) # useful in CSS + widget.setProperty("type", type(arg).__name__) + widget.setAttribute(QtCore.Qt.WA_StyledBackground) + widget.setEnabled(arg["enabled"]) + + # Align label on top of row if widget is over two times heiger + height = (lambda w: w.sizeHint().height()) + label_on_top = height(label) * 2 < height(widget) + alignment = (QtCore.Qt.AlignTop,) if label_on_top else () + + layout = self.layout() + layout.addWidget(label, self._row, 0, *alignment) + layout.addWidget(widget, self._row, 1) + layout.addWidget(reset, self._row, 2, *alignment) + layout.setColumnStretch(1, 1) + + def on_changed(*_): + reset.setVisible(arg["edited"]) + + arg.changed.connect(on_changed) + + self._row += 1 + self._arguments[arg["name"]] = arg + + def clear(self): + assert self._storage, "Cannot clear without persistent storage" + self._storage.clear() + _log.info("Clearing settings @ %s" % self._storage.fileName()) + + def find(self, name): + return self._arguments[name] + + def on_reset(self, arg): + arg.write(arg["default"]) + + def on_changed(self, arg): + arg["edited"] = arg.read() != arg["default"] + self.changed.emit(arg) + + # Optional PEP08 syntax + add_argument = addArgument + + +class QArgument(QtCore.QObject): + """Base class of argument user interface + """ + changed = QtCore.Signal() + + # Provide a left-hand side label for this argument + label = True + # For defining default value for each argument type + default = None + + def __init__(self, name, default=None, **kwargs): + super(QArgument, self).__init__(kwargs.pop("parent", None)) + + kwargs["name"] = name + kwargs["label"] = kwargs.get("label", camel_to_title(name)) + kwargs["default"] = self.default if default is None else default + kwargs["help"] = kwargs.get("help", "") + kwargs["read"] = kwargs.get("read") + kwargs["write"] = kwargs.get("write") + kwargs["enabled"] = bool(kwargs.get("enabled", True)) + kwargs["edited"] = False + + self._data = kwargs + + def __str__(self): + return self["name"] + + def __repr__(self): + return "%s(\"%s\")" % (type(self).__name__, self["name"]) + + def __getitem__(self, key): + return self._data[key] + + def __setitem__(self, key, value): + self._data[key] = value + + def __eq__(self, other): + if isinstance(other, _basestring): + return self["name"] == other + return super(QArgument, self).__eq__(other) + + def __ne__(self, other): + return not self.__eq__(other) + + def create(self): + return QtWidgets.QWidget() + + def read(self): + return self._read() + + def write(self, value): + self._write(value) + self.changed.emit() + + +class Boolean(QArgument): + """Boolean type user interface + + Presented by `QtWidgets.QCheckBox`. + + Arguments: + name (str): The name of argument + label (str, optional): Display name, convert from `name` if not given + help (str, optional): Tool tip message of this argument + default (bool, optional): Argument's default value, default None + enabled (bool, optional): Whether to enable this widget, default True + + """ + def create(self): + widget = QtWidgets.QCheckBox() + widget.clicked.connect(self.changed.emit) + + if isinstance(self, Tristate): + self._read = lambda: widget.checkState() + state = { + 0: QtCore.Qt.Unchecked, + 1: QtCore.Qt.PartiallyChecked, + 2: QtCore.Qt.Checked, + "1": QtCore.Qt.PartiallyChecked, + "0": QtCore.Qt.Unchecked, + "2": QtCore.Qt.Checked, + } + else: + self._read = lambda: bool(widget.checkState()) + state = { + None: QtCore.Qt.Unchecked, + + 0: QtCore.Qt.Unchecked, + 1: QtCore.Qt.Checked, + 2: QtCore.Qt.Checked, + + "0": QtCore.Qt.Unchecked, + "1": QtCore.Qt.Checked, + "2": QtCore.Qt.Checked, + + # May be stored as string, if used with QSettings(..IniFormat) + "false": QtCore.Qt.Unchecked, + "true": QtCore.Qt.Checked, + } + + self._write = lambda value: widget.setCheckState(state[value]) + widget.clicked.connect(self.changed.emit) + + if self["default"] is not None: + self._write(self["default"]) + + return widget + + def read(self): + return self._read() + + +class Tristate(QArgument): + """Not implemented""" + + +class Number(QArgument): + """Base class of numeric type user interface""" + default = 0 + + def create(self): + if isinstance(self, Float): + widget = QtWidgets.QDoubleSpinBox() + widget.setMinimum(self._data.get("min", 0.0)) + widget.setMaximum(self._data.get("max", 99.99)) + else: + widget = QtWidgets.QSpinBox() + widget.setMinimum(self._data.get("min", 0)) + widget.setMaximum(self._data.get("max", 99)) + + widget.editingFinished.connect(self.changed.emit) + self._read = lambda: widget.value() + self._write = lambda value: widget.setValue(value) + + if self["default"] != self.default: + self._write(self["default"]) + + return widget + + +class Integer(Number): + """Integer type user interface + + A subclass of `qargparse.Number`, presented by `QtWidgets.QSpinBox`. + + Arguments: + name (str): The name of argument + label (str, optional): Display name, convert from `name` if not given + help (str, optional): Tool tip message of this argument + default (int, optional): Argument's default value, default 0 + min (int, optional): Argument's minimum value, default 0 + max (int, optional): Argument's maximum value, default 99 + enabled (bool, optional): Whether to enable this widget, default True + + """ + + +class Float(Number): + """Float type user interface + + A subclass of `qargparse.Number`, presented by `QtWidgets.QDoubleSpinBox`. + + Arguments: + name (str): The name of argument + label (str, optional): Display name, convert from `name` if not given + help (str, optional): Tool tip message of this argument + default (float, optional): Argument's default value, default 0.0 + min (float, optional): Argument's minimum value, default 0.0 + max (float, optional): Argument's maximum value, default 99.99 + enabled (bool, optional): Whether to enable this widget, default True + + """ + + +class Range(Number): + """Range type user interface + + A subclass of `qargparse.Number`, not production ready. + + """ + + +class Double3(QArgument): + """Double3 type user interface + + Presented by three `QtWidgets.QLineEdit` widget with `QDoubleValidator` + installed. + + Arguments: + name (str): The name of argument + label (str, optional): Display name, convert from `name` if not given + help (str, optional): Tool tip message of this argument + default (tuple or list, optional): Default (0, 0, 0). + enabled (bool, optional): Whether to enable this widget, default True + + """ + default = (0, 0, 0) + + def create(self): + widget = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + x, y, z = (self.child_arg(layout, i) for i in range(3)) + + self._read = lambda: ( + float(x.text()), float(y.text()), float(z.text())) + self._write = lambda value: [ + w.setText(str(float(v))) for w, v in zip([x, y, z], value)] + + if self["default"] != self.default: + self._write(self["default"]) + + return widget + + def child_arg(self, layout, index): + widget = QtWidgets.QLineEdit() + widget.setValidator(QtGui.QDoubleValidator()) + + default = str(float(self["default"][index])) + widget.setText(default) + + def focusOutEvent(event): + if not widget.text(): + widget.setText(default) # Ensure value exists for `_read` + QtWidgets.QLineEdit.focusOutEvent(widget, event) + widget.focusOutEvent = focusOutEvent + + widget.editingFinished.connect(self.changed.emit) + widget.returnPressed.connect(widget.editingFinished.emit) + + layout.addWidget(widget) + + return widget + + +class String(QArgument): + """String type user interface + + Presented by `QtWidgets.QLineEdit`. + + Arguments: + name (str): The name of argument + label (str, optional): Display name, convert from `name` if not given + help (str, optional): Tool tip message of this argument + default (str, optional): Argument's default value, default None + placeholder (str, optional): Placeholder message for the widget + enabled (bool, optional): Whether to enable this widget, default True + + """ + def __init__(self, *args, **kwargs): + super(String, self).__init__(*args, **kwargs) + self._previous = None + + def create(self): + widget = QtWidgets.QLineEdit() + widget.editingFinished.connect(self.onEditingFinished) + widget.returnPressed.connect(widget.editingFinished.emit) + self._read = lambda: widget.text() + self._write = lambda value: widget.setText(value) + + if isinstance(self, Info): + widget.setReadOnly(True) + widget.setPlaceholderText(self._data.get("placeholder", "")) + + if self["default"] is not None: + self._write(self["default"]) + self._previous = self["default"] + + return widget + + def onEditingFinished(self): + current = self._read() + + if current != self._previous: + self.changed.emit() + self._previous = current + + +class Info(String): + """String type user interface but read-only + + A subclass of `qargparse.String`, presented by `QtWidgets.QLineEdit`. + + Arguments: + name (str): The name of argument + label (str, optional): Display name, convert from `name` if not given + help (str, optional): Tool tip message of this argument + default (str, optional): Argument's default value, default None + enabled (bool, optional): Whether to enable this widget, default True + + """ + + +class Color(String): + """Color type user interface + + A subclass of `qargparse.String`, not production ready. + + """ + + +class Button(QArgument): + """Button type user interface + + Presented by `QtWidgets.QPushButton`. + + Arguments: + name (str): The name of argument + label (str, optional): Display name, convert from `name` if not given + help (str, optional): Tool tip message of this argument + default (bool, optional): Argument's default value, default None + enabled (bool, optional): Whether to enable this widget, default True + + """ + label = False + + def create(self): + widget = QtWidgets.QPushButton(self["label"]) + widget.clicked.connect(self.changed.emit) + + state = [ + QtCore.Qt.Unchecked, + QtCore.Qt.Checked, + ] + + if isinstance(self, Toggle): + widget.setCheckable(True) + if hasattr(widget, "isChecked"): + self._read = lambda: state[int(widget.isChecked())] + self._write = ( + lambda value: widget.setChecked(value) + ) + else: + self._read = lambda: widget.checkState() + self._write = ( + lambda value: widget.setCheckState(state[int(value)]) + ) + else: + self._read = lambda: "clicked" + self._write = lambda value: None + + if self["default"] is not None: + self._write(self["default"]) + + return widget + + +class Toggle(Button): + """Checkable `Button` type user interface + + Presented by `QtWidgets.QPushButton`. + + Arguments: + name (str): The name of argument + label (str, optional): Display name, convert from `name` if not given + help (str, optional): Tool tip message of this argument + default (bool, optional): Argument's default value, default None + enabled (bool, optional): Whether to enable this widget, default True + + """ + + +class InfoList(QArgument): + """String list type user interface + + Presented by `QtWidgets.QListView`, not production ready. + + """ + def __init__(self, name, **kwargs): + kwargs["default"] = kwargs.pop("default", ["Empty"]) + super(InfoList, self).__init__(name, **kwargs) + + def create(self): + class Model(QtCore.QStringListModel): + def data(self, index, role): + return super(Model, self).data(index, role) + + model = QtCore.QStringListModel(self["default"]) + widget = QtWidgets.QListView() + widget.setModel(model) + widget.setEditTriggers(widget.NoEditTriggers) + + self._read = lambda: model.stringList() + self._write = lambda value: model.setStringList(value) + + return widget + + +class Choice(QArgument): + """Argument user interface for selecting one from list + + Presented by `QtWidgets.QListView`. + + Arguments: + name (str): The name of argument + label (str, optional): Display name, convert from `name` if not given + help (str, optional): Tool tip message of this argument + items (list, optional): List of strings for select, default `["Empty"]` + default (str, optional): Default item in `items`, use first of `items` + if not given. + enabled (bool, optional): Whether to enable this widget, default True + + """ + def __init__(self, name, **kwargs): + kwargs["items"] = kwargs.get("items", ["Empty"]) + kwargs["default"] = kwargs.pop("default", kwargs["items"][0]) + super(Choice, self).__init__(name, **kwargs) + + def index(self, value): + """Return numerical equivalent to self.read()""" + return self["items"].index(value) + + def create(self): + def on_changed(selected, deselected): + try: + selected = selected.indexes()[0] + except IndexError: + # At least one item must be selected at all times + selected = deselected.indexes()[0] + + value = selected.data(QtCore.Qt.DisplayRole) + set_current(value) + self.changed.emit() + + def set_current(current): + options = model.stringList() + + if current == "Empty": + index = 0 + else: + for index, member in enumerate(options): + if member == current: + break + else: + raise ValueError( + "%s not a member of %s" % (current, options) + ) + + qindex = model.index(index, 0, QtCore.QModelIndex()) + smodel.setCurrentIndex(qindex, smodel.ClearAndSelect) + self["current"] = options[index] + + def reset(items, default=None): + items = items or ["Empty"] + model.setStringList(items) + set_current(default or items[0]) + + model = QtCore.QStringListModel() + widget = QtWidgets.QListView() + widget.setModel(model) + widget.setEditTriggers(widget.NoEditTriggers) + widget.setSelectionMode(widget.SingleSelection) + smodel = widget.selectionModel() + smodel.selectionChanged.connect(on_changed) + + self._read = lambda: self["current"] + self._write = lambda value: set_current(value) + self.reset = reset + + reset(self["items"], self["default"]) + + return widget + + +class Separator(QArgument): + """Visual separator + + Example: + + item1 + item2 + ------------ + item3 + item4 + + """ + + def create(self): + widget = QtWidgets.QWidget() + + self._read = lambda: None + self._write = lambda value: None + + return widget + + +class Enum(QArgument): + """Argument user interface for selecting one from dropdown list + + Presented by `QtWidgets.QComboBox`. + + Arguments: + name (str): The name of argument + label (str, optional): Display name, convert from `name` if not given + help (str, optional): Tool tip message of this argument + items (list, optional): List of strings for select, default `[]` + default (int, optional): Index of default item, use first of `items` + if not given. + enabled (bool, optional): Whether to enable this widget, default True + + """ + def __init__(self, name, **kwargs): + kwargs["default"] = kwargs.pop("default", 0) + kwargs["items"] = kwargs.get("items", []) + + assert isinstance(kwargs["items"], (tuple, list)), ( + "items must be list" + ) + + super(Enum, self).__init__(name, **kwargs) + + def create(self): + widget = QtWidgets.QComboBox() + widget.addItems(self["items"]) + widget.currentIndexChanged.connect( + lambda index: self.changed.emit()) + + self._read = lambda: widget.currentText() + self._write = lambda value: widget.setCurrentIndex(value) + + if self["default"] is not None: + self._write(self["default"]) + + return widget + + +style = """\ +QWidget { + /* Explicitly specify a size, to account for automatic HDPi */ + font-size: 11px; +} + +*[type="Button"] { + text-align:left; +} + +*[type="Info"] { + background: transparent; + border: none; +} + +QLabel[type="Separator"] { + min-height: 20px; + text-decoration: underline; +} + +QPushButton[type="reset"] { + max-width: 11px; + max-height: 11px; +} + +""" + + +def camelToTitle(text): + """Convert camelCase `text` to Title Case + + Example: + >>> camelToTitle("mixedCase") + "Mixed Case" + >>> camelToTitle("myName") + "My Name" + >>> camelToTitle("you") + "You" + >>> camelToTitle("You") + "You" + >>> camelToTitle("This is That") + "This Is That" + + """ + + return re.sub( + r"((?<=[a-z])[A-Z]|(? Date: Mon, 7 Mar 2022 17:42:34 +0100 Subject: [PATCH 353/483] added qtawesome and qtpy into poetry lock --- .../hosts/fusion/scripts/set_rendermode.py | 2 +- .../hosts/fusion/utility_scripts/switch_ui.py | 2 +- openpype/tools/assetcreator/widget.py | 2 +- openpype/tools/creator/widgets.py | 3 +- openpype/tools/launcher/lib.py | 2 +- openpype/tools/launcher/models.py | 2 +- openpype/tools/launcher/widgets.py | 2 +- openpype/tools/launcher/window.py | 2 +- openpype/tools/loader/lib.py | 2 +- openpype/tools/loader/model.py | 2 +- openpype/tools/mayalookassigner/models.py | 2 +- .../project_manager/project_manager/style.py | 4 +-- openpype/tools/publisher/widgets/widgets.py | 3 +- openpype/tools/pyblish_pype/model.py | 3 +- openpype/tools/sceneinventory/model.py | 4 +-- .../tools/sceneinventory/switch_dialog.py | 2 +- openpype/tools/sceneinventory/view.py | 2 +- openpype/tools/sceneinventory/window.py | 2 +- .../tools/settings/settings/categories.py | 3 +- openpype/tools/settings/settings/widgets.py | 2 +- .../standalonepublish/widgets/model_asset.py | 2 +- .../widgets/model_tasks_template.py | 2 +- .../standalonepublish/widgets/widget_asset.py | 2 +- .../widgets/widget_family_desc.py | 6 ++-- openpype/tools/subsetmanager/window.py | 2 +- openpype/tools/utils/assets_widget.py | 2 +- openpype/tools/utils/lib.py | 2 +- openpype/tools/utils/tasks_widget.py | 3 +- openpype/tools/utils/widgets.py | 2 +- openpype/tools/workfiles/model.py | 2 +- poetry.lock | 28 +++++++++++++++++++ pyproject.toml | 2 ++ 32 files changed, 65 insertions(+), 38 deletions(-) diff --git a/openpype/hosts/fusion/scripts/set_rendermode.py b/openpype/hosts/fusion/scripts/set_rendermode.py index 77a2d8e945..f0638e4fe3 100644 --- a/openpype/hosts/fusion/scripts/set_rendermode.py +++ b/openpype/hosts/fusion/scripts/set_rendermode.py @@ -1,5 +1,5 @@ from Qt import QtWidgets -from avalon.vendor import qtawesome +import qtawesome from openpype.hosts.fusion.api import get_current_comp diff --git a/openpype/hosts/fusion/utility_scripts/switch_ui.py b/openpype/hosts/fusion/utility_scripts/switch_ui.py index afb39f7041..d9eeae25ea 100644 --- a/openpype/hosts/fusion/utility_scripts/switch_ui.py +++ b/openpype/hosts/fusion/utility_scripts/switch_ui.py @@ -6,7 +6,7 @@ from Qt import QtWidgets, QtCore import avalon.api from avalon import io -from avalon.vendor import qtawesome as qta +import qtawesome as qta from openpype import style from openpype.hosts.fusion import api diff --git a/openpype/tools/assetcreator/widget.py b/openpype/tools/assetcreator/widget.py index fd0f438e68..9ad7e19692 100644 --- a/openpype/tools/assetcreator/widget.py +++ b/openpype/tools/assetcreator/widget.py @@ -2,7 +2,7 @@ import logging import contextlib import collections -from avalon.vendor import qtawesome +import qtawesome from Qt import QtWidgets, QtCore, QtGui from avalon import style, io diff --git a/openpype/tools/creator/widgets.py b/openpype/tools/creator/widgets.py index 9dd435c1cc..43df08496b 100644 --- a/openpype/tools/creator/widgets.py +++ b/openpype/tools/creator/widgets.py @@ -3,9 +3,8 @@ import inspect from Qt import QtWidgets, QtCore, QtGui -from avalon.vendor import qtawesome +import qtawesome -from openpype import style from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS from openpype.tools.utils import ErrorMessageBox diff --git a/openpype/tools/launcher/lib.py b/openpype/tools/launcher/lib.py index b4e6a0c3e9..68c759f295 100644 --- a/openpype/tools/launcher/lib.py +++ b/openpype/tools/launcher/lib.py @@ -16,7 +16,7 @@ provides a bridge between the file-based project inventory and configuration. import os from Qt import QtGui -from avalon.vendor import qtawesome +import qtawesome from openpype.api import resources ICON_CACHE = {} diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index effa283318..9036c9cbd5 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -7,7 +7,7 @@ import time import appdirs from Qt import QtCore, QtGui -from avalon.vendor import qtawesome +import qtawesome from avalon import api from openpype.lib import JSONSettingRegistry from openpype.lib.applications import ( diff --git a/openpype/tools/launcher/widgets.py b/openpype/tools/launcher/widgets.py index 30e6531843..62599664fe 100644 --- a/openpype/tools/launcher/widgets.py +++ b/openpype/tools/launcher/widgets.py @@ -2,7 +2,7 @@ import copy import time import collections from Qt import QtWidgets, QtCore, QtGui -from avalon.vendor import qtawesome +import qtawesome from openpype.tools.flickcharm import FlickCharm from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget diff --git a/openpype/tools/launcher/window.py b/openpype/tools/launcher/window.py index b5b6368865..d80b3eabf0 100644 --- a/openpype/tools/launcher/window.py +++ b/openpype/tools/launcher/window.py @@ -8,7 +8,7 @@ from avalon.api import AvalonMongoDB from openpype import style from openpype.api import resources -from avalon.vendor import qtawesome +import qtawesome from .models import ( LauncherModel, ProjectModel diff --git a/openpype/tools/loader/lib.py b/openpype/tools/loader/lib.py index 180dee3eb5..28e94237ec 100644 --- a/openpype/tools/loader/lib.py +++ b/openpype/tools/loader/lib.py @@ -1,7 +1,7 @@ import inspect from Qt import QtGui +import qtawesome -from avalon.vendor import qtawesome from openpype.tools.utils.widgets import ( OptionalAction, OptionDialog diff --git a/openpype/tools/loader/model.py b/openpype/tools/loader/model.py index 10b22d0e17..baee569239 100644 --- a/openpype/tools/loader/model.py +++ b/openpype/tools/loader/model.py @@ -8,8 +8,8 @@ from avalon import ( schema ) from Qt import QtCore, QtGui +import qtawesome -from avalon.vendor import qtawesome from avalon.lib import HeroVersionType from openpype.tools.utils.models import TreeModel, Item diff --git a/openpype/tools/mayalookassigner/models.py b/openpype/tools/mayalookassigner/models.py index 39cab83c61..386b7d7e1e 100644 --- a/openpype/tools/mayalookassigner/models.py +++ b/openpype/tools/mayalookassigner/models.py @@ -1,8 +1,8 @@ from collections import defaultdict from Qt import QtCore +import qtawesome -from avalon.vendor import qtawesome from avalon.style import colors from openpype.tools.utils import models diff --git a/openpype/tools/project_manager/project_manager/style.py b/openpype/tools/project_manager/project_manager/style.py index d24fc7102f..4405d05960 100644 --- a/openpype/tools/project_manager/project_manager/style.py +++ b/openpype/tools/project_manager/project_manager/style.py @@ -1,7 +1,7 @@ import os -from Qt import QtCore, QtGui +from Qt import QtGui -from avalon.vendor import qtawesome +import qtawesome from openpype.tools.utils import paint_image_with_color diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index fb1f0e54aa..9a9fe3193e 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -4,8 +4,7 @@ import re import copy import collections from Qt import QtWidgets, QtCore, QtGui - -from avalon.vendor import qtawesome +import qtawesome from openpype.widgets.attribute_defs import create_widget_for_attr_def from openpype.tools import resources diff --git a/openpype/tools/pyblish_pype/model.py b/openpype/tools/pyblish_pype/model.py index 0faadb5940..2931a379b3 100644 --- a/openpype/tools/pyblish_pype/model.py +++ b/openpype/tools/pyblish_pype/model.py @@ -29,10 +29,9 @@ import pyblish from . import settings, util from .awesome import tags as awesome -import Qt from Qt import QtCore, QtGui +import qtawesome from six import text_type -from .vendor import qtawesome from .constants import PluginStates, InstanceStates, GroupStates, Roles from openpype.api import get_system_settings diff --git a/openpype/tools/sceneinventory/model.py b/openpype/tools/sceneinventory/model.py index 6435e5c488..cba60be355 100644 --- a/openpype/tools/sceneinventory/model.py +++ b/openpype/tools/sceneinventory/model.py @@ -4,9 +4,9 @@ import logging from collections import defaultdict from Qt import QtCore, QtGui -from avalon import api, io, style, schema -from avalon.vendor import qtawesome +import qtawesome +from avalon import api, io, style, schema from avalon.lib import HeroVersionType from openpype.tools.utils.models import TreeModel, Item diff --git a/openpype/tools/sceneinventory/switch_dialog.py b/openpype/tools/sceneinventory/switch_dialog.py index 4946c073d4..93ea68beb4 100644 --- a/openpype/tools/sceneinventory/switch_dialog.py +++ b/openpype/tools/sceneinventory/switch_dialog.py @@ -1,9 +1,9 @@ import collections import logging from Qt import QtWidgets, QtCore +import qtawesome from avalon import io, api, pipeline -from avalon.vendor import qtawesome from .widgets import ( ButtonWithMenu, diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py index ec48b10e47..32c1883de6 100644 --- a/openpype/tools/sceneinventory/view.py +++ b/openpype/tools/sceneinventory/view.py @@ -3,9 +3,9 @@ import logging from functools import partial from Qt import QtWidgets, QtCore +import qtawesome from avalon import io, api, style -from avalon.vendor import qtawesome from avalon.lib import HeroVersionType from openpype.modules import ModulesManager diff --git a/openpype/tools/sceneinventory/window.py b/openpype/tools/sceneinventory/window.py index 095d30cac0..83e4435015 100644 --- a/openpype/tools/sceneinventory/window.py +++ b/openpype/tools/sceneinventory/window.py @@ -2,7 +2,7 @@ import os import sys from Qt import QtWidgets, QtCore -from avalon.vendor import qtawesome +import qtawesome from avalon import io, api from openpype import style diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index 663d497c36..a5b5cd40f0 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -1,9 +1,9 @@ -import os import sys import traceback import contextlib from enum import Enum from Qt import QtWidgets, QtCore +import qtawesome from openpype.lib import get_openpype_version from openpype.tools.utils import set_style_property @@ -63,7 +63,6 @@ from .item_widgets import ( PathInputWidget ) from .color_widget import ColorWidget -from avalon.vendor import qtawesome class CategoryState(Enum): diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index f793aab057..577c2630ab 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -2,7 +2,7 @@ import os import copy import uuid from Qt import QtWidgets, QtCore, QtGui -from avalon.vendor import qtawesome +import qtawesome from avalon.mongodb import ( AvalonMongoConnection, AvalonMongoDB diff --git a/openpype/tools/standalonepublish/widgets/model_asset.py b/openpype/tools/standalonepublish/widgets/model_asset.py index 60afe8f96c..6d764eff9f 100644 --- a/openpype/tools/standalonepublish/widgets/model_asset.py +++ b/openpype/tools/standalonepublish/widgets/model_asset.py @@ -1,8 +1,8 @@ import logging import collections from Qt import QtCore, QtGui +import qtawesome from . import TreeModel, Node -from avalon.vendor import qtawesome from avalon import style diff --git a/openpype/tools/standalonepublish/widgets/model_tasks_template.py b/openpype/tools/standalonepublish/widgets/model_tasks_template.py index 476f45391d..1f36eaa39d 100644 --- a/openpype/tools/standalonepublish/widgets/model_tasks_template.py +++ b/openpype/tools/standalonepublish/widgets/model_tasks_template.py @@ -1,6 +1,6 @@ from Qt import QtCore +import qtawesome from . import Node, TreeModel -from avalon.vendor import qtawesome from avalon import style diff --git a/openpype/tools/standalonepublish/widgets/widget_asset.py b/openpype/tools/standalonepublish/widgets/widget_asset.py index 2886d600bf..d929f227f9 100644 --- a/openpype/tools/standalonepublish/widgets/widget_asset.py +++ b/openpype/tools/standalonepublish/widgets/widget_asset.py @@ -1,9 +1,9 @@ import contextlib from Qt import QtWidgets, QtCore +import qtawesome from openpype.tools.utils import PlaceholderLineEdit -from avalon.vendor import qtawesome from avalon import style from . import RecursiveSortFilterProxyModel, AssetModel diff --git a/openpype/tools/standalonepublish/widgets/widget_family_desc.py b/openpype/tools/standalonepublish/widgets/widget_family_desc.py index 8c95ddf2e4..79681615b9 100644 --- a/openpype/tools/standalonepublish/widgets/widget_family_desc.py +++ b/openpype/tools/standalonepublish/widgets/widget_family_desc.py @@ -1,7 +1,7 @@ -from Qt import QtWidgets, QtCore, QtGui -from . import FamilyRole, PluginRole -from avalon.vendor import qtawesome import six +from Qt import QtWidgets, QtCore, QtGui +import qtawesome +from . import FamilyRole, PluginRole class FamilyDescriptionWidget(QtWidgets.QWidget): diff --git a/openpype/tools/subsetmanager/window.py b/openpype/tools/subsetmanager/window.py index b7430d0626..a53af52174 100644 --- a/openpype/tools/subsetmanager/window.py +++ b/openpype/tools/subsetmanager/window.py @@ -2,9 +2,9 @@ import os import sys from Qt import QtWidgets, QtCore +import qtawesome from avalon import api -from avalon.vendor import qtawesome from openpype import style from openpype.tools.utils import PlaceholderLineEdit diff --git a/openpype/tools/utils/assets_widget.py b/openpype/tools/utils/assets_widget.py index 17164d9e0f..d410b0f1c3 100644 --- a/openpype/tools/utils/assets_widget.py +++ b/openpype/tools/utils/assets_widget.py @@ -3,9 +3,9 @@ import collections import Qt from Qt import QtWidgets, QtCore, QtGui +import qtawesome from avalon import style -from avalon.vendor import qtawesome from openpype.style import get_objected_colors from openpype.tools.flickcharm import FlickCharm diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 01b9e25ef3..1cbc632804 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -4,10 +4,10 @@ import contextlib import collections from Qt import QtWidgets, QtCore, QtGui +import qtawesome import avalon.api from avalon import style -from avalon.vendor import qtawesome from openpype.api import ( get_project_settings, diff --git a/openpype/tools/utils/tasks_widget.py b/openpype/tools/utils/tasks_widget.py index 2a8a45626c..7619f59974 100644 --- a/openpype/tools/utils/tasks_widget.py +++ b/openpype/tools/utils/tasks_widget.py @@ -1,7 +1,7 @@ from Qt import QtWidgets, QtCore, QtGui +import qtawesome from avalon import style -from avalon.vendor import qtawesome from .views import DeselectableTreeView @@ -14,6 +14,7 @@ TASK_ASSIGNEE_ROLE = QtCore.Qt.UserRole + 4 class TasksModel(QtGui.QStandardItemModel): """A model listing the tasks combined for a list of assets""" + def __init__(self, dbcon, parent=None): super(TasksModel, self).__init__(parent=parent) self.dbcon = dbcon diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index 783736a9ca..d5ae909be8 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -2,7 +2,7 @@ import logging from Qt import QtWidgets, QtCore, QtGui import qargparse -from avalon.vendor import qtawesome +import qtawesome from openpype.style import ( get_objected_colors, get_style_image_path diff --git a/openpype/tools/workfiles/model.py b/openpype/tools/workfiles/model.py index 3425cc3df0..b3cf5063e7 100644 --- a/openpype/tools/workfiles/model.py +++ b/openpype/tools/workfiles/model.py @@ -2,9 +2,9 @@ import os import logging from Qt import QtCore +import qtawesome from avalon import style -from avalon.vendor import qtawesome from openpype.tools.utils.models import TreeModel, Item log = logging.getLogger(__name__) diff --git a/poetry.lock b/poetry.lock index b6eba33e0a..a6507bb358 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1219,6 +1219,26 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "qtawesome" +version = "0.7.3" +description = "FontAwesome icons in PyQt and PySide applications" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +qtpy = "*" +six = "*" + +[[package]] +name = "qtpy" +version = "1.11.3" +description = "Provides an abstraction layer on top of the various Qt bindings (PyQt5, PyQt4 and PySide) and additional custom QWidgets." +category = "main" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" + [[package]] name = "recommonmark" version = "0.7.1" @@ -2651,6 +2671,14 @@ pywin32-ctypes = [ {file = "Qt.py-1.3.6-py2.py3-none-any.whl", hash = "sha256:7edf6048d07a6924707506b5ba34a6e05d66dde9a3f4e3a62f9996ccab0b91c7"}, {file = "Qt.py-1.3.6.tar.gz", hash = "sha256:0d78656a2f814602eee304521c7bf5da0cec414818b3833712c77524294c404a"}, ] +qtawesome = [ + {file = "QtAwesome-0.7.3-py2.py3-none-any.whl", hash = "sha256:ddf4530b4af71cec13b24b88a4cdb56ec85b1e44c43c42d0698804c7137b09b0"}, + {file = "QtAwesome-0.7.3.tar.gz", hash = "sha256:b98b9038d19190e83ab26d91c4d8fc3a36591ee2bc7f5016d4438b8240d097bd"}, +] +qtpy = [ + {file = "QtPy-1.11.3-py2.py3-none-any.whl", hash = "sha256:e121fbee8e95645af29c5a4aceba8d657991551fc1aa3b6b6012faf4725a1d20"}, + {file = "QtPy-1.11.3.tar.gz", hash = "sha256:d427addd37386a8d786db81864a5536700861d95bf085cb31d1bea855d699557"}, +] recommonmark = [ {file = "recommonmark-0.7.1-py2.py3-none-any.whl", hash = "sha256:1b1db69af0231efce3fa21b94ff627ea33dee7079a01dd0a7f8482c3da148b3f"}, {file = "recommonmark-0.7.1.tar.gz", hash = "sha256:bdb4db649f2222dcd8d2d844f0006b958d627f732415d399791ee436a3686d67"}, diff --git a/pyproject.toml b/pyproject.toml index 2469cb76a9..106ae788f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,8 @@ pyblish-base = "^1.8.8" pynput = "^1.7.2" # idle manager in tray pymongo = "^3.11.2" "Qt.py" = "^1.3.3" +qtpy = "^1.11.3" +qtawesome = "0.7.3" speedcopy = "^2.1" six = "^1.15" semver = "^2.13.0" # for version resolution From abe340130fb71844b1a7ffe6c6878f0c5b9cf563 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 7 Mar 2022 17:49:15 +0100 Subject: [PATCH 354/483] fixed remaining imports from avalon.vendor --- .../celaction/plugins/publish/submit_celaction_deadline.py | 4 ++-- openpype/hosts/maya/plugins/load/load_vdb_to_vray.py | 2 +- openpype/modules/log_viewer/tray/widgets.py | 2 +- openpype/modules/sync_server/tray/models.py | 2 +- openpype/modules/sync_server/tray/widgets.py | 2 +- openpype/tools/assetcreator/model.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/celaction/plugins/publish/submit_celaction_deadline.py b/openpype/hosts/celaction/plugins/publish/submit_celaction_deadline.py index fd958d11a3..ea109e9445 100644 --- a/openpype/hosts/celaction/plugins/publish/submit_celaction_deadline.py +++ b/openpype/hosts/celaction/plugins/publish/submit_celaction_deadline.py @@ -1,9 +1,9 @@ import os +import re import json import getpass -from avalon.vendor import requests -import re +import requests import pyblish.api diff --git a/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py b/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py index 099c020093..6d5544103d 100644 --- a/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py +++ b/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py @@ -174,7 +174,7 @@ class LoadVDBtoVRay(api.Loader): fname = files[0] else: # Sequence - from avalon.vendor import clique + import clique # todo: check support for negative frames as input collections, remainder = clique.assemble(files) assert len(collections) == 1, ( diff --git a/openpype/modules/log_viewer/tray/widgets.py b/openpype/modules/log_viewer/tray/widgets.py index 5a67780413..ff77405de5 100644 --- a/openpype/modules/log_viewer/tray/widgets.py +++ b/openpype/modules/log_viewer/tray/widgets.py @@ -1,5 +1,5 @@ from Qt import QtCore, QtWidgets -from avalon.vendor import qtawesome +import qtawesome from .models import LogModel, LogsFilterProxy diff --git a/openpype/modules/sync_server/tray/models.py b/openpype/modules/sync_server/tray/models.py index 80f41992cb..7241cc3472 100644 --- a/openpype/modules/sync_server/tray/models.py +++ b/openpype/modules/sync_server/tray/models.py @@ -4,9 +4,9 @@ from bson.objectid import ObjectId from Qt import QtCore from Qt.QtCore import Qt +import qtawesome from openpype.tools.utils.delegates import pretty_timestamp -from avalon.vendor import qtawesome from openpype.lib import PypeLogger from openpype.api import get_local_site_id diff --git a/openpype/modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py index 18487b3d11..6aae9562cf 100644 --- a/openpype/modules/sync_server/tray/widgets.py +++ b/openpype/modules/sync_server/tray/widgets.py @@ -5,6 +5,7 @@ from functools import partial from Qt import QtWidgets, QtCore, QtGui from Qt.QtCore import Qt +import qtawesome from openpype.tools.settings import style @@ -12,7 +13,6 @@ from openpype.api import get_local_site_id from openpype.lib import PypeLogger from openpype.tools.utils.delegates import pretty_timestamp -from avalon.vendor import qtawesome from .models import ( SyncRepresentationSummaryModel, diff --git a/openpype/tools/assetcreator/model.py b/openpype/tools/assetcreator/model.py index f84541ca2a..ae9e0f673a 100644 --- a/openpype/tools/assetcreator/model.py +++ b/openpype/tools/assetcreator/model.py @@ -2,7 +2,7 @@ import re import logging from Qt import QtCore, QtWidgets -from avalon.vendor import qtawesome +import qtawesome from avalon import io from avalon import style From d81da109a799cd002b31d79b1408ef94ffdab92e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 7 Mar 2022 17:59:50 +0100 Subject: [PATCH 355/483] add containers to hierarchy --- openpype/hosts/maya/plugins/load/load_reference.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 0a0ce4536f..3d18a48a93 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -128,9 +128,9 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): return new_nodes def load(self, context, name=None, namespace=None, options=None): - super(ReferenceLoader, self).load(context, name, namespace, options) + container = super(ReferenceLoader, self).load(context, name, namespace, options) # clean containers if present to AVALON_CONTAINERS - self._organize_containers(self[:]) + self._organize_containers(self[:], container[0]) def switch(self, container, representation): self.update(container, representation) @@ -167,11 +167,11 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): ) @staticmethod - def _organize_containers(nodes): - # type: (list) -> None + def _organize_containers(nodes, container): + # type: (list, str) -> None for node in nodes: id_attr = "{}.id".format(node) if not cmds.attributeQuery("id", node=node, exists=True): continue if cmds.getAttr(id_attr) == AVALON_CONTAINER_ID: - cmds.sets(node, forceElement=AVALON_CONTAINERS) + cmds.sets(node, forceElement=container) From b172a2763bd38fd2a191b817f2b53281a88e16bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 7 Mar 2022 18:02:44 +0100 Subject: [PATCH 356/483] =?UTF-8?q?fix=20=F0=9F=90=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openpype/hosts/maya/plugins/load/load_reference.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 3d18a48a93..017f89d08c 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -5,7 +5,6 @@ from avalon.pipeline import AVALON_CONTAINER_ID from openpype.api import get_project_settings from openpype.lib import get_creator_by_name import openpype.hosts.maya.api.plugin -from openpype.hosts.maya.api.pipeline import AVALON_CONTAINERS from openpype.hosts.maya.api.lib import maintained_selection @@ -128,7 +127,8 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): return new_nodes def load(self, context, name=None, namespace=None, options=None): - container = super(ReferenceLoader, self).load(context, name, namespace, options) + container = super(ReferenceLoader, self).load( + context, name, namespace, options) # clean containers if present to AVALON_CONTAINERS self._organize_containers(self[:], container[0]) From f021eaf389053394972d4babd2a076c7d8c5180e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 7 Mar 2022 18:08:25 +0100 Subject: [PATCH 357/483] added python 2 dependency functools32 --- .../python/python_2/functools32/__init__.py | 1 + .../python_2/functools32/_dummy_thread32.py | 158 +++++++ .../python_2/functools32/functools32.py | 423 ++++++++++++++++++ .../python/python_2/functools32/reprlib32.py | 157 +++++++ 4 files changed, 739 insertions(+) create mode 100644 openpype/vendor/python/python_2/functools32/__init__.py create mode 100644 openpype/vendor/python/python_2/functools32/_dummy_thread32.py create mode 100644 openpype/vendor/python/python_2/functools32/functools32.py create mode 100644 openpype/vendor/python/python_2/functools32/reprlib32.py diff --git a/openpype/vendor/python/python_2/functools32/__init__.py b/openpype/vendor/python/python_2/functools32/__init__.py new file mode 100644 index 0000000000..837f7fb651 --- /dev/null +++ b/openpype/vendor/python/python_2/functools32/__init__.py @@ -0,0 +1 @@ +from .functools32 import * diff --git a/openpype/vendor/python/python_2/functools32/_dummy_thread32.py b/openpype/vendor/python/python_2/functools32/_dummy_thread32.py new file mode 100644 index 0000000000..8503b0e3dd --- /dev/null +++ b/openpype/vendor/python/python_2/functools32/_dummy_thread32.py @@ -0,0 +1,158 @@ +"""Drop-in replacement for the thread module. + +Meant to be used as a brain-dead substitute so that threaded code does +not need to be rewritten for when the thread module is not present. + +Suggested usage is:: + + try: + try: + import _thread # Python >= 3 + except: + import thread as _thread # Python < 3 + except ImportError: + import _dummy_thread as _thread + +""" +# Exports only things specified by thread documentation; +# skipping obsolete synonyms allocate(), start_new(), exit_thread(). +__all__ = ['error', 'start_new_thread', 'exit', 'get_ident', 'allocate_lock', + 'interrupt_main', 'LockType'] + +# A dummy value +TIMEOUT_MAX = 2**31 + +# NOTE: this module can be imported early in the extension building process, +# and so top level imports of other modules should be avoided. Instead, all +# imports are done when needed on a function-by-function basis. Since threads +# are disabled, the import lock should not be an issue anyway (??). + +class error(Exception): + """Dummy implementation of _thread.error.""" + + def __init__(self, *args): + self.args = args + +def start_new_thread(function, args, kwargs={}): + """Dummy implementation of _thread.start_new_thread(). + + Compatibility is maintained by making sure that ``args`` is a + tuple and ``kwargs`` is a dictionary. If an exception is raised + and it is SystemExit (which can be done by _thread.exit()) it is + caught and nothing is done; all other exceptions are printed out + by using traceback.print_exc(). + + If the executed function calls interrupt_main the KeyboardInterrupt will be + raised when the function returns. + + """ + if type(args) != type(tuple()): + raise TypeError("2nd arg must be a tuple") + if type(kwargs) != type(dict()): + raise TypeError("3rd arg must be a dict") + global _main + _main = False + try: + function(*args, **kwargs) + except SystemExit: + pass + except: + import traceback + traceback.print_exc() + _main = True + global _interrupt + if _interrupt: + _interrupt = False + raise KeyboardInterrupt + +def exit(): + """Dummy implementation of _thread.exit().""" + raise SystemExit + +def get_ident(): + """Dummy implementation of _thread.get_ident(). + + Since this module should only be used when _threadmodule is not + available, it is safe to assume that the current process is the + only thread. Thus a constant can be safely returned. + """ + return -1 + +def allocate_lock(): + """Dummy implementation of _thread.allocate_lock().""" + return LockType() + +def stack_size(size=None): + """Dummy implementation of _thread.stack_size().""" + if size is not None: + raise error("setting thread stack size not supported") + return 0 + +class LockType(object): + """Class implementing dummy implementation of _thread.LockType. + + Compatibility is maintained by maintaining self.locked_status + which is a boolean that stores the state of the lock. Pickling of + the lock, though, should not be done since if the _thread module is + then used with an unpickled ``lock()`` from here problems could + occur from this class not having atomic methods. + + """ + + def __init__(self): + self.locked_status = False + + def acquire(self, waitflag=None, timeout=-1): + """Dummy implementation of acquire(). + + For blocking calls, self.locked_status is automatically set to + True and returned appropriately based on value of + ``waitflag``. If it is non-blocking, then the value is + actually checked and not set if it is already acquired. This + is all done so that threading.Condition's assert statements + aren't triggered and throw a little fit. + + """ + if waitflag is None or waitflag: + self.locked_status = True + return True + else: + if not self.locked_status: + self.locked_status = True + return True + else: + if timeout > 0: + import time + time.sleep(timeout) + return False + + __enter__ = acquire + + def __exit__(self, typ, val, tb): + self.release() + + def release(self): + """Release the dummy lock.""" + # XXX Perhaps shouldn't actually bother to test? Could lead + # to problems for complex, threaded code. + if not self.locked_status: + raise error + self.locked_status = False + return True + + def locked(self): + return self.locked_status + +# Used to signal that interrupt_main was called in a "thread" +_interrupt = False +# True when not executing in a "thread" +_main = True + +def interrupt_main(): + """Set _interrupt flag to True to have start_new_thread raise + KeyboardInterrupt upon exiting.""" + if _main: + raise KeyboardInterrupt + else: + global _interrupt + _interrupt = True diff --git a/openpype/vendor/python/python_2/functools32/functools32.py b/openpype/vendor/python/python_2/functools32/functools32.py new file mode 100644 index 0000000000..c44551fac0 --- /dev/null +++ b/openpype/vendor/python/python_2/functools32/functools32.py @@ -0,0 +1,423 @@ +"""functools.py - Tools for working with functions and callable objects +""" +# Python module wrapper for _functools C module +# to allow utilities written in Python to be added +# to the functools module. +# Written by Nick Coghlan +# and Raymond Hettinger +# Copyright (C) 2006-2010 Python Software Foundation. +# See C source code for _functools credits/copyright + +__all__ = ['update_wrapper', 'wraps', 'WRAPPER_ASSIGNMENTS', 'WRAPPER_UPDATES', + 'total_ordering', 'cmp_to_key', 'lru_cache', 'reduce', 'partial'] + +from _functools import partial, reduce +from collections import MutableMapping, namedtuple +from .reprlib32 import recursive_repr as _recursive_repr +from weakref import proxy as _proxy +import sys as _sys +try: + from thread import allocate_lock as Lock +except ImportError: + from ._dummy_thread32 import allocate_lock as Lock + +################################################################################ +### OrderedDict +################################################################################ + +class _Link(object): + __slots__ = 'prev', 'next', 'key', '__weakref__' + +class OrderedDict(dict): + 'Dictionary that remembers insertion order' + # An inherited dict maps keys to values. + # The inherited dict provides __getitem__, __len__, __contains__, and get. + # The remaining methods are order-aware. + # Big-O running times for all methods are the same as regular dictionaries. + + # The internal self.__map dict maps keys to links in a doubly linked list. + # The circular doubly linked list starts and ends with a sentinel element. + # The sentinel element never gets deleted (this simplifies the algorithm). + # The sentinel is in self.__hardroot with a weakref proxy in self.__root. + # The prev links are weakref proxies (to prevent circular references). + # Individual links are kept alive by the hard reference in self.__map. + # Those hard references disappear when a key is deleted from an OrderedDict. + + def __init__(self, *args, **kwds): + '''Initialize an ordered dictionary. The signature is the same as + regular dictionaries, but keyword arguments are not recommended because + their insertion order is arbitrary. + + ''' + if len(args) > 1: + raise TypeError('expected at most 1 arguments, got %d' % len(args)) + try: + self.__root + except AttributeError: + self.__hardroot = _Link() + self.__root = root = _proxy(self.__hardroot) + root.prev = root.next = root + self.__map = {} + self.__update(*args, **kwds) + + def __setitem__(self, key, value, + dict_setitem=dict.__setitem__, proxy=_proxy, Link=_Link): + 'od.__setitem__(i, y) <==> od[i]=y' + # Setting a new item creates a new link at the end of the linked list, + # and the inherited dictionary is updated with the new key/value pair. + if key not in self: + self.__map[key] = link = Link() + root = self.__root + last = root.prev + link.prev, link.next, link.key = last, root, key + last.next = link + root.prev = proxy(link) + dict_setitem(self, key, value) + + def __delitem__(self, key, dict_delitem=dict.__delitem__): + 'od.__delitem__(y) <==> del od[y]' + # Deleting an existing item uses self.__map to find the link which gets + # removed by updating the links in the predecessor and successor nodes. + dict_delitem(self, key) + link = self.__map.pop(key) + link_prev = link.prev + link_next = link.next + link_prev.next = link_next + link_next.prev = link_prev + + def __iter__(self): + 'od.__iter__() <==> iter(od)' + # Traverse the linked list in order. + root = self.__root + curr = root.next + while curr is not root: + yield curr.key + curr = curr.next + + def __reversed__(self): + 'od.__reversed__() <==> reversed(od)' + # Traverse the linked list in reverse order. + root = self.__root + curr = root.prev + while curr is not root: + yield curr.key + curr = curr.prev + + def clear(self): + 'od.clear() -> None. Remove all items from od.' + root = self.__root + root.prev = root.next = root + self.__map.clear() + dict.clear(self) + + def popitem(self, last=True): + '''od.popitem() -> (k, v), return and remove a (key, value) pair. + Pairs are returned in LIFO order if last is true or FIFO order if false. + + ''' + if not self: + raise KeyError('dictionary is empty') + root = self.__root + if last: + link = root.prev + link_prev = link.prev + link_prev.next = root + root.prev = link_prev + else: + link = root.next + link_next = link.next + root.next = link_next + link_next.prev = root + key = link.key + del self.__map[key] + value = dict.pop(self, key) + return key, value + + def move_to_end(self, key, last=True): + '''Move an existing element to the end (or beginning if last==False). + + Raises KeyError if the element does not exist. + When last=True, acts like a fast version of self[key]=self.pop(key). + + ''' + link = self.__map[key] + link_prev = link.prev + link_next = link.next + link_prev.next = link_next + link_next.prev = link_prev + root = self.__root + if last: + last = root.prev + link.prev = last + link.next = root + last.next = root.prev = link + else: + first = root.next + link.prev = root + link.next = first + root.next = first.prev = link + + def __sizeof__(self): + sizeof = _sys.getsizeof + n = len(self) + 1 # number of links including root + size = sizeof(self.__dict__) # instance dictionary + size += sizeof(self.__map) * 2 # internal dict and inherited dict + size += sizeof(self.__hardroot) * n # link objects + size += sizeof(self.__root) * n # proxy objects + return size + + update = __update = MutableMapping.update + keys = MutableMapping.keys + values = MutableMapping.values + items = MutableMapping.items + __ne__ = MutableMapping.__ne__ + + __marker = object() + + def pop(self, key, default=__marker): + '''od.pop(k[,d]) -> v, remove specified key and return the corresponding + value. If key is not found, d is returned if given, otherwise KeyError + is raised. + + ''' + if key in self: + result = self[key] + del self[key] + return result + if default is self.__marker: + raise KeyError(key) + return default + + def setdefault(self, key, default=None): + 'od.setdefault(k[,d]) -> od.get(k,d), also set od[k]=d if k not in od' + if key in self: + return self[key] + self[key] = default + return default + + @_recursive_repr() + def __repr__(self): + 'od.__repr__() <==> repr(od)' + if not self: + return '%s()' % (self.__class__.__name__,) + return '%s(%r)' % (self.__class__.__name__, list(self.items())) + + def __reduce__(self): + 'Return state information for pickling' + items = [[k, self[k]] for k in self] + inst_dict = vars(self).copy() + for k in vars(OrderedDict()): + inst_dict.pop(k, None) + if inst_dict: + return (self.__class__, (items,), inst_dict) + return self.__class__, (items,) + + def copy(self): + 'od.copy() -> a shallow copy of od' + return self.__class__(self) + + @classmethod + def fromkeys(cls, iterable, value=None): + '''OD.fromkeys(S[, v]) -> New ordered dictionary with keys from S. + If not specified, the value defaults to None. + + ''' + self = cls() + for key in iterable: + self[key] = value + return self + + def __eq__(self, other): + '''od.__eq__(y) <==> od==y. Comparison to another OD is order-sensitive + while comparison to a regular mapping is order-insensitive. + + ''' + if isinstance(other, OrderedDict): + return len(self)==len(other) and \ + all(p==q for p, q in zip(self.items(), other.items())) + return dict.__eq__(self, other) + +# update_wrapper() and wraps() are tools to help write +# wrapper functions that can handle naive introspection + +WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__doc__') +WRAPPER_UPDATES = ('__dict__',) +def update_wrapper(wrapper, + wrapped, + assigned = WRAPPER_ASSIGNMENTS, + updated = WRAPPER_UPDATES): + """Update a wrapper function to look like the wrapped function + + wrapper is the function to be updated + wrapped is the original function + assigned is a tuple naming the attributes assigned directly + from the wrapped function to the wrapper function (defaults to + functools.WRAPPER_ASSIGNMENTS) + updated is a tuple naming the attributes of the wrapper that + are updated with the corresponding attribute from the wrapped + function (defaults to functools.WRAPPER_UPDATES) + """ + wrapper.__wrapped__ = wrapped + for attr in assigned: + try: + value = getattr(wrapped, attr) + except AttributeError: + pass + else: + setattr(wrapper, attr, value) + for attr in updated: + getattr(wrapper, attr).update(getattr(wrapped, attr, {})) + # Return the wrapper so this can be used as a decorator via partial() + return wrapper + +def wraps(wrapped, + assigned = WRAPPER_ASSIGNMENTS, + updated = WRAPPER_UPDATES): + """Decorator factory to apply update_wrapper() to a wrapper function + + Returns a decorator that invokes update_wrapper() with the decorated + function as the wrapper argument and the arguments to wraps() as the + remaining arguments. Default arguments are as for update_wrapper(). + This is a convenience function to simplify applying partial() to + update_wrapper(). + """ + return partial(update_wrapper, wrapped=wrapped, + assigned=assigned, updated=updated) + +def total_ordering(cls): + """Class decorator that fills in missing ordering methods""" + convert = { + '__lt__': [('__gt__', lambda self, other: not (self < other or self == other)), + ('__le__', lambda self, other: self < other or self == other), + ('__ge__', lambda self, other: not self < other)], + '__le__': [('__ge__', lambda self, other: not self <= other or self == other), + ('__lt__', lambda self, other: self <= other and not self == other), + ('__gt__', lambda self, other: not self <= other)], + '__gt__': [('__lt__', lambda self, other: not (self > other or self == other)), + ('__ge__', lambda self, other: self > other or self == other), + ('__le__', lambda self, other: not self > other)], + '__ge__': [('__le__', lambda self, other: (not self >= other) or self == other), + ('__gt__', lambda self, other: self >= other and not self == other), + ('__lt__', lambda self, other: not self >= other)] + } + roots = set(dir(cls)) & set(convert) + if not roots: + raise ValueError('must define at least one ordering operation: < > <= >=') + root = max(roots) # prefer __lt__ to __le__ to __gt__ to __ge__ + for opname, opfunc in convert[root]: + if opname not in roots: + opfunc.__name__ = opname + opfunc.__doc__ = getattr(int, opname).__doc__ + setattr(cls, opname, opfunc) + return cls + +def cmp_to_key(mycmp): + """Convert a cmp= function into a key= function""" + class K(object): + __slots__ = ['obj'] + def __init__(self, obj): + self.obj = obj + def __lt__(self, other): + return mycmp(self.obj, other.obj) < 0 + def __gt__(self, other): + return mycmp(self.obj, other.obj) > 0 + def __eq__(self, other): + return mycmp(self.obj, other.obj) == 0 + def __le__(self, other): + return mycmp(self.obj, other.obj) <= 0 + def __ge__(self, other): + return mycmp(self.obj, other.obj) >= 0 + def __ne__(self, other): + return mycmp(self.obj, other.obj) != 0 + __hash__ = None + return K + +_CacheInfo = namedtuple("CacheInfo", "hits misses maxsize currsize") + +def lru_cache(maxsize=100): + """Least-recently-used cache decorator. + + If *maxsize* is set to None, the LRU features are disabled and the cache + can grow without bound. + + Arguments to the cached function must be hashable. + + View the cache statistics named tuple (hits, misses, maxsize, currsize) with + f.cache_info(). Clear the cache and statistics with f.cache_clear(). + Access the underlying function with f.__wrapped__. + + See: http://en.wikipedia.org/wiki/Cache_algorithms#Least_Recently_Used + + """ + # Users should only access the lru_cache through its public API: + # cache_info, cache_clear, and f.__wrapped__ + # The internals of the lru_cache are encapsulated for thread safety and + # to allow the implementation to change (including a possible C version). + + def decorating_function(user_function, + tuple=tuple, sorted=sorted, len=len, KeyError=KeyError): + + hits, misses = [0], [0] + kwd_mark = (object(),) # separates positional and keyword args + lock = Lock() # needed because OrderedDict isn't threadsafe + + if maxsize is None: + cache = dict() # simple cache without ordering or size limit + + @wraps(user_function) + def wrapper(*args, **kwds): + key = args + if kwds: + key += kwd_mark + tuple(sorted(kwds.items())) + try: + result = cache[key] + hits[0] += 1 + return result + except KeyError: + pass + result = user_function(*args, **kwds) + cache[key] = result + misses[0] += 1 + return result + else: + cache = OrderedDict() # ordered least recent to most recent + cache_popitem = cache.popitem + cache_renew = cache.move_to_end + + @wraps(user_function) + def wrapper(*args, **kwds): + key = args + if kwds: + key += kwd_mark + tuple(sorted(kwds.items())) + with lock: + try: + result = cache[key] + cache_renew(key) # record recent use of this key + hits[0] += 1 + return result + except KeyError: + pass + result = user_function(*args, **kwds) + with lock: + cache[key] = result # record recent use of this key + misses[0] += 1 + if len(cache) > maxsize: + cache_popitem(0) # purge least recently used cache entry + return result + + def cache_info(): + """Report cache statistics""" + with lock: + return _CacheInfo(hits[0], misses[0], maxsize, len(cache)) + + def cache_clear(): + """Clear the cache and cache statistics""" + with lock: + cache.clear() + hits[0] = misses[0] = 0 + + wrapper.cache_info = cache_info + wrapper.cache_clear = cache_clear + return wrapper + + return decorating_function diff --git a/openpype/vendor/python/python_2/functools32/reprlib32.py b/openpype/vendor/python/python_2/functools32/reprlib32.py new file mode 100644 index 0000000000..af919758ca --- /dev/null +++ b/openpype/vendor/python/python_2/functools32/reprlib32.py @@ -0,0 +1,157 @@ +"""Redo the builtin repr() (representation) but with limits on most sizes.""" + +__all__ = ["Repr", "repr", "recursive_repr"] + +import __builtin__ as builtins +from itertools import islice +try: + from thread import get_ident +except ImportError: + from _dummy_thread32 import get_ident + +def recursive_repr(fillvalue='...'): + 'Decorator to make a repr function return fillvalue for a recursive call' + + def decorating_function(user_function): + repr_running = set() + + def wrapper(self): + key = id(self), get_ident() + if key in repr_running: + return fillvalue + repr_running.add(key) + try: + result = user_function(self) + finally: + repr_running.discard(key) + return result + + # Can't use functools.wraps() here because of bootstrap issues + wrapper.__module__ = getattr(user_function, '__module__') + wrapper.__doc__ = getattr(user_function, '__doc__') + wrapper.__name__ = getattr(user_function, '__name__') + wrapper.__annotations__ = getattr(user_function, '__annotations__', {}) + return wrapper + + return decorating_function + +class Repr: + + def __init__(self): + self.maxlevel = 6 + self.maxtuple = 6 + self.maxlist = 6 + self.maxarray = 5 + self.maxdict = 4 + self.maxset = 6 + self.maxfrozenset = 6 + self.maxdeque = 6 + self.maxstring = 30 + self.maxlong = 40 + self.maxother = 30 + + def repr(self, x): + return self.repr1(x, self.maxlevel) + + def repr1(self, x, level): + typename = type(x).__name__ + if ' ' in typename: + parts = typename.split() + typename = '_'.join(parts) + if hasattr(self, 'repr_' + typename): + return getattr(self, 'repr_' + typename)(x, level) + else: + return self.repr_instance(x, level) + + def _repr_iterable(self, x, level, left, right, maxiter, trail=''): + n = len(x) + if level <= 0 and n: + s = '...' + else: + newlevel = level - 1 + repr1 = self.repr1 + pieces = [repr1(elem, newlevel) for elem in islice(x, maxiter)] + if n > maxiter: pieces.append('...') + s = ', '.join(pieces) + if n == 1 and trail: right = trail + right + return '%s%s%s' % (left, s, right) + + def repr_tuple(self, x, level): + return self._repr_iterable(x, level, '(', ')', self.maxtuple, ',') + + def repr_list(self, x, level): + return self._repr_iterable(x, level, '[', ']', self.maxlist) + + def repr_array(self, x, level): + header = "array('%s', [" % x.typecode + return self._repr_iterable(x, level, header, '])', self.maxarray) + + def repr_set(self, x, level): + x = _possibly_sorted(x) + return self._repr_iterable(x, level, 'set([', '])', self.maxset) + + def repr_frozenset(self, x, level): + x = _possibly_sorted(x) + return self._repr_iterable(x, level, 'frozenset([', '])', + self.maxfrozenset) + + def repr_deque(self, x, level): + return self._repr_iterable(x, level, 'deque([', '])', self.maxdeque) + + def repr_dict(self, x, level): + n = len(x) + if n == 0: return '{}' + if level <= 0: return '{...}' + newlevel = level - 1 + repr1 = self.repr1 + pieces = [] + for key in islice(_possibly_sorted(x), self.maxdict): + keyrepr = repr1(key, newlevel) + valrepr = repr1(x[key], newlevel) + pieces.append('%s: %s' % (keyrepr, valrepr)) + if n > self.maxdict: pieces.append('...') + s = ', '.join(pieces) + return '{%s}' % (s,) + + def repr_str(self, x, level): + s = builtins.repr(x[:self.maxstring]) + if len(s) > self.maxstring: + i = max(0, (self.maxstring-3)//2) + j = max(0, self.maxstring-3-i) + s = builtins.repr(x[:i] + x[len(x)-j:]) + s = s[:i] + '...' + s[len(s)-j:] + return s + + def repr_int(self, x, level): + s = builtins.repr(x) # XXX Hope this isn't too slow... + if len(s) > self.maxlong: + i = max(0, (self.maxlong-3)//2) + j = max(0, self.maxlong-3-i) + s = s[:i] + '...' + s[len(s)-j:] + return s + + def repr_instance(self, x, level): + try: + s = builtins.repr(x) + # Bugs in x.__repr__() can cause arbitrary + # exceptions -- then make up something + except Exception: + return '<%s instance at %x>' % (x.__class__.__name__, id(x)) + if len(s) > self.maxother: + i = max(0, (self.maxother-3)//2) + j = max(0, self.maxother-3-i) + s = s[:i] + '...' + s[len(s)-j:] + return s + + +def _possibly_sorted(x): + # Since not all sequences of items can be sorted and comparison + # functions may raise arbitrary exceptions, return an unsorted + # sequence in that case. + try: + return sorted(x) + except Exception: + return list(x) + +aRepr = Repr() +repr = aRepr.repr From 04ede4539d4896eb6c87ebf3f2a7b7caa977e8a8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 7 Mar 2022 18:14:31 +0100 Subject: [PATCH 358/483] lower jsonschema module version --- poetry.lock | 6 +++--- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index a6507bb358..ee7b839b8d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -674,7 +674,7 @@ ansicon = {version = "*", markers = "platform_system == \"Windows\""} [[package]] name = "jsonschema" -version = "3.2.0" +version = "2.6.0" description = "An implementation of JSON Schema validation for Python" category = "main" optional = false @@ -2121,8 +2121,8 @@ jinxed = [ {file = "jinxed-1.1.0.tar.gz", hash = "sha256:d8f1731f134e9e6b04d95095845ae6c10eb15cb223a5f0cabdea87d4a279c305"}, ] jsonschema = [ - {file = "jsonschema-3.2.0-py2.py3-none-any.whl", hash = "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163"}, - {file = "jsonschema-3.2.0.tar.gz", hash = "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a"}, + {file = "jsonschema-2.6.0-py2.py3-none-any.whl", hash = "sha256:000e68abd33c972a5248544925a0cae7d1125f9bf6c58280d37546b946769a08"}, + {file = "jsonschema-2.6.0.tar.gz", hash = "sha256:6ff5f3180870836cae40f06fa10419f557208175f13ad7bc26caa77beb1f6e02"}, ] keyring = [ {file = "keyring-22.4.0-py3-none-any.whl", hash = "sha256:d6c531f6d12f3304db6029af1d19894bd446ecbbadd22465fa0f096b3e12d258"}, diff --git a/pyproject.toml b/pyproject.toml index 106ae788f9..2b30d92cdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ Click = "^7" dnspython = "^2.1.0" ftrack-python-api = "2.0.*" google-api-python-client = "^1.12.8" # sync server google support (should be separate?) -jsonschema = "^3.2.0" +jsonschema = "^2.6.0" keyring = "^22.0.1" log4mongo = "^1.7" pathlib2= "^2.3.5" # deadline submit publish job only (single place, maybe not needed?) From 8617b6d3892684c6e9dfd255e4c563151dd315b1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 8 Mar 2022 11:57:06 +0100 Subject: [PATCH 359/483] processing review feedback --- openpype/plugins/publish/extract_review.py | 42 ++++++++++------------ 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index fedeee6f08..fb0e553a9e 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -1107,8 +1107,10 @@ class ExtractReview(pyblish.api.InstancePlugin): output.extend([left_line, right_line]) else: - raise ValueError( - "Letterbox state \"{}\" is not recognized".format(state) + raise ValueError(( + "Letterbox not working: ratio set \"{}\", " + "Image ratio\"{}\"").format( + format(ratio, ".3f"), format(output_ratio, ".3f")) ) return output @@ -1124,9 +1126,20 @@ class ExtractReview(pyblish.api.InstancePlugin): """ filters = [] + # if reformat input video file is already reforamted from upstream + reformat_in_baking = bool("reformated" in new_repre["tags"]) + self.log.debug("reformat_in_baking: `{}`".format(reformat_in_baking)) + # Get instance data pixel_aspect = temp_data["pixel_aspect"] + if reformat_in_baking: + self.log.debug(( + "Using resolution from input. It is already " + "reformated from upstream process" + )) + pixel_aspect = 1 + # NOTE Skipped using instance's resolution full_input_path_single_file = temp_data["full_input_path_single_file"] try: @@ -1161,19 +1174,6 @@ class ExtractReview(pyblish.api.InstancePlugin): output_width = output_def.get("width") or None output_height = output_def.get("height") or None - # if nuke baking profile was having set reformat node - reformat_in_baking = bool("reformated" in new_repre["tags"]) - self.log.debug("reformat_in_baking: `{}`".format(reformat_in_baking)) - - if reformat_in_baking: - self.log.debug(( - "Using resolution from input. It is already " - "reformated from baking process" - )) - output_width = output_width or input_width - output_height = output_height or input_height - pixel_aspect = 1 - # Overscal color overscan_color_value = "black" overscan_color = output_def.get("overscan_color") @@ -1202,9 +1202,6 @@ class ExtractReview(pyblish.api.InstancePlugin): output_width = input_width output_height = input_height - letter_box_def = output_def["letter_box"] - letter_box_enabled = letter_box_def["enabled"] - # Make sure input width and height is not an odd number input_width_is_odd = bool(input_width % 2 != 0) input_height_is_odd = bool(input_height % 2 != 0) @@ -1263,6 +1260,9 @@ class ExtractReview(pyblish.api.InstancePlugin): "Output resolution is {}x{}".format(output_width, output_height) ) + letter_box_def = output_def["letter_box"] + letter_box_enabled = letter_box_def["enabled"] + # Skip processing if resolution is same as input's and letterbox is # not set if ( @@ -1347,12 +1347,6 @@ class ExtractReview(pyblish.api.InstancePlugin): # letter_box if letter_box_enabled: - filters.extend([ - "scale={}x{}:flags=lanczos".format( - output_width, output_height - ), - "setsar=1" - ]) filters.extend( self.get_letterbox_filters( letter_box_def, From f753143eec7fb1328838aca2ae12338ae4ac2fd8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 8 Mar 2022 11:59:22 +0100 Subject: [PATCH 360/483] removing what was already removed --- repos/avalon-unreal-integration | 1 - 1 file changed, 1 deletion(-) delete mode 160000 repos/avalon-unreal-integration diff --git a/repos/avalon-unreal-integration b/repos/avalon-unreal-integration deleted file mode 160000 index 43f6ea9439..0000000000 --- a/repos/avalon-unreal-integration +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 43f6ea943980b29c02a170942b566ae11f2b7080 From 319ec9e8684843e3562e3dc7680bbec35e919f94 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 8 Mar 2022 12:32:29 +0100 Subject: [PATCH 361/483] removed unused validate method --- openpype/lib/events.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/openpype/lib/events.py b/openpype/lib/events.py index 9cb80b2a6e..f4ad82d919 100644 --- a/openpype/lib/events.py +++ b/openpype/lib/events.py @@ -223,18 +223,6 @@ class StoredCallbacks: cls._registered_callbacks.append(callback) return callback - @classmethod - def validate(cls): - invalid_callbacks = [] - for callbacks in cls._registered_callbacks: - for callback in tuple(callbacks): - callback.validate_ref() - if not callback.is_ref_valid: - invalid_callbacks.append(callback) - - for callback in invalid_callbacks: - cls._registered_callbacks.remove(callback) - @classmethod def emit_event(cls, event): invalid_callbacks = [] From 605e8dabb0467826ba160767c7102ebfedf828c6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 8 Mar 2022 12:33:29 +0100 Subject: [PATCH 362/483] modified docstring --- openpype/lib/events.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/lib/events.py b/openpype/lib/events.py index f4ad82d919..2ae3efa55d 100644 --- a/openpype/lib/events.py +++ b/openpype/lib/events.py @@ -169,13 +169,14 @@ class EventCallback(object): class Event(object): """Base event object. - Can be used to anything because data are not much specific. Only required - argument is topic which defines why event is happening and may be used for + Can be used for any event because is not specific. Only required argument + is topic which defines why event is happening and may be used for filtering. Arg: topic (str): Identifier of event. data (Any): Data specific for event. Dictionary is recommended. + source (str): Identifier of source. """ _data = {} From 42a185fef9008b58e28d2686ca02013afa719b87 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 8 Mar 2022 12:35:59 +0100 Subject: [PATCH 363/483] change arguments for after.workfile.save event --- openpype/tools/workfiles/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index 87e1492a20..df16498df5 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -859,7 +859,7 @@ class FilesWidget(QtWidgets.QWidget): # Trigger after save events emit_event( "after.workfile.save", - {"filepath": filepath}, + {"filename": work_filename, "workdir_path": self._workdir_path}, source="workfiles.tool" ) From 663d425635d63e89ffd1c893d03b88ef40b6af90 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 8 Mar 2022 12:39:15 +0100 Subject: [PATCH 364/483] changed topics in workfiles tool to have before and after as last part --- openpype/hosts/maya/api/pipeline.py | 2 +- openpype/tools/workfiles/app.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 4945a9ba56..1dbcfbad6b 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -77,7 +77,7 @@ def install(): register_event_callback("new", on_new) register_event_callback("before.save", on_before_save) register_event_callback("taskChanged", on_task_changed) - register_event_callback("before.workfile.save", before_workfile_save) + register_event_callback("workfile.save.before", before_workfile_save) def _set_project(): diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index df16498df5..63958ac57b 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -824,7 +824,7 @@ class FilesWidget(QtWidgets.QWidget): # Trigger before save event emit_event( - "before.workfile.save", + "workfile.save.before", {"filename": work_filename, "workdir_path": self._workdir_path}, source="workfiles.tool" ) @@ -858,7 +858,7 @@ class FilesWidget(QtWidgets.QWidget): ) # Trigger after save events emit_event( - "after.workfile.save", + "workfile.save.after", {"filename": work_filename, "workdir_path": self._workdir_path}, source="workfiles.tool" ) From 2c75a20b340042c40ff924c6e24f5c7b8042653f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 8 Mar 2022 12:42:17 +0100 Subject: [PATCH 365/483] added returncode in maya's 'before.save' --- openpype/hosts/maya/api/pipeline.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 1dbcfbad6b..07743b7fc4 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -169,7 +169,10 @@ def _before_scene_save(return_code, client_data): # in order to block the operation. OpenMaya.MScriptUtil.setBool(return_code, True) - emit_event("before.save") + emit_event( + "before.save", + {"return_code": return_code} + ) def uninstall(): From c84abf1faa9606c70d8fc3e877e14e6276f09df1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 8 Mar 2022 12:50:11 +0100 Subject: [PATCH 366/483] raise TypeError when function is not callable --- openpype/lib/events.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/openpype/lib/events.py b/openpype/lib/events.py index 2ae3efa55d..7bec6ee30d 100644 --- a/openpype/lib/events.py +++ b/openpype/lib/events.py @@ -35,7 +35,11 @@ class EventCallback(object): Args: topic(str): Topic which will be listened. func(func): Callback to a topic. + + Raises: + TypeError: When passed function is not a callable object. """ + def __init__(self, topic, func): self._log = None self._topic = topic @@ -61,26 +65,21 @@ class EventCallback(object): elif callable(func): func_ref = weakref.ref(func) else: - func_ref = None - self.log.warning(( + raise TypeError(( "Registered callback is not callable. \"{}\"" ).format(str(func))) - func_name = None - func_path = None - expect_args = False # Collect additional data about function # - name # - path # - if expect argument or not - if func_ref is not None: - func_name = func.__name__ - func_path = os.path.abspath(inspect.getfile(func)) - if hasattr(inspect, "signature"): - sig = inspect.signature(func) - expect_args = len(sig.parameters) > 0 - else: - expect_args = len(inspect.getargspec(func)[0]) > 0 + func_name = func.__name__ + func_path = os.path.abspath(inspect.getfile(func)) + if hasattr(inspect, "signature"): + sig = inspect.signature(func) + expect_args = len(sig.parameters) > 0 + else: + expect_args = len(inspect.getargspec(func)[0]) > 0 self._func_ref = func_ref self._func_name = func_name From 650260309ecf96ef1a10b96fec17a4e272e6e0d5 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Mar 2022 13:43:04 +0100 Subject: [PATCH 367/483] OP-2860 - extracted get_fps function to lib --- openpype/lib/vendor_bin_utils.py | 20 ++++++++++++++++++++ openpype/scripts/otio_burnin.py | 20 +------------------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/openpype/lib/vendor_bin_utils.py b/openpype/lib/vendor_bin_utils.py index 4c2cf93dfa..c94fd2a956 100644 --- a/openpype/lib/vendor_bin_utils.py +++ b/openpype/lib/vendor_bin_utils.py @@ -130,3 +130,23 @@ def is_oiio_supported(): )) return False return True + + +def get_fps(str_value): + """Returns (str) value of fps from ffprobe frame format (120/1)""" + if str_value == "0/0": + print("WARNING: Source has \"r_frame_rate\" value set to \"0/0\".") + return "Unknown" + + items = str_value.split("/") + if len(items) == 1: + fps = float(items[0]) + + elif len(items) == 2: + fps = float(items[0]) / float(items[1]) + + # Check if fps is integer or float number + if int(fps) == fps: + fps = int(fps) + + return str(fps) diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index abf69645b7..874c08064a 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -6,6 +6,7 @@ import platform import json import opentimelineio_contrib.adapters.ffmpeg_burnins as ffmpeg_burnins import openpype.lib +from openpype.lib.vendor_bin_utils import get_fps ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") @@ -50,25 +51,6 @@ def _get_ffprobe_data(source): return json.loads(out) -def get_fps(str_value): - if str_value == "0/0": - print("WARNING: Source has \"r_frame_rate\" value set to \"0/0\".") - return "Unknown" - - items = str_value.split("/") - if len(items) == 1: - fps = float(items[0]) - - elif len(items) == 2: - fps = float(items[0]) / float(items[1]) - - # Check if fps is integer or float number - if int(fps) == fps: - fps = int(fps) - - return str(fps) - - def _prores_codec_args(stream_data, source_ffmpeg_cmd): output = [] From 5e84f4566ac97b3cb48d99f435b0e894457c3e8a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Mar 2022 13:50:42 +0100 Subject: [PATCH 368/483] OP-2860 - added possibility to get number of frames from video file with ffprobe Previously wrong hardcoded value was used. This implementation needs to be monitored for weird format of published video files. --- .../publish/collect_published_files.py | 71 ++++++++++++++++--- 1 file changed, 63 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py index abad14106f..8b21842635 100644 --- a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py +++ b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py @@ -10,14 +10,18 @@ Provides: import os import clique import tempfile +import math + from avalon import io import pyblish.api -from openpype.lib import prepare_template_data +from openpype.lib import prepare_template_data, get_asset, ffprobe_streams +from openpype.lib.vendor_bin_utils import get_fps from openpype.lib.plugin_tools import ( parse_json, get_subset_name_with_asset_doc ) + class CollectPublishedFiles(pyblish.api.ContextPlugin): """ This collector will try to find json files in provided @@ -49,10 +53,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): self.log.info("task_sub:: {}".format(task_subfolders)) asset_name = context.data["asset"] - asset_doc = io.find_one({ - "type": "asset", - "name": asset_name - }) + asset_doc = get_asset() task_name = context.data["task"] task_type = context.data["taskType"] project_name = context.data["project_name"] @@ -97,11 +98,26 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): instance.data["frameEnd"] = \ instance.data["representations"][0]["frameEnd"] else: - instance.data["frameStart"] = 0 - instance.data["frameEnd"] = 1 + frame_start = asset_doc["data"]["frameStart"] + instance.data["frameStart"] = frame_start + instance.data["frameEnd"] = asset_doc["data"]["frameEnd"] instance.data["representations"] = self._get_single_repre( task_dir, task_data["files"], tags ) + file_url = os.path.join(task_dir, task_data["files"][0]) + duration = self._get_duration(file_url) + if duration: + try: + frame_end = int(frame_start) + math.ceil(duration) + instance.data["frameEnd"] = math.ceil(frame_end) + self.log.debug("frameEnd:: {}".format( + instance.data["frameEnd"])) + except ValueError: + self.log.warning("Unable to count frames " + "duration {}".format(duration)) + + instance.data["handleStart"] = asset_doc["data"]["handleStart"] + instance.data["handleEnd"] = asset_doc["data"]["handleEnd"] self.log.info("instance.data:: {}".format(instance.data)) @@ -127,7 +143,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): return [repre_data] def _process_sequence(self, files, task_dir, tags): - """Prepare reprentations for sequence of files.""" + """Prepare representation for sequence of files.""" collections, remainder = clique.assemble(files) assert len(collections) == 1, \ "Too many collections in {}".format(files) @@ -188,6 +204,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): msg = "No family found for combination of " +\ "task_type: {}, is_sequence:{}, extension: {}".format( task_type, is_sequence, extension) + found_family = "render" assert found_family, msg return (found_family, @@ -243,3 +260,41 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): return version[0].get("version") or 0 else: return 0 + + def _get_duration(self, file_url): + """Return duration in frames""" + try: + streams = ffprobe_streams(file_url, self.log) + except Exception as exc: + raise AssertionError(( + "FFprobe couldn't read information about input file: \"{}\"." + " Error message: {}" + ).format(file_url, str(exc))) + + first_video_stream = None + for stream in streams: + if "width" in stream and "height" in stream: + first_video_stream = stream + break + + if first_video_stream: + nb_frames = stream.get("nb_frames") + if nb_frames: + try: + return int(nb_frames) + except ValueError: + self.log.warning( + "nb_frames {} not convertible".format(nb_frames)) + + duration = stream.get("duration") + frame_rate = get_fps(stream.get("r_frame_rate", '0/0')) + self.log.debu("duration:: {} frame_rate:: {}".format( + duration, frame_rate)) + try: + return float(duration) * float(frame_rate) + except ValueError: + self.log.warning( + "{} or {} cannot be converted".format(duration, + frame_rate)) + + self.log.warning("Cannot get number of frames") From 365901656f5ef395d394b051a2483c39a68d0cf2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 8 Mar 2022 13:50:59 +0100 Subject: [PATCH 369/483] redundant code --- openpype/plugins/publish/extract_review.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index fb0e553a9e..d2d361228a 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -19,7 +19,6 @@ from openpype.lib import ( should_convert_for_ffmpeg, convert_for_ffmpeg, - get_transcode_temp_directory, get_transcode_temp_directory ) import speedcopy From 2d9cecd1ae4cd9bfbd8ac40ffe0d1395591d097d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 8 Mar 2022 15:20:02 +0100 Subject: [PATCH 370/483] replace widht with width --- openpype/plugins/publish/extract_review.py | 42 +++++++++++----------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index d2d361228a..96a90a63b7 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -1000,11 +1000,11 @@ class ExtractReview(pyblish.api.InstancePlugin): if need_mask and not pillar: if fill_color_alpha > 0: top_box = ( - "drawbox=0:0:{widht}:round(" - "({height}-({widht}*(1/{ratio})))/2)" + "drawbox=0:0:{width}:round(" + "({height}-({width}*(1/{ratio})))/2)" ":t=fill:c={color}@{alpha}" ).format( - widht=output_width, + width=output_width, height=output_height, ratio=ratio, color=fill_color_hex, @@ -1013,12 +1013,12 @@ class ExtractReview(pyblish.api.InstancePlugin): bottom_box = ( "drawbox=0:{height}-round(" - "({height}-({widht}*(1/{ratio})))/2)" - ":{widht}:round(({height}-({widht}" + "({height}-({width}*(1/{ratio})))/2)" + ":{width}:round(({height}-({width}" "*(1/{ratio})))/2):t=fill:" "c={color}@{alpha}" ).format( - widht=output_width, + width=output_width, height=output_height, ratio=ratio, color=fill_color_hex, @@ -1028,11 +1028,11 @@ class ExtractReview(pyblish.api.InstancePlugin): if line_color_alpha > 0 and line_thickness > 0: top_line = ( - "drawbox=0:round(({height}-({widht}" - "*(1/{ratio})))/2)-{l_thick}:{widht}:{l_thick}:" + "drawbox=0:round(({height}-({width}" + "*(1/{ratio})))/2)-{l_thick}:{width}:{l_thick}:" "t=fill:c={l_color}@{l_alpha}" ).format( - widht=output_width, + width=output_width, height=output_height, ratio=ratio, l_thick=line_thickness, @@ -1040,11 +1040,11 @@ class ExtractReview(pyblish.api.InstancePlugin): l_alpha=line_color_alpha ) bottom_line = ( - "drawbox=0:{height}-round(({height}-({widht}" + "drawbox=0:{height}-round(({height}-({width}" "*(1/{ratio})))/2)" - ":{widht}:{l_thick}:t=fill:c={l_color}@{l_alpha}" + ":{width}:{l_thick}:t=fill:c={l_color}@{l_alpha}" ).format( - widht=output_width, + width=output_width, height=output_height, ratio=ratio, l_thick=line_thickness, @@ -1056,10 +1056,10 @@ class ExtractReview(pyblish.api.InstancePlugin): elif need_mask and pillar: if fill_color_alpha > 0: left_box = ( - "drawbox=0:0:round(({widht}-({height}" + "drawbox=0:0:round(({width}-({height}" "*{ratio}))/2):{height}:t=fill:c={color}@{alpha}" ).format( - widht=output_width, + width=output_width, height=output_height, ratio=ratio, color=fill_color_hex, @@ -1067,11 +1067,11 @@ class ExtractReview(pyblish.api.InstancePlugin): ) right_box = ( - "drawbox={widht}-round(({widht}-({height}*{ratio}))/2))" - ":0:round(({widht}-({height}*{ratio}))/2):{height}" + "drawbox={width}-round(({width}-({height}*{ratio}))/2))" + ":0:round(({width}-({height}*{ratio}))/2):{height}" ":t=fill:c={color}@{alpha}" ).format( - widht=output_width, + width=output_width, height=output_height, ratio=ratio, color=fill_color_hex, @@ -1081,10 +1081,10 @@ class ExtractReview(pyblish.api.InstancePlugin): if line_color_alpha > 0 and line_thickness > 0: left_line = ( - "drawbox=round(({widht}-({height}*{ratio}))/2)" + "drawbox=round(({width}-({height}*{ratio}))/2)" ":0:{l_thick}:{height}:t=fill:c={l_color}@{l_alpha}" ).format( - widht=output_width, + width=output_width, height=output_height, ratio=ratio, l_thick=line_thickness, @@ -1093,10 +1093,10 @@ class ExtractReview(pyblish.api.InstancePlugin): ) right_line = ( - "drawbox={widht}-round(({widht}-({height}*{ratio}))/2))" + "drawbox={width}-round(({width}-({height}*{ratio}))/2))" ":0:{l_thick}:{height}:t=fill:c={l_color}@{l_alpha}" ).format( - widht=output_width, + width=output_width, height=output_height, ratio=ratio, l_thick=line_thickness, From 404232c37a498af5712b1cb8683fd5b8c883121e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 8 Mar 2022 15:21:07 +0100 Subject: [PATCH 371/483] skip need mask checks --- openpype/plugins/publish/extract_review.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 96a90a63b7..ce7c06bd3c 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -996,8 +996,10 @@ class ExtractReview(pyblish.api.InstancePlugin): output_ratio = output_width / output_height pillar = output_ratio > ratio need_mask = format(output_ratio, ".3f") != format(ratio, ".3f") + if not need_mask: + return [] - if need_mask and not pillar: + if not pillar: if fill_color_alpha > 0: top_box = ( "drawbox=0:0:{width}:round(" @@ -1053,7 +1055,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ) output.extend([top_line, bottom_line]) - elif need_mask and pillar: + else: if fill_color_alpha > 0: left_box = ( "drawbox=0:0:round(({width}-({height}" @@ -1105,13 +1107,6 @@ class ExtractReview(pyblish.api.InstancePlugin): ) output.extend([left_line, right_line]) - else: - raise ValueError(( - "Letterbox not working: ratio set \"{}\", " - "Image ratio\"{}\"").format( - format(ratio, ".3f"), format(output_ratio, ".3f")) - ) - return output def rescaling_filters(self, temp_data, output_def, new_repre): From 70e158792bac4d9d638e1c2838ef335213aeee64 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 8 Mar 2022 15:22:34 +0100 Subject: [PATCH 372/483] fixed pillar boxes --- openpype/plugins/publish/extract_review.py | 45 +++++++++++++--------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index ce7c06bd3c..bec1f75425 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -993,7 +993,10 @@ class ExtractReview(pyblish.api.InstancePlugin): line_color_alpha = float(l_alpha) / 255 # test ratios and define if pillar or letter boxes - output_ratio = output_width / output_height + output_ratio = float(output_width) / float(output_height) + self.log.debug("Output ratio: {} LetterBox ratio: {}".format( + output_ratio, ratio + )) pillar = output_ratio > ratio need_mask = format(output_ratio, ".3f") != format(ratio, ".3f") if not need_mask: @@ -1002,8 +1005,8 @@ class ExtractReview(pyblish.api.InstancePlugin): if not pillar: if fill_color_alpha > 0: top_box = ( - "drawbox=0:0:{width}:round(" - "({height}-({width}*(1/{ratio})))/2)" + "drawbox=0:0:{width}" + ":round(({height}-({width}/{ratio}))/2)" ":t=fill:c={color}@{alpha}" ).format( width=output_width, @@ -1014,11 +1017,11 @@ class ExtractReview(pyblish.api.InstancePlugin): ) bottom_box = ( - "drawbox=0:{height}-round(" - "({height}-({width}*(1/{ratio})))/2)" - ":{width}:round(({height}-({width}" - "*(1/{ratio})))/2):t=fill:" - "c={color}@{alpha}" + "drawbox=0" + ":{height}-round(({height}-({width}/{ratio}))/2)" + ":{width}" + ":round(({height}-({width}/{ratio}))/2)" + ":t=fill:c={color}@{alpha}" ).format( width=output_width, height=output_height, @@ -1030,9 +1033,9 @@ class ExtractReview(pyblish.api.InstancePlugin): if line_color_alpha > 0 and line_thickness > 0: top_line = ( - "drawbox=0:round(({height}-({width}" - "*(1/{ratio})))/2)-{l_thick}:{width}:{l_thick}:" - "t=fill:c={l_color}@{l_alpha}" + "drawbox=0" + ":round(({height}-({width}/{ratio}))/2)-{l_thick}" + ":{width}:{l_thick}:t=fill:c={l_color}@{l_alpha}" ).format( width=output_width, height=output_height, @@ -1042,8 +1045,8 @@ class ExtractReview(pyblish.api.InstancePlugin): l_alpha=line_color_alpha ) bottom_line = ( - "drawbox=0:{height}-round(({height}-({width}" - "*(1/{ratio})))/2)" + "drawbox=0" + ":{height}-round(({height}-({width}/{ratio}))/2)" ":{width}:{l_thick}:t=fill:c={l_color}@{l_alpha}" ).format( width=output_width, @@ -1058,8 +1061,10 @@ class ExtractReview(pyblish.api.InstancePlugin): else: if fill_color_alpha > 0: left_box = ( - "drawbox=0:0:round(({width}-({height}" - "*{ratio}))/2):{height}:t=fill:c={color}@{alpha}" + "drawbox=0:0" + ":round(({width}-({height}*{ratio}))/2)" + ":{height}" + ":t=fill:c={color}@{alpha}" ).format( width=output_width, height=output_height, @@ -1069,8 +1074,11 @@ class ExtractReview(pyblish.api.InstancePlugin): ) right_box = ( - "drawbox={width}-round(({width}-({height}*{ratio}))/2))" - ":0:round(({width}-({height}*{ratio}))/2):{height}" + "drawbox=" + "{width}-round(({width}-({height}*{ratio}))/2)" + ":0" + ":round(({width}-({height}*{ratio}))/2)" + ":{height}" ":t=fill:c={color}@{alpha}" ).format( width=output_width, @@ -1095,7 +1103,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ) right_line = ( - "drawbox={width}-round(({width}-({height}*{ratio}))/2))" + "drawbox={width}-round(({width}-({height}*{ratio}))/2)" ":0:{l_thick}:{height}:t=fill:c={l_color}@{l_alpha}" ).format( width=output_width, @@ -1300,7 +1308,6 @@ class ExtractReview(pyblish.api.InstancePlugin): "scale_factor_by_height: `{}`".format(scale_factor_by_height) ) - # scaling none square pixels and 1920 width if ( input_height != output_height From 413e03cae155de3dbe9e4dce2c4d30ec46d237a8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 8 Mar 2022 15:54:16 +0100 Subject: [PATCH 373/483] moved 'create_hard_link' to path_tools --- openpype/lib/__init__.py | 4 ++-- openpype/lib/path_tools.py | 35 ++++++++++++++++++++++++++++++++ openpype/lib/vendor_bin_utils.py | 35 -------------------------------- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index c4097086e0..34b217f690 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -17,7 +17,6 @@ site.addsitedir(python_version_dir) from .vendor_bin_utils import ( - create_hard_link, find_executable, get_vendor_bin_path, get_oiio_tools_path, @@ -160,6 +159,7 @@ from .plugin_tools import ( ) from .path_tools import ( + create_hard_link, version_up, get_version_from_path, get_last_version_from_path, @@ -210,7 +210,6 @@ __all__ = [ "get_paths_from_environ", "get_global_environments", - "create_hard_link", "get_vendor_bin_path", "get_oiio_tools_path", "get_ffmpeg_tool_path", @@ -293,6 +292,7 @@ __all__ = [ "get_unique_layer_name", "get_background_layers", + "create_hard_link", "version_up", "get_version_from_path", "get_last_version_from_path", diff --git a/openpype/lib/path_tools.py b/openpype/lib/path_tools.py index 71fc0fe25c..c36e45c51f 100644 --- a/openpype/lib/path_tools.py +++ b/openpype/lib/path_tools.py @@ -13,6 +13,41 @@ from .profiles_filtering import filter_profiles log = logging.getLogger(__name__) +def create_hard_link(src_path, dst_path): + """Create hardlink of file. + + Args: + src_path(str): Full path to a file which is used as source for + hardlink. + dst_path(str): Full path to a file where a link of source will be + added. + """ + # Use `os.link` if is available + # - should be for all platforms with newer python versions + if hasattr(os, "link"): + os.link(src_path, dst_path) + return + + # Windows implementation of hardlinks + # - used in Python 2 + if platform.system().lower() == "windows": + import ctypes + from ctypes.wintypes import BOOL + CreateHardLink = ctypes.windll.kernel32.CreateHardLinkW + CreateHardLink.argtypes = [ + ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_void_p + ] + CreateHardLink.restype = BOOL + + res = CreateHardLink(dst_path, src_path, None) + if res == 0: + raise ctypes.WinError() + # Raises not implemented error if gets here + raise NotImplementedError( + "Implementation of hardlink for current environment is missing." + ) + + def _rreplace(s, a, b, n=1): """Replace a with b in string s from right side n times.""" return b.join(s.rsplit(a, n)) diff --git a/openpype/lib/vendor_bin_utils.py b/openpype/lib/vendor_bin_utils.py index 4a62da8f0c..4be016f656 100644 --- a/openpype/lib/vendor_bin_utils.py +++ b/openpype/lib/vendor_bin_utils.py @@ -86,41 +86,6 @@ def find_executable(executable): return None -def create_hard_link(src_path, dst_path): - """Create hardlink of file. - - Args: - src_path(str): Full path to a file which is used as source for - hardlink. - dst_path(str): Full path to a file where a link of source will be - added. - """ - # Use `os.link` if is available - # - should be for all platforms with newer python versions - if hasattr(os, "link"): - os.link(src_path, dst_path) - return - - # Windows implementation of hardlinks - # - used in Python 2 - if platform.system().lower() == "windows": - import ctypes - from ctypes.wintypes import BOOL - CreateHardLink = ctypes.windll.kernel32.CreateHardLinkW - CreateHardLink.argtypes = [ - ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_void_p - ] - CreateHardLink.restype = BOOL - - res = CreateHardLink(dst_path, src_path, None) - if res == 0: - raise ctypes.WinError() - # Raises not implemented error if gets here - raise NotImplementedError( - "Implementation of hardlink for current environment is missing." - ) - - def get_vendor_bin_path(bin_app): """Path to OpenPype vendorized binaries. From 295002d7543f9ac47896bb3c4cb78dc7e1691b08 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 8 Mar 2022 15:54:58 +0100 Subject: [PATCH 374/483] added missing platform --- openpype/lib/path_tools.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/lib/path_tools.py b/openpype/lib/path_tools.py index c36e45c51f..3a9f835272 100644 --- a/openpype/lib/path_tools.py +++ b/openpype/lib/path_tools.py @@ -4,6 +4,7 @@ import abc import json import logging import six +import platform from openpype.settings import get_project_settings From 8c38c4f332c9b188cf1a1abf00a525fc7648ef03 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Mar 2022 16:34:22 +0100 Subject: [PATCH 375/483] OP-2877 - use same value for burnin user, version and representation author --- openpype/modules/ftrack/plugins/publish/collect_username.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/modules/ftrack/plugins/publish/collect_username.py b/openpype/modules/ftrack/plugins/publish/collect_username.py index 84d7f60a3f..a9b746ea51 100644 --- a/openpype/modules/ftrack/plugins/publish/collect_username.py +++ b/openpype/modules/ftrack/plugins/publish/collect_username.py @@ -23,8 +23,11 @@ class CollectUsername(pyblish.api.ContextPlugin): Expects "pype.club" user created on Ftrack and FTRACK_BOT_API_KEY env var set up. + Resets `context.data["user"] to correctly populate `version.author` and + `representation.context.username` + """ - order = pyblish.api.CollectorOrder - 0.488 + order = pyblish.api.CollectorOrder + 0.0015 label = "Collect ftrack username" hosts = ["webpublisher", "photoshop"] targets = ["remotepublish", "filespublish", "tvpaint_worker"] @@ -65,3 +68,4 @@ class CollectUsername(pyblish.api.ContextPlugin): if '@' in burnin_name: burnin_name = burnin_name[:burnin_name.index('@')] os.environ["WEBPUBLISH_OPENPYPE_USERNAME"] = burnin_name + context.data["user"] = burnin_name From e1d07b2b13d7af7c604c5f5293aea2b8c7639c2e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 8 Mar 2022 17:11:13 +0100 Subject: [PATCH 376/483] nuke: adding input resolution of input video file --- .../plugins/publish/extract_review_slate.py | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 7002168cdb..948cee0639 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -14,7 +14,7 @@ class ExtractReviewSlate(openpype.api.Extractor): families = ["slate", "review"] match = pyblish.api.Subset - hosts = ["nuke", "maya", "shell"] + hosts = ["nuke", "shell"] optional = True def process(self, instance): @@ -59,13 +59,44 @@ class ExtractReviewSlate(openpype.api.Extractor): if "slate-frame" not in p_tags: continue + # get repre file + stagingdir = repre["stagingDir"] + input_file = "{0}".format(repre["files"]) + input_path = os.path.join( + os.path.normpath(stagingdir), repre["files"]) + self.log.debug("__ input_path: {}".format(input_path)) + + video_streams = openpype.lib.ffprobe_streams( + input_path, self.log + ) + + # Try to find first stream with defined 'width' and 'height' + # - this is to avoid order of streams where audio can be as first + # - there may be a better way (checking `codec_type`?) + input_width = None + input_height = None + for stream in video_streams: + if "width" in stream and "height" in stream: + input_width = int(stream["width"]) + input_height = int(stream["height"]) + break + + # Raise exception of any stream didn't define input resolution + if input_width is None: + raise AssertionError(( + "FFprobe couldn't read resolution from input file: \"{}\"" + ).format(input_path)) + # values are set in ExtractReview if use_legacy_code: to_width = inst_data["reviewToWidth"] to_height = inst_data["reviewToHeight"] else: - to_width = repre["resolutionWidth"] - to_height = repre["resolutionHeight"] + to_width = input_width + to_height = input_height + + self.log.debug("to_width: `{}`".format(to_width)) + self.log.debug("to_height: `{}`".format(to_height)) # defining image ratios resolution_ratio = ( @@ -94,15 +125,9 @@ class ExtractReviewSlate(openpype.api.Extractor): _remove_at_end = [] - stagingdir = repre["stagingDir"] - input_file = "{0}".format(repre["files"]) - ext = os.path.splitext(input_file)[1] output_file = input_file.replace(ext, "") + suffix + ext - input_path = os.path.join( - os.path.normpath(stagingdir), repre["files"]) - self.log.debug("__ input_path: {}".format(input_path)) _remove_at_end.append(input_path) output_path = os.path.join( From 7600590f7cb78db7f0e008337e2b3d77609cbb4c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 8 Mar 2022 17:31:16 +0100 Subject: [PATCH 377/483] moved avalon creators and added legacy prefix --- openpype/pipeline/__init__.py | 13 +- openpype/pipeline/create/__init__.py | 10 +- openpype/pipeline/create/legacy_create.py | 156 ++++++++++++++++++++++ 3 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 openpype/pipeline/create/legacy_create.py diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index e968df4011..9ac7d15d5b 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -4,7 +4,12 @@ from .create import ( BaseCreator, Creator, AutoCreator, - CreatedInstance + CreatedInstance, + + CreatorError, + + LegacyCreator, + legacy_create, ) from .publish import ( @@ -22,6 +27,12 @@ __all__ = ( "AutoCreator", "CreatedInstance", + "CreatorError", + + # Legacy creation + "LegacyCreator", + "legacy_create", + "PublishValidationError", "KnownPublishError", "OpenPypePyblishPluginMixin" diff --git a/openpype/pipeline/create/__init__.py b/openpype/pipeline/create/__init__.py index 948b719851..9571f56b8f 100644 --- a/openpype/pipeline/create/__init__.py +++ b/openpype/pipeline/create/__init__.py @@ -14,6 +14,11 @@ from .context import ( CreateContext ) +from .legacy_create import ( + LegacyCreator, + legacy_create, +) + __all__ = ( "SUBSET_NAME_ALLOWED_SYMBOLS", @@ -25,5 +30,8 @@ __all__ = ( "AutoCreator", "CreatedInstance", - "CreateContext" + "CreateContext", + + "LegacyCreator", + "legacy_create", ) diff --git a/openpype/pipeline/create/legacy_create.py b/openpype/pipeline/create/legacy_create.py new file mode 100644 index 0000000000..d05cdff689 --- /dev/null +++ b/openpype/pipeline/create/legacy_create.py @@ -0,0 +1,156 @@ +"""Create workflow moved from avalon-core repository. + +Renamed classes and functions +- 'Creator' -> 'LegacyCreator' +- 'create' -> 'legacy_create' +""" + +import logging +import collections + +from openpype.lib import get_subset_name + + +class LegacyCreator(object): + """Determine how assets are created""" + label = None + family = None + defaults = None + maintain_selection = True + + dynamic_subset_keys = [] + + log = logging.getLogger("LegacyCreator") + + def __init__(self, name, asset, options=None, data=None): + self.name = name # For backwards compatibility + self.options = options + + # Default data + self.data = collections.OrderedDict() + self.data["id"] = "pyblish.avalon.instance" + self.data["family"] = self.family + self.data["asset"] = asset + self.data["subset"] = name + self.data["active"] = True + + self.data.update(data or {}) + + def process(self): + pass + + @classmethod + def get_dynamic_data( + cls, variant, task_name, asset_id, project_name, host_name + ): + """Return dynamic data for current Creator plugin. + + By default return keys from `dynamic_subset_keys` attribute as mapping + to keep formatted template unchanged. + + ``` + dynamic_subset_keys = ["my_key"] + --- + output = { + "my_key": "{my_key}" + } + ``` + + Dynamic keys may override default Creator keys (family, task, asset, + ...) but do it wisely if you need. + + All of keys will be converted into 3 variants unchanged, capitalized + and all upper letters. Because of that are all keys lowered. + + This method can be modified to prefill some values just keep in mind it + is class method. + + Returns: + dict: Fill data for subset name template. + """ + dynamic_data = {} + for key in cls.dynamic_subset_keys: + key = key.lower() + dynamic_data[key] = "{" + key + "}" + return dynamic_data + + @classmethod + def get_subset_name( + cls, variant, task_name, asset_id, project_name, host_name=None + ): + """Return subset name created with entered arguments. + + Logic extracted from Creator tool. This method should give ability + to get subset name without the tool. + + TODO: Maybe change `variant` variable. + + By default is output concatenated family with user text. + + Args: + variant (str): What is entered by user in creator tool. + task_name (str): Context's task name. + asset_id (ObjectId): Mongo ID of context's asset. + project_name (str): Context's project name. + host_name (str): Name of host. + + Returns: + str: Formatted subset name with entered arguments. Should match + config's logic. + """ + + dynamic_data = cls.get_dynamic_data( + variant, task_name, asset_id, project_name, host_name + ) + + return get_subset_name( + cls.family, + variant, + task_name, + asset_id, + project_name, + host_name, + dynamic_data=dynamic_data + ) + + +def legacy_create(Creator, name, asset, options=None, data=None): + """Create a new instance + + Associate nodes with a subset and family. These nodes are later + validated, according to their `family`, and integrated into the + shared environment, relative their `subset`. + + Data relative each family, along with default data, are imprinted + into the resulting objectSet. This data is later used by extractors + and finally asset browsers to help identify the origin of the asset. + + Arguments: + Creator (Creator): Class of creator + name (str): Name of subset + asset (str): Name of asset + options (dict, optional): Additional options from GUI + data (dict, optional): Additional data from GUI + + Raises: + NameError on `subset` already exists + KeyError on invalid dynamic property + RuntimeError on host error + + Returns: + Name of instance + + """ + from avalon.api import registered_host + host = registered_host() + plugin = Creator(name, asset, options, data) + + if plugin.maintain_selection is True: + with host.maintained_selection(): + print("Running %s with maintained selection" % plugin) + instance = plugin.process() + return instance + + print("Running %s" % plugin) + instance = plugin.process() + return instance From 4d0d25534647446142a3b2a7dfbb93e6691b979c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Mar 2022 17:36:26 +0100 Subject: [PATCH 378/483] Fix for new publish validations for Harmony --- .../hosts/harmony/plugins/publish/validate_instances.py | 1 - .../harmony/plugins/publish/validate_scene_settings.py | 8 +++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/harmony/plugins/publish/validate_instances.py b/openpype/hosts/harmony/plugins/publish/validate_instances.py index 03b6e5db75..373ef94cc3 100644 --- a/openpype/hosts/harmony/plugins/publish/validate_instances.py +++ b/openpype/hosts/harmony/plugins/publish/validate_instances.py @@ -1,6 +1,5 @@ import os -from avalon import harmony import pyblish.api import openpype.api from openpype.pipeline import PublishXmlValidationError diff --git a/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py b/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py index 19a9d46026..4c3a6c4465 100644 --- a/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py +++ b/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py @@ -105,11 +105,9 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): invalid_keys = set() for key, value in expected_settings.items(): if value != current_settings[key]: - invalid_settings.append({ - "name": key, - "expected": value, - "current": current_settings[key] - }) + invalid_settings.append( + "{} expected: {} found: {}".format(key, value, + current_settings[key])) invalid_keys.add(key) if ((expected_settings["handleStart"] From d4f177f7bc4b6398240504c665777731b0dcb01f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 8 Mar 2022 17:39:50 +0100 Subject: [PATCH 379/483] use moved create functions in hosts --- openpype/__init__.py | 3 +- openpype/api.py | 6 -- openpype/hosts/aftereffects/api/pipeline.py | 5 +- .../plugins/create/create_render.py | 7 +- openpype/hosts/blender/api/pipeline.py | 5 +- openpype/hosts/blender/api/plugin.py | 4 +- .../blender/plugins/load/load_layout_blend.py | 3 +- .../blender/plugins/load/load_layout_json.py | 4 +- .../hosts/blender/plugins/load/load_rig.py | 3 +- openpype/hosts/flame/api/pipeline.py | 5 +- openpype/hosts/flame/api/plugin.py | 3 +- openpype/hosts/fusion/api/pipeline.py | 5 +- .../fusion/plugins/create/create_exr_saver.py | 4 +- openpype/hosts/harmony/api/pipeline.py | 5 +- openpype/hosts/harmony/api/plugin.py | 5 +- openpype/hosts/hiero/api/pipeline.py | 5 +- openpype/hosts/hiero/api/plugin.py | 9 ++- openpype/hosts/houdini/api/pipeline.py | 3 +- openpype/hosts/houdini/api/plugin.py | 9 +-- openpype/hosts/maya/api/pipeline.py | 6 +- openpype/hosts/maya/api/plugin.py | 4 +- .../maya/plugins/create/create_render.py | 2 +- .../maya/plugins/create/create_vrayscene.py | 2 +- .../hosts/maya/plugins/load/load_reference.py | 3 +- openpype/hosts/nuke/api/pipeline.py | 5 +- openpype/hosts/nuke/api/plugin.py | 8 +-- openpype/hosts/photoshop/api/__init__.py | 3 +- openpype/hosts/photoshop/api/pipeline.py | 6 +- openpype/hosts/photoshop/api/plugin.py | 34 ---------- .../photoshop/plugins/create/create_image.py | 4 +- openpype/hosts/resolve/api/pipeline.py | 5 +- openpype/hosts/resolve/api/plugin.py | 3 +- openpype/hosts/tvpaint/api/pipeline.py | 5 +- openpype/hosts/tvpaint/api/plugin.py | 4 +- .../plugins/create/create_render_layer.py | 3 +- .../plugins/create/create_render_pass.py | 2 +- openpype/hosts/unreal/api/pipeline.py | 7 +- openpype/hosts/unreal/api/plugin.py | 4 +- openpype/hosts/webpublisher/api/__init__.py | 5 +- openpype/lib/plugin_tools.py | 2 +- openpype/plugin.py | 67 ------------------- openpype/tests/test_avalon_plugin_presets.py | 7 +- openpype/tools/creator/model.py | 3 +- openpype/tools/creator/window.py | 13 ++-- .../widgets/widget_family.py | 9 +-- 45 files changed, 111 insertions(+), 198 deletions(-) diff --git a/openpype/__init__.py b/openpype/__init__.py index 11b563ebfe..c41afaa47d 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -5,6 +5,7 @@ import platform import functools import logging +from openpype.pipeline import LegacyCreator from .settings import get_project_settings from .lib import ( Anatomy, @@ -113,7 +114,7 @@ def install(): pyblish.register_plugin_path(path) avalon.register_plugin_path(avalon.Loader, path) - avalon.register_plugin_path(avalon.Creator, path) + avalon.register_plugin_path(LegacyCreator, path) avalon.register_plugin_path(avalon.InventoryAction, path) # apply monkey patched discover to original one diff --git a/openpype/api.py b/openpype/api.py index 51854492ab..b692b36065 100644 --- a/openpype/api.py +++ b/openpype/api.py @@ -45,9 +45,6 @@ from .lib.avalon_context import ( from . import resources from .plugin import ( - PypeCreatorMixin, - Creator, - Extractor, ValidatePipelineOrder, @@ -89,9 +86,6 @@ __all__ = [ # Resources "resources", - # Pype creator mixin - "PypeCreatorMixin", - "Creator", # plugin classes "Extractor", # ordering diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 94f1e3d105..ef56e96155 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -9,6 +9,7 @@ from avalon import io, pipeline from openpype import lib from openpype.api import Logger +from openpype.pipeline import LegacyCreator import openpype.hosts.aftereffects from .launch_logic import get_stub @@ -66,7 +67,7 @@ def install(): pyblish.api.register_plugin_path(PUBLISH_PATH) avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) log.info(PUBLISH_PATH) pyblish.api.register_callback( @@ -79,7 +80,7 @@ def install(): def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) def on_pyblish_instance_toggled(instance, old_value, new_value): diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index 8dfc85cdc8..41efb4b0ba 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -1,13 +1,12 @@ -from avalon.api import CreatorError - -import openpype.api +from openpype.pipeline import create +from openpype.pipeline import CreatorError from openpype.hosts.aftereffects.api import ( get_stub, list_instances ) -class CreateRender(openpype.api.Creator): +class CreateRender(create.LegacyCreator): """Render folder for publish. Creates subsets in format 'familyTaskSubsetname', diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 6da0ba3dcb..1c9820ff22 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -14,6 +14,7 @@ import avalon.api from avalon import io, schema from avalon.pipeline import AVALON_CONTAINER_ID +from openpype.pipeline import LegacyCreator from openpype.api import Logger import openpype.hosts.blender @@ -46,7 +47,7 @@ def install(): pyblish.api.register_plugin_path(str(PUBLISH_PATH)) avalon.api.register_plugin_path(avalon.api.Loader, str(LOAD_PATH)) - avalon.api.register_plugin_path(avalon.api.Creator, str(CREATE_PATH)) + avalon.api.register_plugin_path(LegacyCreator, str(CREATE_PATH)) lib.append_user_scripts() @@ -67,7 +68,7 @@ def uninstall(): pyblish.api.deregister_plugin_path(str(PUBLISH_PATH)) avalon.api.deregister_plugin_path(avalon.api.Loader, str(LOAD_PATH)) - avalon.api.deregister_plugin_path(avalon.api.Creator, str(CREATE_PATH)) + avalon.api.deregister_plugin_path(LegacyCreator, str(CREATE_PATH)) if not IS_HEADLESS: ops.unregister() diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 8c9ab9a27f..20d1e4c8db 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -6,7 +6,7 @@ from typing import Dict, List, Optional import bpy import avalon.api -from openpype.api import PypeCreatorMixin +from openpype.pipeline import LegacyCreator from .pipeline import AVALON_CONTAINERS from .ops import ( MainThreadItem, @@ -129,7 +129,7 @@ def deselect_all(): bpy.context.view_layer.objects.active = active -class Creator(PypeCreatorMixin, avalon.api.Creator): +class Creator(LegacyCreator): """Base class for Creator plug-ins.""" defaults = ['Main'] diff --git a/openpype/hosts/blender/plugins/load/load_layout_blend.py b/openpype/hosts/blender/plugins/load/load_layout_blend.py index 8029c38b4a..7f8ae610c6 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_blend.py +++ b/openpype/hosts/blender/plugins/load/load_layout_blend.py @@ -8,6 +8,7 @@ import bpy from avalon import api from openpype import lib +from openpype.pipeline import legacy_create from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, @@ -159,7 +160,7 @@ class BlendLayoutLoader(plugin.AssetLoader): raise ValueError("Creator plugin \"CreateAnimation\" was " "not found.") - api.create( + legacy_create( creator_plugin, name=local_obj.name.split(':')[-1] + "_animation", asset=asset, diff --git a/openpype/hosts/blender/plugins/load/load_layout_json.py b/openpype/hosts/blender/plugins/load/load_layout_json.py index 0a5bdeecaa..91817deb60 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_json.py +++ b/openpype/hosts/blender/plugins/load/load_layout_json.py @@ -8,7 +8,7 @@ from typing import Dict, Optional import bpy from avalon import api -from openpype import lib +from openpype.pipeline import legacy_create from openpype.hosts.blender.api.pipeline import ( AVALON_INSTANCES, AVALON_CONTAINERS, @@ -118,7 +118,7 @@ class JsonLayoutLoader(plugin.AssetLoader): # raise ValueError("Creator plugin \"CreateCamera\" was " # "not found.") - # api.create( + # legacy_create( # creator_plugin, # name="camera", # # name=f"{unique_number}_{subset}_animation", diff --git a/openpype/hosts/blender/plugins/load/load_rig.py b/openpype/hosts/blender/plugins/load/load_rig.py index eb6d273a51..eacabd3447 100644 --- a/openpype/hosts/blender/plugins/load/load_rig.py +++ b/openpype/hosts/blender/plugins/load/load_rig.py @@ -9,6 +9,7 @@ import bpy from avalon import api from avalon.blender import lib as avalon_lib from openpype import lib +from openpype.pipeline import legacy_create from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, @@ -248,7 +249,7 @@ class BlendRigLoader(plugin.AssetLoader): animation_asset = options.get('animation_asset') - api.create( + legacy_create( creator_plugin, name=namespace + "_animation", # name=f"{unique_number}_{subset}_animation", diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index af071439ef..f802cf160b 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -7,6 +7,7 @@ from avalon import api as avalon from avalon.pipeline import AVALON_CONTAINER_ID from pyblish import api as pyblish from openpype.api import Logger +from openpype.pipeline import LegacyCreator from .lib import ( set_segment_data_marker, set_publish_attribute, @@ -33,7 +34,7 @@ def install(): pyblish.register_host("flame") pyblish.register_plugin_path(PUBLISH_PATH) avalon.register_plugin_path(avalon.Loader, LOAD_PATH) - avalon.register_plugin_path(avalon.Creator, CREATE_PATH) + avalon.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) log.info("OpenPype Flame plug-ins registred ...") @@ -48,7 +49,7 @@ def uninstall(): log.info("Deregistering Flame plug-ins..") pyblish.deregister_plugin_path(PUBLISH_PATH) avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) - avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) + avalon.deregister_plugin_path(LegacyCreator, CREATE_PATH) avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH) # register callback for switching publishable diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index ec49db1601..92296752de 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -7,6 +7,7 @@ from xml.etree import ElementTree as ET import six from Qt import QtWidgets, QtCore import openpype.api as openpype +from openpype.pipeline import LegacyCreator from openpype import style import avalon.api as avalon from . import ( @@ -299,7 +300,7 @@ class Spacer(QtWidgets.QWidget): self.setLayout(layout) -class Creator(openpype.Creator): +class Creator(LegacyCreator): """Creator class wrapper """ clip_color = constants.COLOR_MAP["purple"] diff --git a/openpype/hosts/fusion/api/pipeline.py b/openpype/hosts/fusion/api/pipeline.py index 64dda0bc8a..5ac56fcbed 100644 --- a/openpype/hosts/fusion/api/pipeline.py +++ b/openpype/hosts/fusion/api/pipeline.py @@ -11,6 +11,7 @@ import avalon.api from avalon.pipeline import AVALON_CONTAINER_ID from openpype.api import Logger +from openpype.pipeline import LegacyCreator import openpype.hosts.fusion log = Logger().get_logger(__name__) @@ -63,7 +64,7 @@ def install(): log.info("Registering Fusion plug-ins..") avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) pyblish.api.register_callback( @@ -87,7 +88,7 @@ def uninstall(): log.info("Deregistering Fusion plug-ins..") avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) avalon.api.deregister_plugin_path( avalon.api.InventoryAction, INVENTORY_PATH ) diff --git a/openpype/hosts/fusion/plugins/create/create_exr_saver.py b/openpype/hosts/fusion/plugins/create/create_exr_saver.py index 04717f4746..ff8bdb21ef 100644 --- a/openpype/hosts/fusion/plugins/create/create_exr_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_exr_saver.py @@ -1,13 +1,13 @@ import os -import openpype.api +from openpype.pipeline import create from openpype.hosts.fusion.api import ( get_current_comp, comp_lock_and_undo_chunk ) -class CreateOpenEXRSaver(openpype.api.Creator): +class CreateOpenEXRSaver(create.LegacyCreator): name = "openexrDefault" label = "Create OpenEXR Saver" diff --git a/openpype/hosts/harmony/api/pipeline.py b/openpype/hosts/harmony/api/pipeline.py index 17d2870876..6d0f5e9416 100644 --- a/openpype/hosts/harmony/api/pipeline.py +++ b/openpype/hosts/harmony/api/pipeline.py @@ -9,6 +9,7 @@ import avalon.api from avalon.pipeline import AVALON_CONTAINER_ID from openpype import lib +from openpype.pipeline import LegacyCreator import openpype.hosts.harmony import openpype.hosts.harmony.api as harmony @@ -179,7 +180,7 @@ def install(): pyblish.api.register_host("harmony") pyblish.api.register_plugin_path(PUBLISH_PATH) avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) log.info(PUBLISH_PATH) # Register callbacks. @@ -193,7 +194,7 @@ def install(): def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) def on_pyblish_instance_toggled(instance, old_value, new_value): diff --git a/openpype/hosts/harmony/api/plugin.py b/openpype/hosts/harmony/api/plugin.py index d6d61a547a..c55d200d30 100644 --- a/openpype/hosts/harmony/api/plugin.py +++ b/openpype/hosts/harmony/api/plugin.py @@ -1,9 +1,8 @@ -import avalon.api -from openpype.api import PypeCreatorMixin +from openpype.pipeline import LegacyCreator import openpype.hosts.harmony.api as harmony -class Creator(PypeCreatorMixin, avalon.api.Creator): +class Creator(LegacyCreator): """Creator plugin to create instances in Harmony. By default a Composite node is created to support any number of nodes in diff --git a/openpype/hosts/hiero/api/pipeline.py b/openpype/hosts/hiero/api/pipeline.py index cbcaf23994..5cb23ea355 100644 --- a/openpype/hosts/hiero/api/pipeline.py +++ b/openpype/hosts/hiero/api/pipeline.py @@ -9,6 +9,7 @@ from avalon import api as avalon from avalon import schema from pyblish import api as pyblish from openpype.api import Logger +from openpype.pipeline import LegacyCreator from openpype.tools.utils import host_tools from . import lib, menu, events @@ -45,7 +46,7 @@ def install(): pyblish.register_host("hiero") pyblish.register_plugin_path(PUBLISH_PATH) avalon.register_plugin_path(avalon.Loader, LOAD_PATH) - avalon.register_plugin_path(avalon.Creator, CREATE_PATH) + avalon.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) # register callback for switching publishable @@ -67,7 +68,7 @@ def uninstall(): pyblish.deregister_host("hiero") pyblish.deregister_plugin_path(PUBLISH_PATH) avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) - avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) + avalon.deregister_plugin_path(LegacyCreator, CREATE_PATH) # register callback for switching publishable pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled) diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index 3506af2d6a..3963985f0c 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -1,12 +1,15 @@ -import re import os +import re +from copy import deepcopy + import hiero + from Qt import QtWidgets, QtCore from avalon.vendor import qargparse import avalon.api as avalon import openpype.api as openpype +from openpype.pipeline import LegacyCreator from . import lib -from copy import deepcopy log = openpype.Logger().get_logger(__name__) @@ -589,7 +592,7 @@ class ClipLoader: return track_item -class Creator(openpype.Creator): +class Creator(LegacyCreator): """Creator class wrapper """ clip_color = "Purple" diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 1c08e72d65..21027dad2e 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -11,6 +11,7 @@ import avalon.api from avalon.pipeline import AVALON_CONTAINER_ID from avalon.lib import find_submodule +from openpype.pipeline import LegacyCreator import openpype.hosts.houdini from openpype.hosts.houdini.api import lib @@ -48,7 +49,7 @@ def install(): pyblish.api.register_plugin_path(PUBLISH_PATH) avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) log.info("Installing callbacks ... ") # avalon.on("init", on_init) diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 4967d01d43..2bbb65aa05 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -2,11 +2,12 @@ """Houdini specific Avalon/Pyblish plugin definitions.""" import sys import six -import avalon.api -from avalon.api import CreatorError import hou -from openpype.api import PypeCreatorMixin +from openpype.pipeline import ( + CreatorError, + LegacyCreator +) from .lib import imprint @@ -14,7 +15,7 @@ class OpenPypeCreatorError(CreatorError): pass -class Creator(PypeCreatorMixin, avalon.api.Creator): +class Creator(LegacyCreator): """Creator plugin to create instances in Houdini To support the wide range of node types for render output (Alembic, VDB, diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 1b3bb9feb3..8c3669c5d1 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -2,7 +2,6 @@ import os import sys import errno import logging -import contextlib from maya import utils, cmds, OpenMaya import maya.api.OpenMaya as om @@ -17,6 +16,7 @@ import openpype.hosts.maya from openpype.tools.utils import host_tools from openpype.lib import any_outdated from openpype.lib.path_tools import HostDirmap +from openpype.pipeline import LegacyCreator from openpype.hosts.maya.lib import copy_workspace_mel from . import menu, lib @@ -50,7 +50,7 @@ def install(): pyblish.api.register_host("maya") avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) log.info(PUBLISH_PATH) @@ -176,7 +176,7 @@ def uninstall(): pyblish.api.deregister_host("maya") avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) avalon.api.deregister_plugin_path( avalon.api.InventoryAction, INVENTORY_PATH ) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index bdb8fcf13a..5e52985fec 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -4,7 +4,7 @@ from maya import cmds from avalon import api from avalon.vendor import qargparse -from openpype.api import PypeCreatorMixin +from openpype.pipeline import LegacyCreator from .pipeline import containerise from . import lib @@ -77,7 +77,7 @@ def get_reference_node_parents(ref): return parents -class Creator(PypeCreatorMixin, api.Creator): +class Creator(LegacyCreator): defaults = ['Main'] def process(self): diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 743ec26778..9002ae3876 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -19,9 +19,9 @@ from openpype.api import ( get_project_settings, get_asset) from openpype.modules import ModulesManager +from openpype.pipeline import CreatorError from avalon.api import Session -from avalon.api import CreatorError class CreateRender(plugin.Creator): diff --git a/openpype/hosts/maya/plugins/create/create_vrayscene.py b/openpype/hosts/maya/plugins/create/create_vrayscene.py index f2096d902e..fa9c59e016 100644 --- a/openpype/hosts/maya/plugins/create/create_vrayscene.py +++ b/openpype/hosts/maya/plugins/create/create_vrayscene.py @@ -19,10 +19,10 @@ from openpype.api import ( get_project_settings ) +from openpype.pipeline import CreatorError from openpype.modules import ModulesManager from avalon.api import Session -from avalon.api import CreatorError class CreateVRayScene(plugin.Creator): diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 0565b0b95c..25db5fb1e5 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -3,6 +3,7 @@ from maya import cmds from avalon import api from openpype.api import get_project_settings from openpype.lib import get_creator_by_name +from openpype.pipeline import legacy_create import openpype.hosts.maya.api.plugin from openpype.hosts.maya.api.lib import maintained_selection @@ -151,7 +152,7 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): creator_plugin = get_creator_by_name(self.animation_creator_name) with maintained_selection(): cmds.select([output, controls] + roots, noExpand=True) - api.create( + legacy_create( creator_plugin, name=namespace, asset=asset, diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index 8c6c9ca55b..d98a951491 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -14,6 +14,7 @@ from openpype.api import ( BuildWorkfile, get_current_project_settings ) +from openpype.pipeline import LegacyCreator from openpype.tools.utils import host_tools from .command import viewer_update_and_undo_stop @@ -98,7 +99,7 @@ def install(): log.info("Registering Nuke plug-ins..") pyblish.api.register_plugin_path(PUBLISH_PATH) avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) # Register Avalon event for workfiles loading. @@ -124,7 +125,7 @@ def uninstall(): pyblish.deregister_host("nuke") pyblish.api.deregister_plugin_path(PUBLISH_PATH) avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) pyblish.api.deregister_callback( "instanceToggled", on_pyblish_instance_toggled) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 11e30d9fcd..ff186cd685 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -6,10 +6,8 @@ import nuke import avalon.api -from openpype.api import ( - get_current_project_settings, - PypeCreatorMixin -) +from openpype.api import get_current_project_settings +from openpype.pipeline import LegacyCreator from .lib import ( Knobby, check_subsetname_exists, @@ -20,7 +18,7 @@ from .lib import ( ) -class OpenPypeCreator(PypeCreatorMixin, avalon.api.Creator): +class OpenPypeCreator(LegacyCreator): """Pype Nuke Creator class wrapper""" node_color = "0xdfea5dff" diff --git a/openpype/hosts/photoshop/api/__init__.py b/openpype/hosts/photoshop/api/__init__.py index 4cc2aa2c78..17ea957066 100644 --- a/openpype/hosts/photoshop/api/__init__.py +++ b/openpype/hosts/photoshop/api/__init__.py @@ -16,7 +16,6 @@ from .pipeline import ( ) from .plugin import ( PhotoshopLoader, - Creator, get_unique_layer_name ) from .workio import ( @@ -42,11 +41,11 @@ __all__ = [ "list_instances", "remove_instance", "install", + "uninstall", "containerise", # Plugin "PhotoshopLoader", - "Creator", "get_unique_layer_name", # workfiles diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index 25983f2471..662e9dbebc 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -1,5 +1,4 @@ import os -import sys from Qt import QtWidgets import pyblish.api @@ -7,6 +6,7 @@ import avalon.api from avalon import pipeline, io from openpype.api import Logger +from openpype.pipeline import LegacyCreator import openpype.hosts.photoshop from . import lib @@ -68,7 +68,7 @@ def install(): pyblish.api.register_plugin_path(PUBLISH_PATH) avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) log.info(PUBLISH_PATH) pyblish.api.register_callback( @@ -81,7 +81,7 @@ def install(): def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) def ls(): diff --git a/openpype/hosts/photoshop/api/plugin.py b/openpype/hosts/photoshop/api/plugin.py index e0db67de2c..c577c67d82 100644 --- a/openpype/hosts/photoshop/api/plugin.py +++ b/openpype/hosts/photoshop/api/plugin.py @@ -33,37 +33,3 @@ class PhotoshopLoader(avalon.api.Loader): @staticmethod def get_stub(): return stub() - - -class Creator(avalon.api.Creator): - """Creator plugin to create instances in Photoshop - - A LayerSet is created to support any number of layers in an instance. If - the selection is used, these layers will be added to the LayerSet. - """ - - def process(self): - # Photoshop can have multiple LayerSets with the same name, which does - # not work with Avalon. - msg = "Instance with name \"{}\" already exists.".format(self.name) - stub = lib.stub() # only after Photoshop is up - for layer in stub.get_layers(): - if self.name.lower() == layer.Name.lower(): - msg = QtWidgets.QMessageBox() - msg.setIcon(QtWidgets.QMessageBox.Warning) - msg.setText(msg) - msg.exec_() - return False - - # Store selection because adding a group will change selection. - with lib.maintained_selection(): - - # Add selection to group. - if (self.options or {}).get("useSelection"): - group = stub.group_selected_layers(self.name) - else: - group = stub.create_group(self.name) - - stub.imprint(group, self.data) - - return group diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index 344a53f47e..a001b5f171 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -1,9 +1,9 @@ from Qt import QtWidgets -import openpype.api +from openpype.pipeline import create from openpype.hosts.photoshop import api as photoshop -class CreateImage(openpype.api.Creator): +class CreateImage(create.LegacyCreator): """Image folder for publish.""" name = "imageDefault" diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py index 2dc5136c8a..c82545268b 100644 --- a/openpype/hosts/resolve/api/pipeline.py +++ b/openpype/hosts/resolve/api/pipeline.py @@ -9,6 +9,7 @@ from avalon import schema from avalon.pipeline import AVALON_CONTAINER_ID from pyblish import api as pyblish from openpype.api import Logger +from openpype.pipeline import LegacyCreator from . import lib from . import PLUGINS_DIR from openpype.tools.utils import host_tools @@ -42,7 +43,7 @@ def install(): log.info("Registering DaVinci Resovle plug-ins..") avalon.register_plugin_path(avalon.Loader, LOAD_PATH) - avalon.register_plugin_path(avalon.Creator, CREATE_PATH) + avalon.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) # register callback for switching publishable @@ -67,7 +68,7 @@ def uninstall(): log.info("Deregistering DaVinci Resovle plug-ins..") avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) - avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) + avalon.deregister_plugin_path(LegacyCreator, CREATE_PATH) avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH) # register callback for switching publishable diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 8612cf82ec..b6791f7225 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -2,6 +2,7 @@ import re import uuid from avalon import api import openpype.api as pype +from openpype.pipeline import LegacyCreator from openpype.hosts import resolve from avalon.vendor import qargparse from . import lib @@ -493,7 +494,7 @@ class TimelineItemLoader(api.Loader): pass -class Creator(pype.PypeCreatorMixin, api.Creator): +class Creator(LegacyCreator): """Creator class wrapper """ marker_color = "Purple" diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index 74eb41892c..f4599047b4 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -14,6 +14,7 @@ from avalon.pipeline import AVALON_CONTAINER_ID from openpype.hosts import tvpaint from openpype.api import get_current_project_settings +from openpype.pipeline import LegacyCreator from .lib import ( execute_george, @@ -76,7 +77,7 @@ def install(): pyblish.api.register_host("tvpaint") pyblish.api.register_plugin_path(PUBLISH_PATH) avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) registered_callbacks = ( pyblish.api.registered_callbacks().get("instanceToggled") or [] @@ -98,7 +99,7 @@ def uninstall(): pyblish.api.deregister_host("tvpaint") pyblish.api.deregister_plugin_path(PUBLISH_PATH) avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) def containerise( diff --git a/openpype/hosts/tvpaint/api/plugin.py b/openpype/hosts/tvpaint/api/plugin.py index af80c9eae2..8510794f06 100644 --- a/openpype/hosts/tvpaint/api/plugin.py +++ b/openpype/hosts/tvpaint/api/plugin.py @@ -3,14 +3,14 @@ import uuid import avalon.api -from openpype.api import PypeCreatorMixin +from openpype.pipeline import LegacyCreator from openpype.hosts.tvpaint.api import ( pipeline, lib ) -class Creator(PypeCreatorMixin, avalon.api.Creator): +class Creator(LegacyCreator): def __init__(self, *args, **kwargs): super(Creator, self).__init__(*args, **kwargs) # Add unified identifier created with `uuid` module diff --git a/openpype/hosts/tvpaint/plugins/create/create_render_layer.py b/openpype/hosts/tvpaint/plugins/create/create_render_layer.py index 40a7d15990..c1af9632b1 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render_layer.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render_layer.py @@ -1,5 +1,4 @@ -from avalon.api import CreatorError - +from openpype.pipeline import CreatorError from openpype.lib import prepare_template_data from openpype.hosts.tvpaint.api import ( plugin, diff --git a/openpype/hosts/tvpaint/plugins/create/create_render_pass.py b/openpype/hosts/tvpaint/plugins/create/create_render_pass.py index af962052fc..a7f717ccec 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render_pass.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render_pass.py @@ -1,4 +1,4 @@ -from avalon.api import CreatorError +from openpype.pipeline import CreatorError from openpype.lib import prepare_template_data from openpype.hosts.tvpaint.api import ( plugin, diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index ad64d56e9e..8ab19bd697 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -7,6 +7,7 @@ import pyblish.api from avalon.pipeline import AVALON_CONTAINER_ID from avalon import api +from openpype.pipeline import LegacyCreator from openpype.tools.utils import host_tools import openpype.hosts.unreal @@ -44,7 +45,7 @@ def install(): logger.info("installing OpenPype for Unreal") pyblish.api.register_plugin_path(str(PUBLISH_PATH)) api.register_plugin_path(api.Loader, str(LOAD_PATH)) - api.register_plugin_path(api.Creator, str(CREATE_PATH)) + api.register_plugin_path(LegacyCreator, str(CREATE_PATH)) _register_callbacks() _register_events() @@ -53,7 +54,7 @@ def uninstall(): """Uninstall Unreal configuration for Avalon.""" pyblish.api.deregister_plugin_path(str(PUBLISH_PATH)) api.deregister_plugin_path(api.Loader, str(LOAD_PATH)) - api.deregister_plugin_path(api.Creator, str(CREATE_PATH)) + api.deregister_plugin_path(LegacyCreator, str(CREATE_PATH)) def _register_callbacks(): @@ -70,7 +71,7 @@ def _register_events(): pass -class Creator(api.Creator): +class Creator(LegacyCreator): hosts = ["unreal"] asset_types = [] diff --git a/openpype/hosts/unreal/api/plugin.py b/openpype/hosts/unreal/api/plugin.py index 2327fc09c8..dd2e7750f0 100644 --- a/openpype/hosts/unreal/api/plugin.py +++ b/openpype/hosts/unreal/api/plugin.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- from abc import ABC -import openpype.api +from openpype.pipeline import LegacyCreator import avalon.api -class Creator(openpype.api.Creator): +class Creator(LegacyCreator): """This serves as skeleton for future OpenPype specific functionality""" defaults = ['Main'] diff --git a/openpype/hosts/webpublisher/api/__init__.py b/openpype/hosts/webpublisher/api/__init__.py index e40d46d662..6ce8a58fc2 100644 --- a/openpype/hosts/webpublisher/api/__init__.py +++ b/openpype/hosts/webpublisher/api/__init__.py @@ -5,6 +5,7 @@ from avalon import api as avalon from avalon import io from pyblish import api as pyblish import openpype.hosts.webpublisher +from openpype.pipeline import LegacyCreator log = logging.getLogger("openpype.hosts.webpublisher") @@ -25,7 +26,7 @@ def install(): pyblish.register_plugin_path(PUBLISH_PATH) avalon.register_plugin_path(avalon.Loader, LOAD_PATH) - avalon.register_plugin_path(avalon.Creator, CREATE_PATH) + avalon.register_plugin_path(LegacyCreator, CREATE_PATH) log.info(PUBLISH_PATH) io.install() @@ -35,7 +36,7 @@ def install(): def uninstall(): pyblish.deregister_plugin_path(PUBLISH_PATH) avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) - avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) + avalon.deregister_plugin_path(LegacyCreator, CREATE_PATH) # to have required methods for interface diff --git a/openpype/lib/plugin_tools.py b/openpype/lib/plugin_tools.py index 183aad939a..19765a6f4a 100644 --- a/openpype/lib/plugin_tools.py +++ b/openpype/lib/plugin_tools.py @@ -293,7 +293,7 @@ def set_plugin_attributes_from_settings( plugin_type = None if superclass.__name__.split(".")[-1] in ("Loader", "SubsetLoader"): plugin_type = "load" - elif superclass.__name__.split(".")[-1] == "Creator": + elif superclass.__name__.split(".")[-1] in ("Creator", "LegacyCreator"): plugin_type = "create" if not host_name or not project_name or plugin_type is None: diff --git a/openpype/plugin.py b/openpype/plugin.py index 45c9a08209..3569936dac 100644 --- a/openpype/plugin.py +++ b/openpype/plugin.py @@ -3,79 +3,12 @@ import os import pyblish.api import avalon.api -from openpype.lib import get_subset_name - ValidatePipelineOrder = pyblish.api.ValidatorOrder + 0.05 ValidateContentsOrder = pyblish.api.ValidatorOrder + 0.1 ValidateSceneOrder = pyblish.api.ValidatorOrder + 0.2 ValidateMeshOrder = pyblish.api.ValidatorOrder + 0.3 -class PypeCreatorMixin: - """Helper to override avalon's default class methods. - - Mixin class must be used as first in inheritance order to override methods. - """ - dynamic_subset_keys = [] - - @classmethod - def get_dynamic_data( - cls, variant, task_name, asset_id, project_name, host_name - ): - """Return dynamic data for current Creator plugin. - - By default return keys from `dynamic_subset_keys` attribute as mapping - to keep formatted template unchanged. - - ``` - dynamic_subset_keys = ["my_key"] - --- - output = { - "my_key": "{my_key}" - } - ``` - - Dynamic keys may override default Creator keys (family, task, asset, - ...) but do it wisely if you need. - - All of keys will be converted into 3 variants unchanged, capitalized - and all upper letters. Because of that are all keys lowered. - - This method can be modified to prefill some values just keep in mind it - is class method. - - Returns: - dict: Fill data for subset name template. - """ - dynamic_data = {} - for key in cls.dynamic_subset_keys: - key = key.lower() - dynamic_data[key] = "{" + key + "}" - return dynamic_data - - @classmethod - def get_subset_name( - cls, variant, task_name, asset_id, project_name, host_name=None - ): - dynamic_data = cls.get_dynamic_data( - variant, task_name, asset_id, project_name, host_name - ) - - return get_subset_name( - cls.family, - variant, - task_name, - asset_id, - project_name, - host_name, - dynamic_data=dynamic_data - ) - - -class Creator(PypeCreatorMixin, avalon.api.Creator): - pass - - class ContextPlugin(pyblish.api.ContextPlugin): def process(cls, *args, **kwargs): super(ContextPlugin, cls).process(cls, *args, **kwargs) diff --git a/openpype/tests/test_avalon_plugin_presets.py b/openpype/tests/test_avalon_plugin_presets.py index ec21385d23..f1b1a94713 100644 --- a/openpype/tests/test_avalon_plugin_presets.py +++ b/openpype/tests/test_avalon_plugin_presets.py @@ -1,8 +1,9 @@ import avalon.api as api import openpype +from openpype.pipeline import LegacyCreator -class MyTestCreator(api.Creator): +class MyTestCreator(LegacyCreator): my_test_property = "A" @@ -26,8 +27,8 @@ def test_avalon_plugin_presets(monkeypatch, printer): openpype.install() api.register_host(Test()) - api.register_plugin(api.Creator, MyTestCreator) - plugins = api.discover(api.Creator) + api.register_plugin(LegacyCreator, MyTestCreator) + plugins = api.discover(LegacyCreator) printer("Test if we got our test plugin") assert MyTestCreator in plugins for p in plugins: diff --git a/openpype/tools/creator/model.py b/openpype/tools/creator/model.py index 6907e8f0aa..ef61c6e0f0 100644 --- a/openpype/tools/creator/model.py +++ b/openpype/tools/creator/model.py @@ -2,6 +2,7 @@ import uuid from Qt import QtGui, QtCore from avalon import api +from openpype.pipeline import LegacyCreator from . constants import ( FAMILY_ROLE, @@ -21,7 +22,7 @@ class CreatorsModel(QtGui.QStandardItemModel): self._creators_by_id = {} items = [] - creators = api.discover(api.Creator) + creators = api.discover(LegacyCreator) for creator in creators: item_id = str(uuid.uuid4()) self._creators_by_id[item_id] = creator diff --git a/openpype/tools/creator/window.py b/openpype/tools/creator/window.py index f1d0849dfe..51cc66e715 100644 --- a/openpype/tools/creator/window.py +++ b/openpype/tools/creator/window.py @@ -9,7 +9,12 @@ from avalon import api, io from openpype import style from openpype.api import get_current_project_settings from openpype.tools.utils.lib import qt_app_context -from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS +from openpype.pipeline.create import ( + SUBSET_NAME_ALLOWED_SYMBOLS, + legacy_create, + CreatorError, + LegacyCreator, +) from .model import CreatorsModel from .widgets import ( @@ -422,7 +427,7 @@ class CreatorWindow(QtWidgets.QDialog): error_info = None try: - api.create( + legacy_create( creator_plugin, subset_name, asset_name, @@ -430,7 +435,7 @@ class CreatorWindow(QtWidgets.QDialog): data={"variant": variant} ) - except api.CreatorError as exc: + except CreatorError as exc: self.echo("Creator error: {}".format(str(exc))) error_info = (str(exc), None) @@ -486,7 +491,7 @@ def show(debug=False, parent=None): if debug: from avalon import mock for creator in mock.creators: - api.register_plugin(api.Creator, creator) + api.register_plugin(LegacyCreator, creator) import traceback sys.excepthook = lambda typ, val, tb: traceback.print_last() diff --git a/openpype/tools/standalonepublish/widgets/widget_family.py b/openpype/tools/standalonepublish/widgets/widget_family.py index ae44899a89..08cd45bbf2 100644 --- a/openpype/tools/standalonepublish/widgets/widget_family.py +++ b/openpype/tools/standalonepublish/widgets/widget_family.py @@ -1,14 +1,11 @@ -import os import re from Qt import QtWidgets, QtCore from . import HelpRole, FamilyRole, ExistsRole, PluginRole, PluginKeyRole from . import FamilyDescriptionWidget -from openpype.api import ( - get_project_settings, - Creator -) +from openpype.api import get_project_settings +from openpype.pipeline import LegacyCreator from openpype.lib import TaskNotSetError from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS @@ -390,7 +387,7 @@ class FamilyWidget(QtWidgets.QWidget): sp_settings = settings.get('standalonepublisher', {}) for key, creator_data in sp_settings.get("create", {}).items(): - creator = type(key, (Creator, ), creator_data) + creator = type(key, (LegacyCreator, ), creator_data) label = creator.label or creator.family item = QtWidgets.QListWidgetItem(label) From d1cc05487343485db215cb88b7ce4d4879152368 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Wed, 9 Mar 2022 03:36:53 +0000 Subject: [PATCH 380/483] [Automated] Bump version --- CHANGELOG.md | 19 +++++++++---------- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 711517e6c6..fa479d8f05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,12 @@ # Changelog -## [3.9.0-nightly.6](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.9.0-nightly.7](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...HEAD) **Deprecated:** +- AssetCreator: Remove the tool [\#2845](https://github.com/pypeclub/OpenPype/pull/2845) - Houdini: Remove unused code [\#2779](https://github.com/pypeclub/OpenPype/pull/2779) ### 📖 Documentation @@ -16,21 +17,23 @@ **🚀 Enhancements** +- New: Validation exceptions [\#2841](https://github.com/pypeclub/OpenPype/pull/2841) - Ftrack: Can sync fps as string [\#2836](https://github.com/pypeclub/OpenPype/pull/2836) +- General: Custom function for find executable [\#2822](https://github.com/pypeclub/OpenPype/pull/2822) - General: Color dialog UI fixes [\#2817](https://github.com/pypeclub/OpenPype/pull/2817) +- global: letter box calculated on output as last process [\#2812](https://github.com/pypeclub/OpenPype/pull/2812) - Nuke: adding Reformat to baking mov plugin [\#2811](https://github.com/pypeclub/OpenPype/pull/2811) - Manager: Update all to latest button [\#2805](https://github.com/pypeclub/OpenPype/pull/2805) - General: Set context environments for non host applications [\#2803](https://github.com/pypeclub/OpenPype/pull/2803) -- Houdini: Remove duplicate ValidateOutputNode plug-in [\#2780](https://github.com/pypeclub/OpenPype/pull/2780) - Tray publisher: New Tray Publisher host \(beta\) [\#2778](https://github.com/pypeclub/OpenPype/pull/2778) -- Slack: Added regex for filtering on subset names [\#2775](https://github.com/pypeclub/OpenPype/pull/2775) -- Houdini: Implement Reset Frame Range [\#2770](https://github.com/pypeclub/OpenPype/pull/2770) - Flame: use Shot Name on segment for asset name [\#2751](https://github.com/pypeclub/OpenPype/pull/2751) -- Houdini: Move Houdini Save Current File to beginning of ExtractorOrder [\#2747](https://github.com/pypeclub/OpenPype/pull/2747) -- RoyalRender: Minor enhancements [\#2700](https://github.com/pypeclub/OpenPype/pull/2700) **🐛 Bug fixes** +- WebPublisher: Fix username stored in DB [\#2852](https://github.com/pypeclub/OpenPype/pull/2852) +- WebPublisher: Fix wrong number of frames for video file [\#2851](https://github.com/pypeclub/OpenPype/pull/2851) +- Nuke: fix multiple baking profile farm publishing [\#2842](https://github.com/pypeclub/OpenPype/pull/2842) +- Blender: Fixed parameters for FBX export of the camera [\#2840](https://github.com/pypeclub/OpenPype/pull/2840) - Maya: Stop creation of reviews for Cryptomattes [\#2832](https://github.com/pypeclub/OpenPype/pull/2832) - Deadline: Remove recreated event [\#2828](https://github.com/pypeclub/OpenPype/pull/2828) - Deadline: Added missing events folder [\#2827](https://github.com/pypeclub/OpenPype/pull/2827) @@ -46,10 +49,6 @@ - Ftrack: Unset task ids from asset versions before tasks are removed [\#2800](https://github.com/pypeclub/OpenPype/pull/2800) - Slack: fail gracefully if slack exception [\#2798](https://github.com/pypeclub/OpenPype/pull/2798) - Flame: Fix version string in default settings [\#2783](https://github.com/pypeclub/OpenPype/pull/2783) -- Houdini: Fix open last workfile [\#2767](https://github.com/pypeclub/OpenPype/pull/2767) -- Maya: Fix `unique\_namespace` when in an namespace that is empty [\#2759](https://github.com/pypeclub/OpenPype/pull/2759) -- Maya: Remove some unused code [\#2709](https://github.com/pypeclub/OpenPype/pull/2709) -- Multiple hosts: unify menu style across hosts [\#2693](https://github.com/pypeclub/OpenPype/pull/2693) **Merged pull requests:** diff --git a/openpype/version.py b/openpype/version.py index d977e87243..55ac148ed1 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.9.0-nightly.6" +__version__ = "3.9.0-nightly.7" diff --git a/pyproject.toml b/pyproject.toml index 2469cb76a9..541932bce6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.9.0-nightly.6" # OpenPype +version = "3.9.0-nightly.7" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From ee71d3b6580f363d95404b13283339ea055a183d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 9 Mar 2022 10:13:51 +0100 Subject: [PATCH 381/483] fix getattr clalback on dynamic module --- openpype/modules/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index c7078475df..175957ae39 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -61,6 +61,7 @@ class _ModuleClass(object): def __init__(self, name): # Call setattr on super class super(_ModuleClass, self).__setattr__("name", name) + super(_ModuleClass, self).__setattr__("__name__", name) # Where modules and interfaces are stored super(_ModuleClass, self).__setattr__("__attributes__", dict()) @@ -72,7 +73,7 @@ class _ModuleClass(object): if attr_name not in self.__attributes__: if attr_name in ("__path__", "__file__"): return None - raise ImportError("No module named {}.{}".format( + raise AttributeError("'{}' has not attribute '{}'".format( self.name, attr_name )) return self.__attributes__[attr_name] From aef78c3c7580c978873c3e89ea34e1a00a2a4b92 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 9 Mar 2022 11:34:02 +0100 Subject: [PATCH 382/483] use new version of error dialog and pass parent to a dialog --- .../tools/publisher/widgets/create_dialog.py | 124 +++++++++--------- 1 file changed, 65 insertions(+), 59 deletions(-) diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index c5b77eca8b..5ebcd7291d 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -14,6 +14,8 @@ from openpype.pipeline.create import ( SUBSET_NAME_ALLOWED_SYMBOLS ) +from openpype.tools.utils import ErrorMessageBox + from .widgets import IconValuePixmapLabel from .assets_widget import CreateDialogAssetsWidget from .tasks_widget import CreateDialogTasksWidget @@ -27,7 +29,7 @@ from ..constants import ( SEPARATORS = ("---separator---", "---") -class CreateErrorMessageBox(QtWidgets.QDialog): +class CreateErrorMessageBox(ErrorMessageBox): def __init__( self, creator_label, @@ -35,24 +37,38 @@ class CreateErrorMessageBox(QtWidgets.QDialog): asset_name, exc_msg, formatted_traceback, - parent=None + parent ): - super(CreateErrorMessageBox, self).__init__(parent) - self.setWindowTitle("Creation failed") - self.setFocusPolicy(QtCore.Qt.StrongFocus) - if not parent: - self.setWindowFlags( - self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint - ) + self._creator_label = creator_label + self._subset_name = subset_name + self._asset_name = asset_name + self._exc_msg = exc_msg + self._formatted_traceback = formatted_traceback + super(CreateErrorMessageBox, self).__init__("Creation failed", parent) - body_layout = QtWidgets.QVBoxLayout(self) - - main_label = ( + def _create_top_widget(self, parent_widget): + label_widget = QtWidgets.QLabel(parent_widget) + label_widget.setText( "Failed to create" ) - main_label_widget = QtWidgets.QLabel(main_label, self) - body_layout.addWidget(main_label_widget) + return label_widget + def _get_report_data(self): + report_message = ( + "{creator}: Failed to create Subset: \"{subset}\"" + " in Asset: \"{asset}\"" + "\n\nError: {message}" + ).format( + creator=self._creator_label, + subset=self._subset_name, + asset=self._asset_name, + message=self._exc_msg, + ) + if self._formatted_traceback: + report_message += "\n\n{}".format(self._formatted_traceback) + return [report_message] + + def _create_content(self, content_layout): item_name_template = ( "Creator: {}
" "Subset: {}
" @@ -61,48 +77,29 @@ class CreateErrorMessageBox(QtWidgets.QDialog): exc_msg_template = "{}" line = self._create_line() - body_layout.addWidget(line) + content_layout.addWidget(line) - item_name = item_name_template.format( - creator_label, subset_name, asset_name - ) - item_name_widget = QtWidgets.QLabel( - item_name.replace("\n", "
"), self - ) - body_layout.addWidget(item_name_widget) - - exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
")) - message_label_widget = QtWidgets.QLabel(exc_msg, self) - body_layout.addWidget(message_label_widget) - - if formatted_traceback: - tb_widget = QtWidgets.QLabel( - formatted_traceback.replace("\n", "
"), self + item_name_widget = QtWidgets.QLabel(self) + item_name_widget.setText( + item_name_template.format( + self._creator_label, self._subset_name, self._asset_name ) - tb_widget.setTextInteractionFlags( - QtCore.Qt.TextBrowserInteraction - ) - body_layout.addWidget(tb_widget) - - footer_widget = QtWidgets.QWidget(self) - footer_layout = QtWidgets.QHBoxLayout(footer_widget) - button_box = QtWidgets.QDialogButtonBox(QtCore.Qt.Vertical) - button_box.setStandardButtons( - QtWidgets.QDialogButtonBox.StandardButton.Ok ) - button_box.accepted.connect(self._on_accept) - footer_layout.addWidget(button_box, alignment=QtCore.Qt.AlignRight) - body_layout.addWidget(footer_widget) + content_layout.addWidget(item_name_widget) - def _on_accept(self): - self.close() + message_label_widget = QtWidgets.QLabel(self) + message_label_widget.setText( + exc_msg_template.format(self.convert_text_for_html(self._exc_msg)) + ) + content_layout.addWidget(message_label_widget) - def _create_line(self): - line = QtWidgets.QFrame(self) - line.setFixedHeight(2) - line.setFrameShape(QtWidgets.QFrame.HLine) - line.setFrameShadow(QtWidgets.QFrame.Sunken) - return line + if self._formatted_traceback: + line_widget = self._create_line() + tb_widget = self._create_traceback_widget( + self._formatted_traceback + ) + content_layout.addWidget(line_widget) + content_layout.addWidget(tb_widget) # TODO add creator identifier/label to details @@ -201,7 +198,7 @@ class CreateDialog(QtWidgets.QDialog): self._prereq_available = False - self.message_dialog = None + self._message_dialog = None name_pattern = "^[{}]*$".format(SUBSET_NAME_ALLOWED_SYMBOLS) self._name_pattern = name_pattern @@ -694,14 +691,18 @@ class CreateDialog(QtWidgets.QDialog): "family": family } - error_info = None + error_msg = None + formatted_traceback = None try: self.controller.create( - creator_identifier, subset_name, instance_data, pre_create_data + creator_identifier, + subset_name, + instance_data, + pre_create_data ) except CreatorError as exc: - error_info = (str(exc), None) + error_msg = str(exc) # Use bare except because some hosts raise their exceptions that # do not inherit from python's `BaseException` @@ -710,12 +711,17 @@ class CreateDialog(QtWidgets.QDialog): formatted_traceback = "".join(traceback.format_exception( exc_type, exc_value, exc_traceback )) - error_info = (str(exc_value), formatted_traceback) + error_msg = str(exc_value) - if error_info: + if error_msg is not None: box = CreateErrorMessageBox( - creator_label, subset_name, asset_name, *error_info + creator_label, + subset_name, + asset_name, + error_msg, + formatted_traceback, + parent=self ) box.show() # Store dialog so is not garbage collected before is shown - self.message_dialog = box + self._message_dialog = box From 52ad3caf170c9ba6c9e6c1c68ed91591adf57bd6 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 9 Mar 2022 12:04:35 +0100 Subject: [PATCH 383/483] Fix frameEnd was 1 frame too high --- .../plugins/publish/collect_published_files.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py index 8b21842635..afd6f349db 100644 --- a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py +++ b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py @@ -105,17 +105,18 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): task_dir, task_data["files"], tags ) file_url = os.path.join(task_dir, task_data["files"][0]) - duration = self._get_duration(file_url) - if duration: + no_of_frames = self._get_number_of_frames(file_url) + if no_of_frames: try: - frame_end = int(frame_start) + math.ceil(duration) - instance.data["frameEnd"] = math.ceil(frame_end) + frame_end = int(frame_start) + math.ceil(no_of_frames) + instance.data["frameEnd"] = math.ceil(frame_end) - 1 self.log.debug("frameEnd:: {}".format( instance.data["frameEnd"])) except ValueError: self.log.warning("Unable to count frames " - "duration {}".format(duration)) + "duration {}".format(no_of_frames)) + # raise ValueError("STOP") instance.data["handleStart"] = asset_doc["data"]["handleStart"] instance.data["handleEnd"] = asset_doc["data"]["handleEnd"] @@ -261,7 +262,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): else: return 0 - def _get_duration(self, file_url): + def _get_number_of_frames(self, file_url): """Return duration in frames""" try: streams = ffprobe_streams(file_url, self.log) @@ -288,7 +289,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): duration = stream.get("duration") frame_rate = get_fps(stream.get("r_frame_rate", '0/0')) - self.log.debu("duration:: {} frame_rate:: {}".format( + self.log.debug("duration:: {} frame_rate:: {}".format( duration, frame_rate)) try: return float(duration) * float(frame_rate) From 65cd0c55a839b03fcc471e1215b2dcf3f46d974e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 9 Mar 2022 12:08:11 +0100 Subject: [PATCH 384/483] added check of subclasses in patched discover --- openpype/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/openpype/__init__.py b/openpype/__init__.py index c41afaa47d..942112835b 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -59,10 +59,15 @@ def patched_discover(superclass): """ # run original discover and get plugins plugins = _original_discover(superclass) + filtered_plugins = [ + plugin + for plugin in plugins + if issubclass(plugin, superclass) + ] - set_plugin_attributes_from_settings(plugins, superclass) + set_plugin_attributes_from_settings(filtered_plugins, superclass) - return plugins + return filtered_plugins @import_wrapper From 6d2ebaa68e3ec4d448845d89738465edc671a255 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Mar 2022 12:11:27 +0100 Subject: [PATCH 385/483] remove unused code --- openpype/hosts/blender/plugins/load/load_layout_json.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/load/load_layout_json.py b/openpype/hosts/blender/plugins/load/load_layout_json.py index 91817deb60..5b5f9ab83d 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_json.py +++ b/openpype/hosts/blender/plugins/load/load_layout_json.py @@ -8,7 +8,6 @@ from typing import Dict, Optional import bpy from avalon import api -from openpype.pipeline import legacy_create from openpype.hosts.blender.api.pipeline import ( AVALON_INSTANCES, AVALON_CONTAINERS, From f515f360dc204d122a7288306a1bd47289959fdc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 9 Mar 2022 14:34:18 +0100 Subject: [PATCH 386/483] Choose project widget is more clear --- openpype/style/style.css | 6 ++++ openpype/tools/traypublisher/window.py | 38 +++++++++++++++++--------- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/openpype/style/style.css b/openpype/style/style.css index ba40b780ab..5586cf766d 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -1266,6 +1266,12 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { font-size: 15pt; font-weight: 750; } +#ChooseProjectFrame { + border-radius: 10px; +} +#ChooseProjectView { + background: transparent; +} /* Globally used names */ #Separator { diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py index 53f8ca450a..4c35d84f98 100644 --- a/openpype/tools/traypublisher/window.py +++ b/openpype/tools/traypublisher/window.py @@ -28,38 +28,50 @@ class StandaloneOverlayWidget(QtWidgets.QFrame): super(StandaloneOverlayWidget, self).__init__(publisher_window) self.setObjectName("OverlayFrame") + middle_frame = QtWidgets.QFrame(self) + middle_frame.setObjectName("ChooseProjectFrame") + + content_widget = QtWidgets.QWidget(middle_frame) + # Create db connection for projects model dbcon = AvalonMongoDB() dbcon.install() - header_label = QtWidgets.QLabel("Choose project", self) + header_label = QtWidgets.QLabel("Choose project", content_widget) header_label.setObjectName("ChooseProjectLabel") # Create project models and view projects_model = ProjectModel(dbcon) projects_proxy = ProjectSortFilterProxy() projects_proxy.setSourceModel(projects_model) - projects_view = QtWidgets.QListView(self) + projects_view = QtWidgets.QListView(content_widget) + projects_view.setObjectName("ChooseProjectView") projects_view.setModel(projects_proxy) projects_view.setEditTriggers( QtWidgets.QAbstractItemView.NoEditTriggers ) - confirm_btn = QtWidgets.QPushButton("Choose", self) + confirm_btn = QtWidgets.QPushButton("Confirm", content_widget) btns_layout = QtWidgets.QHBoxLayout() btns_layout.addStretch(1) btns_layout.addWidget(confirm_btn, 0) - layout = QtWidgets.QGridLayout(self) - layout.addWidget(header_label, 0, 1, alignment=QtCore.Qt.AlignCenter) - layout.addWidget(projects_view, 1, 1) - layout.addLayout(btns_layout, 2, 1) - layout.setColumnStretch(0, 1) - layout.setColumnStretch(1, 0) - layout.setColumnStretch(2, 1) - layout.setRowStretch(0, 0) - layout.setRowStretch(1, 1) - layout.setRowStretch(2, 0) + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.setSpacing(20) + content_layout.addWidget(header_label, 0) + content_layout.addWidget(projects_view, 1) + content_layout.addLayout(btns_layout, 0) + + middle_layout = QtWidgets.QHBoxLayout(middle_frame) + middle_layout.setContentsMargins(30, 30, 10, 10) + middle_layout.addWidget(content_widget) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.addStretch(1) + main_layout.addWidget(middle_frame, 3) + main_layout.addStretch(1) projects_view.doubleClicked.connect(self._on_double_click) confirm_btn.clicked.connect(self._on_confirm_click) From 9280eacf40d821bad8fa85c8db78ac551728b957 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 9 Mar 2022 14:38:22 +0100 Subject: [PATCH 387/483] missing task does not make invalid asset --- openpype/pipeline/create/context.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 706279fd72..c2757a4502 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -1005,12 +1005,14 @@ class CreateContext: if not instances: return - task_names_by_asset_name = collections.defaultdict(set) + task_names_by_asset_name = {} for instance in instances: task_name = instance.get("task") asset_name = instance.get("asset") - if asset_name and task_name: - task_names_by_asset_name[asset_name].add(task_name) + if asset_name: + task_names_by_asset_name[asset_name] = set() + if task_name: + task_names_by_asset_name[asset_name].add(task_name) asset_names = [ asset_name From 20b3b38fb8ed4ecedd0dca398fae0a934f16f1b6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 9 Mar 2022 16:18:02 +0100 Subject: [PATCH 388/483] change ratio --- openpype/tools/traypublisher/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py index 4c35d84f98..d0453c4f23 100644 --- a/openpype/tools/traypublisher/window.py +++ b/openpype/tools/traypublisher/window.py @@ -70,7 +70,7 @@ class StandaloneOverlayWidget(QtWidgets.QFrame): main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(10, 10, 10, 10) main_layout.addStretch(1) - main_layout.addWidget(middle_frame, 3) + main_layout.addWidget(middle_frame, 2) main_layout.addStretch(1) projects_view.doubleClicked.connect(self._on_double_click) From acb7546e3896c6ec66c9c80a9e6fb68fa2a6468f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 9 Mar 2022 16:25:03 +0100 Subject: [PATCH 389/483] handle 'TaskNotSetError' in create dialog --- .../tools/publisher/widgets/create_dialog.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index c5b77eca8b..607060da7e 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -8,7 +8,7 @@ try: except Exception: commonmark = None from Qt import QtWidgets, QtCore, QtGui - +from openpype.lib import TaskNotSetError from openpype.pipeline.create import ( CreatorError, SUBSET_NAME_ALLOWED_SYMBOLS @@ -566,10 +566,9 @@ class CreateDialog(QtWidgets.QDialog): if variant_value is None: variant_value = self.variant_input.text() - match = self._compiled_name_pattern.match(variant_value) - valid = bool(match) - self.create_btn.setEnabled(valid) - if not valid: + self.create_btn.setEnabled(True) + if not self._compiled_name_pattern.match(variant_value): + self.create_btn.setEnabled(False) self._set_variant_state_property("invalid") self.subset_name_input.setText("< Invalid variant >") return @@ -579,9 +578,16 @@ class CreateDialog(QtWidgets.QDialog): asset_doc = copy.deepcopy(self._asset_doc) # Calculate subset name with Creator plugin - subset_name = self._selected_creator.get_subset_name( - variant_value, task_name, asset_doc, project_name - ) + try: + subset_name = self._selected_creator.get_subset_name( + variant_value, task_name, asset_doc, project_name + ) + except TaskNotSetError: + self.create_btn.setEnabled(False) + self._set_variant_state_property("invalid") + self.subset_name_input.setText("< Missing task >") + return + self.subset_name_input.setText(subset_name) self._validate_subset_name(subset_name, variant_value) From 09dbeffe7122d9ad3ee37aab4354637bbbb16e6c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 9 Mar 2022 16:42:49 +0100 Subject: [PATCH 390/483] global: slate could not be created when prores 4444 --- .../plugins/publish/extract_review_slate.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 7002168cdb..f9ed3cfab6 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -347,8 +347,21 @@ class ExtractReviewSlate(openpype.api.Extractor): profile_name = no_audio_stream.get("profile") if profile_name: - profile_name = profile_name.replace(" ", "_").lower() - codec_args.append("-profile:v {}".format(profile_name)) + # Rest of arguments is prores_kw specific + if codec_name == "prores_ks": + codec_tag_to_profile_map = { + "apco": "proxy", + "apcs": "lt", + "apcn": "standard", + "apch": "hq", + "ap4h": "4444", + "ap4x": "4444xq" + } + codec_tag_str = no_audio_stream.get("codec_tag_string") + if codec_tag_str: + profile = codec_tag_to_profile_map.get(codec_tag_str) + if profile: + codec_args.extend(["-profile:v", profile]) pix_fmt = no_audio_stream.get("pix_fmt") if pix_fmt: From a0b2995f15c2f40b81b73d0f8356a75eaa81bc32 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 9 Mar 2022 17:39:08 +0100 Subject: [PATCH 391/483] tasks model has ability to have empty task --- .../tools/publisher/widgets/tasks_widget.py | 19 +++++++--- openpype/tools/publisher/widgets/widgets.py | 35 +++++++++++++++---- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/openpype/tools/publisher/widgets/tasks_widget.py b/openpype/tools/publisher/widgets/tasks_widget.py index a0b3a340ae..2d1cc017af 100644 --- a/openpype/tools/publisher/widgets/tasks_widget.py +++ b/openpype/tools/publisher/widgets/tasks_widget.py @@ -17,9 +17,10 @@ class TasksModel(QtGui.QStandardItemModel): controller (PublisherController): Controller which handles creation and publishing. """ - def __init__(self, controller): + def __init__(self, controller, allow_empty_task=False): super(TasksModel, self).__init__() + self._allow_empty_task = allow_empty_task self._controller = controller self._items_by_name = {} self._asset_names = [] @@ -70,8 +71,14 @@ class TasksModel(QtGui.QStandardItemModel): task_name (str): Name of task which should be available in asset's tasks. """ - task_names = self._task_names_by_asset_name.get(asset_name) - if task_names and task_name in task_names: + if asset_name not in self._task_names_by_asset_name: + return False + + if self._allow_empty_task and not task_name: + return True + + task_names = self._task_names_by_asset_name[asset_name] + if task_name in task_names: return True return False @@ -92,6 +99,8 @@ class TasksModel(QtGui.QStandardItemModel): new_task_names = self.get_intersection_of_tasks( task_names_by_asset_name ) + if self._allow_empty_task: + new_task_names.add("") old_task_names = set(self._items_by_name.keys()) if new_task_names == old_task_names: return @@ -111,7 +120,9 @@ class TasksModel(QtGui.QStandardItemModel): item.setData(task_name, TASK_NAME_ROLE) self._items_by_name[task_name] = item new_items.append(item) - root_item.appendRows(new_items) + + if new_items: + root_item.appendRows(new_items) def headerData(self, section, orientation, role=None): if role is None: diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index fb1f0e54aa..3f913d7e52 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -7,6 +7,7 @@ from Qt import QtWidgets, QtCore, QtGui from avalon.vendor import qtawesome +from openpype.lib import TaskNotSetError from openpype.widgets.attribute_defs import create_widget_for_attr_def from openpype.tools import resources from openpype.tools.flickcharm import FlickCharm @@ -490,13 +491,16 @@ class TasksCombobox(QtWidgets.QComboBox): delegate = QtWidgets.QStyledItemDelegate() self.setItemDelegate(delegate) - model = TasksModel(controller) - self.setModel(model) + model = TasksModel(controller, True) + proxy_model = QtCore.QSortFilterProxyModel() + proxy_model.setSourceModel(model) + self.setModel(proxy_model) self.currentIndexChanged.connect(self._on_index_change) self._delegate = delegate self._model = model + self._proxy_model = proxy_model self._origin_value = [] self._origin_selection = [] self._selected_items = [] @@ -596,6 +600,7 @@ class TasksCombobox(QtWidgets.QComboBox): self._ignore_index_change = True self._model.set_asset_names(asset_names) + self._proxy_model.invalidate() self._ignore_index_change = False @@ -1016,10 +1021,26 @@ class GlobalAttrsWidget(QtWidgets.QWidget): asset_doc = asset_docs_by_name[new_asset_name] - new_subset_name = instance.creator.get_subset_name( - new_variant_value, new_task_name, asset_doc, project_name - ) + try: + new_subset_name = instance.creator.get_subset_name( + new_variant_value, new_task_name, asset_doc, project_name + ) + except TaskNotSetError: + instance.set_task_invalid(True) + continue + subset_names.add(new_subset_name) + if variant_value is not None: + instance["variant"] = variant_value + + if asset_name is not None: + instance["asset"] = asset_name + instance.set_asset_invalid(False) + + if task_name is not None: + instance["task"] = task_name + instance.set_task_invalid(False) + instance["subset"] = new_subset_name self.subset_value_widget.set_value(subset_names) @@ -1098,7 +1119,9 @@ class GlobalAttrsWidget(QtWidgets.QWidget): variants.add(instance.get("variant") or self.unknown_value) families.add(instance.get("family") or self.unknown_value) asset_name = instance.get("asset") or self.unknown_value - task_name = instance.get("task") or self.unknown_value + task_name = instance.get("task") + if task_name is None: + task_name = self.unknown_value asset_names.add(asset_name) asset_task_combinations.append((asset_name, task_name)) subset_names.add(instance.get("subset") or self.unknown_value) From 8efbe92ccb3b90981e8a17ba1ef4c1a9d81e22b6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 9 Mar 2022 17:39:41 +0100 Subject: [PATCH 392/483] changed "Sumbit" to "Confirm" --- openpype/tools/publisher/widgets/widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 3f913d7e52..a5528e52a6 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -937,7 +937,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): family_value_widget.set_value() subset_value_widget.set_value() - submit_btn = QtWidgets.QPushButton("Submit", self) + submit_btn = QtWidgets.QPushButton("Confirm", self) cancel_btn = QtWidgets.QPushButton("Cancel", self) submit_btn.setEnabled(False) cancel_btn.setEnabled(False) From db1f2ce8c415f2fdf99c201f90602e18f00e69be Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 9 Mar 2022 17:44:33 +0100 Subject: [PATCH 393/483] fix method name --- openpype/hosts/testhost/plugins/publish/collect_context.py | 2 +- openpype/hosts/testhost/plugins/publish/collect_instance_1.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/testhost/plugins/publish/collect_context.py b/openpype/hosts/testhost/plugins/publish/collect_context.py index bbb8477cdf..0ab98fb84b 100644 --- a/openpype/hosts/testhost/plugins/publish/collect_context.py +++ b/openpype/hosts/testhost/plugins/publish/collect_context.py @@ -19,7 +19,7 @@ class CollectContextDataTestHost( hosts = ["testhost"] @classmethod - def get_instance_attr_defs(cls): + def get_attribute_defs(cls): return [ attribute_definitions.BoolDef( "test_bool", diff --git a/openpype/hosts/testhost/plugins/publish/collect_instance_1.py b/openpype/hosts/testhost/plugins/publish/collect_instance_1.py index 979ab83f11..3c035eccb6 100644 --- a/openpype/hosts/testhost/plugins/publish/collect_instance_1.py +++ b/openpype/hosts/testhost/plugins/publish/collect_instance_1.py @@ -20,7 +20,7 @@ class CollectInstanceOneTestHost( hosts = ["testhost"] @classmethod - def get_instance_attr_defs(cls): + def get_attribute_defs(cls): return [ attribute_definitions.NumberDef( "version", From 342fa4c817c740eb8afb5d4c823595f9bd5eeaad Mon Sep 17 00:00:00 2001 From: joblet Date: Wed, 9 Mar 2022 17:50:06 +0100 Subject: [PATCH 394/483] fix phooshop and after effects openPype plugin path --- website/docs/artist_hosts_aftereffects.md | 2 +- website/docs/artist_hosts_photoshop.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/docs/artist_hosts_aftereffects.md b/website/docs/artist_hosts_aftereffects.md index f9ef40fe1a..a9660bd13c 100644 --- a/website/docs/artist_hosts_aftereffects.md +++ b/website/docs/artist_hosts_aftereffects.md @@ -15,7 +15,7 @@ sidebar_label: AfterEffects ## Setup -To install the extension, download, install [Anastasyi's Extension Manager](https://install.anastasiy.com/). Open Anastasyi's Extension Manager and select AfterEffects in menu. Then go to `{path to pype}/repos/avalon-core/avalon/aftereffects/extension.zxp`. +To install the extension, download, install [Anastasyi's Extension Manager](https://install.anastasiy.com/). Open Anastasyi's Extension Manager and select AfterEffects in menu. Then go to `{path to pype}hosts/aftereffects/api/extension.zxp`. Drag extension.zxp and drop it to Anastasyi's Extension Manager. The extension will install itself. diff --git a/website/docs/artist_hosts_photoshop.md b/website/docs/artist_hosts_photoshop.md index 16539bcf79..b2b5fd58da 100644 --- a/website/docs/artist_hosts_photoshop.md +++ b/website/docs/artist_hosts_photoshop.md @@ -14,7 +14,7 @@ sidebar_label: Photoshop ## Setup -To install the extension, download, install [Anastasyi's Extension Manager](https://install.anastasiy.com/). Open Anastasyi's Extension Manager and select Photoshop in menu. Then go to `{path to pype}/repos/avalon-core/avalon/photoshop/extension.zxp`. Drag extension.zxp and drop it to Anastasyi's Extension Manager. The extension will install itself. +To install the extension, download, install [Anastasyi's Extension Manager](https://install.anastasiy.com/). Open Anastasyi's Extension Manager and select Photoshop in menu. Then go to `{path to pype}hosts/photoshop/api/extension.zxp`. Drag extension.zxp and drop it to Anastasyi's Extension Manager. The extension will install itself. ## Usage From c1304f4e691fec99de2d639992dee04a5957bc7a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 9 Mar 2022 18:45:05 +0100 Subject: [PATCH 395/483] it is possible to catch invalid tasks on confirm submit --- openpype/tools/publisher/widgets/widgets.py | 65 ++++++++++++++++----- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index a5528e52a6..ace7b4e02d 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -472,6 +472,28 @@ class AssetsField(BaseClickableFrame): self.set_selected_items(self._origin_value) +class TasksComboboxProxy(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(TasksComboboxProxy, self).__init__(*args, **kwargs) + self._filter_empty = False + + def set_filter_empty(self, filter_empty): + if self._filter_empty is filter_empty: + return + self._filter_empty = filter_empty + self.invalidate() + + def filterAcceptsRow(self, source_row, parent_index): + if self._filter_empty: + model = self.sourceModel() + source_index = model.index( + source_row, self.filterKeyColumn(), parent_index + ) + if not source_index.data(QtCore.Qt.DisplayRole): + return False + return True + + class TasksCombobox(QtWidgets.QComboBox): """Combobox to show tasks for selected instances. @@ -492,7 +514,7 @@ class TasksCombobox(QtWidgets.QComboBox): self.setItemDelegate(delegate) model = TasksModel(controller, True) - proxy_model = QtCore.QSortFilterProxyModel() + proxy_model = TasksComboboxProxy() proxy_model.setSourceModel(model) self.setModel(proxy_model) @@ -511,6 +533,14 @@ class TasksCombobox(QtWidgets.QComboBox): self._text = None + def set_invalid_empty_task(self, invalid=True): + self._proxy_model.set_filter_empty(invalid) + if invalid: + self._set_is_valid(False) + self.set_text("< One or more subsets require Task selected >") + else: + self.set_text(None) + def set_multiselection_text(self, text): """Change text shown when multiple different tasks are in context.""" self._multiselection_text = text @@ -600,7 +630,8 @@ class TasksCombobox(QtWidgets.QComboBox): self._ignore_index_change = True self._model.set_asset_names(asset_names) - self._proxy_model.invalidate() + self._proxy_model.set_filter_empty(False) + self._proxy_model.sort(0) self._ignore_index_change = False @@ -646,6 +677,9 @@ class TasksCombobox(QtWidgets.QComboBox): asset_task_combinations (list): List of tuples. Each item in the list contain asset name and task name. """ + self._proxy_model.set_filter_empty(False) + self._proxy_model.sort(0) + if asset_task_combinations is None: asset_task_combinations = [] @@ -1003,21 +1037,19 @@ class GlobalAttrsWidget(QtWidgets.QWidget): project_name = self.controller.project_name subset_names = set() + invalid_tasks = False for instance in self._current_instances: - if variant_value is not None: - instance["variant"] = variant_value - - if asset_name is not None: - instance["asset"] = asset_name - instance.set_asset_invalid(False) - - if task_name is not None: - instance["task"] = task_name - instance.set_task_invalid(False) - new_variant_value = instance.get("variant") new_asset_name = instance.get("asset") new_task_name = instance.get("task") + if variant_value is not None: + new_variant_value = variant_value + + if asset_name is not None: + new_asset_name = asset_name + + if task_name is not None: + new_task_name = task_name asset_doc = asset_docs_by_name[new_asset_name] @@ -1026,7 +1058,9 @@ class GlobalAttrsWidget(QtWidgets.QWidget): new_variant_value, new_task_name, asset_doc, project_name ) except TaskNotSetError: + invalid_tasks = True instance.set_task_invalid(True) + subset_names.add(instance["subset"]) continue subset_names.add(new_subset_name) @@ -1043,10 +1077,13 @@ class GlobalAttrsWidget(QtWidgets.QWidget): instance["subset"] = new_subset_name + if invalid_tasks: + self.task_value_widget.set_invalid_empty_task() + self.subset_value_widget.set_value(subset_names) self._set_btns_enabled(False) - self._set_btns_visible(False) + self._set_btns_visible(invalid_tasks) self.instance_context_changed.emit() From 41bee1d2fac264cac48d3d5f6d2edd5139c5e2b5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 9 Mar 2022 18:49:57 +0100 Subject: [PATCH 396/483] change checkstate of goups on key press events --- .../tools/publisher/widgets/list_view_widgets.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/openpype/tools/publisher/widgets/list_view_widgets.py b/openpype/tools/publisher/widgets/list_view_widgets.py index 23a86cd070..6bddaf66c8 100644 --- a/openpype/tools/publisher/widgets/list_view_widgets.py +++ b/openpype/tools/publisher/widgets/list_view_widgets.py @@ -467,12 +467,22 @@ class InstanceListView(AbstractInstanceView): else: active = False + group_names = set() for instance_id in selected_instance_ids: widget = self._widgets_by_id.get(instance_id) - if widget is not None: - widget.set_active(active) + if widget is None: + continue + + widget.set_active(active) + group_name = self._group_by_instance_id.get(instance_id) + if group_name is not None: + group_names.add(group_name) + + for group_name in group_names: + self._update_group_checkstate(group_name) def _update_group_checkstate(self, group_name): + """Update checkstate of one group.""" widget = self._group_widgets.get(group_name) if widget is None: return From 5a2f917c2aec35286efe030f5b4f0c28c3fb293d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 9 Mar 2022 18:54:54 +0100 Subject: [PATCH 397/483] empty task is stored as None --- openpype/tools/publisher/widgets/widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index ace7b4e02d..200e85ba5c 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -1072,7 +1072,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): instance.set_asset_invalid(False) if task_name is not None: - instance["task"] = task_name + instance["task"] = task_name or None instance.set_task_invalid(False) instance["subset"] = new_subset_name From 86ddbf8c5cf5455249cfe8d11d58593333190a5e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 9 Mar 2022 18:56:35 +0100 Subject: [PATCH 398/483] task as none will use empty string --- openpype/tools/publisher/widgets/widgets.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 200e85ba5c..8a950feb8b 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -1156,9 +1156,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): variants.add(instance.get("variant") or self.unknown_value) families.add(instance.get("family") or self.unknown_value) asset_name = instance.get("asset") or self.unknown_value - task_name = instance.get("task") - if task_name is None: - task_name = self.unknown_value + task_name = instance.get("task") or "" asset_names.add(asset_name) asset_task_combinations.append((asset_name, task_name)) subset_names.add(instance.get("subset") or self.unknown_value) From a7b8cf26260f60c69d212dae7fb611c46503da72 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 9 Mar 2022 19:20:04 +0100 Subject: [PATCH 399/483] add plugin to report before it's processing --- openpype/tools/publisher/control.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index 5a84b1d8ca..6707feac9c 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -873,8 +873,6 @@ class PublisherController: """ for idx, plugin in enumerate(self.publish_plugins): self._publish_progress = idx - # Add plugin to publish report - self._publish_report.add_plugin_iter(plugin, self._publish_context) # Reset current plugin validations error self._publish_current_plugin_validation_errors = None @@ -902,6 +900,9 @@ class PublisherController: ): yield MainThreadItem(self.stop_publish) + # Add plugin to publish report + self._publish_report.add_plugin_iter(plugin, self._publish_context) + # Trigger callback that new plugin is going to be processed self._trigger_callbacks( self._publish_plugin_changed_callback_refs, plugin From 23b5f3828a4d248590f1035875eb05d0f674fea4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 9 Mar 2022 19:20:23 +0100 Subject: [PATCH 400/483] don't use task if is not available in intergrate new --- openpype/plugins/publish/integrate_new.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 6e0940d459..33d365fe42 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -192,11 +192,15 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "short": task_code } - else: + elif "task" in anatomy_data: # Just set 'task_name' variable to context task task_name = anatomy_data["task"]["name"] task_type = anatomy_data["task"]["type"] + else: + task_name = None + task_type = None + # Fill family in anatomy data anatomy_data["family"] = instance.data.get("family") @@ -816,8 +820,12 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): # - is there a chance that task name is not filled in anatomy # data? # - should we use context task in that case? - task_name = instance.data["anatomyData"]["task"]["name"] - task_type = instance.data["anatomyData"]["task"]["type"] + anatomy_data = instance.data["anatomyData"] + task_name = None + task_type = None + if "task" in anatomy_data: + task_name = anatomy_data["task"]["name"] + task_type = anatomy_data["task"]["type"] filtering_criteria = { "families": instance.data["family"], "hosts": instance.context.data["hostName"], From ca94cf247aceac8a3c61d38465625c9c82c85aed Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Thu, 10 Mar 2022 09:37:42 +0100 Subject: [PATCH 401/483] add refactor sections to CI --- .github/workflows/prerelease.yml | 2 +- .github/workflows/release.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 258458e2d4..d9b4d8089c 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -43,7 +43,7 @@ jobs: uses: heinrichreimer/github-changelog-generator-action@v2.2 with: token: ${{ secrets.ADMIN_TOKEN }} - addSections: '{"documentation":{"prefix":"### 📖 Documentation","labels":["type: documentation"]},"tests":{"prefix":"### ✅ Testing","labels":["tests"]},"feature":{"prefix":"**🆕 New features**", "labels":["type: feature"]},"breaking":{"prefix":"**💥 Breaking**", "labels":["breaking"]},"enhancements":{"prefix":"**🚀 Enhancements**", "labels":["type: enhancement"]},"bugs":{"prefix":"**🐛 Bug fixes**", "labels":["type: bug"]},"deprecated":{"prefix":"**⚠️ Deprecations**", "labels":["depreciated"]}}' + addSections: '{"documentation":{"prefix":"### 📖 Documentation","labels":["type: documentation"]},"tests":{"prefix":"### ✅ Testing","labels":["tests"]},"feature":{"prefix":"**🆕 New features**", "labels":["type: feature"]},"breaking":{"prefix":"**💥 Breaking**", "labels":["breaking"]},"enhancements":{"prefix":"**🚀 Enhancements**", "labels":["type: enhancement"]},"bugs":{"prefix":"**🐛 Bug fixes**", "labels":["type: bug"]},"deprecated":{"prefix":"**⚠️ Deprecations**", "labels":["depreciated"]}, "refactor":{"prefix":"**🔀 Refactored code**", "labels":["refactor"]}}' issues: false issuesWoLabels: false sinceTag: "3.0.0" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f85525c26..917e6c884c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: uses: heinrichreimer/github-changelog-generator-action@v2.2 with: token: ${{ secrets.ADMIN_TOKEN }} - addSections: '{"tests":{"prefix":"### ✅ Testing","labels":["tests"]},"feature":{"prefix":"**🆕 New features**", "labels":["type: feature"]},"breaking":{"prefix":"**💥 Breaking**", "labels":["breaking"]},"enhancements":{"prefix":"**🚀 Enhancements**", "labels":["type: enhancement"]},"bugs":{"prefix":"**🐛 Bug fixes**", "labels":["type: bug"]},"deprecated":{"prefix":"**⚠️ Deprecations**", "labels":["depreciated"]},"documentation":{"prefix":"### 📖 Documentation","labels":["type: documentation"]}}' + addSections: '{"documentation":{"prefix":"### 📖 Documentation","labels":["type: documentation"]},"tests":{"prefix":"### ✅ Testing","labels":["tests"]},"feature":{"prefix":"**🆕 New features**", "labels":["type: feature"]},"breaking":{"prefix":"**💥 Breaking**", "labels":["breaking"]},"enhancements":{"prefix":"**🚀 Enhancements**", "labels":["type: enhancement"]},"bugs":{"prefix":"**🐛 Bug fixes**", "labels":["type: bug"]},"deprecated":{"prefix":"**⚠️ Deprecations**", "labels":["depreciated"]}, "refactor":{"prefix":"**🔀 Refactored code**", "labels":["refactor"]}}' issues: false issuesWoLabels: false sinceTag: "3.0.0" @@ -81,7 +81,7 @@ jobs: uses: heinrichreimer/github-changelog-generator-action@v2.2 with: token: ${{ secrets.ADMIN_TOKEN }} - addSections: '{"documentation":{"prefix":"### 📖 Documentation","labels":["type: documentation"]},"tests":{"prefix":"### ✅ Testing","labels":["tests"]},"feature":{"prefix":"**🆕 New features**", "labels":["type: feature"]},"breaking":{"prefix":"**💥 Breaking**", "labels":["breaking"]},"enhancements":{"prefix":"**🚀 Enhancements**", "labels":["type: enhancement"]},"bugs":{"prefix":"**🐛 Bug fixes**", "labels":["type: bug"]},"deprecated":{"prefix":"**⚠️ Deprecations**", "labels":["depreciated"]}}' + addSections: '{"documentation":{"prefix":"### 📖 Documentation","labels":["type: documentation"]},"tests":{"prefix":"### ✅ Testing","labels":["tests"]},"feature":{"prefix":"**🆕 New features**", "labels":["type: feature"]},"breaking":{"prefix":"**💥 Breaking**", "labels":["breaking"]},"enhancements":{"prefix":"**🚀 Enhancements**", "labels":["type: enhancement"]},"bugs":{"prefix":"**🐛 Bug fixes**", "labels":["type: bug"]},"deprecated":{"prefix":"**⚠️ Deprecations**", "labels":["depreciated"]}, "refactor":{"prefix":"**🔀 Refactored code**", "labels":["refactor"]}}' issues: false issuesWoLabels: false sinceTag: ${{ steps.version.outputs.last_release }} From c274e64c6aac478fac799df50b0cdc8c1d209fe0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 10 Mar 2022 09:47:39 +0100 Subject: [PATCH 402/483] fix houdini topic --- openpype/hosts/houdini/api/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index e34b8811b9..6cfb713661 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -108,7 +108,7 @@ def on_file_event_callback(event): elif event == hou.hipFileEventType.AfterSave: emit_event("save") elif event == hou.hipFileEventType.BeforeSave: - emit_event("before_save") + emit_event("before.save") elif event == hou.hipFileEventType.AfterClear: emit_event("new") From e61c8d992bc3a2308293b9077bc5ae85fa82ba05 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 10 Mar 2022 10:57:01 +0100 Subject: [PATCH 403/483] fix hardlink for windows --- openpype/lib/path_tools.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/lib/path_tools.py b/openpype/lib/path_tools.py index 3a9f835272..851bc872fb 100644 --- a/openpype/lib/path_tools.py +++ b/openpype/lib/path_tools.py @@ -43,6 +43,7 @@ def create_hard_link(src_path, dst_path): res = CreateHardLink(dst_path, src_path, None) if res == 0: raise ctypes.WinError() + return # Raises not implemented error if gets here raise NotImplementedError( "Implementation of hardlink for current environment is missing." From 58aaa25c5536cdc61d1852e132c3e88e313dc587 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 10 Mar 2022 11:56:06 +0100 Subject: [PATCH 404/483] added short description widget --- .../tools/publisher/widgets/create_dialog.py | 101 +++++++++++------- 1 file changed, 63 insertions(+), 38 deletions(-) diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index 6396b77901..83418b8bef 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -107,67 +107,100 @@ class CreatorDescriptionWidget(QtWidgets.QWidget): def __init__(self, parent=None): super(CreatorDescriptionWidget, self).__init__(parent=parent) - icon_widget = IconValuePixmapLabel(None, self) + # --- Short description widget --- + short_desc_widget = QtWidgets.QWidget(self) + + icon_widget = IconValuePixmapLabel(None, short_desc_widget) icon_widget.setObjectName("FamilyIconLabel") - family_label = QtWidgets.QLabel("family") + # --- Short description inputs --- + short_desc_input_widget = QtWidgets.QWidget(short_desc_widget) + + family_label = QtWidgets.QLabel(short_desc_input_widget) family_label.setAlignment( QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft ) - description_label = QtWidgets.QLabel("description") + description_label = QtWidgets.QLabel(short_desc_input_widget) description_label.setAlignment( QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft ) - detail_description_widget = QtWidgets.QTextEdit(self) + short_desc_input_layout = QtWidgets.QVBoxLayout( + short_desc_input_widget + ) + short_desc_input_layout.setSpacing(0) + short_desc_input_layout.addWidget(family_label) + short_desc_input_layout.addWidget(description_label) + # -------------------------------- + + short_desc_layout = QtWidgets.QHBoxLayout(short_desc_widget) + short_desc_layout.setContentsMargins(0, 0, 0, 0) + short_desc_layout.addWidget(icon_widget, 0) + short_desc_layout.addWidget(short_desc_input_widget, 1) + # -------------------------------- + + separator_widget = QtWidgets.QWidget(self) + separator_widget.setObjectName("Separator") + separator_widget.setMinimumHeight(2) + separator_widget.setMaximumHeight(2) + + # --- Bottom part ---------------- + bottom_widget = QtWidgets.QWidget(self) + + # Precreate attributes widget + pre_create_widget = PreCreateWidget(bottom_widget) + + # Detailed description of creator + detail_description_widget = QtWidgets.QTextEdit(bottom_widget) detail_description_widget.setObjectName("InfoText") detail_description_widget.setTextInteractionFlags( QtCore.Qt.TextBrowserInteraction ) + # TODO add HELP button + detail_description_widget.setVisible(False) - label_layout = QtWidgets.QVBoxLayout() - label_layout.setSpacing(0) - label_layout.addWidget(family_label) - label_layout.addWidget(description_label) - - top_layout = QtWidgets.QHBoxLayout() - top_layout.setContentsMargins(0, 0, 0, 0) - top_layout.addWidget(icon_widget, 0) - top_layout.addLayout(label_layout, 1) + bottom_layout = QtWidgets.QHBoxLayout(bottom_widget) + bottom_layout.setContentsMargins(0, 0, 0, 0) + bottom_layout.addWidget(pre_create_widget, 1) + bottom_layout.addWidget(detail_description_widget, 1) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.addLayout(top_layout, 0) - layout.addWidget(detail_description_widget, 1) + layout.addWidget(short_desc_widget, 0) + layout.addWidget(separator_widget, 0) + layout.addWidget(bottom_widget, 1) - self.icon_widget = icon_widget - self.family_label = family_label - self.description_label = description_label - self.detail_description_widget = detail_description_widget + self._icon_widget = icon_widget + self._family_label = family_label + self._description_label = description_label + self._detail_description_widget = detail_description_widget + self._pre_create_widget = pre_create_widget def set_plugin(self, plugin=None): if not plugin: - self.icon_widget.set_icon_def(None) - self.family_label.setText("") - self.description_label.setText("") - self.detail_description_widget.setPlainText("") + self._icon_widget.set_icon_def(None) + self._family_label.setText("") + self._description_label.setText("") + self._detail_description_widget.setPlainText("") + self._pre_create_widget.set_plugin(plugin) return plugin_icon = plugin.get_icon() description = plugin.get_description() or "" detailed_description = plugin.get_detail_description() or "" - self.icon_widget.set_icon_def(plugin_icon) - self.family_label.setText("{}".format(plugin.family)) - self.family_label.setTextInteractionFlags(QtCore.Qt.NoTextInteraction) - self.description_label.setText(description) + self._icon_widget.set_icon_def(plugin_icon) + self._family_label.setText("{}".format(plugin.family)) + self._family_label.setTextInteractionFlags(QtCore.Qt.NoTextInteraction) + self._description_label.setText(description) if commonmark: html = commonmark.commonmark(detailed_description) - self.detail_description_widget.setHtml(html) + self._detail_description_widget.setHtml(html) else: - self.detail_description_widget.setMarkdown(detailed_description) + self._detail_description_widget.setMarkdown(detailed_description) + self._pre_create_widget.set_plugin(plugin) class CreateDialog(QtWidgets.QDialog): @@ -215,12 +248,7 @@ class CreateDialog(QtWidgets.QDialog): context_layout.addWidget(assets_widget, 2) context_layout.addWidget(tasks_widget, 1) - # Precreate attributes widgets - pre_create_widget = PreCreateWidget(self) - - # TODO add HELP button creator_description_widget = CreatorDescriptionWidget(self) - creator_description_widget.setVisible(False) creators_view = QtWidgets.QListView(self) creators_model = QtGui.QStandardItemModel() @@ -264,7 +292,7 @@ class CreateDialog(QtWidgets.QDialog): splitter_widget = QtWidgets.QSplitter(self) splitter_widget.addWidget(context_widget) splitter_widget.addWidget(mid_widget) - splitter_widget.addWidget(pre_create_widget) + splitter_widget.addWidget(creator_description_widget) splitter_widget.setStretchFactor(0, 1) splitter_widget.setStretchFactor(1, 1) splitter_widget.setStretchFactor(2, 1) @@ -295,8 +323,6 @@ class CreateDialog(QtWidgets.QDialog): self._splitter_widget = splitter_widget - self._pre_create_widget = pre_create_widget - self._context_widget = context_widget self._assets_widget = assets_widget self._tasks_widget = tasks_widget @@ -510,7 +536,6 @@ class CreateDialog(QtWidgets.QDialog): creator = self.controller.manual_creators.get(identifier) self.creator_description_widget.set_plugin(creator) - self._pre_create_widget.set_plugin(creator) self._selected_creator = creator From 2bce15f106611e9e3e6572b3d6c6b7cb77509f67 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 10 Mar 2022 11:57:21 +0100 Subject: [PATCH 405/483] changed Name to Variant --- openpype/tools/publisher/widgets/widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 4e55e86491..5ced469b59 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -982,7 +982,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): btns_layout.addWidget(cancel_btn) main_layout = QtWidgets.QFormLayout(self) - main_layout.addRow("Name", variant_input) + main_layout.addRow("Variant", variant_input) main_layout.addRow("Asset", asset_value_widget) main_layout.addRow("Task", task_value_widget) main_layout.addRow("Family", family_value_widget) From 752af659bee065658e76d7a5ecb0996864de8069 Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Thu, 10 Mar 2022 13:01:52 +0100 Subject: [PATCH 406/483] families is None for group/gizmo --- openpype/hosts/nuke/plugins/publish/precollect_instances.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/precollect_instances.py b/openpype/hosts/nuke/plugins/publish/precollect_instances.py index 97ddef0a59..29c706f302 100644 --- a/openpype/hosts/nuke/plugins/publish/precollect_instances.py +++ b/openpype/hosts/nuke/plugins/publish/precollect_instances.py @@ -80,7 +80,7 @@ class PreCollectNukeInstances(pyblish.api.ContextPlugin): # Add all nodes in group instances. if node.Class() == "Group": # only alter families for render family - if "write" in families_ak.lower(): + if families_ak and "write" in families_ak.lower(): target = node["render"].value() if target == "Use existing frames": # Local rendering From 76b630aaac31e03ac867fa6ea4affa6a2c7b82b6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 10 Mar 2022 14:44:07 +0100 Subject: [PATCH 407/483] added description widget and detailed info widget --- openpype/style/style.css | 13 ++ .../tools/publisher/widgets/create_dialog.py | 208 +++++++++++++----- 2 files changed, 165 insertions(+), 56 deletions(-) diff --git a/openpype/style/style.css b/openpype/style/style.css index 5586cf766d..df83600973 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -836,6 +836,19 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { } /* New Create/Publish UI */ +#CreateDialogHelpButton { + background: rgba(255, 255, 255, 31); + border-top-right-radius: 0; + border-bottom-right-radius: 0; + font-size: 10pt; + font-weight: bold; + padding: 3px 3px 3px 3px; +} + +#CreateDialogHelpButton:hover { + background: rgba(255, 255, 255, 63); +} + #PublishLogConsole { font-family: "Noto Sans Mono"; } diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index 83418b8bef..27ce97955a 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -103,18 +103,16 @@ class CreateErrorMessageBox(ErrorMessageBox): # TODO add creator identifier/label to details -class CreatorDescriptionWidget(QtWidgets.QWidget): +class CreatorShortDescWidget(QtWidgets.QWidget): def __init__(self, parent=None): - super(CreatorDescriptionWidget, self).__init__(parent=parent) + super(CreatorShortDescWidget, self).__init__(parent=parent) # --- Short description widget --- - short_desc_widget = QtWidgets.QWidget(self) - - icon_widget = IconValuePixmapLabel(None, short_desc_widget) + icon_widget = IconValuePixmapLabel(None, self) icon_widget.setObjectName("FamilyIconLabel") # --- Short description inputs --- - short_desc_input_widget = QtWidgets.QWidget(short_desc_widget) + short_desc_input_widget = QtWidgets.QWidget(self) family_label = QtWidgets.QLabel(short_desc_input_widget) family_label.setAlignment( @@ -134,73 +132,69 @@ class CreatorDescriptionWidget(QtWidgets.QWidget): short_desc_input_layout.addWidget(description_label) # -------------------------------- - short_desc_layout = QtWidgets.QHBoxLayout(short_desc_widget) - short_desc_layout.setContentsMargins(0, 0, 0, 0) - short_desc_layout.addWidget(icon_widget, 0) - short_desc_layout.addWidget(short_desc_input_widget, 1) - # -------------------------------- - - separator_widget = QtWidgets.QWidget(self) - separator_widget.setObjectName("Separator") - separator_widget.setMinimumHeight(2) - separator_widget.setMaximumHeight(2) - - # --- Bottom part ---------------- - bottom_widget = QtWidgets.QWidget(self) - - # Precreate attributes widget - pre_create_widget = PreCreateWidget(bottom_widget) - - # Detailed description of creator - detail_description_widget = QtWidgets.QTextEdit(bottom_widget) - detail_description_widget.setObjectName("InfoText") - detail_description_widget.setTextInteractionFlags( - QtCore.Qt.TextBrowserInteraction - ) - # TODO add HELP button - detail_description_widget.setVisible(False) - - bottom_layout = QtWidgets.QHBoxLayout(bottom_widget) - bottom_layout.setContentsMargins(0, 0, 0, 0) - bottom_layout.addWidget(pre_create_widget, 1) - bottom_layout.addWidget(detail_description_widget, 1) - - layout = QtWidgets.QVBoxLayout(self) + layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(short_desc_widget, 0) - layout.addWidget(separator_widget, 0) - layout.addWidget(bottom_widget, 1) + layout.addWidget(icon_widget, 0) + layout.addWidget(short_desc_input_widget, 1) + # -------------------------------- self._icon_widget = icon_widget self._family_label = family_label self._description_label = description_label - self._detail_description_widget = detail_description_widget - self._pre_create_widget = pre_create_widget def set_plugin(self, plugin=None): if not plugin: self._icon_widget.set_icon_def(None) self._family_label.setText("") self._description_label.setText("") - self._detail_description_widget.setPlainText("") - self._pre_create_widget.set_plugin(plugin) return plugin_icon = plugin.get_icon() description = plugin.get_description() or "" - detailed_description = plugin.get_detail_description() or "" self._icon_widget.set_icon_def(plugin_icon) self._family_label.setText("{}".format(plugin.family)) self._family_label.setTextInteractionFlags(QtCore.Qt.NoTextInteraction) self._description_label.setText(description) - if commonmark: - html = commonmark.commonmark(detailed_description) - self._detail_description_widget.setHtml(html) + +class HelpButton(QtWidgets.QPushButton): + resized = QtCore.Signal() + + def __init__(self, *args, **kwargs): + super(HelpButton, self).__init__(*args, **kwargs) + self.setObjectName("CreateDialogHelpButton") + + self._expanded = None + self.set_expanded() + + def set_expanded(self, expanded=None): + if self._expanded is expanded: + if expanded is not None: + return + expanded = False + self._expanded = expanded + if expanded: + text = "<" else: - self._detail_description_widget.setMarkdown(detailed_description) - self._pre_create_widget.set_plugin(plugin) + text = "?" + self.setText(text) + + self._update_size() + + def _update_size(self): + new_size = self.minimumSizeHint() + if self.size() != new_size: + self.resize(new_size) + self.resized.emit() + + def showEvent(self, event): + super(HelpButton, self).showEvent(event) + self._update_size() + + def resizeEvent(self, event): + super(HelpButton, self).resizeEvent(event) + self._update_size() class CreateDialog(QtWidgets.QDialog): @@ -248,8 +242,7 @@ class CreateDialog(QtWidgets.QDialog): context_layout.addWidget(assets_widget, 2) context_layout.addWidget(tasks_widget, 1) - creator_description_widget = CreatorDescriptionWidget(self) - + # --- Creators view --- creators_view = QtWidgets.QListView(self) creators_model = QtGui.QStandardItemModel() creators_view.setModel(creators_model) @@ -288,24 +281,65 @@ class CreateDialog(QtWidgets.QDialog): mid_layout.addWidget(creators_view, 1) mid_layout.addLayout(form_layout, 0) mid_layout.addWidget(create_btn, 0) + # ------------ + + # --- Creator short info and attr defs --- + creator_attrs_widget = QtWidgets.QWidget(self) + + creator_short_desc_widget = CreatorShortDescWidget( + creator_attrs_widget + ) + + separator_widget = QtWidgets.QWidget(self) + separator_widget.setObjectName("Separator") + separator_widget.setMinimumHeight(2) + separator_widget.setMaximumHeight(2) + + # Precreate attributes widget + pre_create_widget = PreCreateWidget(creator_attrs_widget) + + creator_attrs_layout = QtWidgets.QVBoxLayout(creator_attrs_widget) + creator_attrs_layout.setContentsMargins(0, 0, 0, 0) + creator_attrs_layout.addWidget(creator_short_desc_widget, 0) + creator_attrs_layout.addWidget(separator_widget, 0) + creator_attrs_layout.addWidget(pre_create_widget, 1) + # ------------------------------------- + + # --- Detailed information about creator --- + # Detailed description of creator + detail_description_widget = QtWidgets.QTextEdit(self) + detail_description_widget.setObjectName("InfoText") + detail_description_widget.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + detail_description_widget.setVisible(False) + # ------------------------------------------- splitter_widget = QtWidgets.QSplitter(self) splitter_widget.addWidget(context_widget) splitter_widget.addWidget(mid_widget) - splitter_widget.addWidget(creator_description_widget) + splitter_widget.addWidget(creator_attrs_widget) + splitter_widget.addWidget(detail_description_widget) splitter_widget.setStretchFactor(0, 1) splitter_widget.setStretchFactor(1, 1) splitter_widget.setStretchFactor(2, 1) + splitter_widget.setStretchFactor(3, 1) layout = QtWidgets.QHBoxLayout(self) layout.addWidget(splitter_widget, 1) + # Floating help button + help_btn = HelpButton(self) + prereq_timer = QtCore.QTimer() prereq_timer.setInterval(50) prereq_timer.setSingleShot(True) prereq_timer.timeout.connect(self._on_prereq_timer) + help_btn.clicked.connect(self._on_help_btn) + help_btn.resized.connect(self._on_help_btn_resize) + create_btn.clicked.connect(self._on_create) variant_input.returnPressed.connect(self._on_create) variant_input.textChanged.connect(self._on_variant_change) @@ -326,7 +360,6 @@ class CreateDialog(QtWidgets.QDialog): self._context_widget = context_widget self._assets_widget = assets_widget self._tasks_widget = tasks_widget - self.creator_description_widget = creator_description_widget self.subset_name_input = subset_name_input @@ -339,6 +372,11 @@ class CreateDialog(QtWidgets.QDialog): self.creators_view = creators_view self.create_btn = create_btn + self._creator_short_desc_widget = creator_short_desc_widget + self._pre_create_widget = pre_create_widget + self._detail_description_widget = detail_description_widget + self._help_btn = help_btn + self._prereq_timer = prereq_timer self._first_show = True @@ -532,10 +570,62 @@ class CreateDialog(QtWidgets.QDialog): identifier = new_index.data(CREATOR_IDENTIFIER_ROLE) self._set_creator(identifier) + def _update_help_btn(self): + pos_x = self.width() - self._help_btn.width() + point = self._creator_short_desc_widget.rect().topRight() + mapped_point = self._creator_short_desc_widget.mapTo(self, point) + pos_y = mapped_point.y() + self._help_btn.move(max(0, pos_x), max(0, pos_y)) + + def _on_help_btn_resize(self): + self._update_help_btn() + + def _on_help_btn(self): + final_size = self.size() + cur_sizes = self._splitter_widget.sizes() + spacing = self._splitter_widget.handleWidth() + + sizes = [] + for idx, value in enumerate(cur_sizes): + if idx < 3: + sizes.append(value) + + now_visible = self._detail_description_widget.isVisible() + if now_visible: + width = final_size.width() - ( + spacing + self._detail_description_widget.width() + ) + + else: + last_size = self._detail_description_widget.sizeHint().width() + width = final_size.width() + spacing + last_size + sizes.append(last_size) + + final_size.setWidth(width) + + self._detail_description_widget.setVisible(not now_visible) + self._splitter_widget.setSizes(sizes) + self.resize(final_size) + + self._help_btn.set_expanded(not now_visible) + + def _set_creator_detailed_text(self, creator): + if not creator: + self._detail_description_widget.setPlainText("") + return + detailed_description = creator.get_detail_description() or "" + if commonmark: + html = commonmark.commonmark(detailed_description) + self._detail_description_widget.setHtml(html) + else: + self._detail_description_widget.setMarkdown(detailed_description) + def _set_creator(self, identifier): creator = self.controller.manual_creators.get(identifier) - self.creator_description_widget.set_plugin(creator) + self._creator_short_desc_widget.set_plugin(creator) + self._set_creator_detailed_text(creator) + self._pre_create_widget.set_plugin(creator) self._selected_creator = creator @@ -694,8 +784,14 @@ class CreateDialog(QtWidgets.QDialog): if self._last_pos is not None: self.move(self._last_pos) + self._update_help_btn() + self.refresh() + def resizeEvent(self, event): + super(CreateDialog, self).resizeEvent(event) + self._update_help_btn() + def _on_create(self): indexes = self.creators_view.selectedIndexes() if not indexes or len(indexes) > 1: From be19709107bc7c33781175c6948fa43b38891cd1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 10 Mar 2022 15:56:02 +0100 Subject: [PATCH 408/483] nuke: fix slate check for frame length --- openpype/hosts/nuke/plugins/publish/extract_slate_frame.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py index 50e5f995f4..a91181c81b 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py +++ b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py @@ -48,8 +48,13 @@ class ExtractSlateFrame(openpype.api.Extractor): self.log.info( "StagingDir `{0}`...".format(instance.data["stagingDir"])) + frame_start = instance.data["frameStart"] + frame_end = instance.data["frameEnd"] + handle_start = instance.data["handleStart"] + handle_end = instance.data["handleEnd"] + frame_length = int( - instance.data["frameEnd"] - instance.data["frameStart"] + 1 + (frame_start - frame_end + 1) + (handle_start + handle_end) ) temporary_nodes = [] From 32923208687a221fb11f2f85cb43680292bc3981 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 10 Mar 2022 15:56:39 +0100 Subject: [PATCH 409/483] implemented get asset icon function --- openpype/tools/utils/__init__.py | 1 + openpype/tools/utils/assets_widget.py | 27 +++++------------ openpype/tools/utils/lib.py | 42 +++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index c15e9f8139..6ab9e75b52 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -16,6 +16,7 @@ from .lib import ( set_style_property, DynamicQThread, qt_app_context, + get_asset_icon, ) from .models import ( diff --git a/openpype/tools/utils/assets_widget.py b/openpype/tools/utils/assets_widget.py index d410b0f1c3..4c77b81c0e 100644 --- a/openpype/tools/utils/assets_widget.py +++ b/openpype/tools/utils/assets_widget.py @@ -16,7 +16,10 @@ from .views import ( ) from .widgets import PlaceholderLineEdit from .models import RecursiveSortFilterProxyModel -from .lib import DynamicQThread +from .lib import ( + DynamicQThread, + get_asset_icon +) if Qt.__binding__ == "PySide": from PySide.QtGui import QStyleOptionViewItemV4 @@ -508,25 +511,9 @@ class AssetModel(QtGui.QStandardItemModel): item.setData(asset_label, QtCore.Qt.DisplayRole) item.setData(asset_label, ASSET_LABEL_ROLE) - icon_color = asset_data.get("color") or style.colors.default - icon_name = asset_data.get("icon") - if not icon_name: - # Use default icons if no custom one is specified. - # If it has children show a full folder, otherwise - # show an open folder - if item.rowCount() > 0: - icon_name = "folder" - else: - icon_name = "folder-o" - - try: - # font-awesome key - full_icon_name = "fa.{0}".format(icon_name) - icon = qtawesome.icon(full_icon_name, color=icon_color) - item.setData(icon, QtCore.Qt.DecorationRole) - - except Exception: - pass + has_children = item.rowCount() > 0 + icon = get_asset_icon(asset_data, has_children) + item.setData(icon, QtCore.Qt.DecorationRole) def _threaded_fetch(self): asset_docs = self._fetch_asset_docs() diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 1cbc632804..d57b44728d 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -98,6 +98,48 @@ application = qt_app_context class SharedObjects: jobs = {} + icons = {} + + +def get_qta_icon_by_name_and_color(icon_name, icon_color): + if not icon_name or not icon_color: + return None + + full_icon_name = "{0}-{1}".format(icon_name, icon_color) + if full_icon_name in SharedObjects.icons: + return SharedObjects.icons[full_icon_name] + + variants = [icon_name] + qta_instance = qtawesome._instance() + for key in qta_instance.charmap.keys(): + variants.append("{0}.{1}".format(key, icon_name)) + + icon = None + for variant in variants: + try: + icon = qtawesome.icon(variant, color=icon_color) + break + except Exception: + pass + + SharedObjects.icons[full_icon_name] = icon + return icon + + +def get_asset_icon(asset_doc, has_children=False): + asset_data = asset_doc.get("data") or {} + icon_color = asset_data.get("color") or style.colors.default + icon_name = asset_data.get("icon") + if not icon_name: + # Use default icons if no custom one is specified. + # If it has children show a full folder, otherwise + # show an open folder + if has_children: + icon_name = "folder" + else: + icon_name = "folder-o" + + return get_qta_icon_by_name_and_color(icon_name, icon_color) def schedule(func, time, channel="default"): From ff440612a2bc00b84ebd9275192f12025732bb62 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 10 Mar 2022 15:56:50 +0100 Subject: [PATCH 410/483] added icons into publisher UI --- openpype/tools/publisher/widgets/assets_widget.py | 7 ++++++- openpype/tools/publisher/widgets/tasks_widget.py | 3 +++ openpype/tools/utils/lib.py | 10 ++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/openpype/tools/publisher/widgets/assets_widget.py b/openpype/tools/publisher/widgets/assets_widget.py index b8696a2665..984da59c77 100644 --- a/openpype/tools/publisher/widgets/assets_widget.py +++ b/openpype/tools/publisher/widgets/assets_widget.py @@ -3,7 +3,8 @@ import collections from Qt import QtWidgets, QtCore, QtGui from openpype.tools.utils import ( PlaceholderLineEdit, - RecursiveSortFilterProxyModel + RecursiveSortFilterProxyModel, + get_asset_icon, ) from openpype.tools.utils.assets_widget import ( SingleSelectAssetsWidget, @@ -102,11 +103,15 @@ class AssetsHierarchyModel(QtGui.QStandardItemModel): for name in sorted(children_by_name.keys()): child = children_by_name[name] child_id = child["_id"] + has_children = bool(assets_by_parent_id.get(child_id)) + icon = get_asset_icon(child, has_children) + item = QtGui.QStandardItem(name) item.setFlags( QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable ) + item.setData(icon, QtCore.Qt.DecorationRole) item.setData(child_id, ASSET_ID_ROLE) item.setData(name, ASSET_NAME_ROLE) diff --git a/openpype/tools/publisher/widgets/tasks_widget.py b/openpype/tools/publisher/widgets/tasks_widget.py index 2d1cc017af..8a913b7114 100644 --- a/openpype/tools/publisher/widgets/tasks_widget.py +++ b/openpype/tools/publisher/widgets/tasks_widget.py @@ -1,6 +1,7 @@ from Qt import QtCore, QtGui from openpype.tools.utils.tasks_widget import TasksWidget, TASK_NAME_ROLE +from openpype.tools.utils.lib import get_task_icon class TasksModel(QtGui.QStandardItemModel): @@ -118,6 +119,8 @@ class TasksModel(QtGui.QStandardItemModel): item = QtGui.QStandardItem(task_name) item.setData(task_name, TASK_NAME_ROLE) + if task_name: + item.setData(get_task_icon(), QtCore.Qt.DecorationRole) self._items_by_name[task_name] = item new_items.append(item) diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index d57b44728d..042ceaab88 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -142,6 +142,16 @@ def get_asset_icon(asset_doc, has_children=False): return get_qta_icon_by_name_and_color(icon_name, icon_color) +def get_task_icon(): + """Get icon for a task. + + TODO: Get task icon based on data in database. + + Icon should be defined by task type which is stored on project. + """ + return get_qta_icon_by_name_and_color("fa.male", style.colors.default) + + def schedule(func, time, channel="default"): """Run `func` at a later `time` in a dedicated `channel` From 87719ed878a55ec096a9cf57f0e3493577b3b3ce Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 10 Mar 2022 15:59:28 +0100 Subject: [PATCH 411/483] nuke: wrong expression --- openpype/hosts/nuke/plugins/publish/extract_slate_frame.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py index a91181c81b..e917a28046 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py +++ b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py @@ -54,7 +54,7 @@ class ExtractSlateFrame(openpype.api.Extractor): handle_end = instance.data["handleEnd"] frame_length = int( - (frame_start - frame_end + 1) + (handle_start + handle_end) + (frame_end - frame_start + 1) + (handle_start + handle_end) ) temporary_nodes = [] From 0b36fc2c65356b9b787cb79e58cf3822c4bd1e81 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 10 Mar 2022 16:08:12 +0100 Subject: [PATCH 412/483] fixing reformat in extract review when slate reformate --- openpype/plugins/publish/extract_review.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index bec1f75425..f9a02f58bb 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -1165,6 +1165,18 @@ class ExtractReview(pyblish.api.InstancePlugin): input_height = int(stream["height"]) break + # Get instance data + pixel_aspect = temp_data["pixel_aspect"] + + if reformat_in_baking: + self.log.debug(( + "Using resolution from input. It is already " + "reformated from upstream process" + )) + pixel_aspect = 1 + output_width = input_width + output_height = input_height + # Raise exception of any stream didn't define input resolution if input_width is None: raise AssertionError(( From 99d7912495b5a57eba48f5d614aac6cd08e7d1a3 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 10 Mar 2022 16:12:11 +0100 Subject: [PATCH 413/483] defining default none values for Output resolution --- openpype/plugins/publish/extract_review.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index f9a02f58bb..0b139a73e4 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -1159,6 +1159,8 @@ class ExtractReview(pyblish.api.InstancePlugin): # - there may be a better way (checking `codec_type`?) input_width = None input_height = None + output_width = None + output_height = None for stream in streams: if "width" in stream and "height" in stream: input_width = int(stream["width"]) @@ -1185,8 +1187,8 @@ class ExtractReview(pyblish.api.InstancePlugin): # NOTE Setting only one of `width` or `heigth` is not allowed # - settings value can't have None but has value of 0 - output_width = output_def.get("width") or None - output_height = output_def.get("height") or None + output_width = output_width or output_def.get("width") or None + output_height = output_height or output_def.get("height") or None # Overscal color overscan_color_value = "black" From 68171265dc684490b2594dec218c8f82ed100ec3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 10 Mar 2022 17:41:58 +0100 Subject: [PATCH 414/483] fix plugin name from 'oiio' to 'OpenPypeTileAssembler' --- openpype/settings/defaults/project_settings/deadline.json | 2 +- .../schemas/projects_schema/schema_project_deadline.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 7aff6d0b30..5bb0a4022e 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -46,7 +46,7 @@ "enabled": true, "optional": false, "active": true, - "tile_assembler_plugin": "oiio", + "tile_assembler_plugin": "OpenPypeTileAssembler", "use_published": true, "asset_dependencies": true, "group": "none", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index 788b1d8575..e6097a2b14 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -103,7 +103,7 @@ "DraftTileAssembler": "Draft Tile Assembler" }, { - "oiio": "Open Image IO" + "OpenPypeTileAssembler": "Open Image IO" } ] }, From 2f4b165aafaa71d600b0e61376d11c5fab78ebac Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 10 Mar 2022 18:55:59 +0100 Subject: [PATCH 415/483] fix info reading from oiio --- openpype/lib/transcoding.py | 19 +- .../OpenPypeTileAssembler.py | 269 ++++++++++++++---- 2 files changed, 233 insertions(+), 55 deletions(-) diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index e89fa6331e..462745bcda 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -90,7 +90,7 @@ class RationalToInt: if len(parts) != 1: bottom = float(parts[1]) - self._value = top / bottom + self._value = float(top) / float(bottom) self._string_value = string_value @property @@ -170,6 +170,23 @@ def convert_value_by_type_name(value_type, value, logger=None): if value_type == "rational2i": return RationalToInt(value) + if value_type == "vector": + parts = [part.strip() for part in value.split(",")] + output = [] + for part in parts: + if part == "-nan": + output.append(None) + continue + try: + part = float(part) + except ValueError: + pass + output.append(part) + return output + + if value_type == "timecode": + return value + # Array of other types is converted to list re_result = ARRAY_TYPE_REGEX.findall(value_type) if re_result: diff --git a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py index cf864b03d3..9fca1b5391 100644 --- a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py +++ b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py @@ -5,8 +5,9 @@ Todo: Currently we support only EXRs with their data window set. """ import os +import re import subprocess -from xml.dom import minidom +import xml.etree.ElementTree from System.IO import Path @@ -15,17 +16,220 @@ from Deadline.Scripting import ( FileUtils, RepositoryUtils, SystemUtils) -INT_KEYS = { - "x", "y", "height", "width", "full_x", "full_y", - "full_width", "full_height", "full_depth", "full_z", - "tile_width", "tile_height", "tile_depth", "deep", "depth", - "nchannels", "z_channel", "alpha_channel", "subimages" +STRING_TAGS = { + "format" } -LIST_KEYS = { - "channelnames" +INT_TAGS = { + "x", "y", "z", + "width", "height", "depth", + "full_x", "full_y", "full_z", + "full_width", "full_height", "full_depth", + "tile_width", "tile_height", "tile_depth", + "nchannels", + "alpha_channel", + "z_channel", + "deep", + "subimages", } +XML_CHAR_REF_REGEX_HEX = re.compile(r"&#x?[0-9a-fA-F]+;") + +# Regex to parse array attributes +ARRAY_TYPE_REGEX = re.compile(r"^(int|float|string)\[\d+\]$") + + +def convert_value_by_type_name(value_type, value): + """Convert value to proper type based on type name. + + In some cases value types have custom python class. + """ + + # Simple types + if value_type == "string": + return value + + if value_type == "int": + return int(value) + + if value_type == "float": + return float(value) + + # Vectors will probably have more types + if value_type == "vec2f": + return [float(item) for item in value.split(",")] + + # Matrix should be always have square size of element 3x3, 4x4 + # - are returned as list of lists + if value_type == "matrix": + output = [] + current_index = -1 + parts = value.split(",") + parts_len = len(parts) + if parts_len == 1: + divisor = 1 + elif parts_len == 4: + divisor = 2 + elif parts_len == 9: + divisor == 3 + elif parts_len == 16: + divisor = 4 + else: + print("Unknown matrix resolution {}. Value: \"{}\"".format( + parts_len, value + )) + for part in parts: + output.append(float(part)) + return output + + for idx, item in enumerate(parts): + list_index = idx % divisor + if list_index > current_index: + current_index = list_index + output.append([]) + output[list_index].append(float(item)) + return output + + if value_type == "rational2i": + parts = value.split("/") + top = float(parts[0]) + bottom = 1.0 + if len(parts) != 1: + bottom = float(parts[1]) + return float(top) / float(bottom) + + if value_type == "vector": + parts = [part.strip() for part in value.split(",")] + output = [] + for part in parts: + if part == "-nan": + output.append(None) + continue + try: + part = float(part) + except ValueError: + pass + output.append(part) + return output + + if value_type == "timecode": + return value + + # Array of other types is converted to list + re_result = ARRAY_TYPE_REGEX.findall(value_type) + if re_result: + array_type = re_result[0] + output = [] + for item in value.split(","): + output.append( + convert_value_by_type_name(array_type, item) + ) + return output + + print(( + "MISSING IMPLEMENTATION:" + " Unknown attrib type \"{}\". Value: {}" + ).format(value_type, value)) + return value + + +def parse_oiio_xml_output(xml_string): + """Parse xml output from OIIO info command.""" + output = {} + if not xml_string: + return output + + # Fix values with ampresand (lazy fix) + # - oiiotool exports invalid xml which ElementTree can't handle + # e.g. "" + # WARNING: this will affect even valid character entities. If you need + # those values correctly, this must take care of valid character ranges. + # See https://github.com/pypeclub/OpenPype/pull/2729 + matches = XML_CHAR_REF_REGEX_HEX.findall(xml_string) + for match in matches: + new_value = match.replace("&", "&") + xml_string = xml_string.replace(match, new_value) + + tree = xml.etree.ElementTree.fromstring(xml_string) + attribs = {} + output["attribs"] = attribs + for child in tree: + tag_name = child.tag + if tag_name == "attrib": + attrib_def = child.attrib + value = convert_value_by_type_name( + attrib_def["type"], child.text + ) + + attribs[attrib_def["name"]] = value + continue + + # Channels are stored as tex on each child + if tag_name == "channelnames": + value = [] + for channel in child: + value.append(channel.text) + + # Convert known integer type tags to int + elif tag_name in INT_TAGS: + value = int(child.text) + + # Keep value of known string tags + elif tag_name in STRING_TAGS: + value = child.text + + # Keep value as text for unknown tags + # - feel free to add more tags + else: + value = child.text + print(( + "MISSING IMPLEMENTATION:" + " Unknown tag \"{}\". Value \"{}\"" + ).format(tag_name, value)) + + output[child.tag] = value + + return output + + +def info_about_input(oiiotool_path, filepath): + args = [ + oiiotool_path, + "--info", + "-v", + "-i:infoformat=xml", + filepath + ] + popen = subprocess.Popen(args, stdout=subprocess.PIPE) + _stdout, _stderr = popen.communicate() + output = "" + if _stdout: + output += _stdout.decode("utf-8") + + if _stderr: + output += _stderr.decode("utf-8") + + output = output.replace("\r\n", "\n") + xml_started = False + lines = [] + for line in output.split("\n"): + if not xml_started: + if not line.startswith("<"): + continue + xml_started = True + if xml_started: + lines.append(line) + + if not xml_started: + raise ValueError( + "Failed to read input file \"{}\".\nOutput:\n{}".format( + filepath, output + ) + ) + xml_text = "\n".join(lines) + return parse_oiio_xml_output(xml_text) + + def GetDeadlinePlugin(): # noqa: N802 """Helper.""" return OpenPypeTileAssembler() @@ -218,8 +422,9 @@ class OpenPypeTileAssembler(DeadlinePlugin): # Create new image with output resolution, and with same type and # channels as input + oiiotool_path = self.render_executable() first_tile_path = tile_info[0]["filepath"] - first_tile_info = self.info_about_input(first_tile_path) + first_tile_info = info_about_input(oiiotool_path, first_tile_path) create_arg_template = "--create{} {}x{} {}" image_type = "" @@ -236,7 +441,7 @@ class OpenPypeTileAssembler(DeadlinePlugin): for tile in tile_info: path = tile["filepath"] pos_x = tile["pos_x"] - tile_height = self.info_about_input(path)["height"] + tile_height = info_about_input(oiiotool_path, path)["height"] if self.renderer == "vray": pos_y = tile["pos_y"] else: @@ -326,47 +531,3 @@ class OpenPypeTileAssembler(DeadlinePlugin): ffmpeg_args.append("\"{}\"".format(output_path)) return ffmpeg_args - - def info_about_input(self, input_path): - args = [self.render_executable(), "--info:format=xml", input_path] - popen = subprocess.Popen( - " ".join(args), - shell=True, - stdout=subprocess.PIPE - ) - popen_output = popen.communicate()[0].replace(b"\r\n", b"") - - xmldoc = minidom.parseString(popen_output) - image_spec = None - for main_child in xmldoc.childNodes: - if main_child.nodeName.lower() == "imagespec": - image_spec = main_child - break - - info = {} - if not image_spec: - return info - - def child_check(node): - if len(node.childNodes) != 1: - self.FailRender(( - "Implementation BUG. Node {} has more children than 1" - ).format(node.nodeName)) - - for child in image_spec.childNodes: - if child.nodeName in LIST_KEYS: - values = [] - for node in child.childNodes: - child_check(node) - values.append(node.childNodes[0].nodeValue) - - info[child.nodeName] = values - - elif child.nodeName in INT_KEYS: - child_check(child) - info[child.nodeName] = int(child.childNodes[0].nodeValue) - - else: - child_check(child) - info[child.nodeName] = child.childNodes[0].nodeValue - return info From 2f9dcc0c55683c6e5a338dda6a05fdd1dafd250e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 10 Mar 2022 22:51:15 +0100 Subject: [PATCH 416/483] flame: define fps table --- openpype/hosts/flame/hooks/pre_flame_setup.py | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index 0d63b0d926..5db5757d50 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -63,7 +63,7 @@ class FlamePrelaunch(PreLaunchHook): _db_p_data = project_doc["data"] width = _db_p_data["resolutionWidth"] height = _db_p_data["resolutionHeight"] - fps = float(_db_p_data["fps"]) + fps = float(_db_p_data["fps_string"]) project_data = { "Name": project_doc["name"], @@ -73,7 +73,7 @@ class FlamePrelaunch(PreLaunchHook): "FrameWidth": int(width), "FrameHeight": int(height), "AspectRatio": float((width / height) * _db_p_data["pixelAspect"]), - "FrameRate": "{} fps".format(fps), + "FrameRate": self._get_flame_fps(fps), "FrameDepth": str(imageio_flame["project"]["frameDepth"]), "FieldDominance": str(imageio_flame["project"]["fieldDominance"]) } @@ -101,6 +101,28 @@ class FlamePrelaunch(PreLaunchHook): self.launch_context.launch_args.extend(app_arguments) + def _get_flame_fps(self, fps_num): + fps_table = { + float(23.976): "23.976 fps", + int(25): "25 fps", + int(24): "24 fps", + float(29.97): "29.97 fps DF", + int(30): "30 fps", + int(50): "50 fps", + float(59.94): "59.94 fps DF", + int(60): "60 fps" + } + + match_key = min(fps_table.keys(), key=lambda x: abs(x - fps_num)) + + try: + return fps_table[match_key] + except KeyError as msg: + raise KeyError(( + "Missing FPS key in conversion table. " + "Following keys are available: {}".format(fps_table.keys()) + )) from msg + def _add_pythonpath(self): pythonpath = self.launch_context.env.get("PYTHONPATH") From 3996223ce530158b84101203f2b957874c90c470 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 10 Mar 2022 23:21:00 +0100 Subject: [PATCH 417/483] flame: colour policy can be path --- openpype/hosts/flame/api/scripts/wiretap_com.py | 9 ++++++++- .../projects_schema/schemas/schema_anatomy_imageio.json | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/api/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py index c864399608..ee906c2608 100644 --- a/openpype/hosts/flame/api/scripts/wiretap_com.py +++ b/openpype/hosts/flame/api/scripts/wiretap_com.py @@ -420,13 +420,20 @@ class WireTapCom(object): RuntimeError: Not able to set colorspace policy """ color_policy = color_policy or "Legacy" + + # check if the colour policy in custom dir + if not os.path.exists(color_policy): + color_policy = "/syncolor/policies/Autodesk/{}".format( + color_policy) + + # create arguments project_colorspace_cmd = [ os.path.join( self.wiretap_tools_dir, "wiretap_duplicate_node" ), "-s", - "/syncolor/policies/Autodesk/{}".format(color_policy), + color_policy, "-n", "/projects/{}/syncolor".format(project_name) ] diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json index 3bec19c3d0..2867332d82 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json @@ -457,7 +457,7 @@ { "type": "text", "key": "colourPolicy", - "label": "Colour Policy" + "label": "Colour Policy (name or path)" }, { "type": "text", From bdcb4ab8b32d2e63215acdbb9c01715007d53c93 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 11 Mar 2022 00:06:36 +0100 Subject: [PATCH 418/483] move method to parent --- openpype/hosts/maya/api/plugin.py | 19 ++++++++++++++++--- .../hosts/maya/plugins/load/load_reference.py | 12 +----------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index bdb8fcf13a..e5edb39d20 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -5,6 +5,7 @@ from maya import cmds from avalon import api from avalon.vendor import qargparse from openpype.api import PypeCreatorMixin +from avalon.pipeline import AVALON_CONTAINER_ID from .pipeline import containerise from . import lib @@ -168,16 +169,18 @@ class ReferenceLoader(Loader): return ref_node = get_reference_node(nodes, self.log) - loaded_containers.append(containerise( + container = containerise( name=name, namespace=namespace, nodes=[ref_node], context=context, loader=self.__class__.__name__ - )) - + ) + loaded_containers.append(container) + self._organize_containers([ref_node], container) c += 1 namespace = None + return loaded_containers def process_reference(self, context, name, namespace, data): @@ -310,3 +313,13 @@ class ReferenceLoader(Loader): deleteNamespaceContent=True) except RuntimeError: pass + + @staticmethod + def _organize_containers(nodes, container): + # type: (list, str) -> None + for node in nodes: + id_attr = "{}.id".format(node) + if not cmds.attributeQuery("id", node=node, exists=True): + continue + if cmds.getAttr(id_attr) == AVALON_CONTAINER_ID: + cmds.sets(node, forceElement=container) diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 017f89d08c..ece02bef49 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -1,7 +1,7 @@ import os from maya import cmds from avalon import api -from avalon.pipeline import AVALON_CONTAINER_ID + from openpype.api import get_project_settings from openpype.lib import get_creator_by_name import openpype.hosts.maya.api.plugin @@ -165,13 +165,3 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): options={"useSelection": True}, data={"dependencies": dependency} ) - - @staticmethod - def _organize_containers(nodes, container): - # type: (list, str) -> None - for node in nodes: - id_attr = "{}.id".format(node) - if not cmds.attributeQuery("id", node=node, exists=True): - continue - if cmds.getAttr(id_attr) == AVALON_CONTAINER_ID: - cmds.sets(node, forceElement=container) From c92943ca1addeded642b5f5cdd69f732378d1d50 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 11 Mar 2022 00:18:59 +0100 Subject: [PATCH 419/483] remove case insensivity in family processing --- .../hosts/maya/plugins/publish/extract_maya_scene_raw.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py index 591789917e..5cc7b52090 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py @@ -59,10 +59,9 @@ class ExtractMayaSceneRaw(openpype.api.Extractor): members = instance[:] loaded_containers = None - if {f.lower() for f in self.add_for_families}.intersection( - {f.lower() for f in instance.data.get("families")}, - {instance.data.get("family").lower()}, - ): + if set(self.add_for_families).intersection( + set(instance.data.get("families")), + set(instance.data.get("family").lower())): loaded_containers = self._add_loaded_containers(members) selection = members From 21351af22e05067dcff38e5aea074eed175564b0 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Fri, 11 Mar 2022 09:14:05 +0000 Subject: [PATCH 420/483] [Automated] Bump version --- CHANGELOG.md | 40 +++++++++++++++++++++------------------- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa479d8f05..f971c33208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.9.0-nightly.7](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.9.0-nightly.8](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...HEAD) @@ -9,15 +9,13 @@ - AssetCreator: Remove the tool [\#2845](https://github.com/pypeclub/OpenPype/pull/2845) - Houdini: Remove unused code [\#2779](https://github.com/pypeclub/OpenPype/pull/2779) -### 📖 Documentation - -- Documentation: fixed broken links [\#2799](https://github.com/pypeclub/OpenPype/pull/2799) -- Documentation: broken link fix [\#2785](https://github.com/pypeclub/OpenPype/pull/2785) -- Various testing updates [\#2726](https://github.com/pypeclub/OpenPype/pull/2726) - **🚀 Enhancements** +- NewPublisher: Descriptions and Icons in creator dialog [\#2867](https://github.com/pypeclub/OpenPype/pull/2867) +- NewPublisher: Changing task on publishing instance [\#2863](https://github.com/pypeclub/OpenPype/pull/2863) +- TrayPublisher: Choose project widget is more clear [\#2859](https://github.com/pypeclub/OpenPype/pull/2859) - New: Validation exceptions [\#2841](https://github.com/pypeclub/OpenPype/pull/2841) +- Maya: add loaded containers to published instance [\#2837](https://github.com/pypeclub/OpenPype/pull/2837) - Ftrack: Can sync fps as string [\#2836](https://github.com/pypeclub/OpenPype/pull/2836) - General: Custom function for find executable [\#2822](https://github.com/pypeclub/OpenPype/pull/2822) - General: Color dialog UI fixes [\#2817](https://github.com/pypeclub/OpenPype/pull/2817) @@ -25,11 +23,16 @@ - Nuke: adding Reformat to baking mov plugin [\#2811](https://github.com/pypeclub/OpenPype/pull/2811) - Manager: Update all to latest button [\#2805](https://github.com/pypeclub/OpenPype/pull/2805) - General: Set context environments for non host applications [\#2803](https://github.com/pypeclub/OpenPype/pull/2803) -- Tray publisher: New Tray Publisher host \(beta\) [\#2778](https://github.com/pypeclub/OpenPype/pull/2778) -- Flame: use Shot Name on segment for asset name [\#2751](https://github.com/pypeclub/OpenPype/pull/2751) **🐛 Bug fixes** +- Deadline: Fix plugin name for tile assemble [\#2868](https://github.com/pypeclub/OpenPype/pull/2868) +- General: Fix hardlink for windows [\#2864](https://github.com/pypeclub/OpenPype/pull/2864) +- General: ffmpeg was crashing on slate merge [\#2860](https://github.com/pypeclub/OpenPype/pull/2860) +- WebPublisher: Video file was published with one too many frame [\#2858](https://github.com/pypeclub/OpenPype/pull/2858) +- New Publisher: Error dialog got right styles [\#2857](https://github.com/pypeclub/OpenPype/pull/2857) +- General: Fix getattr clalback on dynamic modules [\#2855](https://github.com/pypeclub/OpenPype/pull/2855) +- Nuke: slate resolution to input video resolution [\#2853](https://github.com/pypeclub/OpenPype/pull/2853) - WebPublisher: Fix username stored in DB [\#2852](https://github.com/pypeclub/OpenPype/pull/2852) - WebPublisher: Fix wrong number of frames for video file [\#2851](https://github.com/pypeclub/OpenPype/pull/2851) - Nuke: fix multiple baking profile farm publishing [\#2842](https://github.com/pypeclub/OpenPype/pull/2842) @@ -42,26 +45,25 @@ - General: Host name was formed from obsolete code [\#2821](https://github.com/pypeclub/OpenPype/pull/2821) - Settings UI: Fix "Apply from" action [\#2820](https://github.com/pypeclub/OpenPype/pull/2820) - Ftrack: Job killer with missing user [\#2819](https://github.com/pypeclub/OpenPype/pull/2819) +- Nuke: Use AVALON\_APP to get value for "app" key [\#2818](https://github.com/pypeclub/OpenPype/pull/2818) - StandalonePublisher: use dynamic groups in subset names [\#2816](https://github.com/pypeclub/OpenPype/pull/2816) - Settings UI: Search case sensitivity [\#2810](https://github.com/pypeclub/OpenPype/pull/2810) - Flame Babypublisher optimalization [\#2806](https://github.com/pypeclub/OpenPype/pull/2806) -- resolve: fixing fusion module loading [\#2802](https://github.com/pypeclub/OpenPype/pull/2802) -- Ftrack: Unset task ids from asset versions before tasks are removed [\#2800](https://github.com/pypeclub/OpenPype/pull/2800) -- Slack: fail gracefully if slack exception [\#2798](https://github.com/pypeclub/OpenPype/pull/2798) -- Flame: Fix version string in default settings [\#2783](https://github.com/pypeclub/OpenPype/pull/2783) -**Merged pull requests:** +**🔀 Refactored code** +- General: Move create logic from avalon to OpenPype [\#2854](https://github.com/pypeclub/OpenPype/pull/2854) +- General: Add vendors from avalon [\#2848](https://github.com/pypeclub/OpenPype/pull/2848) - General: Move change context functions [\#2839](https://github.com/pypeclub/OpenPype/pull/2839) - Tools: Don't use avalon tools code [\#2829](https://github.com/pypeclub/OpenPype/pull/2829) - Move Unreal Implementation to OpenPype [\#2823](https://github.com/pypeclub/OpenPype/pull/2823) -- Nuke: Use AVALON\_APP to get value for "app" key [\#2818](https://github.com/pypeclub/OpenPype/pull/2818) -- Ftrack: Moved module one hierarchy level higher [\#2792](https://github.com/pypeclub/OpenPype/pull/2792) -- SyncServer: Moved module one hierarchy level higher [\#2791](https://github.com/pypeclub/OpenPype/pull/2791) -- Royal render: Move module one hierarchy level higher [\#2790](https://github.com/pypeclub/OpenPype/pull/2790) -- Deadline: Move module one hierarchy level higher [\#2789](https://github.com/pypeclub/OpenPype/pull/2789) - General: Extract template formatting from anatomy [\#2766](https://github.com/pypeclub/OpenPype/pull/2766) +**Merged pull requests:** + +- Nuke: gizmo precollect fix [\#2866](https://github.com/pypeclub/OpenPype/pull/2866) +- Nuke: Fix family test in validate\_write\_legacy to work with stillImage [\#2847](https://github.com/pypeclub/OpenPype/pull/2847) + ## [3.8.2](https://github.com/pypeclub/OpenPype/tree/3.8.2) (2022-02-07) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.8.2-nightly.3...3.8.2) diff --git a/openpype/version.py b/openpype/version.py index 55ac148ed1..d4af8d760f 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.9.0-nightly.7" +__version__ = "3.9.0-nightly.8" diff --git a/pyproject.toml b/pyproject.toml index f0d295a44c..fe681266ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.9.0-nightly.7" # OpenPype +version = "3.9.0-nightly.8" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From f3f781b43578dc1cef8cbbbf5c9276c5e48e7469 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 11 Mar 2022 11:03:15 +0100 Subject: [PATCH 421/483] added subset name filtering in ExtractReview --- openpype/plugins/publish/extract_review.py | 25 ++++++++++++++++--- .../defaults/project_settings/global.json | 3 ++- .../schemas/schema_global_publish.json | 9 +++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 0b139a73e4..b8599454ee 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -103,9 +103,10 @@ class ExtractReview(pyblish.api.InstancePlugin): self.log.debug("Matching profile: \"{}\"".format(json.dumps(profile))) + subset_name = instance.data.get("subset") instance_families = self.families_from_instance(instance) - filtered_outputs = self.filter_outputs_by_families( - profile, instance_families + filtered_outputs = self.filter_output_defs( + profile, subset_name, instance_families ) # Store `filename_suffix` to save arguments profile_outputs = [] @@ -1651,7 +1652,7 @@ class ExtractReview(pyblish.api.InstancePlugin): return True return False - def filter_outputs_by_families(self, profile, families): + def filter_output_defs(self, profile, subset_name, families): """Return outputs matching input instance families. Output definitions without families filter are marked as valid. @@ -1684,6 +1685,24 @@ class ExtractReview(pyblish.api.InstancePlugin): if not self.families_filter_validation(families, families_filters): continue + # Subsets name filters + subset_filters = [ + subset_filter + for subset_filter in output_filters.get("subsets", []) + # Skip empty strings + if subset_filter + ] + if subset_name and subset_filters: + match = False + for subset_filter in subset_filters: + compiled = re.compile(subset_filter) + if compiled.search(subset_name): + match = True + break + + if not match: + continue + filtered_outputs[filename_suffix] = output_def return filtered_outputs diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 9c44d9bc86..30a71b044a 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -87,7 +87,8 @@ "render", "review", "ftrack" - ] + ], + "subsets": [] }, "overscan_crop": "", "overscan_color": [ diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json index 3eea7ccb30..12043d4205 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json @@ -291,6 +291,15 @@ "label": "Families", "type": "list", "object_type": "text" + }, + { + "type": "separator" + }, + { + "key": "subsets", + "label": "Subsets", + "type": "list", + "object_type": "text" } ] }, From fe1e2b306840a24caf0702d88182dc69812f237f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 11 Mar 2022 11:50:41 +0100 Subject: [PATCH 422/483] flame: adding `export_type` attribute --- .../publish/extract_subset_resources.py | 3 +++ .../defaults/project_settings/flame.json | 1 + .../projects_schema/schema_project_flame.json | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index db85bede85..7be41bbb76 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -22,6 +22,7 @@ class ExtractSubsetResources(openpype.api.Extractor): "ext": "jpg", "xml_preset_file": "Jpeg (8-bit).xml", "xml_preset_dir": "", + "export_type": "File Sequence", "colorspace_out": "Output - sRGB", "representation_add_range": False, "representation_tags": ["thumbnail"] @@ -30,6 +31,7 @@ class ExtractSubsetResources(openpype.api.Extractor): "ext": "mov", "xml_preset_file": "Apple iPad (1920x1080).xml", "xml_preset_dir": "", + "export_type": "Movie", "colorspace_out": "Output - Rec.709", "representation_add_range": True, "representation_tags": [ @@ -84,6 +86,7 @@ class ExtractSubsetResources(openpype.api.Extractor): kwargs = {} preset_file = preset_config["xml_preset_file"] preset_dir = preset_config["xml_preset_dir"] + export_type = preset_config["export_type"] repre_tags = preset_config["representation_tags"] color_out = preset_config["colorspace_out"] diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index 6fb6f55528..ef9c2b1041 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -27,6 +27,7 @@ "ext": "exr", "xml_preset_file": "OpenEXR (16-bit fp DWAA).xml", "xml_preset_dir": "", + "export_type": "File Sequence", "colorspace_out": "ACES - ACEScg", "representation_add_range": true, "representation_tags": [] diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json index dc88d11f61..1f30b45981 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -171,6 +171,24 @@ "label": "XML preset folder (optional)", "type": "text" }, + { + "key": "export_type", + "label": "Eport clip type", + "type": "enum", + "default": "File Sequence", + "enum_items": [ + { + "Movie": "Movie" + }, + { + "File Sequence": "File Sequence" + }, + { + "Sequence Publish": "Sequence Publish" + } + ] + + }, { "key": "colorspace_out", "label": "Output color (imageio)", From 6706e90d1852f063670fcba5a1e630dde0c19a17 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 11 Mar 2022 12:41:14 +0100 Subject: [PATCH 423/483] =?UTF-8?q?Fixes=20for=20=F0=9F=8E=AB=20OP-2825?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openpype/hosts/maya/api/plugin.py | 3 ++- .../hosts/maya/plugins/load/load_reference.py | 8 +------- .../plugins/publish/extract_maya_scene_raw.py | 18 +++++++----------- 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index e0c21645e4..feaceacebc 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -178,7 +178,7 @@ class ReferenceLoader(Loader): loader=self.__class__.__name__ ) loaded_containers.append(container) - self._organize_containers([ref_node], container) + self._organize_containers(nodes, container) c += 1 namespace = None @@ -318,6 +318,7 @@ class ReferenceLoader(Loader): @staticmethod def _organize_containers(nodes, container): # type: (list, str) -> None + """Put containers in loaded data to correct hierarchy.""" for node in nodes: id_attr = "{}.id".format(node) if not cmds.attributeQuery("id", node=node, exists=True): diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 66cf95a643..d358c62724 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -121,18 +121,12 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): if family == "rig": self._post_process_rig(name, namespace, context, options) else: - if "translate" in options: cmds.setAttr(group_name + ".t", *options["translate"]) + print(new_nodes) return new_nodes - def load(self, context, name=None, namespace=None, options=None): - container = super(ReferenceLoader, self).load( - context, name, namespace, options) - # clean containers if present to AVALON_CONTAINERS - self._organize_containers(self[:], container[0]) - def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py index 5cc7b52090..2e1260c374 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py @@ -58,16 +58,12 @@ class ExtractMayaSceneRaw(openpype.api.Extractor): else: members = instance[:] - loaded_containers = None - if set(self.add_for_families).intersection( - set(instance.data.get("families")), - set(instance.data.get("family").lower())): - loaded_containers = self._add_loaded_containers(members) - selection = members - if loaded_containers: - self.log.info(loaded_containers) - selection += loaded_containers + if set(self.add_for_families).intersection( + set(instance.data.get("families"))) or \ + instance.data.get("family") in self.add_for_families: + selection += self._add_loaded_containers(members) + self.log.info(selection) # Perform extraction self.log.info("Performing extraction ...") @@ -105,7 +101,7 @@ class ExtractMayaSceneRaw(openpype.api.Extractor): if cmds.referenceQuery(ref, isNodeReferenced=True) ] - refs_to_include = set(refs_to_include) + members_with_refs = set(refs_to_include).union(members) obj_sets = cmds.ls("*.id", long=True, type="objectSet", recursive=True, objectsOnly=True) @@ -121,7 +117,7 @@ class ExtractMayaSceneRaw(openpype.api.Extractor): continue set_content = set(cmds.sets(obj_set, query=True)) - if set_content.intersection(refs_to_include): + if set_content.intersection(members_with_refs): loaded_containers.append(obj_set) return loaded_containers From 032fce29b3b642440e2ad1c8a4c17dabf0f705e0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 11 Mar 2022 13:39:23 +0100 Subject: [PATCH 424/483] flame: adding xml preset modify method --- openpype/hosts/flame/api/__init__.py | 6 ++++-- openpype/hosts/flame/api/render_utils.py | 27 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index 56bbadd2fc..f210c27f87 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -68,7 +68,8 @@ from .workio import ( ) from .render_utils import ( export_clip, - get_preset_path_by_xml_name + get_preset_path_by_xml_name, + modify_preset_file ) __all__ = [ @@ -140,5 +141,6 @@ __all__ = [ # render utils "export_clip", - "get_preset_path_by_xml_name" + "get_preset_path_by_xml_name", + "modify_preset_file" ] diff --git a/openpype/hosts/flame/api/render_utils.py b/openpype/hosts/flame/api/render_utils.py index 1b086646cc..473fb2f985 100644 --- a/openpype/hosts/flame/api/render_utils.py +++ b/openpype/hosts/flame/api/render_utils.py @@ -1,4 +1,5 @@ import os +from xml.etree import ElementTree as ET def export_clip(export_path, clip, preset_path, **kwargs): @@ -123,3 +124,29 @@ def get_preset_path_by_xml_name(xml_preset_name): # if nothing found then return False return False + + +def modify_preset_file(xml_path, staging_dir, data): + """Modify xml preset with input data + + Args: + xml_path (str ): path for input xml preset + staging_dir (str): staging dir path + data (dict): data where key is xmlTag and value as string + + Returns: + str: _description_ + """ + # create temp path + dirname, basename = os.path.split(xml_path) + temp_path = os.path.join(staging_dir, basename) + + # change xml following data keys + with open(xml_path, "r") as datafile: + tree = ET.parse(datafile) + for key, value in data.items(): + for element in tree.findall(".//{}".format(key)): + element.text = str(value) + tree.write(temp_path) + + return temp_path From e8b2299c00c5989430855faef2dd28005f5203ba Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 11 Mar 2022 09:55:14 +0100 Subject: [PATCH 425/483] OP-2815 - copied webserver tool for AE from avalon to openpype --- .../hosts/aftereffects/api/launch_logic.py | 2 +- openpype/hosts/aftereffects/api/ws_stub.py | 2 +- openpype/tools/adobe_webserver/app.py | 237 ++++++++++++++++++ openpype/tools/adobe_webserver/readme.txt | 12 + 4 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 openpype/tools/adobe_webserver/app.py create mode 100644 openpype/tools/adobe_webserver/readme.txt diff --git a/openpype/hosts/aftereffects/api/launch_logic.py b/openpype/hosts/aftereffects/api/launch_logic.py index 97f14c9332..c549268978 100644 --- a/openpype/hosts/aftereffects/api/launch_logic.py +++ b/openpype/hosts/aftereffects/api/launch_logic.py @@ -15,7 +15,7 @@ from Qt import QtCore from openpype.tools.utils import host_tools from avalon import api -from avalon.tools.webserver.app import WebServerTool +from openpype.tools.adobe_webserver.app import WebServerTool from .ws_stub import AfterEffectsServerStub diff --git a/openpype/hosts/aftereffects/api/ws_stub.py b/openpype/hosts/aftereffects/api/ws_stub.py index 5a0600e92e..b0893310c1 100644 --- a/openpype/hosts/aftereffects/api/ws_stub.py +++ b/openpype/hosts/aftereffects/api/ws_stub.py @@ -8,7 +8,7 @@ import logging import attr from wsrpc_aiohttp import WebSocketAsync -from avalon.tools.webserver.app import WebServerTool +from openpype.tools.adobe_webserver.app import WebServerTool @attr.s diff --git a/openpype/tools/adobe_webserver/app.py b/openpype/tools/adobe_webserver/app.py new file mode 100644 index 0000000000..b79d6c6c60 --- /dev/null +++ b/openpype/tools/adobe_webserver/app.py @@ -0,0 +1,237 @@ +"""This Webserver tool is python 3 specific. + +Don't import directly to avalon.tools or implementation of Python 2 hosts +would break. +""" +import os +import logging +import urllib +import threading +import asyncio +import socket + +from aiohttp import web + +from wsrpc_aiohttp import ( + WSRPCClient +) + +from avalon import api + +log = logging.getLogger(__name__) + + +class WebServerTool: + """ + Basic POC implementation of asychronic websocket RPC server. + Uses class in external_app_1.py to mimic implementation for single + external application. + 'test_client' folder contains two test implementations of client + """ + _instance = None + + def __init__(self): + WebServerTool._instance = self + + self.client = None + self.handlers = {} + self.on_stop_callbacks = [] + + port = None + host_name = "localhost" + websocket_url = os.getenv("WEBSOCKET_URL") + if websocket_url: + parsed = urllib.parse.urlparse(websocket_url) + port = parsed.port + host_name = parsed.netloc.split(":")[0] + if not port: + port = 8098 # fallback + + self.port = port + self.host_name = host_name + + self.app = web.Application() + + # add route with multiple methods for single "external app" + self.webserver_thread = WebServerThread(self, self.port) + + def add_route(self, *args, **kwargs): + self.app.router.add_route(*args, **kwargs) + + def add_static(self, *args, **kwargs): + self.app.router.add_static(*args, **kwargs) + + def start_server(self): + if self.webserver_thread and not self.webserver_thread.is_alive(): + self.webserver_thread.start() + + def stop_server(self): + self.stop() + + async def send_context_change(self, host): + """ + Calls running webserver to inform about context change + + Used when new PS/AE should be triggered, + but one already running, without + this publish would point to old context. + """ + client = WSRPCClient(os.getenv("WEBSOCKET_URL"), + loop=asyncio.get_event_loop()) + await client.connect() + + project = api.Session["AVALON_PROJECT"] + asset = api.Session["AVALON_ASSET"] + task = api.Session["AVALON_TASK"] + log.info("Sending context change to {}-{}-{}".format(project, + asset, + task)) + + await client.call('{}.set_context'.format(host), + project=project, asset=asset, task=task) + await client.close() + + def port_occupied(self, host_name, port): + """ + Check if 'url' is already occupied. + + This could mean, that app is already running and we are trying open it + again. In that case, use existing running webserver. + Check here is easier than capturing exception from thread. + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + result = True + try: + sock.bind((host_name, port)) + result = False + except: + print("Port is in use") + + return result + + def call(self, func): + log.debug("websocket.call {}".format(func)) + future = asyncio.run_coroutine_threadsafe( + func, + self.webserver_thread.loop + ) + result = future.result() + return result + + @staticmethod + def get_instance(): + if WebServerTool._instance is None: + WebServerTool() + return WebServerTool._instance + + @property + def is_running(self): + if not self.webserver_thread: + return False + return self.webserver_thread.is_running + + def stop(self): + if not self.is_running: + return + try: + log.debug("Stopping websocket server") + self.webserver_thread.is_running = False + self.webserver_thread.stop() + except Exception: + log.warning( + "Error has happened during Killing websocket server", + exc_info=True + ) + + def thread_stopped(self): + for callback in self.on_stop_callbacks: + callback() + + +class WebServerThread(threading.Thread): + """ Listener for websocket rpc requests. + + It would be probably better to "attach" this to main thread (as for + example Harmony needs to run something on main thread), but currently + it creates separate thread and separate asyncio event loop + """ + def __init__(self, module, port): + super(WebServerThread, self).__init__() + + self.is_running = False + self.port = port + self.module = module + self.loop = None + self.runner = None + self.site = None + self.tasks = [] + + def run(self): + self.is_running = True + + try: + log.info("Starting web server") + self.loop = asyncio.new_event_loop() # create new loop for thread + asyncio.set_event_loop(self.loop) + + self.loop.run_until_complete(self.start_server()) + + websocket_url = "ws://localhost:{}/ws".format(self.port) + + log.debug( + "Running Websocket server on URL: \"{}\"".format(websocket_url) + ) + + asyncio.ensure_future(self.check_shutdown(), loop=self.loop) + self.loop.run_forever() + except Exception: + self.is_running = False + log.warning( + "Websocket Server service has failed", exc_info=True + ) + raise + finally: + self.loop.close() # optional + + self.is_running = False + self.module.thread_stopped() + log.info("Websocket server stopped") + + async def start_server(self): + """ Starts runner and TCPsite """ + self.runner = web.AppRunner(self.module.app) + await self.runner.setup() + self.site = web.TCPSite(self.runner, 'localhost', self.port) + await self.site.start() + + def stop(self): + """Sets is_running flag to false, 'check_shutdown' shuts server down""" + self.is_running = False + + async def check_shutdown(self): + """ Future that is running and checks if server should be running + periodically. + """ + while self.is_running: + while self.tasks: + task = self.tasks.pop(0) + log.debug("waiting for task {}".format(task)) + await task + log.debug("returned value {}".format(task.result)) + + await asyncio.sleep(0.5) + + log.debug("Starting shutdown") + await self.site.stop() + log.debug("Site stopped") + await self.runner.cleanup() + log.debug("Runner stopped") + tasks = [task for task in asyncio.all_tasks() if + task is not asyncio.current_task()] + list(map(lambda task: task.cancel(), tasks)) # cancel all the tasks + results = await asyncio.gather(*tasks, return_exceptions=True) + log.debug(f'Finished awaiting cancelled tasks, results: {results}...') + await self.loop.shutdown_asyncgens() + # to really make sure everything else has time to stop + await asyncio.sleep(0.07) + self.loop.stop() diff --git a/openpype/tools/adobe_webserver/readme.txt b/openpype/tools/adobe_webserver/readme.txt new file mode 100644 index 0000000000..06cf140fc4 --- /dev/null +++ b/openpype/tools/adobe_webserver/readme.txt @@ -0,0 +1,12 @@ +Adobe webserver +--------------- +Aiohttp (Asyncio) based websocket server used for communication with host +applications, currently only for Adobe (but could be used for any non python +DCC which has websocket client). + +This webserver is started in spawned Python process that opens DCC during +its launch, waits for connection from DCC and handles communication going +forward. Server is closed before Python process is killed. + +(Different from `openpype/modules/webserver` as that one is running in Tray, +this one is running in spawn Python process.) \ No newline at end of file From 8a3be301414af9542ecb50b4424d8895d479ae4f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 11 Mar 2022 14:31:00 +0100 Subject: [PATCH 426/483] OP-2815 - moved webserver tool to Openpype repo for PS --- openpype/hosts/photoshop/api/launch_logic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/photoshop/api/launch_logic.py b/openpype/hosts/photoshop/api/launch_logic.py index 112cd8fe3f..0021905cb5 100644 --- a/openpype/hosts/photoshop/api/launch_logic.py +++ b/openpype/hosts/photoshop/api/launch_logic.py @@ -14,7 +14,7 @@ from openpype.api import Logger from openpype.tools.utils import host_tools from avalon import api -from avalon.tools.webserver.app import WebServerTool +from openpype.tools.adobe_webserver.app import WebServerTool from .ws_stub import PhotoshopServerStub From 7c34eb7331f4e67dc4ac24f257c77602d2bbd0e5 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 11 Mar 2022 14:43:26 +0100 Subject: [PATCH 427/483] OP-2815 - added missed import for PS --- openpype/hosts/photoshop/api/ws_stub.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py index d4406d17b9..64d89f5420 100644 --- a/openpype/hosts/photoshop/api/ws_stub.py +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -2,12 +2,11 @@ Stub handling connection from server to client. Used anywhere solution is calling client methods. """ -import sys import json import attr from wsrpc_aiohttp import WebSocketAsync -from avalon.tools.webserver.app import WebServerTool +from openpype.tools.adobe_webserver.app import WebServerTool @attr.s From c033eaad65afcb43752f9c5da85b94bab31bffb6 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 11 Mar 2022 15:13:43 +0100 Subject: [PATCH 428/483] fix update --- openpype/hosts/maya/api/plugin.py | 5 +++++ openpype/hosts/maya/plugins/load/load_reference.py | 2 -- .../hosts/maya/plugins/publish/extract_maya_scene_raw.py | 7 +++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index feaceacebc..1f90b3ffbd 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -247,6 +247,11 @@ class ReferenceLoader(Loader): self.log.warning("Ignoring file read error:\n%s", exc) + shapes = cmds.ls(content, shapes=True, long=True) + new_nodes = (list(set(content) - set(shapes))) + + self._organize_containers(new_nodes, container["objectName"]) + # Reapply alembic settings. if representation["name"] == "abc" and alembic_data: alembic_nodes = cmds.ls( diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index d358c62724..04a25f6493 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -123,8 +123,6 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): else: if "translate" in options: cmds.setAttr(group_name + ".t", *options["translate"]) - - print(new_nodes) return new_nodes def switch(self, container, representation): diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py index 2e1260c374..9d73bea7d2 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py @@ -60,10 +60,9 @@ class ExtractMayaSceneRaw(openpype.api.Extractor): selection = members if set(self.add_for_families).intersection( - set(instance.data.get("families"))) or \ + set(instance.data.get("families", []))) or \ instance.data.get("family") in self.add_for_families: - selection += self._add_loaded_containers(members) - self.log.info(selection) + selection += self._get_loaded_containers(members) # Perform extraction self.log.info("Performing extraction ...") @@ -93,7 +92,7 @@ class ExtractMayaSceneRaw(openpype.api.Extractor): self.log.info("Extracted instance '%s' to: %s" % (instance.name, path)) @staticmethod - def _add_loaded_containers(members): + def _get_loaded_containers(members): # type: (list) -> list refs_to_include = [ cmds.referenceQuery(ref, referenceNode=True) From 9052adfbd35855c03a1ccb3da99ff0b019d5c73c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 11 Mar 2022 15:56:33 +0100 Subject: [PATCH 429/483] flame: exporting also sequence_clip with `Sequence Publish` preset with mark in/out it should only publish particular segment --- .../publish/extract_subset_resources.py | 113 ++++++++++++++---- 1 file changed, 92 insertions(+), 21 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 7be41bbb76..bfd723f5d8 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -56,21 +56,35 @@ class ExtractSubsetResources(openpype.api.Extractor): ): instance.data["representations"] = [] - frame_start = instance.data["frameStart"] - handle_start = instance.data["handleStart"] - frame_start_handle = frame_start - handle_start - source_first_frame = instance.data["sourceFirstFrame"] - source_start_handles = instance.data["sourceStartH"] - source_end_handles = instance.data["sourceEndH"] - source_duration_handles = ( - source_end_handles - source_start_handles) + 1 - + # flame objects + segment = instance.data["item"] + sequence_clip = instance.context.data["flameSequence"] clip_data = instance.data["flameSourceClip"] clip = clip_data["PyClip"] - in_mark = (source_start_handles - source_first_frame) + 1 - out_mark = in_mark + source_duration_handles + # segment's parent track name + s_track_name = segment.parent.name.get_value() + # get configured workfile frame start/end (handles excluded) + frame_start = instance.data["frameStart"] + # get media source first frame + source_first_frame = instance.data["sourceFirstFrame"] + + # get timeline in/out of segment + clip_in = instance.data["clipIn"] + clip_out = instance.data["clipOut"] + + # get handles value - take only the max from both + handle_start = instance.data["handleStart"] + handle_end = instance.data["handleStart"] + handles = max(handle_start, handle_end) + + # get media source range with handles + source_end_handles = instance.data["sourceEndH"] + source_start_handles = instance.data["sourceStartH"] + source_end_handles = instance.data["sourceEndH"] + + # create staging dir path staging_dir = self.staging_dir(instance) # add default preset type for thumbnail and reviewable video @@ -79,16 +93,52 @@ class ExtractSubsetResources(openpype.api.Extractor): export_presets = deepcopy(self.default_presets) export_presets.update(self.export_presets_mapping) - # with maintained duplication loop all presets - with opfapi.maintained_object_duplication(clip) as duplclip: - # loop all preset names and - for unique_name, preset_config in export_presets.items(): + # loop all preset names and + for unique_name, preset_config in export_presets.items(): + modify_xml_data = {} + + # get all presets attributes + preset_file = preset_config["xml_preset_file"] + preset_dir = preset_config["xml_preset_dir"] + export_type = preset_config["export_type"] + repre_tags = preset_config["representation_tags"] + color_out = preset_config["colorspace_out"] + + # get frame range with handles for representation range + frame_start_handle = frame_start - handle_start + source_duration_handles = ( + source_end_handles - source_start_handles) + 1 + + # define in/out marks + in_mark = (source_start_handles - source_first_frame) + 1 + out_mark = in_mark + source_duration_handles + + # by default export source clips + exporting_clip = clip + + if export_type == "Sequence Publish": + # change export clip to sequence + exporting_clip = sequence_clip + + # change in/out marks to timeline in/out + in_mark = clip_in + out_mark = clip_out + + # add xml tags modifications + modify_xml_data.update({ + "exportHandles": True, + "nbHandles": handles, + "startFrame": frame_start + }) + + # with maintained duplication loop all presets + with opfapi.maintained_object_duplication( + exporting_clip) as duplclip: kwargs = {} - preset_file = preset_config["xml_preset_file"] - preset_dir = preset_config["xml_preset_dir"] - export_type = preset_config["export_type"] - repre_tags = preset_config["representation_tags"] - color_out = preset_config["colorspace_out"] + + if export_type == "Sequence Publish": + # only keep visible layer where instance segment is child + self.hide_other_tracks(duplclip, s_track_name) # validate xml preset file is filled if preset_file == "": @@ -111,10 +161,13 @@ class ExtractSubsetResources(openpype.api.Extractor): ) # create preset path - preset_path = str(os.path.join( + preset_orig_xml_path = str(os.path.join( preset_dir, preset_file )) + preset_path = opfapi.modify_preset_file( + preset_orig_xml_path, staging_dir, modify_xml_data) + # define kwargs based on preset type if "thumbnail" in unique_name: kwargs["thumb_frame_number"] = in_mark + ( @@ -125,6 +178,7 @@ class ExtractSubsetResources(openpype.api.Extractor): "out_mark": out_mark }) + # get and make export dir paths export_dir_path = str(os.path.join( staging_dir, unique_name )) @@ -135,6 +189,7 @@ class ExtractSubsetResources(openpype.api.Extractor): export_dir_path, duplclip, preset_path, **kwargs) extension = preset_config["ext"] + # create representation data representation_data = { "name": unique_name, @@ -249,3 +304,19 @@ class ExtractSubsetResources(openpype.api.Extractor): ) return new_stage_dir, new_files_list + + def hide_other_tracks(self, sequence_clip, track_name): + """Helper method used only if sequence clip is used + + Args: + sequence_clip (flame.Clip): sequence clip + track_name (str): track name + """ + # create otio tracks and clips + for ver in sequence_clip.versions: + for track in ver.tracks: + if len(track.segments) == 0 and track.hidden: + continue + + if track.name.get_value() != track_name: + track.hidden = True From 78d566b654074935141968e91429dc4be89510d6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 11 Mar 2022 16:40:41 +0100 Subject: [PATCH 430/483] replaced usage of avalon.lib.time with new function get_formatted_current_time --- .../hosts/harmony/plugins/publish/collect_farm_render.py | 3 ++- openpype/hosts/maya/plugins/publish/collect_render.py | 3 ++- openpype/hosts/maya/plugins/publish/collect_vrayscene.py | 3 ++- openpype/lib/__init__.py | 6 +++++- openpype/lib/abstract_collect_render.py | 2 +- openpype/lib/config.py | 6 ++++++ openpype/plugins/publish/collect_time.py | 6 +++--- 7 files changed, 21 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py index 85237094e4..35b123f97d 100644 --- a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py +++ b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py @@ -5,6 +5,7 @@ from pathlib import Path import attr from avalon import api +from openpype.lib import get_formatted_current_time import openpype.lib.abstract_collect_render import openpype.hosts.harmony.api as harmony from openpype.lib.abstract_collect_render import RenderInstance @@ -138,7 +139,7 @@ class CollectFarmRender(openpype.lib.abstract_collect_render. render_instance = HarmonyRenderInstance( version=version, - time=api.time(), + time=get_formatted_current_time(), source=context.data["currentFile"], label=node.split("/")[1], subset=subset_name, diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index d99e81573b..a525b562f3 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -50,6 +50,7 @@ import maya.app.renderSetup.model.renderSetup as renderSetup import pyblish.api from avalon import api +from openpype.lib import get_formatted_current_time from openpype.hosts.maya.api.lib_renderproducts import get as get_layer_render_products # noqa: E501 from openpype.hosts.maya.api import lib @@ -328,7 +329,7 @@ class CollectMayaRender(pyblish.api.ContextPlugin): "family": "renderlayer", "families": ["renderlayer"], "asset": asset, - "time": api.time(), + "time": get_formatted_current_time(), "author": context.data["user"], # Add source to allow tracing back to the scene from # which was submitted originally diff --git a/openpype/hosts/maya/plugins/publish/collect_vrayscene.py b/openpype/hosts/maya/plugins/publish/collect_vrayscene.py index c1e5d388af..327fc836dc 100644 --- a/openpype/hosts/maya/plugins/publish/collect_vrayscene.py +++ b/openpype/hosts/maya/plugins/publish/collect_vrayscene.py @@ -7,6 +7,7 @@ from maya import cmds import pyblish.api from avalon import api +from openpype.lib import get_formatted_current_time from openpype.hosts.maya.api import lib @@ -117,7 +118,7 @@ class CollectVrayScene(pyblish.api.InstancePlugin): "family": "vrayscene_layer", "families": ["vrayscene_layer"], "asset": api.Session["AVALON_ASSET"], - "time": api.time(), + "time": get_formatted_current_time(), "author": context.data["user"], # Add source to allow tracing back to the scene from # which was submitted originally diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 34b217f690..761ad3e9a0 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -63,7 +63,10 @@ from .anatomy import ( Anatomy ) -from .config import get_datetime_data +from .config import ( + get_datetime_data, + get_formatted_current_time +) from .python_module_tools import ( import_filepath, @@ -309,6 +312,7 @@ __all__ = [ "Anatomy", "get_datetime_data", + "get_formatted_current_time", "PypeLogger", "get_default_components", diff --git a/openpype/lib/abstract_collect_render.py b/openpype/lib/abstract_collect_render.py index 3839aad45d..7c768e280c 100644 --- a/openpype/lib/abstract_collect_render.py +++ b/openpype/lib/abstract_collect_render.py @@ -26,7 +26,7 @@ class RenderInstance(object): # metadata version = attr.ib() # instance version - time = attr.ib() # time of instance creation (avalon.api.time()) + time = attr.ib() # time of instance creation (get_formatted_current_time) source = attr.ib() # path to source scene file label = attr.ib() # label to show in GUI subset = attr.ib() # subset name diff --git a/openpype/lib/config.py b/openpype/lib/config.py index ba394cfd56..57e8efa57d 100644 --- a/openpype/lib/config.py +++ b/openpype/lib/config.py @@ -74,3 +74,9 @@ def get_datetime_data(datetime_obj=None): "S": str(int(seconds)), "SS": str(seconds), } + + +def get_formatted_current_time(): + return datetime.datetime.now().strftime( + "%Y%m%dT%H%M%SZ" + ) diff --git a/openpype/plugins/publish/collect_time.py b/openpype/plugins/publish/collect_time.py index e0adc7dfc3..7a005cc9cb 100644 --- a/openpype/plugins/publish/collect_time.py +++ b/openpype/plugins/publish/collect_time.py @@ -1,12 +1,12 @@ import pyblish.api -from avalon import api +from openpype.lib import get_formatted_current_time class CollectTime(pyblish.api.ContextPlugin): """Store global time at the time of publish""" label = "Collect Current Time" - order = pyblish.api.CollectorOrder + order = pyblish.api.CollectorOrder - 0.499 def process(self, context): - context.data["time"] = api.time() + context.data["time"] = get_formatted_current_time() From cf1453029f14dde78a18d8560ee55e69871bfe46 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 11 Mar 2022 17:13:26 +0100 Subject: [PATCH 431/483] remove obsolete code and set cast --- openpype/hosts/maya/api/plugin.py | 6 +----- .../maya/plugins/publish/extract_maya_scene_raw.py | 12 ++++++------ 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 1f90b3ffbd..48d7c465ec 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -247,10 +247,7 @@ class ReferenceLoader(Loader): self.log.warning("Ignoring file read error:\n%s", exc) - shapes = cmds.ls(content, shapes=True, long=True) - new_nodes = (list(set(content) - set(shapes))) - - self._organize_containers(new_nodes, container["objectName"]) + self._organize_containers(content, container["objectName"]) # Reapply alembic settings. if representation["name"] == "abc" and alembic_data: @@ -289,7 +286,6 @@ class ReferenceLoader(Loader): to remove from scene. """ - from maya import cmds node = container["objectName"] diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py index 9d73bea7d2..389995d30c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py @@ -94,13 +94,13 @@ class ExtractMayaSceneRaw(openpype.api.Extractor): @staticmethod def _get_loaded_containers(members): # type: (list) -> list - refs_to_include = [ - cmds.referenceQuery(ref, referenceNode=True) - for ref in members - if cmds.referenceQuery(ref, isNodeReferenced=True) - ] + refs_to_include = { + cmds.referenceQuery(node, referenceNode=True) + for node in members + if cmds.referenceQuery(node, isNodeReferenced=True) + } - members_with_refs = set(refs_to_include).union(members) + members_with_refs = refs_to_include.union(members) obj_sets = cmds.ls("*.id", long=True, type="objectSet", recursive=True, objectsOnly=True) From 9b0fba249068179a4608e31b9b5d406f241d63c6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 11 Mar 2022 17:55:00 +0100 Subject: [PATCH 432/483] moved ffprobe_streams into transcoding, seprated and renamed to get_ffprobe_streams --- .../publish/extract_trim_video_audio.py | 7 +- .../publish/collect_published_files.py | 8 ++- openpype/lib/__init__.py | 8 ++- openpype/lib/transcoding.py | 65 ++++++++++++++++++- openpype/lib/vendor_bin_utils.py | 52 --------------- openpype/plugins/publish/extract_review.py | 4 +- .../plugins/publish/extract_review_slate.py | 20 +++--- .../tests/test_lib_restructuralization.py | 2 +- 8 files changed, 95 insertions(+), 71 deletions(-) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py index c18de5bc1c..f327895b83 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py @@ -2,6 +2,9 @@ import os import pyblish.api import openpype.api +from openpype.lib import ( + get_ffmpeg_tool_path, +) from pprint import pformat @@ -27,7 +30,7 @@ class ExtractTrimVideoAudio(openpype.api.Extractor): instance.data["representations"] = list() # get ffmpet path - ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") + ffmpeg_path = get_ffmpeg_tool_path("ffmpeg") # get staging dir staging_dir = self.staging_dir(instance) @@ -44,7 +47,7 @@ class ExtractTrimVideoAudio(openpype.api.Extractor): clip_trimed_path = os.path.join( staging_dir, instance.data["name"] + ext) # # check video file metadata - # input_data = plib.ffprobe_streams(video_file_path)[0] + # input_data = plib.get_ffprobe_streams(video_file_path)[0] # self.log.debug(f"__ input_data: `{input_data}`") start = float(instance.data["clipInH"]) diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py index afd6f349db..be33e6bb4e 100644 --- a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py +++ b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py @@ -14,7 +14,11 @@ import math from avalon import io import pyblish.api -from openpype.lib import prepare_template_data, get_asset, ffprobe_streams +from openpype.lib import ( + prepare_template_data, + get_asset, + get_ffprobe_streams +) from openpype.lib.vendor_bin_utils import get_fps from openpype.lib.plugin_tools import ( parse_json, @@ -265,7 +269,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): def _get_number_of_frames(self, file_url): """Return duration in frames""" try: - streams = ffprobe_streams(file_url, self.log) + streams = get_ffprobe_streams(file_url, self.log) except Exception as exc: raise AssertionError(( "FFprobe couldn't read information about input file: \"{}\"." diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 761ad3e9a0..bf4a3781bc 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -21,7 +21,6 @@ from .vendor_bin_utils import ( get_vendor_bin_path, get_oiio_tools_path, get_ffmpeg_tool_path, - ffprobe_streams, is_oiio_supported ) from .env_tools import ( @@ -84,7 +83,9 @@ from .profiles_filtering import ( from .transcoding import ( get_transcode_temp_directory, should_convert_for_ffmpeg, - convert_for_ffmpeg + convert_for_ffmpeg, + get_ffprobe_data, + get_ffprobe_streams, ) from .avalon_context import ( CURRENT_DOC_SCHEMAS, @@ -216,7 +217,6 @@ __all__ = [ "get_vendor_bin_path", "get_oiio_tools_path", "get_ffmpeg_tool_path", - "ffprobe_streams", "is_oiio_supported", "import_filepath", @@ -228,6 +228,8 @@ __all__ = [ "get_transcode_temp_directory", "should_convert_for_ffmpeg", "convert_for_ffmpeg", + "get_ffprobe_data", + "get_ffprobe_streams", "CURRENT_DOC_SCHEMAS", "PROJECT_NAME_ALLOWED_SYMBOLS", diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index 462745bcda..8137c4ae11 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -1,15 +1,18 @@ import os import re import logging +import json import collections import tempfile +import subprocess import xml.etree.ElementTree from .execute import run_subprocess from .vendor_bin_utils import ( + get_ffmpeg_tool_path, get_oiio_tools_path, - is_oiio_supported + is_oiio_supported, ) # Max length of string that is supported by ffmpeg @@ -483,3 +486,63 @@ def convert_for_ffmpeg( logger.debug("Conversion command: {}".format(" ".join(oiio_cmd))) run_subprocess(oiio_cmd, logger=logger) + + +# FFMPEG functions +def get_ffprobe_data(path_to_file, logger=None): + """Load data about entered filepath via ffprobe. + + Args: + path_to_file (str): absolute path + logger (logging.Logger): injected logger, if empty new is created + """ + if not logger: + logger = logging.getLogger(__name__) + logger.info( + "Getting information about input \"{}\".".format(path_to_file) + ) + args = [ + get_ffmpeg_tool_path("ffprobe"), + "-hide_banner", + "-loglevel", "fatal", + "-show_error", + "-show_format", + "-show_streams", + "-show_programs", + "-show_chapters", + "-show_private_data", + "-print_format", "json", + path_to_file + ] + + logger.debug("FFprobe command: {}".format( + subprocess.list2cmdline(args) + )) + popen = subprocess.Popen( + args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + + popen_stdout, popen_stderr = popen.communicate() + if popen_stdout: + logger.debug("FFprobe stdout:\n{}".format( + popen_stdout.decode("utf-8") + )) + + if popen_stderr: + logger.warning("FFprobe stderr:\n{}".format( + popen_stderr.decode("utf-8") + )) + + return json.loads(popen_stdout) + + +def get_ffprobe_streams(path_to_file, logger=None): + """Load streams from entered filepath via ffprobe. + + Args: + path_to_file (str): absolute path + logger (logging.Logger): injected logger, if empty new is created + """ + return get_ffprobe_data(path_to_file, logger)["streams"] diff --git a/openpype/lib/vendor_bin_utils.py b/openpype/lib/vendor_bin_utils.py index 4b11f1c046..36510f4238 100644 --- a/openpype/lib/vendor_bin_utils.py +++ b/openpype/lib/vendor_bin_utils.py @@ -1,8 +1,6 @@ import os import logging -import json import platform -import subprocess log = logging.getLogger("Vendor utils") @@ -138,56 +136,6 @@ def get_ffmpeg_tool_path(tool="ffmpeg"): return find_executable(os.path.join(ffmpeg_dir, tool)) -def ffprobe_streams(path_to_file, logger=None): - """Load streams from entered filepath via ffprobe. - - Args: - path_to_file (str): absolute path - logger (logging.getLogger): injected logger, if empty new is created - - """ - if not logger: - logger = log - logger.info( - "Getting information about input \"{}\".".format(path_to_file) - ) - args = [ - get_ffmpeg_tool_path("ffprobe"), - "-hide_banner", - "-loglevel", "fatal", - "-show_error", - "-show_format", - "-show_streams", - "-show_programs", - "-show_chapters", - "-show_private_data", - "-print_format", "json", - path_to_file - ] - - logger.debug("FFprobe command: {}".format( - subprocess.list2cmdline(args) - )) - popen = subprocess.Popen( - args, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - - popen_stdout, popen_stderr = popen.communicate() - if popen_stdout: - logger.debug("FFprobe stdout:\n{}".format( - popen_stdout.decode("utf-8") - )) - - if popen_stderr: - logger.warning("FFprobe stderr:\n{}".format( - popen_stderr.decode("utf-8") - )) - - return json.loads(popen_stdout)["streams"] - - def is_oiio_supported(): """Checks if oiiotool is configured for this platform. diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index b8599454ee..f046194c0d 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -13,7 +13,7 @@ import pyblish.api import openpype.api from openpype.lib import ( get_ffmpeg_tool_path, - ffprobe_streams, + get_ffprobe_streams, path_to_subprocess_arg, @@ -1146,7 +1146,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # NOTE Skipped using instance's resolution full_input_path_single_file = temp_data["full_input_path_single_file"] try: - streams = ffprobe_streams( + streams = get_ffprobe_streams( full_input_path_single_file, self.log ) except Exception as exc: diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 5442cf2211..e27a0b28bd 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -1,7 +1,11 @@ import os import openpype.api -import openpype.lib import pyblish +from openpype.lib import ( + path_to_subprocess_arg, + get_ffmpeg_tool_path, + get_ffprobe_streams, +) class ExtractReviewSlate(openpype.api.Extractor): @@ -24,9 +28,9 @@ class ExtractReviewSlate(openpype.api.Extractor): suffix = "_slate" slate_path = inst_data.get("slateFrame") - ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") + ffmpeg_path = get_ffmpeg_tool_path("ffmpeg") - slate_streams = openpype.lib.ffprobe_streams(slate_path, self.log) + slate_streams = get_ffprobe_streams(slate_path, self.log) # Try to find first stream with defined 'width' and 'height' # - this is to avoid order of streams where audio can be as first # - there may be a better way (checking `codec_type`?)+ @@ -66,7 +70,7 @@ class ExtractReviewSlate(openpype.api.Extractor): os.path.normpath(stagingdir), repre["files"]) self.log.debug("__ input_path: {}".format(input_path)) - video_streams = openpype.lib.ffprobe_streams( + video_streams = get_ffprobe_streams( input_path, self.log ) @@ -143,7 +147,7 @@ class ExtractReviewSlate(openpype.api.Extractor): else: input_args.extend(repre["outputDef"].get('input', [])) input_args.append("-loop 1 -i {}".format( - openpype.lib.path_to_subprocess_arg(slate_path) + path_to_subprocess_arg(slate_path) )) input_args.extend([ "-r {}".format(fps), @@ -216,12 +220,12 @@ class ExtractReviewSlate(openpype.api.Extractor): slate_v_path = slate_path.replace(".png", ext) output_args.append( - openpype.lib.path_to_subprocess_arg(slate_v_path) + path_to_subprocess_arg(slate_v_path) ) _remove_at_end.append(slate_v_path) slate_args = [ - openpype.lib.path_to_subprocess_arg(ffmpeg_path), + path_to_subprocess_arg(ffmpeg_path), " ".join(input_args), " ".join(output_args) ] @@ -345,7 +349,7 @@ class ExtractReviewSlate(openpype.api.Extractor): try: # Get information about input file via ffprobe tool - streams = openpype.lib.ffprobe_streams(full_input_path, self.log) + streams = get_ffprobe_streams(full_input_path, self.log) except Exception: self.log.warning( "Could not get codec data from input.", diff --git a/openpype/tests/test_lib_restructuralization.py b/openpype/tests/test_lib_restructuralization.py index d0461e55fb..94080e550d 100644 --- a/openpype/tests/test_lib_restructuralization.py +++ b/openpype/tests/test_lib_restructuralization.py @@ -24,7 +24,7 @@ def test_backward_compatibility(printer): from openpype.lib import get_hierarchy from openpype.lib import get_linked_assets from openpype.lib import get_latest_version - from openpype.lib import ffprobe_streams + from openpype.lib import get_ffprobe_streams from openpype.hosts.fusion.lib import switch_item From fbf57760abe67c0d39b7851fd70f688628ddf767 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 11 Mar 2022 17:55:36 +0100 Subject: [PATCH 433/483] added functions to replicate ffmpeg format and codec based on input data --- openpype/lib/__init__.py | 4 + openpype/lib/transcoding.py | 186 ++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index bf4a3781bc..523ced8022 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -86,6 +86,8 @@ from .transcoding import ( convert_for_ffmpeg, get_ffprobe_data, get_ffprobe_streams, + get_ffmpeg_codec_args, + get_ffmpeg_format_args, ) from .avalon_context import ( CURRENT_DOC_SCHEMAS, @@ -230,6 +232,8 @@ __all__ = [ "convert_for_ffmpeg", "get_ffprobe_data", "get_ffprobe_streams", + "get_ffmpeg_codec_args", + "get_ffmpeg_format_args", "CURRENT_DOC_SCHEMAS", "PROJECT_NAME_ALLOWED_SYMBOLS", diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index 8137c4ae11..554fad8813 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -546,3 +546,189 @@ def get_ffprobe_streams(path_to_file, logger=None): logger (logging.Logger): injected logger, if empty new is created """ return get_ffprobe_data(path_to_file, logger)["streams"] + + +def get_ffmpeg_format_args(ffprobe_data, source_ffmpeg_cmd=None): + """Copy format from input metadata for output. + + Args: + ffprobe_data(dict): Data received from ffprobe. + source_ffmpeg_cmd(str): Command that created input if available. + """ + input_format = ffprobe_data.get("format") or {} + if input_format.get("format_name") == "mxf": + return _ffmpeg_mxf_format_args(ffprobe_data, source_ffmpeg_cmd) + return [] + + +def _ffmpeg_mxf_format_args(ffprobe_data, source_ffmpeg_cmd): + input_format = ffprobe_data["format"] + format_tags = input_format.get("tags") or {} + product_name = format_tags.get("product_name") or "" + output = [] + if "opatom" in product_name.lower(): + output.extend(["-f", "mxf_opatom"]) + return output + + +def get_ffmpeg_codec_args(ffprobe_data, source_ffmpeg_cmd=None, logger=None): + """Copy codec from input metadata for output. + + Args: + ffprobe_data(dict): Data received from ffprobe. + source_ffmpeg_cmd(str): Command that created input if available. + """ + if logger is None: + logger = logging.getLogger(__name__) + + video_stream = None + no_audio_stream = None + for stream in ffprobe_data["streams"]: + codec_type = stream["codec_type"] + if codec_type == "video": + video_stream = stream + break + elif no_audio_stream is None and codec_type != "audio": + no_audio_stream = stream + + if video_stream is None: + if no_audio_stream is None: + logger.warning( + "Couldn't find stream that is not an audio file." + ) + return [] + logger.info( + "Didn't find video stream. Using first non audio stream." + ) + video_stream = no_audio_stream + + codec_name = video_stream.get("codec_name") + # Codec "prores" + if codec_name == "prores": + return _ffmpeg_prores_codec_args(video_stream, source_ffmpeg_cmd) + + # Codec "h264" + if codec_name == "h264": + return _ffmpeg_h264_codec_args(video_stream, source_ffmpeg_cmd) + + # Coded DNxHD + if codec_name == "dnxhd": + return _ffmpeg_dnxhd_codec_args(video_stream, source_ffmpeg_cmd) + + output = [] + if codec_name: + output.extend(["-codec:v", codec_name]) + + bit_rate = video_stream.get("bit_rate") + if bit_rate: + output.extend(["-b:v", bit_rate]) + + pix_fmt = video_stream.get("pix_fmt") + if pix_fmt: + output.extend(["-pix_fmt", pix_fmt]) + + output.extend(["-g", "1"]) + + return output + + +def _ffmpeg_prores_codec_args(stream_data, source_ffmpeg_cmd): + output = [] + + tags = stream_data.get("tags") or {} + encoder = tags.get("encoder") or "" + if encoder.endswith("prores_ks"): + codec_name = "prores_ks" + + elif encoder.endswith("prores_aw"): + codec_name = "prores_aw" + + else: + codec_name = "prores" + + output.extend(["-codec:v", codec_name]) + + pix_fmt = stream_data.get("pix_fmt") + if pix_fmt: + output.extend(["-pix_fmt", pix_fmt]) + + # Rest of arguments is prores_kw specific + if codec_name == "prores_ks": + codec_tag_to_profile_map = { + "apco": "proxy", + "apcs": "lt", + "apcn": "standard", + "apch": "hq", + "ap4h": "4444", + "ap4x": "4444xq" + } + codec_tag_str = stream_data.get("codec_tag_string") + if codec_tag_str: + profile = codec_tag_to_profile_map.get(codec_tag_str) + if profile: + output.extend(["-profile:v", profile]) + + return output + + +def _ffmpeg_h264_codec_args(stream_data, source_ffmpeg_cmd): + output = ["-codec:v", "h264"] + + # Use arguments from source if are available source arguments + if source_ffmpeg_cmd: + copy_args = ( + "-crf", + "-b:v", "-vb", + "-minrate", "-minrate:", + "-maxrate", "-maxrate:", + "-bufsize", "-bufsize:" + ) + args = source_ffmpeg_cmd.split(" ") + for idx, arg in enumerate(args): + if arg in copy_args: + output.extend([arg, args[idx + 1]]) + + pix_fmt = stream_data.get("pix_fmt") + if pix_fmt: + output.extend(["-pix_fmt", pix_fmt]) + + output.extend(["-intra"]) + output.extend(["-g", "1"]) + + return output + + +def _ffmpeg_dnxhd_codec_args(stream_data, source_ffmpeg_cmd): + output = ["-codec:v", "dnxhd"] + + # Use source profile (profiles in metadata are not usable in args directly) + profile = stream_data.get("profile") or "" + # Lower profile and replace space with underscore + cleaned_profile = profile.lower().replace(" ", "_") + dnx_profiles = { + "dnxhd", + "dnxhr_lb", + "dnxhr_sq", + "dnxhr_hq", + "dnxhr_hqx", + "dnxhr_444" + } + if cleaned_profile in dnx_profiles: + output.extend(["-profile:v", cleaned_profile]) + + pix_fmt = stream_data.get("pix_fmt") + if pix_fmt: + output.extend(["-pix_fmt", pix_fmt]) + + # Use arguments from source if are available source arguments + if source_ffmpeg_cmd: + copy_args = ( + "-b:v", "-vb", + ) + args = source_ffmpeg_cmd.split(" ") + for idx, arg in enumerate(args): + if arg in copy_args: + output.extend([arg, args[idx + 1]]) + + output.extend(["-g", "1"]) + return output From 50c0580feff4142db32f183452d17a801d2dcf13 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 11 Mar 2022 17:59:52 +0100 Subject: [PATCH 434/483] use new functions in extract review slate and otio burnins --- .../publish/collect_published_files.py | 8 +- openpype/lib/__init__.py | 2 + openpype/lib/transcoding.py | 20 ++ openpype/lib/vendor_bin_utils.py | 20 -- .../plugins/publish/extract_review_slate.py | 52 ++---- openpype/scripts/otio_burnin.py | 172 ++---------------- 6 files changed, 53 insertions(+), 221 deletions(-) diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py index be33e6bb4e..65cef14703 100644 --- a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py +++ b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py @@ -17,9 +17,9 @@ import pyblish.api from openpype.lib import ( prepare_template_data, get_asset, - get_ffprobe_streams + get_ffprobe_streams, + convert_ffprobe_fps_value, ) -from openpype.lib.vendor_bin_utils import get_fps from openpype.lib.plugin_tools import ( parse_json, get_subset_name_with_asset_doc @@ -292,7 +292,9 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): "nb_frames {} not convertible".format(nb_frames)) duration = stream.get("duration") - frame_rate = get_fps(stream.get("r_frame_rate", '0/0')) + frame_rate = convert_ffprobe_fps_value( + stream.get("r_frame_rate", '0/0') + ) self.log.debug("duration:: {} frame_rate:: {}".format( duration, frame_rate)) try: diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 523ced8022..d5cde3031f 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -88,6 +88,7 @@ from .transcoding import ( get_ffprobe_streams, get_ffmpeg_codec_args, get_ffmpeg_format_args, + convert_ffprobe_fps_value, ) from .avalon_context import ( CURRENT_DOC_SCHEMAS, @@ -234,6 +235,7 @@ __all__ = [ "get_ffprobe_streams", "get_ffmpeg_codec_args", "get_ffmpeg_format_args", + "convert_ffprobe_fps_value", "CURRENT_DOC_SCHEMAS", "PROJECT_NAME_ALLOWED_SYMBOLS", diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index 554fad8813..6181ff6d13 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -732,3 +732,23 @@ def _ffmpeg_dnxhd_codec_args(stream_data, source_ffmpeg_cmd): output.extend(["-g", "1"]) return output + + +def convert_ffprobe_fps_value(str_value): + """Returns (str) value of fps from ffprobe frame format (120/1)""" + if str_value == "0/0": + print("WARNING: Source has \"r_frame_rate\" value set to \"0/0\".") + return "Unknown" + + items = str_value.split("/") + if len(items) == 1: + fps = float(items[0]) + + elif len(items) == 2: + fps = float(items[0]) / float(items[1]) + + # Check if fps is integer or float number + if int(fps) == fps: + fps = int(fps) + + return str(fps) diff --git a/openpype/lib/vendor_bin_utils.py b/openpype/lib/vendor_bin_utils.py index 36510f4238..23e28ea304 100644 --- a/openpype/lib/vendor_bin_utils.py +++ b/openpype/lib/vendor_bin_utils.py @@ -152,23 +152,3 @@ def is_oiio_supported(): )) return False return True - - -def get_fps(str_value): - """Returns (str) value of fps from ffprobe frame format (120/1)""" - if str_value == "0/0": - print("WARNING: Source has \"r_frame_rate\" value set to \"0/0\".") - return "Unknown" - - items = str_value.split("/") - if len(items) == 1: - fps = float(items[0]) - - elif len(items) == 2: - fps = float(items[0]) / float(items[1]) - - # Check if fps is integer or float number - if int(fps) == fps: - fps = int(fps) - - return str(fps) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index e27a0b28bd..460d546340 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -4,7 +4,10 @@ import pyblish from openpype.lib import ( path_to_subprocess_arg, get_ffmpeg_tool_path, + get_ffprobe_data, get_ffprobe_streams, + get_ffmpeg_codec_args, + get_ffmpeg_format_args, ) @@ -161,7 +164,7 @@ class ExtractReviewSlate(openpype.api.Extractor): output_args.extend(repre["_profile"].get('output', [])) else: # Codecs are copied from source for whole input - codec_args = self.codec_args(repre) + codec_args = self._get_codec_args(repre) output_args.extend(codec_args) # make sure colors are correct @@ -335,7 +338,7 @@ class ExtractReviewSlate(openpype.api.Extractor): return vf_back - def codec_args(self, repre): + def _get_codec_args(self, repre): """Detect possible codec arguments from representation.""" codec_args = [] @@ -349,7 +352,7 @@ class ExtractReviewSlate(openpype.api.Extractor): try: # Get information about input file via ffprobe tool - streams = get_ffprobe_streams(full_input_path, self.log) + ffprobe_data = get_ffprobe_data(full_input_path, self.log) except Exception: self.log.warning( "Could not get codec data from input.", @@ -357,42 +360,11 @@ class ExtractReviewSlate(openpype.api.Extractor): ) return codec_args - # Try to find first stream that is not an audio - no_audio_stream = None - for stream in streams: - if stream.get("codec_type") != "audio": - no_audio_stream = stream - break + codec_args.extend( + get_ffmpeg_format_args(ffprobe_data) + ) + codec_args.extend( + get_ffmpeg_codec_args(ffprobe_data, logger=self.log) + ) - if no_audio_stream is None: - self.log.warning(( - "Couldn't find stream that is not an audio from file \"{}\"" - ).format(full_input_path)) - return codec_args - - codec_name = no_audio_stream.get("codec_name") - if codec_name: - codec_args.append("-codec:v {}".format(codec_name)) - - profile_name = no_audio_stream.get("profile") - if profile_name: - # Rest of arguments is prores_kw specific - if codec_name == "prores_ks": - codec_tag_to_profile_map = { - "apco": "proxy", - "apcs": "lt", - "apcn": "standard", - "apch": "hq", - "ap4h": "4444", - "ap4x": "4444xq" - } - codec_tag_str = no_audio_stream.get("codec_tag_string") - if codec_tag_str: - profile = codec_tag_to_profile_map.get(codec_tag_str) - if profile: - codec_args.extend(["-profile:v", profile]) - - pix_fmt = no_audio_stream.get("pix_fmt") - if pix_fmt: - codec_args.append("-pix_fmt {}".format(pix_fmt)) return codec_args diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index 874c08064a..1f57891b84 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -5,12 +5,17 @@ import subprocess import platform import json import opentimelineio_contrib.adapters.ffmpeg_burnins as ffmpeg_burnins -import openpype.lib -from openpype.lib.vendor_bin_utils import get_fps + +from openpype.lib import ( + get_ffmpeg_tool_path, + get_ffmpeg_codec_args, + get_ffmpeg_format_args, + convert_ffprobe_fps_value, +) -ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") -ffprobe_path = openpype.lib.get_ffmpeg_tool_path("ffprobe") +ffmpeg_path = get_ffmpeg_tool_path("ffmpeg") +ffprobe_path = get_ffmpeg_tool_path("ffprobe") FFMPEG = ( @@ -51,157 +56,6 @@ def _get_ffprobe_data(source): return json.loads(out) -def _prores_codec_args(stream_data, source_ffmpeg_cmd): - output = [] - - tags = stream_data.get("tags") or {} - encoder = tags.get("encoder") or "" - if encoder.endswith("prores_ks"): - codec_name = "prores_ks" - - elif encoder.endswith("prores_aw"): - codec_name = "prores_aw" - - else: - codec_name = "prores" - - output.extend(["-codec:v", codec_name]) - - pix_fmt = stream_data.get("pix_fmt") - if pix_fmt: - output.extend(["-pix_fmt", pix_fmt]) - - # Rest of arguments is prores_kw specific - if codec_name == "prores_ks": - codec_tag_to_profile_map = { - "apco": "proxy", - "apcs": "lt", - "apcn": "standard", - "apch": "hq", - "ap4h": "4444", - "ap4x": "4444xq" - } - codec_tag_str = stream_data.get("codec_tag_string") - if codec_tag_str: - profile = codec_tag_to_profile_map.get(codec_tag_str) - if profile: - output.extend(["-profile:v", profile]) - - return output - - -def _h264_codec_args(stream_data, source_ffmpeg_cmd): - output = ["-codec:v", "h264"] - - # Use arguments from source if are available source arguments - if source_ffmpeg_cmd: - copy_args = ( - "-crf", - "-b:v", "-vb", - "-minrate", "-minrate:", - "-maxrate", "-maxrate:", - "-bufsize", "-bufsize:" - ) - args = source_ffmpeg_cmd.split(" ") - for idx, arg in enumerate(args): - if arg in copy_args: - output.extend([arg, args[idx + 1]]) - - pix_fmt = stream_data.get("pix_fmt") - if pix_fmt: - output.extend(["-pix_fmt", pix_fmt]) - - output.extend(["-intra"]) - output.extend(["-g", "1"]) - - return output - - -def _dnxhd_codec_args(stream_data, source_ffmpeg_cmd): - output = ["-codec:v", "dnxhd"] - - # Use source profile (profiles in metadata are not usable in args directly) - profile = stream_data.get("profile") or "" - # Lower profile and replace space with underscore - cleaned_profile = profile.lower().replace(" ", "_") - dnx_profiles = { - "dnxhd", - "dnxhr_lb", - "dnxhr_sq", - "dnxhr_hq", - "dnxhr_hqx", - "dnxhr_444" - } - if cleaned_profile in dnx_profiles: - output.extend(["-profile:v", cleaned_profile]) - - pix_fmt = stream_data.get("pix_fmt") - if pix_fmt: - output.extend(["-pix_fmt", pix_fmt]) - - # Use arguments from source if are available source arguments - if source_ffmpeg_cmd: - copy_args = ( - "-b:v", "-vb", - ) - args = source_ffmpeg_cmd.split(" ") - for idx, arg in enumerate(args): - if arg in copy_args: - output.extend([arg, args[idx + 1]]) - - output.extend(["-g", "1"]) - return output - - -def _mxf_format_args(ffprobe_data, source_ffmpeg_cmd): - input_format = ffprobe_data["format"] - format_tags = input_format.get("tags") or {} - product_name = format_tags.get("product_name") or "" - output = [] - if "opatom" in product_name.lower(): - output.extend(["-f", "mxf_opatom"]) - return output - - -def get_format_args(ffprobe_data, source_ffmpeg_cmd): - input_format = ffprobe_data.get("format") or {} - if input_format.get("format_name") == "mxf": - return _mxf_format_args(ffprobe_data, source_ffmpeg_cmd) - return [] - - -def get_codec_args(ffprobe_data, source_ffmpeg_cmd): - stream_data = ffprobe_data["streams"][0] - codec_name = stream_data.get("codec_name") - # Codec "prores" - if codec_name == "prores": - return _prores_codec_args(stream_data, source_ffmpeg_cmd) - - # Codec "h264" - if codec_name == "h264": - return _h264_codec_args(stream_data, source_ffmpeg_cmd) - - # Coded DNxHD - if codec_name == "dnxhd": - return _dnxhd_codec_args(stream_data, source_ffmpeg_cmd) - - output = [] - if codec_name: - output.extend(["-codec:v", codec_name]) - - bit_rate = stream_data.get("bit_rate") - if bit_rate: - output.extend(["-b:v", bit_rate]) - - pix_fmt = stream_data.get("pix_fmt") - if pix_fmt: - output.extend(["-pix_fmt", pix_fmt]) - - output.extend(["-g", "1"]) - - return output - - class ModifiedBurnins(ffmpeg_burnins.Burnins): ''' This is modification of OTIO FFmpeg Burnin adapter. @@ -592,7 +446,9 @@ def burnins_from_data( data["resolution_height"] = stream.get("height", MISSING_KEY_VALUE) if "fps" not in data: - data["fps"] = get_fps(stream.get("r_frame_rate", "0/0")) + data["fps"] = convert_ffprobe_fps_value( + stream.get("r_frame_rate", "0/0") + ) # Check frame start and add expression if is available if frame_start is not None: @@ -703,10 +559,10 @@ def burnins_from_data( else: ffmpeg_args.extend( - get_format_args(burnin.ffprobe_data, source_ffmpeg_cmd) + get_ffmpeg_format_args(burnin.ffprobe_data, source_ffmpeg_cmd) ) ffmpeg_args.extend( - get_codec_args(burnin.ffprobe_data, source_ffmpeg_cmd) + get_ffmpeg_codec_args(burnin.ffprobe_data, source_ffmpeg_cmd) ) # Use arguments from source if are available source arguments if source_ffmpeg_cmd: From 3ef05cc480598ea1c900f48bd704c4d808647e3d Mon Sep 17 00:00:00 2001 From: OpenPype Date: Sat, 12 Mar 2022 03:37:54 +0000 Subject: [PATCH 435/483] [Automated] Bump version --- CHANGELOG.md | 10 +++++----- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f971c33208..ebc563c90b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,16 @@ # Changelog -## [3.9.0-nightly.8](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.9.0-nightly.9](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...HEAD) **Deprecated:** - AssetCreator: Remove the tool [\#2845](https://github.com/pypeclub/OpenPype/pull/2845) -- Houdini: Remove unused code [\#2779](https://github.com/pypeclub/OpenPype/pull/2779) **🚀 Enhancements** +- General: Subset name filtering in ExtractReview outpus [\#2872](https://github.com/pypeclub/OpenPype/pull/2872) - NewPublisher: Descriptions and Icons in creator dialog [\#2867](https://github.com/pypeclub/OpenPype/pull/2867) - NewPublisher: Changing task on publishing instance [\#2863](https://github.com/pypeclub/OpenPype/pull/2863) - TrayPublisher: Choose project widget is more clear [\#2859](https://github.com/pypeclub/OpenPype/pull/2859) @@ -22,10 +22,10 @@ - global: letter box calculated on output as last process [\#2812](https://github.com/pypeclub/OpenPype/pull/2812) - Nuke: adding Reformat to baking mov plugin [\#2811](https://github.com/pypeclub/OpenPype/pull/2811) - Manager: Update all to latest button [\#2805](https://github.com/pypeclub/OpenPype/pull/2805) -- General: Set context environments for non host applications [\#2803](https://github.com/pypeclub/OpenPype/pull/2803) **🐛 Bug fixes** +- General: Missing time function [\#2877](https://github.com/pypeclub/OpenPype/pull/2877) - Deadline: Fix plugin name for tile assemble [\#2868](https://github.com/pypeclub/OpenPype/pull/2868) - General: Fix hardlink for windows [\#2864](https://github.com/pypeclub/OpenPype/pull/2864) - General: ffmpeg was crashing on slate merge [\#2860](https://github.com/pypeclub/OpenPype/pull/2860) @@ -47,13 +47,13 @@ - Ftrack: Job killer with missing user [\#2819](https://github.com/pypeclub/OpenPype/pull/2819) - Nuke: Use AVALON\_APP to get value for "app" key [\#2818](https://github.com/pypeclub/OpenPype/pull/2818) - StandalonePublisher: use dynamic groups in subset names [\#2816](https://github.com/pypeclub/OpenPype/pull/2816) -- Settings UI: Search case sensitivity [\#2810](https://github.com/pypeclub/OpenPype/pull/2810) -- Flame Babypublisher optimalization [\#2806](https://github.com/pypeclub/OpenPype/pull/2806) **🔀 Refactored code** +- Refactor: move webserver tool to openpype [\#2876](https://github.com/pypeclub/OpenPype/pull/2876) - General: Move create logic from avalon to OpenPype [\#2854](https://github.com/pypeclub/OpenPype/pull/2854) - General: Add vendors from avalon [\#2848](https://github.com/pypeclub/OpenPype/pull/2848) +- General: Basic event system [\#2846](https://github.com/pypeclub/OpenPype/pull/2846) - General: Move change context functions [\#2839](https://github.com/pypeclub/OpenPype/pull/2839) - Tools: Don't use avalon tools code [\#2829](https://github.com/pypeclub/OpenPype/pull/2829) - Move Unreal Implementation to OpenPype [\#2823](https://github.com/pypeclub/OpenPype/pull/2823) diff --git a/openpype/version.py b/openpype/version.py index d4af8d760f..39d3037221 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.9.0-nightly.8" +__version__ = "3.9.0-nightly.9" diff --git a/pyproject.toml b/pyproject.toml index fe681266ca..7a7411fdfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.9.0-nightly.8" # OpenPype +version = "3.9.0-nightly.9" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 8d14f5fb7017adac8401137a6815ca49f1bd1a8b Mon Sep 17 00:00:00 2001 From: OpenPype Date: Mon, 14 Mar 2022 08:17:30 +0000 Subject: [PATCH 436/483] [Automated] Release --- CHANGELOG.md | 11 ++++------- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebc563c90b..5acb161bf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ # Changelog -## [3.9.0-nightly.9](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.9.0](https://github.com/pypeclub/OpenPype/tree/3.9.0) (2022-03-14) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...HEAD) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...3.9.0) **Deprecated:** @@ -27,6 +27,7 @@ - General: Missing time function [\#2877](https://github.com/pypeclub/OpenPype/pull/2877) - Deadline: Fix plugin name for tile assemble [\#2868](https://github.com/pypeclub/OpenPype/pull/2868) +- Nuke: gizmo precollect fix [\#2866](https://github.com/pypeclub/OpenPype/pull/2866) - General: Fix hardlink for windows [\#2864](https://github.com/pypeclub/OpenPype/pull/2864) - General: ffmpeg was crashing on slate merge [\#2860](https://github.com/pypeclub/OpenPype/pull/2860) - WebPublisher: Video file was published with one too many frame [\#2858](https://github.com/pypeclub/OpenPype/pull/2858) @@ -35,6 +36,7 @@ - Nuke: slate resolution to input video resolution [\#2853](https://github.com/pypeclub/OpenPype/pull/2853) - WebPublisher: Fix username stored in DB [\#2852](https://github.com/pypeclub/OpenPype/pull/2852) - WebPublisher: Fix wrong number of frames for video file [\#2851](https://github.com/pypeclub/OpenPype/pull/2851) +- Nuke: Fix family test in validate\_write\_legacy to work with stillImage [\#2847](https://github.com/pypeclub/OpenPype/pull/2847) - Nuke: fix multiple baking profile farm publishing [\#2842](https://github.com/pypeclub/OpenPype/pull/2842) - Blender: Fixed parameters for FBX export of the camera [\#2840](https://github.com/pypeclub/OpenPype/pull/2840) - Maya: Stop creation of reviews for Cryptomattes [\#2832](https://github.com/pypeclub/OpenPype/pull/2832) @@ -59,11 +61,6 @@ - Move Unreal Implementation to OpenPype [\#2823](https://github.com/pypeclub/OpenPype/pull/2823) - General: Extract template formatting from anatomy [\#2766](https://github.com/pypeclub/OpenPype/pull/2766) -**Merged pull requests:** - -- Nuke: gizmo precollect fix [\#2866](https://github.com/pypeclub/OpenPype/pull/2866) -- Nuke: Fix family test in validate\_write\_legacy to work with stillImage [\#2847](https://github.com/pypeclub/OpenPype/pull/2847) - ## [3.8.2](https://github.com/pypeclub/OpenPype/tree/3.8.2) (2022-02-07) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.8.2-nightly.3...3.8.2) diff --git a/openpype/version.py b/openpype/version.py index 39d3037221..d2182ac7da 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.9.0-nightly.9" +__version__ = "3.9.0" diff --git a/pyproject.toml b/pyproject.toml index 7a7411fdfd..681702560a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.9.0-nightly.9" # OpenPype +version = "3.9.0" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 3e840894137cbf40f9aff29e59cc4663c5dc29ec Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Mon, 14 Mar 2022 10:07:11 +0100 Subject: [PATCH 437/483] update avalon submodule --- repos/avalon-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/repos/avalon-core b/repos/avalon-core index ffe9e910f1..7753d15507 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit ffe9e910f1f382e222d457d8e4a8426c41ed43ae +Subproject commit 7753d15507afadc143b7d49db8fcfaa6a29fed91 From 4fed92a1e542c88fa6f4c5091efebf280d2fc02b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 10:57:23 +0100 Subject: [PATCH 438/483] copied base of load logic into openpype --- openpype/pipeline/load/__init__.py | 78 ++++ openpype/pipeline/load/plugins.py | 129 ++++++ openpype/pipeline/load/utils.py | 706 +++++++++++++++++++++++++++++ 3 files changed, 913 insertions(+) create mode 100644 openpype/pipeline/load/__init__.py create mode 100644 openpype/pipeline/load/plugins.py create mode 100644 openpype/pipeline/load/utils.py diff --git a/openpype/pipeline/load/__init__.py b/openpype/pipeline/load/__init__.py new file mode 100644 index 0000000000..2af15e8705 --- /dev/null +++ b/openpype/pipeline/load/__init__.py @@ -0,0 +1,78 @@ +from .utils import ( + HeroVersionType, + IncompatibleLoaderError, + + get_repres_contexts, + get_subset_contexts, + get_representation_context, + + load_with_repre_context, + load_with_subset_context, + load_with_subset_contexts, + + load_representation, + remove_container, + update_container, + switch_container, + + get_loader_identifier, + + get_representation_path_from_context, + get_representation_path, + + is_compatible_loader, + + loaders_from_repre_context, + loaders_from_representation, +) + +from .plugins import ( + LoaderPlugin, + SubsetLoaderPlugin, + + discover_loader_plugins, + register_loader_plugin, + deregister_loader_plugins_path, + register_loader_plugins_path, + deregister_loader_plugin, +) + + +__all__ = ( + # utils.py + "HeroVersionType", + "IncompatibleLoaderError", + + "get_repres_contexts", + "get_subset_contexts", + "get_representation_context", + + "load_with_repre_context", + "load_with_subset_context", + "load_with_subset_contexts", + + "load_representation", + "remove_container", + "update_container", + "switch_container", + + "get_loader_identifier", + + "get_representation_path_from_context", + "get_representation_path", + + "is_compatible_loader", + + "loaders_from_repre_context", + "loaders_from_representation", + + # plugins.py + "LoaderPlugin", + "SubsetLoaderPlugin", + + "discover_loader_plugins", + "register_loader_plugin", + "deregister_loader_plugins_path", + "register_loader_plugins_path", + "deregister_loader_plugin", +) diff --git a/openpype/pipeline/load/plugins.py b/openpype/pipeline/load/plugins.py new file mode 100644 index 0000000000..d7e21e1248 --- /dev/null +++ b/openpype/pipeline/load/plugins.py @@ -0,0 +1,129 @@ +import logging + +from avalon.api import ( + discover, + register_plugin, + deregister_plugin, + register_plugin_path, + deregister_plugin_path, +) + +from .utils import get_representation_path_from_context + + +class LoaderPlugin(list): + """Load representation into host application + + Arguments: + context (dict): avalon-core:context-1.0 + name (str, optional): Use pre-defined name + namespace (str, optional): Use pre-defined namespace + + .. versionadded:: 4.0 + This class was introduced + + """ + + families = list() + representations = list() + order = 0 + is_multiple_contexts_compatible = False + + options = [] + + log = logging.getLogger("SubsetLoader") + log.propagate = True + + def __init__(self, context): + self.fname = self.filepath_from_context(context) + + @classmethod + def get_representations(cls): + return cls.representations + + def filepath_from_context(self, context): + return get_representation_path_from_context(context) + + def load(self, context, name=None, namespace=None, options=None): + """Load asset via database + + Arguments: + context (dict): Full parenthood of representation to load + name (str, optional): Use pre-defined name + namespace (str, optional): Use pre-defined namespace + options (dict, optional): Additional settings dictionary + + """ + raise NotImplementedError("Loader.load() must be " + "implemented by subclass") + + def update(self, container, representation): + """Update `container` to `representation` + + Arguments: + container (avalon-core:container-1.0): Container to update, + from `host.ls()`. + representation (dict): Update the container to this representation. + + """ + raise NotImplementedError("Loader.update() must be " + "implemented by subclass") + + def remove(self, container): + """Remove a container + + Arguments: + container (avalon-core:container-1.0): Container to remove, + from `host.ls()`. + + Returns: + bool: Whether the container was deleted + + """ + + raise NotImplementedError("Loader.remove() must be " + "implemented by subclass") + + @classmethod + def get_options(cls, contexts): + """ + Returns static (cls) options or could collect from 'contexts'. + + Args: + contexts (list): of repre or subset contexts + Returns: + (list) + """ + return cls.options or [] + + +class SubsetLoaderPlugin(LoaderPlugin): + """Load subset into host application + Arguments: + context (dict): avalon-core:context-1.0 + name (str, optional): Use pre-defined name + namespace (str, optional): Use pre-defined namespace + """ + + def __init__(self, context): + pass + + +def discover_loader_plugins(): + return discover(LoaderPlugin) + + +def register_loader_plugin(plugin): + return register_plugin(LoaderPlugin, plugin) + + +def deregister_loader_plugins_path(path): + deregister_plugin_path(LoaderPlugin, path) + + +def register_loader_plugins_path(path): + return register_plugin_path(LoaderPlugin, path) + + +def deregister_loader_plugin(plugin): + deregister_plugin(LoaderPlugin, plugin) diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py new file mode 100644 index 0000000000..4ef0f099d7 --- /dev/null +++ b/openpype/pipeline/load/utils.py @@ -0,0 +1,706 @@ +import os +import platform +import copy +import getpass +import logging +import inspect +import numbers + +import six + +from avalon import io, schema +from avalon.api import Session, registered_root + +from openpype.lib import Anatomy + +log = logging.getLogger(__name__) + + +class HeroVersionType(object): + def __init__(self, version): + assert isinstance(version, numbers.Integral), ( + "Version is not an integer. \"{}\" {}".format( + version, str(type(version)) + ) + ) + self.version = version + + def __str__(self): + return str(self.version) + + def __int__(self): + return int(self.version) + + def __format__(self, format_spec): + return self.version.__format__(format_spec) + + +class IncompatibleLoaderError(ValueError): + """Error when Loader is incompatible with a representation.""" + pass + + +def get_repres_contexts(representation_ids, dbcon=None): + """Return parenthood context for representation. + + Args: + representation_ids (list): The representation ids. + dbcon (AvalonMongoDB): Mongo connection object. `avalon.io` used when + not entered. + + Returns: + dict: The full representation context by representation id. + keys are repre_id, value is dictionary with full: + asset_doc + version_doc + subset_doc + repre_doc + + """ + if not dbcon: + dbcon = io + + contexts = {} + if not representation_ids: + return contexts + + _representation_ids = [] + for repre_id in representation_ids: + if isinstance(repre_id, six.string_types): + repre_id = io.ObjectId(repre_id) + _representation_ids.append(repre_id) + + repre_docs = dbcon.find({ + "type": "representation", + "_id": {"$in": _representation_ids} + }) + repre_docs_by_id = {} + version_ids = set() + for repre_doc in repre_docs: + version_ids.add(repre_doc["parent"]) + repre_docs_by_id[repre_doc["_id"]] = repre_doc + + version_docs = dbcon.find({ + "type": {"$in": ["version", "hero_version"]}, + "_id": {"$in": list(version_ids)} + }) + + version_docs_by_id = {} + hero_version_docs = [] + versions_for_hero = set() + subset_ids = set() + for version_doc in version_docs: + if version_doc["type"] == "hero_version": + hero_version_docs.append(version_doc) + versions_for_hero.add(version_doc["version_id"]) + version_docs_by_id[version_doc["_id"]] = version_doc + subset_ids.add(version_doc["parent"]) + + if versions_for_hero: + _version_docs = dbcon.find({ + "type": "version", + "_id": {"$in": list(versions_for_hero)} + }) + _version_data_by_id = { + version_doc["_id"]: version_doc["data"] + for version_doc in _version_docs + } + + for hero_version_doc in hero_version_docs: + hero_version_id = hero_version_doc["_id"] + version_id = hero_version_doc["version_id"] + version_data = copy.deepcopy(_version_data_by_id[version_id]) + version_docs_by_id[hero_version_id]["data"] = version_data + + subset_docs = dbcon.find({ + "type": "subset", + "_id": {"$in": list(subset_ids)} + }) + subset_docs_by_id = {} + asset_ids = set() + for subset_doc in subset_docs: + subset_docs_by_id[subset_doc["_id"]] = subset_doc + asset_ids.add(subset_doc["parent"]) + + asset_docs = dbcon.find({ + "type": "asset", + "_id": {"$in": list(asset_ids)} + }) + asset_docs_by_id = { + asset_doc["_id"]: asset_doc + for asset_doc in asset_docs + } + + project_doc = dbcon.find_one({"type": "project"}) + + for repre_id, repre_doc in repre_docs_by_id.items(): + version_doc = version_docs_by_id[repre_doc["parent"]] + subset_doc = subset_docs_by_id[version_doc["parent"]] + asset_doc = asset_docs_by_id[subset_doc["parent"]] + context = { + "project": { + "name": project_doc["name"], + "code": project_doc["data"].get("code") + }, + "asset": asset_doc, + "subset": subset_doc, + "version": version_doc, + "representation": repre_doc, + } + contexts[repre_id] = context + + return contexts + + +def get_subset_contexts(subset_ids, dbcon=None): + """Return parenthood context for subset. + + Provides context on subset granularity - less detail than + 'get_repre_contexts'. + Args: + subset_ids (list): The subset ids. + dbcon (AvalonMongoDB): Mongo connection object. `avalon.io` used when + not entered. + Returns: + dict: The full representation context by representation id. + """ + if not dbcon: + dbcon = io + + contexts = {} + if not subset_ids: + return contexts + + _subset_ids = set() + for subset_id in subset_ids: + if isinstance(subset_id, six.string_types): + subset_id = io.ObjectId(subset_id) + _subset_ids.add(subset_id) + + subset_docs = dbcon.find({ + "type": "subset", + "_id": {"$in": list(_subset_ids)} + }) + subset_docs_by_id = {} + asset_ids = set() + for subset_doc in subset_docs: + subset_docs_by_id[subset_doc["_id"]] = subset_doc + asset_ids.add(subset_doc["parent"]) + + asset_docs = dbcon.find({ + "type": "asset", + "_id": {"$in": list(asset_ids)} + }) + asset_docs_by_id = { + asset_doc["_id"]: asset_doc + for asset_doc in asset_docs + } + + project_doc = dbcon.find_one({"type": "project"}) + + for subset_id, subset_doc in subset_docs_by_id.items(): + asset_doc = asset_docs_by_id[subset_doc["parent"]] + context = { + "project": { + "name": project_doc["name"], + "code": project_doc["data"].get("code") + }, + "asset": asset_doc, + "subset": subset_doc + } + contexts[subset_id] = context + + return contexts + + +def get_representation_context(representation): + """Return parenthood context for representation. + + Args: + representation (str or io.ObjectId or dict): The representation id + or full representation as returned by the database. + + Returns: + dict: The full representation context. + + """ + + assert representation is not None, "This is a bug" + + if isinstance(representation, (six.string_types, io.ObjectId)): + representation = io.find_one( + {"_id": io.ObjectId(str(representation))}) + + version, subset, asset, project = io.parenthood(representation) + + assert all([representation, version, subset, asset, project]), ( + "This is a bug" + ) + + context = { + "project": { + "name": project["name"], + "code": project["data"].get("code", '') + }, + "asset": asset, + "subset": subset, + "version": version, + "representation": representation, + } + + return context + + +def load_with_repre_context( + Loader, repre_context, namespace=None, name=None, options=None, **kwargs +): + + # Ensure the Loader is compatible for the representation + if not is_compatible_loader(Loader, repre_context): + raise IncompatibleLoaderError( + "Loader {} is incompatible with {}".format( + Loader.__name__, repre_context["subset"]["name"] + ) + ) + + # Ensure options is a dictionary when no explicit options provided + if options is None: + options = kwargs.get("data", dict()) # "data" for backward compat + + assert isinstance(options, dict), "Options must be a dictionary" + + # Fallback to subset when name is None + if name is None: + name = repre_context["subset"]["name"] + + log.info( + "Running '%s' on '%s'" % ( + Loader.__name__, repre_context["asset"]["name"] + ) + ) + + loader = Loader(repre_context) + return loader.load(repre_context, name, namespace, options) + + +def load_with_subset_context( + Loader, subset_context, namespace=None, name=None, options=None, **kwargs +): + + # Ensure options is a dictionary when no explicit options provided + if options is None: + options = kwargs.get("data", dict()) # "data" for backward compat + + assert isinstance(options, dict), "Options must be a dictionary" + + # Fallback to subset when name is None + if name is None: + name = subset_context["subset"]["name"] + + log.info( + "Running '%s' on '%s'" % ( + Loader.__name__, subset_context["asset"]["name"] + ) + ) + + loader = Loader(subset_context) + return loader.load(subset_context, name, namespace, options) + + +def load_with_subset_contexts( + Loader, subset_contexts, namespace=None, name=None, options=None, **kwargs +): + + # Ensure options is a dictionary when no explicit options provided + if options is None: + options = kwargs.get("data", dict()) # "data" for backward compat + + assert isinstance(options, dict), "Options must be a dictionary" + + # Fallback to subset when name is None + joined_subset_names = " | ".join( + context["subset"]["name"] + for context in subset_contexts + ) + if name is None: + name = joined_subset_names + + log.info( + "Running '{}' on '{}'".format(Loader.__name__, joined_subset_names) + ) + + loader = Loader(subset_contexts) + return loader.load(subset_contexts, name, namespace, options) + + +def load_representation( + Loader, representation, namespace=None, name=None, options=None, **kwargs +): + """Use Loader to load a representation. + + Args: + Loader (Loader): The loader class to trigger. + representation (str or io.ObjectId or dict): The representation id + or full representation as returned by the database. + namespace (str, Optional): The namespace to assign. Defaults to None. + name (str, Optional): The name to assign. Defaults to subset name. + options (dict, Optional): Additional options to pass on to the loader. + + Returns: + The return of the `loader.load()` method. + + Raises: + IncompatibleLoaderError: When the loader is not compatible with + the representation. + + """ + + context = get_representation_context(representation) + return load_with_repre_context( + Loader, + context, + namespace=namespace, + name=name, + options=options, + **kwargs + ) + + +def get_loader_identifier(loader): + """Loader identifier from loader plugin or object. + + Identifier should be stored to container for future management. + """ + if not inspect.isclass(loader): + loader = loader.__class__ + return loader.__name__ + + +def _get_container_loader(container): + """Return the Loader corresponding to the container""" + from .plugins import discover_loader_plugins + + loader = container["loader"] + for Plugin in discover_loader_plugins(): + # TODO: Ensure the loader is valid + if get_loader_identifier(Plugin) == loader: + return Plugin + return None + + +def remove_container(container): + """Remove a container""" + + Loader = _get_container_loader(container) + if not Loader: + raise RuntimeError("Can't remove container. See log for details.") + + loader = Loader(get_representation_context(container["representation"])) + return loader.remove(container) + + +def update_container(container, version=-1): + """Update a container""" + + # Compute the different version from 'representation' + current_representation = io.find_one({ + "_id": io.ObjectId(container["representation"]) + }) + + assert current_representation is not None, "This is a bug" + + current_version, subset, asset, project = io.parenthood( + current_representation) + + if version == -1: + new_version = io.find_one({ + "type": "version", + "parent": subset["_id"] + }, sort=[("name", -1)]) + else: + if isinstance(version, HeroVersionType): + version_query = { + "parent": subset["_id"], + "type": "hero_version" + } + else: + version_query = { + "parent": subset["_id"], + "type": "version", + "name": version + } + new_version = io.find_one(version_query) + + assert new_version is not None, "This is a bug" + + new_representation = io.find_one({ + "type": "representation", + "parent": new_version["_id"], + "name": current_representation["name"] + }) + + assert new_representation is not None, "Representation wasn't found" + + path = get_representation_path(new_representation) + assert os.path.exists(path), "Path {} doesn't exist".format(path) + + # Run update on the Loader for this container + Loader = _get_container_loader(container) + if not Loader: + raise RuntimeError("Can't update container. See log for details.") + + loader = Loader(get_representation_context(container["representation"])) + return loader.update(container, new_representation) + + +def switch_container(container, representation, loader_plugin=None): + """Switch a container to representation + + Args: + container (dict): container information + representation (dict): representation data from document + + Returns: + function call + """ + + # Get the Loader for this container + if loader_plugin is None: + loader_plugin = _get_container_loader(container) + + if not loader_plugin: + raise RuntimeError("Can't switch container. See log for details.") + + if not hasattr(loader_plugin, "switch"): + # Backwards compatibility (classes without switch support + # might be better to just have "switch" raise NotImplementedError + # on the base class of Loader\ + raise RuntimeError("Loader '{}' does not support 'switch'".format( + loader_plugin.label + )) + + # Get the new representation to switch to + new_representation = io.find_one({ + "type": "representation", + "_id": representation["_id"], + }) + + new_context = get_representation_context(new_representation) + if not is_compatible_loader(loader_plugin, new_context): + raise AssertionError("Must be compatible Loader") + + loader = loader_plugin(new_context) + + return loader.switch(container, new_representation) + + +def get_representation_path_from_context(context): + """Preparation wrapper using only context as a argument""" + representation = context['representation'] + project_doc = context.get("project") + root = None + session_project = Session.get("AVALON_PROJECT") + if project_doc and project_doc["name"] != session_project: + anatomy = Anatomy(project_doc["name"]) + root = anatomy.roots_obj + + return get_representation_path(representation, root) + + +def get_representation_path(representation, root=None, dbcon=None): + """Get filename from representation document + + There are three ways of getting the path from representation which are + tried in following sequence until successful. + 1. Get template from representation['data']['template'] and data from + representation['context']. Then format template with the data. + 2. Get template from project['config'] and format it with default data set + 3. Get representation['data']['path'] and use it directly + + Args: + representation(dict): representation document from the database + + Returns: + str: fullpath of the representation + + """ + + from openpype.lib import StringTemplate + + if dbcon is None: + dbcon = io + + if root is None: + root = registered_root() + + def path_from_represenation(): + try: + template = representation["data"]["template"] + except KeyError: + return None + + try: + context = representation["context"] + context["root"] = root + template_obj = StringTemplate(template) + path = str(template_obj.format(context)) + # Force replacing backslashes with forward slashed if not on + # windows + if platform.system().lower() != "windows": + path = path.replace("\\", "/") + except KeyError: + # Template references unavailable data + return None + + if not path: + return path + + normalized_path = os.path.normpath(path) + if os.path.exists(normalized_path): + return normalized_path + return path + + def path_from_config(): + try: + version_, subset, asset, project = dbcon.parenthood(representation) + except ValueError: + log.debug( + "Representation %s wasn't found in database, " + "like a bug" % representation["name"] + ) + return None + + try: + template = project["config"]["template"]["publish"] + except KeyError: + log.debug( + "No template in project %s, " + "likely a bug" % project["name"] + ) + return None + + # default list() in get would not discover missing parents on asset + parents = asset.get("data", {}).get("parents") + if parents is not None: + hierarchy = "/".join(parents) + + # Cannot fail, required members only + data = { + "root": root, + "project": { + "name": project["name"], + "code": project.get("data", {}).get("code") + }, + "asset": asset["name"], + "silo": asset.get("silo"), + "hierarchy": hierarchy, + "subset": subset["name"], + "version": version_["name"], + "representation": representation["name"], + "family": representation.get("context", {}).get("family"), + "user": dbcon.Session.get("AVALON_USER", getpass.getuser()), + "app": dbcon.Session.get("AVALON_APP", ""), + "task": dbcon.Session.get("AVALON_TASK", "") + } + + try: + template_obj = StringTemplate(template) + path = str(template_obj.format(data)) + # Force replacing backslashes with forward slashed if not on + # windows + if platform.system().lower() != "windows": + path = path.replace("\\", "/") + + except KeyError as e: + log.debug("Template references unavailable data: %s" % e) + return None + + normalized_path = os.path.normpath(path) + if os.path.exists(normalized_path): + return normalized_path + return path + + def path_from_data(): + if "path" not in representation["data"]: + return None + + path = representation["data"]["path"] + # Force replacing backslashes with forward slashed if not on + # windows + if platform.system().lower() != "windows": + path = path.replace("\\", "/") + + if os.path.exists(path): + return os.path.normpath(path) + + dir_path, file_name = os.path.split(path) + if not os.path.exists(dir_path): + return + + base_name, ext = os.path.splitext(file_name) + file_name_items = None + if "#" in base_name: + file_name_items = [part for part in base_name.split("#") if part] + elif "%" in base_name: + file_name_items = base_name.split("%") + + if not file_name_items: + return + + filename_start = file_name_items[0] + + for _file in os.listdir(dir_path): + if _file.startswith(filename_start) and _file.endswith(ext): + return os.path.normpath(path) + + return ( + path_from_represenation() or + path_from_config() or + path_from_data() + ) + + +def is_compatible_loader(Loader, context): + """Return whether a loader is compatible with a context. + + This checks the version's families and the representation for the given + Loader. + + Returns: + bool + + """ + maj_version, _ = schema.get_schema_version(context["subset"]["schema"]) + if maj_version < 3: + families = context["version"]["data"].get("families", []) + else: + families = context["subset"]["data"]["families"] + + representation = context["representation"] + has_family = ( + "*" in Loader.families or any( + family in Loader.families for family in families + ) + ) + representations = Loader.get_representations() + has_representation = ( + "*" in representations or representation["name"] in representations + ) + return has_family and has_representation + + +def loaders_from_repre_context(loaders, repre_context): + """Return compatible loaders for by representaiton's context.""" + + return [ + loader + for loader in loaders + if is_compatible_loader(loader, repre_context) + ] + + +def loaders_from_representation(loaders, representation): + """Return all compatible loaders for a representation.""" + + context = get_representation_context(representation) + return loaders_from_repre_context(loaders, context) From 6fe1b5b96554266b1e426fb61016ec5891b1563b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 10:58:43 +0100 Subject: [PATCH 439/483] porpagate some load function in openpype.pipeline --- openpype/pipeline/__init__.py | 45 ++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index 7147e56dd2..d582ef1d07 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -12,6 +12,27 @@ from .create import ( legacy_create, ) +from .load import ( + HeroVersionType, + IncompatibleLoaderError, + LoaderPlugin, + SubsetLoaderPlugin, + + discover_loader_plugins, + register_loader_plugin, + deregister_loader_plugins_path, + register_loader_plugins_path, + deregister_loader_plugin, + + load_representation, + remove_container, + update_container, + switch_container, + + loaders_from_representation, + get_representation_path, +) + from .publish import ( PublishValidationError, PublishXmlValidationError, @@ -23,6 +44,7 @@ from .publish import ( __all__ = ( "attribute_definitions", + # --- Create --- "BaseCreator", "Creator", "AutoCreator", @@ -30,10 +52,31 @@ __all__ = ( "CreatorError", - # Legacy creation + # - legacy creation "LegacyCreator", "legacy_create", + # --- Load --- + "HeroVersionType", + "IncompatibleLoaderError", + "LoaderPlugin", + "SubsetLoaderPlugin", + + "discover_loader_plugins", + "register_loader_plugin", + "deregister_loader_plugins_path", + "register_loader_plugins_path", + "deregister_loader_plugin", + + "load_representation", + "remove_container", + "update_container", + "switch_container", + + "loaders_from_representation", + "get_representation_path", + + # --- Publish --- "PublishValidationError", "PublishXmlValidationError", "KnownPublishError", From 25c7e56613c78a69e22afe49b1c4fd6ea18adabc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 11:16:02 +0100 Subject: [PATCH 440/483] move import of LegacyCreator to function --- openpype/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/__init__.py b/openpype/__init__.py index 7d046e4ef4..7720c9dfb8 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -5,7 +5,6 @@ import platform import functools import logging -from openpype.pipeline import LegacyCreator from .settings import get_project_settings from .lib import ( Anatomy, @@ -76,6 +75,7 @@ def install(): """Install Pype to Avalon.""" from pyblish.lib import MessageHandler from openpype.modules import load_modules + from openpype.pipeline import LegacyCreator from avalon import pipeline # Make sure modules are loaded From c5ac2290f69566b6b4a52ede5551056270e373e6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 11:36:17 +0100 Subject: [PATCH 441/483] use moved functions in hosts and tools --- openpype/__init__.py | 13 +++-- openpype/hosts/aftereffects/api/pipeline.py | 10 ++-- openpype/hosts/aftereffects/api/plugin.py | 5 +- .../plugins/load/load_background.py | 5 +- .../aftereffects/plugins/load/load_file.py | 4 +- openpype/hosts/blender/api/pipeline.py | 10 ++-- openpype/hosts/blender/api/plugin.py | 10 ++-- .../hosts/blender/plugins/load/load_abc.py | 4 +- .../hosts/blender/plugins/load/load_action.py | 35 ++++++------- .../hosts/blender/plugins/load/load_audio.py | 4 +- .../blender/plugins/load/load_camera_blend.py | 4 +- .../blender/plugins/load/load_camera_fbx.py | 4 +- .../hosts/blender/plugins/load/load_fbx.py | 4 +- .../blender/plugins/load/load_layout_blend.py | 8 +-- .../blender/plugins/load/load_layout_json.py | 18 ++++--- .../hosts/blender/plugins/load/load_look.py | 4 +- .../hosts/blender/plugins/load/load_model.py | 4 +- .../hosts/blender/plugins/load/load_rig.py | 16 +++--- openpype/hosts/flame/api/pipeline.py | 10 ++-- openpype/hosts/flame/api/plugin.py | 8 +-- .../hosts/flame/plugins/load/load_clip.py | 2 +- openpype/hosts/fusion/api/lib.py | 4 +- openpype/hosts/fusion/api/pipeline.py | 10 ++-- openpype/hosts/fusion/plugins/load/actions.py | 6 +-- .../fusion/plugins/load/load_sequence.py | 10 ++-- openpype/hosts/harmony/api/README.md | 2 +- openpype/hosts/harmony/api/pipeline.py | 10 ++-- .../hosts/harmony/plugins/load/load_audio.py | 9 ++-- .../harmony/plugins/load/load_background.py | 11 ++-- .../plugins/load/load_imagesequence.py | 9 ++-- .../harmony/plugins/load/load_palette.py | 9 ++-- .../harmony/plugins/load/load_template.py | 9 ++-- .../plugins/load/load_template_workfile.py | 9 ++-- openpype/hosts/hiero/api/pipeline.py | 10 ++-- openpype/hosts/hiero/api/plugin.py | 6 +-- .../hosts/hiero/plugins/load/load_clip.py | 5 +- openpype/hosts/houdini/api/pipeline.py | 7 ++- .../hosts/houdini/plugins/load/actions.py | 6 +-- .../houdini/plugins/load/load_alembic.py | 10 ++-- .../hosts/houdini/plugins/load/load_ass.py | 12 +++-- .../hosts/houdini/plugins/load/load_camera.py | 9 ++-- .../hosts/houdini/plugins/load/load_hda.py | 12 +++-- .../hosts/houdini/plugins/load/load_image.py | 9 ++-- .../houdini/plugins/load/load_usd_layer.py | 9 ++-- .../plugins/load/load_usd_reference.py | 9 ++-- .../hosts/houdini/plugins/load/load_vdb.py | 9 ++-- .../houdini/plugins/load/show_usdview.py | 4 +- .../plugins/publish/extract_usd_layered.py | 3 +- openpype/hosts/maya/api/lib.py | 16 ++++-- openpype/hosts/maya/api/pipeline.py | 10 ++-- openpype/hosts/maya/api/plugin.py | 11 ++-- openpype/hosts/maya/api/setdress.py | 46 ++++++++++------- .../plugins/inventory/import_modelrender.py | 14 +++-- openpype/hosts/maya/plugins/load/actions.py | 8 +-- openpype/hosts/maya/plugins/load/load_ass.py | 11 ++-- .../hosts/maya/plugins/load/load_assembly.py | 13 ++--- .../hosts/maya/plugins/load/load_audio.py | 10 ++-- .../hosts/maya/plugins/load/load_gpucache.py | 10 ++-- .../maya/plugins/load/load_image_plane.py | 10 ++-- openpype/hosts/maya/plugins/load/load_look.py | 5 +- .../hosts/maya/plugins/load/load_matchmove.py | 4 +- .../maya/plugins/load/load_redshift_proxy.py | 9 ++-- .../maya/plugins/load/load_rendersetup.py | 11 ++-- .../maya/plugins/load/load_vdb_to_redshift.py | 5 +- .../maya/plugins/load/load_vdb_to_vray.py | 10 ++-- .../hosts/maya/plugins/load/load_vrayproxy.py | 15 ++++-- .../hosts/maya/plugins/load/load_vrayscene.py | 9 ++-- .../maya/plugins/load/load_yeti_cache.py | 12 +++-- openpype/hosts/nuke/api/pipeline.py | 10 ++-- openpype/hosts/nuke/api/plugin.py | 9 ++-- openpype/hosts/nuke/plugins/load/actions.py | 6 +-- .../hosts/nuke/plugins/load/load_backdrop.py | 10 ++-- .../nuke/plugins/load/load_camera_abc.py | 10 ++-- openpype/hosts/nuke/plugins/load/load_clip.py | 5 +- .../hosts/nuke/plugins/load/load_effects.py | 10 ++-- .../nuke/plugins/load/load_effects_ip.py | 10 ++-- .../hosts/nuke/plugins/load/load_gizmo.py | 10 ++-- .../hosts/nuke/plugins/load/load_gizmo_ip.py | 10 ++-- .../hosts/nuke/plugins/load/load_image.py | 10 ++-- .../hosts/nuke/plugins/load/load_matchmove.py | 4 +- .../hosts/nuke/plugins/load/load_model.py | 10 ++-- .../nuke/plugins/load/load_script_precomp.py | 10 ++-- .../nuke/plugins/publish/precollect_writes.py | 5 +- .../plugins/publish/validate_read_legacy.py | 12 +++-- openpype/hosts/photoshop/api/README.md | 7 +-- openpype/hosts/photoshop/api/pipeline.py | 10 ++-- openpype/hosts/photoshop/api/plugin.py | 4 +- .../photoshop/plugins/load/load_image.py | 4 +- .../plugins/load/load_image_from_sequence.py | 2 +- .../photoshop/plugins/load/load_reference.py | 5 +- openpype/hosts/resolve/api/pipeline.py | 10 ++-- openpype/hosts/resolve/api/plugin.py | 11 ++-- .../hosts/resolve/plugins/load/load_clip.py | 9 ++-- openpype/hosts/tvpaint/api/pipeline.py | 10 ++-- openpype/hosts/tvpaint/api/plugin.py | 9 ++-- openpype/hosts/unreal/api/pipeline.py | 10 ++-- openpype/hosts/unreal/api/plugin.py | 8 +-- .../load/load_alembic_geometrycache.py | 5 +- .../plugins/load/load_alembic_skeletalmesh.py | 5 +- .../plugins/load/load_alembic_staticmesh.py | 5 +- .../unreal/plugins/load/load_animation.py | 5 +- .../hosts/unreal/plugins/load/load_layout.py | 20 +++++--- .../hosts/unreal/plugins/load/load_rig.py | 5 +- .../unreal/plugins/load/load_staticmeshfbx.py | 5 +- openpype/hosts/webpublisher/api/__init__.py | 7 --- openpype/lib/avalon_context.py | 13 +++-- openpype/lib/path_templates.py | 10 ++++ openpype/lib/plugin_tools.py | 7 +-- .../plugins/publish/submit_publish_job.py | 4 +- .../ftrack/event_handlers_user/action_rv.py | 5 +- openpype/pipeline/create/legacy_create.py | 1 + openpype/plugins/load/add_site.py | 4 +- openpype/plugins/load/copy_file.py | 6 ++- openpype/plugins/load/copy_file_path.py | 4 +- openpype/plugins/load/delete_old_versions.py | 19 +++---- openpype/plugins/load/delivery.py | 4 +- openpype/plugins/load/open_djv.py | 5 +- openpype/plugins/load/open_file.py | 4 +- openpype/plugins/load/remove_site.py | 4 +- openpype/tools/loader/model.py | 2 +- openpype/tools/loader/widgets.py | 51 +++++++++++-------- openpype/tools/mayalookassigner/commands.py | 7 +-- .../tools/mayalookassigner/vray_proxies.py | 14 +++-- openpype/tools/sceneinventory/model.py | 5 +- .../tools/sceneinventory/switch_dialog.py | 13 +++-- openpype/tools/sceneinventory/view.py | 18 ++++--- openpype/tools/utils/delegates.py | 2 +- 127 files changed, 711 insertions(+), 427 deletions(-) diff --git a/openpype/__init__.py b/openpype/__init__.py index 942112835b..755036168d 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -5,7 +5,12 @@ import platform import functools import logging -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + register_loader_plugins_path, + deregister_loader_plugins_path, +) + from .settings import get_project_settings from .lib import ( Anatomy, @@ -90,7 +95,7 @@ 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) + register_loader_plugins_path(LOAD_PATH) project_name = os.environ.get("AVALON_PROJECT") @@ -118,7 +123,7 @@ def install(): continue pyblish.register_plugin_path(path) - avalon.register_plugin_path(avalon.Loader, path) + register_loader_plugins_path(path) avalon.register_plugin_path(LegacyCreator, path) avalon.register_plugin_path(avalon.InventoryAction, path) @@ -141,7 +146,7 @@ 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) + deregister_loader_plugins_path(LOAD_PATH) log.info("Global plug-ins unregistred") # restore original discover diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index ef56e96155..2dc41bd8b9 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -9,7 +9,11 @@ from avalon import io, pipeline from openpype import lib from openpype.api import Logger -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + register_loader_plugins_path, + deregister_loader_plugins_path, +) import openpype.hosts.aftereffects from .launch_logic import get_stub @@ -66,7 +70,7 @@ def install(): pyblish.api.register_host("aftereffects") pyblish.api.register_plugin_path(PUBLISH_PATH) - avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) + register_loader_plugins_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) log.info(PUBLISH_PATH) @@ -79,7 +83,7 @@ def install(): def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) - avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) + deregister_loader_plugins_path(LOAD_PATH) avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) diff --git a/openpype/hosts/aftereffects/api/plugin.py b/openpype/hosts/aftereffects/api/plugin.py index fbe07663dd..29705cc5be 100644 --- a/openpype/hosts/aftereffects/api/plugin.py +++ b/openpype/hosts/aftereffects/api/plugin.py @@ -1,9 +1,8 @@ -import avalon.api +from openpype.pipeline import LoaderPlugin from .launch_logic import get_stub -class AfterEffectsLoader(avalon.api.Loader): +class AfterEffectsLoader(LoaderPlugin): @staticmethod def get_stub(): return get_stub() - diff --git a/openpype/hosts/aftereffects/plugins/load/load_background.py b/openpype/hosts/aftereffects/plugins/load/load_background.py index 1a2d6fc432..be43cae44e 100644 --- a/openpype/hosts/aftereffects/plugins/load/load_background.py +++ b/openpype/hosts/aftereffects/plugins/load/load_background.py @@ -1,11 +1,10 @@ import re -import avalon.api - from openpype.lib import ( get_background_layers, get_unique_layer_name ) +from openpype.pipeline import get_representation_path from openpype.hosts.aftereffects.api import ( AfterEffectsLoader, containerise @@ -78,7 +77,7 @@ class BackgroundLoader(AfterEffectsLoader): else: # switching version - keep same name comp_name = container["namespace"] - path = avalon.api.get_representation_path(representation) + path = get_representation_path(representation) layers = get_background_layers(path) comp = stub.reload_background(container["members"][1], diff --git a/openpype/hosts/aftereffects/plugins/load/load_file.py b/openpype/hosts/aftereffects/plugins/load/load_file.py index 9dbbf7aae1..9eb9e80a2c 100644 --- a/openpype/hosts/aftereffects/plugins/load/load_file.py +++ b/openpype/hosts/aftereffects/plugins/load/load_file.py @@ -1,8 +1,8 @@ import re -import avalon.api from openpype import lib +from openpype.pipeline import get_representation_path from openpype.hosts.aftereffects.api import ( AfterEffectsLoader, containerise @@ -92,7 +92,7 @@ class FileLoader(AfterEffectsLoader): "{}_{}".format(context["asset"], context["subset"])) else: # switching version - keep same name layer_name = container["namespace"] - path = avalon.api.get_representation_path(representation) + path = get_representation_path(representation) # with aftereffects.maintained_selection(): # TODO stub.replace_item(layer.id, path, stub.LOADED_ICON + layer_name) stub.imprint( diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 1c9820ff22..f3a5c941eb 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -14,7 +14,11 @@ import avalon.api from avalon import io, schema from avalon.pipeline import AVALON_CONTAINER_ID -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + register_loader_plugins_path, + deregister_loader_plugins_path, +) from openpype.api import Logger import openpype.hosts.blender @@ -46,7 +50,7 @@ def install(): pyblish.api.register_host("blender") pyblish.api.register_plugin_path(str(PUBLISH_PATH)) - avalon.api.register_plugin_path(avalon.api.Loader, str(LOAD_PATH)) + register_loader_plugins_path(str(LOAD_PATH)) avalon.api.register_plugin_path(LegacyCreator, str(CREATE_PATH)) lib.append_user_scripts() @@ -67,7 +71,7 @@ def uninstall(): pyblish.api.deregister_host("blender") pyblish.api.deregister_plugin_path(str(PUBLISH_PATH)) - avalon.api.deregister_plugin_path(avalon.api.Loader, str(LOAD_PATH)) + deregister_loader_plugins_path(str(LOAD_PATH)) avalon.api.deregister_plugin_path(LegacyCreator, str(CREATE_PATH)) if not IS_HEADLESS: diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 20d1e4c8db..3207f543b7 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -5,8 +5,10 @@ from typing import Dict, List, Optional import bpy -import avalon.api -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + LoaderPlugin, +) from .pipeline import AVALON_CONTAINERS from .ops import ( MainThreadItem, @@ -145,13 +147,13 @@ class Creator(LegacyCreator): return collection -class Loader(avalon.api.Loader): +class Loader(LoaderPlugin): """Base class for Loader plug-ins.""" hosts = ["blender"] -class AssetLoader(avalon.api.Loader): +class AssetLoader(LoaderPlugin): """A basic AssetLoader for Blender This will implement the basic logic for linking/appending assets diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 07800521c9..3daaeceffe 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -6,7 +6,7 @@ from typing import Dict, List, Optional import bpy -from avalon import api +from openpype.pipeline import get_representation_path from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, @@ -178,7 +178,7 @@ class CacheModelLoader(plugin.AssetLoader): """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) - libpath = Path(api.get_representation_path(representation)) + libpath = Path(get_representation_path(representation)) extension = libpath.suffix.lower() self.log.info( diff --git a/openpype/hosts/blender/plugins/load/load_action.py b/openpype/hosts/blender/plugins/load/load_action.py index a9d8522220..3c8fe988f0 100644 --- a/openpype/hosts/blender/plugins/load/load_action.py +++ b/openpype/hosts/blender/plugins/load/load_action.py @@ -5,9 +5,13 @@ from pathlib import Path from pprint import pformat from typing import Dict, List, Optional -from avalon import api, blender import bpy +from openpype.pipeline import get_representation_path import openpype.hosts.blender.api.plugin +from openpype.hosts.blender.api.pipeline import ( + containerise_existing, + AVALON_PROPERTY, +) logger = logging.getLogger("openpype").getChild("blender").getChild("load_action") @@ -49,7 +53,7 @@ class BlendActionLoader(openpype.hosts.blender.api.plugin.AssetLoader): container = bpy.data.collections.new(lib_container) container.name = container_name - blender.pipeline.containerise_existing( + containerise_existing( container, name, namespace, @@ -57,8 +61,7 @@ class BlendActionLoader(openpype.hosts.blender.api.plugin.AssetLoader): self.__class__.__name__, ) - container_metadata = container.get( - blender.pipeline.AVALON_PROPERTY) + container_metadata = container.get(AVALON_PROPERTY) container_metadata["libpath"] = libpath container_metadata["lib_container"] = lib_container @@ -90,16 +93,16 @@ class BlendActionLoader(openpype.hosts.blender.api.plugin.AssetLoader): anim_data.action.make_local() - if not obj.get(blender.pipeline.AVALON_PROPERTY): + if not obj.get(AVALON_PROPERTY): - obj[blender.pipeline.AVALON_PROPERTY] = dict() + obj[AVALON_PROPERTY] = dict() - avalon_info = obj[blender.pipeline.AVALON_PROPERTY] + avalon_info = obj[AVALON_PROPERTY] avalon_info.update({"container_name": container_name}) objects_list.append(obj) - animation_container.pop(blender.pipeline.AVALON_PROPERTY) + animation_container.pop(AVALON_PROPERTY) # Save the list of objects in the metadata container container_metadata["objects"] = objects_list @@ -128,7 +131,7 @@ class BlendActionLoader(openpype.hosts.blender.api.plugin.AssetLoader): container["objectName"] ) - libpath = Path(api.get_representation_path(representation)) + libpath = Path(get_representation_path(representation)) extension = libpath.suffix.lower() logger.info( @@ -153,8 +156,7 @@ class BlendActionLoader(openpype.hosts.blender.api.plugin.AssetLoader): f"Unsupported file: {libpath}" ) - collection_metadata = collection.get( - blender.pipeline.AVALON_PROPERTY) + collection_metadata = collection.get(AVALON_PROPERTY) collection_libpath = collection_metadata["libpath"] normalized_collection_libpath = ( @@ -225,16 +227,16 @@ class BlendActionLoader(openpype.hosts.blender.api.plugin.AssetLoader): strip.action = anim_data.action strip.action_frame_end = anim_data.action.frame_range[1] - if not obj.get(blender.pipeline.AVALON_PROPERTY): + if not obj.get(AVALON_PROPERTY): - obj[blender.pipeline.AVALON_PROPERTY] = dict() + obj[AVALON_PROPERTY] = dict() - avalon_info = obj[blender.pipeline.AVALON_PROPERTY] + avalon_info = obj[AVALON_PROPERTY] avalon_info.update({"container_name": collection.name}) objects_list.append(obj) - anim_container.pop(blender.pipeline.AVALON_PROPERTY) + anim_container.pop(AVALON_PROPERTY) # Save the list of objects in the metadata container collection_metadata["objects"] = objects_list @@ -266,8 +268,7 @@ class BlendActionLoader(openpype.hosts.blender.api.plugin.AssetLoader): "Nested collections are not supported." ) - collection_metadata = collection.get( - blender.pipeline.AVALON_PROPERTY) + collection_metadata = collection.get(AVALON_PROPERTY) objects = collection_metadata["objects"] lib_container = collection_metadata["lib_container"] diff --git a/openpype/hosts/blender/plugins/load/load_audio.py b/openpype/hosts/blender/plugins/load/load_audio.py index e065150c15..b95c5db270 100644 --- a/openpype/hosts/blender/plugins/load/load_audio.py +++ b/openpype/hosts/blender/plugins/load/load_audio.py @@ -6,7 +6,7 @@ from typing import Dict, List, Optional import bpy -from avalon import api +from openpype.pipeline import get_representation_path from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, @@ -102,7 +102,7 @@ class AudioLoader(plugin.AssetLoader): """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) - libpath = Path(api.get_representation_path(representation)) + libpath = Path(get_representation_path(representation)) self.log.info( "Container: %s\nRepresentation: %s", diff --git a/openpype/hosts/blender/plugins/load/load_camera_blend.py b/openpype/hosts/blender/plugins/load/load_camera_blend.py index 61955f124d..6ed2e8a575 100644 --- a/openpype/hosts/blender/plugins/load/load_camera_blend.py +++ b/openpype/hosts/blender/plugins/load/load_camera_blend.py @@ -7,7 +7,7 @@ from typing import Dict, List, Optional import bpy -from avalon import api +from openpype.pipeline import get_representation_path from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, @@ -155,7 +155,7 @@ class BlendCameraLoader(plugin.AssetLoader): """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) - libpath = Path(api.get_representation_path(representation)) + libpath = Path(get_representation_path(representation)) extension = libpath.suffix.lower() self.log.info( diff --git a/openpype/hosts/blender/plugins/load/load_camera_fbx.py b/openpype/hosts/blender/plugins/load/load_camera_fbx.py index 175ddacf9f..626ed44f08 100644 --- a/openpype/hosts/blender/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/blender/plugins/load/load_camera_fbx.py @@ -6,7 +6,7 @@ from typing import Dict, List, Optional import bpy -from avalon import api +from openpype.pipeline import get_representation_path from openpype.hosts.blender.api import plugin, lib from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, @@ -143,7 +143,7 @@ class FbxCameraLoader(plugin.AssetLoader): """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) - libpath = Path(api.get_representation_path(representation)) + libpath = Path(get_representation_path(representation)) extension = libpath.suffix.lower() self.log.info( diff --git a/openpype/hosts/blender/plugins/load/load_fbx.py b/openpype/hosts/blender/plugins/load/load_fbx.py index c6e6af5592..2d249ef647 100644 --- a/openpype/hosts/blender/plugins/load/load_fbx.py +++ b/openpype/hosts/blender/plugins/load/load_fbx.py @@ -6,7 +6,7 @@ from typing import Dict, List, Optional import bpy -from avalon import api +from openpype.pipeline import get_representation_path from openpype.hosts.blender.api import plugin, lib from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, @@ -187,7 +187,7 @@ class FbxModelLoader(plugin.AssetLoader): """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) - libpath = Path(api.get_representation_path(representation)) + libpath = Path(get_representation_path(representation)) extension = libpath.suffix.lower() self.log.info( diff --git a/openpype/hosts/blender/plugins/load/load_layout_blend.py b/openpype/hosts/blender/plugins/load/load_layout_blend.py index 7f8ae610c6..d87df3c010 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_blend.py +++ b/openpype/hosts/blender/plugins/load/load_layout_blend.py @@ -6,9 +6,11 @@ from typing import Dict, List, Optional import bpy -from avalon import api from openpype import lib -from openpype.pipeline import legacy_create +from openpype.pipeline import ( + legacy_create, + get_representation_path, +) from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, @@ -309,7 +311,7 @@ class BlendLayoutLoader(plugin.AssetLoader): """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) - libpath = Path(api.get_representation_path(representation)) + libpath = Path(get_representation_path(representation)) extension = libpath.suffix.lower() self.log.info( diff --git a/openpype/hosts/blender/plugins/load/load_layout_json.py b/openpype/hosts/blender/plugins/load/load_layout_json.py index 5b5f9ab83d..499d2c49f3 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_json.py +++ b/openpype/hosts/blender/plugins/load/load_layout_json.py @@ -7,7 +7,13 @@ from typing import Dict, Optional import bpy -from avalon import api +from openpype.pipeline import ( + discover_loader_plugins, + remove_container, + load_representation, + get_representation_path, + loaders_from_representation, +) from openpype.hosts.blender.api.pipeline import ( AVALON_INSTANCES, AVALON_CONTAINERS, @@ -33,7 +39,7 @@ class JsonLayoutLoader(plugin.AssetLoader): objects = list(asset_group.children) for obj in objects: - api.remove(obj.get(AVALON_PROPERTY)) + remove_container(obj.get(AVALON_PROPERTY)) def _remove_animation_instances(self, asset_group): instances = bpy.data.collections.get(AVALON_INSTANCES) @@ -66,13 +72,13 @@ class JsonLayoutLoader(plugin.AssetLoader): with open(libpath, "r") as fp: data = json.load(fp) - all_loaders = api.discover(api.Loader) + all_loaders = discover_loader_plugins() for element in data: reference = element.get('reference') family = element.get('family') - loaders = api.loaders_from_representation(all_loaders, reference) + loaders = loaders_from_representation(all_loaders, reference) loader = self._get_loader(loaders, family) if not loader: @@ -102,7 +108,7 @@ class JsonLayoutLoader(plugin.AssetLoader): # at this time it will not return anything. The assets will be # loaded in the next Blender cycle, so we use the options to # set the transform, parent and assign the action, if there is one. - api.load( + load_representation( loader, reference, namespace=instance_name, @@ -188,7 +194,7 @@ class JsonLayoutLoader(plugin.AssetLoader): """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) - libpath = Path(api.get_representation_path(representation)) + libpath = Path(get_representation_path(representation)) extension = libpath.suffix.lower() self.log.info( diff --git a/openpype/hosts/blender/plugins/load/load_look.py b/openpype/hosts/blender/plugins/load/load_look.py index 066ec0101b..70d1b95f02 100644 --- a/openpype/hosts/blender/plugins/load/load_look.py +++ b/openpype/hosts/blender/plugins/load/load_look.py @@ -8,7 +8,7 @@ import os import json import bpy -from avalon import api +from openpype.pipeline import get_representation_path from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( containerise_existing, @@ -140,7 +140,7 @@ class BlendLookLoader(plugin.AssetLoader): def update(self, container: Dict, representation: Dict): collection = bpy.data.collections.get(container["objectName"]) - libpath = Path(api.get_representation_path(representation)) + libpath = Path(get_representation_path(representation)) extension = libpath.suffix.lower() self.log.info( diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py index 04ece0b338..18d01dcb29 100644 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ b/openpype/hosts/blender/plugins/load/load_model.py @@ -6,7 +6,7 @@ from typing import Dict, List, Optional import bpy -from avalon import api +from openpype.pipeline import get_representation_path from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, @@ -195,7 +195,7 @@ class BlendModelLoader(plugin.AssetLoader): """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) - libpath = Path(api.get_representation_path(representation)) + libpath = Path(get_representation_path(representation)) extension = libpath.suffix.lower() self.log.info( diff --git a/openpype/hosts/blender/plugins/load/load_rig.py b/openpype/hosts/blender/plugins/load/load_rig.py index eacabd3447..cec088076c 100644 --- a/openpype/hosts/blender/plugins/load/load_rig.py +++ b/openpype/hosts/blender/plugins/load/load_rig.py @@ -6,11 +6,15 @@ from typing import Dict, List, Optional import bpy -from avalon import api -from avalon.blender import lib as avalon_lib from openpype import lib -from openpype.pipeline import legacy_create -from openpype.hosts.blender.api import plugin +from openpype.pipeline import ( + legacy_create, + get_representation_path, +) +from openpype.hosts.blender.api import ( + plugin, + get_selection, +) from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, @@ -263,7 +267,7 @@ class BlendRigLoader(plugin.AssetLoader): if anim_file: bpy.ops.import_scene.fbx(filepath=anim_file, anim_offset=0.0) - imported = avalon_lib.get_selection() + imported = get_selection() armature = [ o for o in asset_group.children if o.type == 'ARMATURE'][0] @@ -307,7 +311,7 @@ class BlendRigLoader(plugin.AssetLoader): """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) - libpath = Path(api.get_representation_path(representation)) + libpath = Path(get_representation_path(representation)) extension = libpath.suffix.lower() self.log.info( diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index f802cf160b..6a045214c3 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -7,7 +7,11 @@ from avalon import api as avalon from avalon.pipeline import AVALON_CONTAINER_ID from pyblish import api as pyblish from openpype.api import Logger -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + register_loader_plugins_path, + deregister_loader_plugins_path, +) from .lib import ( set_segment_data_marker, set_publish_attribute, @@ -33,7 +37,7 @@ def install(): pyblish.register_host("flame") pyblish.register_plugin_path(PUBLISH_PATH) - avalon.register_plugin_path(avalon.Loader, LOAD_PATH) + register_loader_plugins_path(LOAD_PATH) avalon.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) log.info("OpenPype Flame plug-ins registred ...") @@ -48,7 +52,7 @@ def uninstall(): log.info("Deregistering Flame plug-ins..") pyblish.deregister_plugin_path(PUBLISH_PATH) - avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) + deregister_loader_plugins_path(LOAD_PATH) avalon.deregister_plugin_path(LegacyCreator, CREATE_PATH) avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 5221701a2f..4c9d3c5383 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -7,9 +7,11 @@ import six import qargparse from Qt import QtWidgets, QtCore import openpype.api as openpype -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + LoaderPlugin, +) from openpype import style -import avalon.api as avalon from . import ( lib as flib, pipeline as fpipeline, @@ -660,7 +662,7 @@ class PublishableClip: # Publishing plugin functions # Loader plugin functions -class ClipLoader(avalon.Loader): +class ClipLoader(LoaderPlugin): """A basic clip loader for Flame This will implement the basic behavior for a loader to inherit from that diff --git a/openpype/hosts/flame/plugins/load/load_clip.py b/openpype/hosts/flame/plugins/load/load_clip.py index 8ba01d6937..8980f72cb8 100644 --- a/openpype/hosts/flame/plugins/load/load_clip.py +++ b/openpype/hosts/flame/plugins/load/load_clip.py @@ -172,7 +172,7 @@ class LoadClip(opfapi.ClipLoader): # version_name = version.get("name", None) # colorspace = version_data.get("colorspace", None) # object_name = "{}_{}".format(name, namespace) - # file = api.get_representation_path(representation).replace("\\", "/") + # file = get_representation_path(representation).replace("\\", "/") # clip = track_item.source() # # reconnect media to new path diff --git a/openpype/hosts/fusion/api/lib.py b/openpype/hosts/fusion/api/lib.py index 5d97f83032..2bb5ea8aae 100644 --- a/openpype/hosts/fusion/api/lib.py +++ b/openpype/hosts/fusion/api/lib.py @@ -5,8 +5,8 @@ import contextlib from Qt import QtGui -import avalon.api from avalon import io +from openpype.pipeline import switch_container from .pipeline import get_current_comp, comp_lock_and_undo_chunk self = sys.modules[__name__] @@ -142,7 +142,7 @@ def switch_item(container, assert representation, ("Could not find representation in the database " "with the name '%s'" % representation_name) - avalon.api.switch(container, representation) + switch_container(container, representation) return representation diff --git a/openpype/hosts/fusion/api/pipeline.py b/openpype/hosts/fusion/api/pipeline.py index 5ac56fcbed..3f5da7fcc7 100644 --- a/openpype/hosts/fusion/api/pipeline.py +++ b/openpype/hosts/fusion/api/pipeline.py @@ -11,7 +11,11 @@ import avalon.api from avalon.pipeline import AVALON_CONTAINER_ID from openpype.api import Logger -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + register_loader_plugins_path, + deregister_loader_plugins_path, +) import openpype.hosts.fusion log = Logger().get_logger(__name__) @@ -63,7 +67,7 @@ def install(): pyblish.api.register_plugin_path(PUBLISH_PATH) log.info("Registering Fusion plug-ins..") - avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) + register_loader_plugins_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) @@ -87,7 +91,7 @@ def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) log.info("Deregistering Fusion plug-ins..") - avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) + deregister_loader_plugins_path(LOAD_PATH) avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) avalon.api.deregister_plugin_path( avalon.api.InventoryAction, INVENTORY_PATH diff --git a/openpype/hosts/fusion/plugins/load/actions.py b/openpype/hosts/fusion/plugins/load/actions.py index 6af99e4c56..bc59cec77f 100644 --- a/openpype/hosts/fusion/plugins/load/actions.py +++ b/openpype/hosts/fusion/plugins/load/actions.py @@ -2,10 +2,10 @@ """ -from avalon import api +from openpype.pipeline import load -class FusionSetFrameRangeLoader(api.Loader): +class FusionSetFrameRangeLoader(load.LoaderPlugin): """Specific loader of Alembic for the avalon.animation family""" families = ["animation", @@ -39,7 +39,7 @@ class FusionSetFrameRangeLoader(api.Loader): lib.update_frame_range(start, end) -class FusionSetFrameRangeWithHandlesLoader(api.Loader): +class FusionSetFrameRangeWithHandlesLoader(load.LoaderPlugin): """Specific loader of Alembic for the avalon.animation family""" families = ["animation", diff --git a/openpype/hosts/fusion/plugins/load/load_sequence.py b/openpype/hosts/fusion/plugins/load/load_sequence.py index ea118585bf..075820de35 100644 --- a/openpype/hosts/fusion/plugins/load/load_sequence.py +++ b/openpype/hosts/fusion/plugins/load/load_sequence.py @@ -1,8 +1,12 @@ import os import contextlib -from avalon import api, io +from avalon import io +from openpype.pipeline import ( + load, + get_representation_path, +) from openpype.hosts.fusion.api import ( imprint_container, get_current_comp, @@ -117,7 +121,7 @@ def loader_shift(loader, frame, relative=True): return int(shift) -class FusionLoadSequence(api.Loader): +class FusionLoadSequence(load.LoaderPlugin): """Load image sequence into Fusion""" families = ["imagesequence", "review", "render"] @@ -204,7 +208,7 @@ class FusionLoadSequence(api.Loader): assert tool.ID == "Loader", "Must be Loader" comp = tool.Comp() - root = os.path.dirname(api.get_representation_path(representation)) + root = os.path.dirname(get_representation_path(representation)) path = self._get_first_image(root) # Get start frame from version data diff --git a/openpype/hosts/harmony/api/README.md b/openpype/hosts/harmony/api/README.md index a8d182736a..e8d354e1e6 100644 --- a/openpype/hosts/harmony/api/README.md +++ b/openpype/hosts/harmony/api/README.md @@ -575,7 +575,7 @@ replace_files = """function %s_replace_files(args) """ % (signature, signature) -class ImageSequenceLoader(api.Loader): +class ImageSequenceLoader(load.LoaderPlugin): """Load images Stores the imported asset in a container named after the asset. """ diff --git a/openpype/hosts/harmony/api/pipeline.py b/openpype/hosts/harmony/api/pipeline.py index 6d0f5e9416..be183902a7 100644 --- a/openpype/hosts/harmony/api/pipeline.py +++ b/openpype/hosts/harmony/api/pipeline.py @@ -9,7 +9,11 @@ import avalon.api from avalon.pipeline import AVALON_CONTAINER_ID from openpype import lib -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + register_loader_plugins_path, + deregister_loader_plugins_path, +) import openpype.hosts.harmony import openpype.hosts.harmony.api as harmony @@ -179,7 +183,7 @@ def install(): pyblish.api.register_host("harmony") pyblish.api.register_plugin_path(PUBLISH_PATH) - avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) + register_loader_plugins_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) log.info(PUBLISH_PATH) @@ -193,7 +197,7 @@ def install(): def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) - avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) + deregister_loader_plugins_path(LOAD_PATH) avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) diff --git a/openpype/hosts/harmony/plugins/load/load_audio.py b/openpype/hosts/harmony/plugins/load/load_audio.py index 57ea8ae312..e18a6de097 100644 --- a/openpype/hosts/harmony/plugins/load/load_audio.py +++ b/openpype/hosts/harmony/plugins/load/load_audio.py @@ -1,4 +1,7 @@ -from avalon import api +from openpype.pipeline import ( + load, + get_representation_path, +) import openpype.hosts.harmony.api as harmony sig = harmony.signature() @@ -29,7 +32,7 @@ function %s(args) """ % (sig, sig) -class ImportAudioLoader(api.Loader): +class ImportAudioLoader(load.LoaderPlugin): """Import audio.""" families = ["shot", "audio"] @@ -37,7 +40,7 @@ class ImportAudioLoader(api.Loader): label = "Import Audio" def load(self, context, name=None, namespace=None, data=None): - wav_file = api.get_representation_path(context["representation"]) + wav_file = get_representation_path(context["representation"]) harmony.send( {"function": func, "args": [context["subset"]["name"], wav_file]} ) diff --git a/openpype/hosts/harmony/plugins/load/load_background.py b/openpype/hosts/harmony/plugins/load/load_background.py index 686d6b5b7b..9c01fe3cd8 100644 --- a/openpype/hosts/harmony/plugins/load/load_background.py +++ b/openpype/hosts/harmony/plugins/load/load_background.py @@ -1,7 +1,10 @@ import os import json -from avalon import api +from openpype.pipeline import ( + load, + get_representation_path, +) import openpype.hosts.harmony.api as harmony import openpype.lib @@ -226,7 +229,7 @@ replace_files """ -class BackgroundLoader(api.Loader): +class BackgroundLoader(load.LoaderPlugin): """Load images Stores the imported asset in a container named after the asset. """ @@ -278,7 +281,7 @@ class BackgroundLoader(api.Loader): def update(self, container, representation): - path = api.get_representation_path(representation) + path = get_representation_path(representation) with open(path) as json_file: data = json.load(json_file) @@ -297,7 +300,7 @@ class BackgroundLoader(api.Loader): bg_folder = os.path.dirname(path) - path = api.get_representation_path(representation) + path = get_representation_path(representation) print(container) diff --git a/openpype/hosts/harmony/plugins/load/load_imagesequence.py b/openpype/hosts/harmony/plugins/load/load_imagesequence.py index 310f9bdb61..18695438d5 100644 --- a/openpype/hosts/harmony/plugins/load/load_imagesequence.py +++ b/openpype/hosts/harmony/plugins/load/load_imagesequence.py @@ -6,12 +6,15 @@ from pathlib import Path import clique -from avalon import api +from openpype.pipeline import ( + load, + get_representation_path, +) import openpype.hosts.harmony.api as harmony import openpype.lib -class ImageSequenceLoader(api.Loader): +class ImageSequenceLoader(load.LoaderPlugin): """Load image sequences. Stores the imported asset in a container named after the asset. @@ -79,7 +82,7 @@ class ImageSequenceLoader(api.Loader): self_name = self.__class__.__name__ node = container.get("nodes").pop() - path = api.get_representation_path(representation) + path = get_representation_path(representation) collections, remainder = clique.assemble( os.listdir(os.path.dirname(path)) ) diff --git a/openpype/hosts/harmony/plugins/load/load_palette.py b/openpype/hosts/harmony/plugins/load/load_palette.py index 2e0f70d135..1da3e61e1b 100644 --- a/openpype/hosts/harmony/plugins/load/load_palette.py +++ b/openpype/hosts/harmony/plugins/load/load_palette.py @@ -1,11 +1,14 @@ import os import shutil -from avalon import api +from openpype.pipeline import ( + load, + get_representation_path, +) import openpype.hosts.harmony.api as harmony -class ImportPaletteLoader(api.Loader): +class ImportPaletteLoader(load.LoaderPlugin): """Import palettes.""" families = ["palette", "harmony.palette"] @@ -31,7 +34,7 @@ class ImportPaletteLoader(api.Loader): scene_path = harmony.send( {"function": "scene.currentProjectPath"} )["result"] - src = api.get_representation_path(representation) + src = get_representation_path(representation) dst = os.path.join( scene_path, "palette-library", diff --git a/openpype/hosts/harmony/plugins/load/load_template.py b/openpype/hosts/harmony/plugins/load/load_template.py index 112e613ae6..c6dc9d913b 100644 --- a/openpype/hosts/harmony/plugins/load/load_template.py +++ b/openpype/hosts/harmony/plugins/load/load_template.py @@ -6,12 +6,15 @@ import os import shutil import uuid -from avalon import api +from openpype.pipeline import ( + load, + get_representation_path, +) import openpype.hosts.harmony.api as harmony import openpype.lib -class TemplateLoader(api.Loader): +class TemplateLoader(load.LoaderPlugin): """Load Harmony template as container. .. todo:: @@ -38,7 +41,7 @@ class TemplateLoader(api.Loader): # Load template. self_name = self.__class__.__name__ temp_dir = tempfile.mkdtemp() - zip_file = api.get_representation_path(context["representation"]) + zip_file = get_representation_path(context["representation"]) template_path = os.path.join(temp_dir, "temp.tpl") with zipfile.ZipFile(zip_file, "r") as zip_ref: zip_ref.extractall(template_path) diff --git a/openpype/hosts/harmony/plugins/load/load_template_workfile.py b/openpype/hosts/harmony/plugins/load/load_template_workfile.py index c21b8194b1..2b84a43b35 100644 --- a/openpype/hosts/harmony/plugins/load/load_template_workfile.py +++ b/openpype/hosts/harmony/plugins/load/load_template_workfile.py @@ -3,11 +3,14 @@ import zipfile import os import shutil -from avalon import api +from openpype.pipeline import ( + load, + get_representation_path, +) import openpype.hosts.harmony.api as harmony -class ImportTemplateLoader(api.Loader): +class ImportTemplateLoader(load.LoaderPlugin): """Import templates.""" families = ["harmony.template", "workfile"] @@ -17,7 +20,7 @@ class ImportTemplateLoader(api.Loader): def load(self, context, name=None, namespace=None, data=None): # Import template. temp_dir = tempfile.mkdtemp() - zip_file = api.get_representation_path(context["representation"]) + zip_file = get_representation_path(context["representation"]) template_path = os.path.join(temp_dir, "temp.tpl") with zipfile.ZipFile(zip_file, "r") as zip_ref: zip_ref.extractall(template_path) diff --git a/openpype/hosts/hiero/api/pipeline.py b/openpype/hosts/hiero/api/pipeline.py index 5cb23ea355..f27b7a4f81 100644 --- a/openpype/hosts/hiero/api/pipeline.py +++ b/openpype/hosts/hiero/api/pipeline.py @@ -9,7 +9,11 @@ from avalon import api as avalon from avalon import schema from pyblish import api as pyblish from openpype.api import Logger -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + register_loader_plugins_path, + deregister_loader_plugins_path, +) from openpype.tools.utils import host_tools from . import lib, menu, events @@ -45,7 +49,7 @@ def install(): log.info("Registering Hiero plug-ins..") pyblish.register_host("hiero") pyblish.register_plugin_path(PUBLISH_PATH) - avalon.register_plugin_path(avalon.Loader, LOAD_PATH) + register_loader_plugins_path(LOAD_PATH) avalon.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) @@ -67,7 +71,7 @@ def uninstall(): log.info("Deregistering Hiero plug-ins..") pyblish.deregister_host("hiero") pyblish.deregister_plugin_path(PUBLISH_PATH) - avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) + deregister_loader_plugins_path(LOAD_PATH) avalon.deregister_plugin_path(LegacyCreator, CREATE_PATH) # register callback for switching publishable diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index 53928aca41..54e66bf99a 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -6,9 +6,9 @@ import hiero from Qt import QtWidgets, QtCore import qargparse -import avalon.api as avalon + import openpype.api as openpype -from openpype.pipeline import LegacyCreator +from openpype.pipeline import LoaderPlugin, LegacyCreator from . import lib log = openpype.Logger().get_logger(__name__) @@ -306,7 +306,7 @@ def get_reference_node_parents(ref): return parents -class SequenceLoader(avalon.Loader): +class SequenceLoader(LoaderPlugin): """A basic SequenceLoader for Resolve This will implement the basic behavior for a loader to inherit from that diff --git a/openpype/hosts/hiero/plugins/load/load_clip.py b/openpype/hosts/hiero/plugins/load/load_clip.py index b905dd4431..d3908695a2 100644 --- a/openpype/hosts/hiero/plugins/load/load_clip.py +++ b/openpype/hosts/hiero/plugins/load/load_clip.py @@ -1,4 +1,5 @@ -from avalon import io, api +from avalon import io +from openpype.pipeline import get_representation_path import openpype.hosts.hiero.api as phiero # from openpype.hosts.hiero.api import plugin, lib # reload(lib) @@ -112,7 +113,7 @@ class LoadClip(phiero.SequenceLoader): version_name = version.get("name", None) colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) - file = api.get_representation_path(representation).replace("\\", "/") + file = get_representation_path(representation).replace("\\", "/") clip = track_item.source() # reconnect media to new path diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 21027dad2e..66c1c84308 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -11,7 +11,10 @@ import avalon.api from avalon.pipeline import AVALON_CONTAINER_ID from avalon.lib import find_submodule -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + register_loader_plugin_path, +) import openpype.hosts.houdini from openpype.hosts.houdini.api import lib @@ -48,7 +51,7 @@ def install(): pyblish.api.register_host("hpython") pyblish.api.register_plugin_path(PUBLISH_PATH) - avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) + register_loader_plugin_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) log.info("Installing callbacks ... ") diff --git a/openpype/hosts/houdini/plugins/load/actions.py b/openpype/hosts/houdini/plugins/load/actions.py index acdb998c16..63d74c39a5 100644 --- a/openpype/hosts/houdini/plugins/load/actions.py +++ b/openpype/hosts/houdini/plugins/load/actions.py @@ -2,10 +2,10 @@ """ -from avalon import api +from openpype.pipeline import load -class SetFrameRangeLoader(api.Loader): +class SetFrameRangeLoader(load.LoaderPlugin): """Set Houdini frame range""" families = [ @@ -43,7 +43,7 @@ class SetFrameRangeLoader(api.Loader): hou.playbar.setPlaybackRange(start, end) -class SetFrameRangeWithHandlesLoader(api.Loader): +class SetFrameRangeWithHandlesLoader(load.LoaderPlugin): """Set Maya frame range including pre- and post-handles""" families = [ diff --git a/openpype/hosts/houdini/plugins/load/load_alembic.py b/openpype/hosts/houdini/plugins/load/load_alembic.py index eaab81f396..0214229d5a 100644 --- a/openpype/hosts/houdini/plugins/load/load_alembic.py +++ b/openpype/hosts/houdini/plugins/load/load_alembic.py @@ -1,10 +1,12 @@ import os -from avalon import api - +from openpype.pipeline import ( + load, + get_representation_path, +) from openpype.hosts.houdini.api import pipeline -class AbcLoader(api.Loader): +class AbcLoader(load.LoaderPlugin): """Specific loader of Alembic for the avalon.animation family""" families = ["model", "animation", "pointcache", "gpuCache"] @@ -90,7 +92,7 @@ class AbcLoader(api.Loader): return # Update the file path - file_path = api.get_representation_path(representation) + file_path = get_representation_path(representation) file_path = file_path.replace("\\", "/") alembic_node.setParms({"fileName": file_path}) diff --git a/openpype/hosts/houdini/plugins/load/load_ass.py b/openpype/hosts/houdini/plugins/load/load_ass.py index 8c272044ec..0144bbaefd 100644 --- a/openpype/hosts/houdini/plugins/load/load_ass.py +++ b/openpype/hosts/houdini/plugins/load/load_ass.py @@ -1,11 +1,15 @@ import os -from avalon import api -from avalon.houdini import pipeline import clique +from openpype.pipeline import ( + load, + get_representation_path, +) + +from openpype.hosts.houdini.api import pipeline -class AssLoader(api.Loader): +class AssLoader(load.LoaderPlugin): """Load .ass with Arnold Procedural""" families = ["ass"] @@ -88,7 +92,7 @@ class AssLoader(api.Loader): def update(self, container, representation): # Update the file path - file_path = api.get_representation_path(representation) + file_path = get_representation_path(representation) file_path = file_path.replace("\\", "/") procedural = container["node"] diff --git a/openpype/hosts/houdini/plugins/load/load_camera.py b/openpype/hosts/houdini/plugins/load/load_camera.py index 8916d3b9b7..ef57d115da 100644 --- a/openpype/hosts/houdini/plugins/load/load_camera.py +++ b/openpype/hosts/houdini/plugins/load/load_camera.py @@ -1,4 +1,7 @@ -from avalon import api +from openpype.pipeline import ( + load, + get_representation_path, +) from openpype.hosts.houdini.api import pipeline @@ -74,7 +77,7 @@ def transfer_non_default_values(src, dest, ignore=None): dest_parm.setFromParm(parm) -class CameraLoader(api.Loader): +class CameraLoader(load.LoaderPlugin): """Specific loader of Alembic for the avalon.animation family""" families = ["camera"] @@ -129,7 +132,7 @@ class CameraLoader(api.Loader): node = container["node"] # Update the file path - file_path = api.get_representation_path(representation) + file_path = get_representation_path(representation) file_path = file_path.replace("\\", "/") # Update attributes diff --git a/openpype/hosts/houdini/plugins/load/load_hda.py b/openpype/hosts/houdini/plugins/load/load_hda.py index f5f2fb7481..2438570c6e 100644 --- a/openpype/hosts/houdini/plugins/load/load_hda.py +++ b/openpype/hosts/houdini/plugins/load/load_hda.py @@ -1,10 +1,13 @@ # -*- coding: utf-8 -*- -from avalon import api - +import os +from openpype.pipeline import ( + load, + get_representation_path, +) from openpype.hosts.houdini.api import pipeline -class HdaLoader(api.Loader): +class HdaLoader(load.LoaderPlugin): """Load Houdini Digital Asset file.""" families = ["hda"] @@ -15,7 +18,6 @@ class HdaLoader(api.Loader): color = "orange" def load(self, context, name=None, namespace=None, data=None): - import os import hou # Format file name, Houdini only wants forward slashes @@ -49,7 +51,7 @@ class HdaLoader(api.Loader): import hou hda_node = container["node"] - file_path = api.get_representation_path(representation) + file_path = get_representation_path(representation) file_path = file_path.replace("\\", "/") hou.hda.installFile(file_path) defs = hda_node.type().allInstalledDefinitions() diff --git a/openpype/hosts/houdini/plugins/load/load_image.py b/openpype/hosts/houdini/plugins/load/load_image.py index 39f583677b..bd9ea3eee3 100644 --- a/openpype/hosts/houdini/plugins/load/load_image.py +++ b/openpype/hosts/houdini/plugins/load/load_image.py @@ -1,6 +1,9 @@ import os -from avalon import api +from openpype.pipeline import ( + load, + get_representation_path, +) from openpype.hosts.houdini.api import lib, pipeline import hou @@ -37,7 +40,7 @@ def get_image_avalon_container(): return image_container -class ImageLoader(api.Loader): +class ImageLoader(load.LoaderPlugin): """Specific loader of Alembic for the avalon.animation family""" families = ["colorbleed.imagesequence"] @@ -87,7 +90,7 @@ class ImageLoader(api.Loader): node = container["node"] # Update the file path - file_path = api.get_representation_path(representation) + file_path = get_representation_path(representation) file_path = file_path.replace("\\", "/") file_path = self._get_file_sequence(file_path) diff --git a/openpype/hosts/houdini/plugins/load/load_usd_layer.py b/openpype/hosts/houdini/plugins/load/load_usd_layer.py index 0d4378b480..d803e6abfe 100644 --- a/openpype/hosts/houdini/plugins/load/load_usd_layer.py +++ b/openpype/hosts/houdini/plugins/load/load_usd_layer.py @@ -1,8 +1,11 @@ -from avalon import api +from openpype.pipeline import ( + load, + get_representation_path, +) from openpype.hosts.houdini.api import lib, pipeline -class USDSublayerLoader(api.Loader): +class USDSublayerLoader(load.LoaderPlugin): """Sublayer USD file in Solaris""" families = [ @@ -57,7 +60,7 @@ class USDSublayerLoader(api.Loader): node = container["node"] # Update the file path - file_path = api.get_representation_path(representation) + file_path = get_representation_path(representation) file_path = file_path.replace("\\", "/") # Update attributes diff --git a/openpype/hosts/houdini/plugins/load/load_usd_reference.py b/openpype/hosts/houdini/plugins/load/load_usd_reference.py index 0edd8d9af6..fdb443f4cf 100644 --- a/openpype/hosts/houdini/plugins/load/load_usd_reference.py +++ b/openpype/hosts/houdini/plugins/load/load_usd_reference.py @@ -1,8 +1,11 @@ -from avalon import api +from openpype.pipeline import ( + load, + get_representation_path, +) from openpype.hosts.houdini.api import lib, pipeline -class USDReferenceLoader(api.Loader): +class USDReferenceLoader(load.LoaderPlugin): """Reference USD file in Solaris""" families = [ @@ -57,7 +60,7 @@ class USDReferenceLoader(api.Loader): node = container["node"] # Update the file path - file_path = api.get_representation_path(representation) + file_path = get_representation_path(representation) file_path = file_path.replace("\\", "/") # Update attributes diff --git a/openpype/hosts/houdini/plugins/load/load_vdb.py b/openpype/hosts/houdini/plugins/load/load_vdb.py index 40aa7a1d18..06bb9e45e4 100644 --- a/openpype/hosts/houdini/plugins/load/load_vdb.py +++ b/openpype/hosts/houdini/plugins/load/load_vdb.py @@ -1,11 +1,14 @@ import os import re -from avalon import api +from openpype.pipeline import ( + load, + get_representation_path, +) from openpype.hosts.houdini.api import pipeline -class VdbLoader(api.Loader): +class VdbLoader(load.LoaderPlugin): """Specific loader of Alembic for the avalon.animation family""" families = ["vdbcache"] @@ -96,7 +99,7 @@ class VdbLoader(api.Loader): return # Update the file path - file_path = api.get_representation_path(representation) + file_path = get_representation_path(representation) file_path = self.format_path(file_path) file_node.setParms({"fileName": file_path}) diff --git a/openpype/hosts/houdini/plugins/load/show_usdview.py b/openpype/hosts/houdini/plugins/load/show_usdview.py index f23974094e..8066615181 100644 --- a/openpype/hosts/houdini/plugins/load/show_usdview.py +++ b/openpype/hosts/houdini/plugins/load/show_usdview.py @@ -1,7 +1,7 @@ -from avalon import api +from openpype.pipeline import load -class ShowInUsdview(api.Loader): +class ShowInUsdview(load.LoaderPlugin): """Open USD file in usdview""" families = ["colorbleed.usd"] diff --git a/openpype/hosts/houdini/plugins/publish/extract_usd_layered.py b/openpype/hosts/houdini/plugins/publish/extract_usd_layered.py index 645bd05d4b..3e842ae766 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_usd_layered.py +++ b/openpype/hosts/houdini/plugins/publish/extract_usd_layered.py @@ -7,6 +7,7 @@ from collections import deque import pyblish.api import openpype.api +from openpype.pipeline import get_representation_path import openpype.hosts.houdini.api.usd as hou_usdlib from openpype.hosts.houdini.api.lib import render_rop @@ -308,7 +309,7 @@ class ExtractUSDLayered(openpype.api.Extractor): self.log.debug("No existing representation..") return False - old_file = api.get_representation_path(representation) + old_file = get_representation_path(representation) if not os.path.exists(old_file): return False diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 41c67a6209..94efbb7a07 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -17,10 +17,16 @@ import bson from maya import cmds, mel import maya.api.OpenMaya as om -from avalon import api, io, pipeline +from avalon import api, io from openpype import lib from openpype.api import get_anatomy_settings +from openpype.pipeline import ( + discover_loader_plugins, + loaders_from_representation, + get_representation_path, + load_representation, +) from .commands import reset_frame_range @@ -1580,21 +1586,21 @@ def assign_look_by_version(nodes, version_id): log.info("Using look for the first time ..") # Load file - loaders = api.loaders_from_representation(api.discover(api.Loader), - representation_id) + _loaders = discover_loader_plugins() + loaders = loaders_from_representation(_loaders, representation_id) Loader = next((i for i in loaders if i.__name__ == "LookLoader"), None) if Loader is None: raise RuntimeError("Could not find LookLoader, this is a bug") # Reference the look file with maintained_selection(): - container_node = pipeline.load(Loader, look_representation) + container_node = load_representation(Loader, look_representation) # Get container members shader_nodes = get_container_members(container_node) # Load relationships - shader_relation = api.get_representation_path(json_representation) + shader_relation = get_representation_path(json_representation) with open(shader_relation, "r") as f: relationships = json.load(f) diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 8c3669c5d1..23e21894bd 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -16,7 +16,11 @@ import openpype.hosts.maya from openpype.tools.utils import host_tools from openpype.lib import any_outdated from openpype.lib.path_tools import HostDirmap -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + register_loader_plugins_path, + deregister_loader_plugins_path, +) from openpype.hosts.maya.lib import copy_workspace_mel from . import menu, lib @@ -49,7 +53,7 @@ def install(): pyblish.api.register_host("mayapy") pyblish.api.register_host("maya") - avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) + register_loader_plugins_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) log.info(PUBLISH_PATH) @@ -175,7 +179,7 @@ def uninstall(): pyblish.api.deregister_host("mayapy") pyblish.api.deregister_host("maya") - avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) + deregister_loader_plugins_path(LOAD_PATH) avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) avalon.api.deregister_plugin_path( avalon.api.InventoryAction, INVENTORY_PATH diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index e0c21645e4..12cbd00257 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -4,9 +4,12 @@ from maya import cmds import qargparse -from avalon import api from avalon.pipeline import AVALON_CONTAINER_ID -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + LoaderPlugin, + get_representation_path, +) from .pipeline import containerise from . import lib @@ -95,7 +98,7 @@ class Creator(LegacyCreator): return instance -class Loader(api.Loader): +class Loader(LoaderPlugin): hosts = ["maya"] @@ -194,7 +197,7 @@ class ReferenceLoader(Loader): node = container["objectName"] - path = api.get_representation_path(representation) + path = get_representation_path(representation) # Get reference node from container members members = get_container_members(node) diff --git a/openpype/hosts/maya/api/setdress.py b/openpype/hosts/maya/api/setdress.py index 1a7c3933a1..74ee292eb2 100644 --- a/openpype/hosts/maya/api/setdress.py +++ b/openpype/hosts/maya/api/setdress.py @@ -8,7 +8,15 @@ import copy import six from maya import cmds -from avalon import api, io +from avalon import io +from openpype.pipeline import ( + discover_loader_plugins, + loaders_from_representation, + load_representation, + update_container, + remove_container, + get_representation_path, +) from openpype.hosts.maya.api.lib import ( matrix_equals, unique_namespace @@ -120,12 +128,13 @@ def load_package(filepath, name, namespace=None): root = "{}:{}".format(namespace, name) containers = [] - all_loaders = api.discover(api.Loader) + all_loaders = discover_loader_plugins() for representation_id, instances in data.items(): # Find the compatible loaders - loaders = api.loaders_from_representation(all_loaders, - representation_id) + loaders = loaders_from_representation( + all_loaders, representation_id + ) for instance in instances: container = _add(instance=instance, @@ -180,9 +189,11 @@ def _add(instance, representation_id, loaders, namespace, root="|"): instance['loader'], instance) raise RuntimeError("Loader is missing.") - container = api.load(Loader, - representation_id, - namespace=instance['namespace']) + container = load_representation( + Loader, + representation_id, + namespace=instance['namespace'] + ) # Get the root from the loaded container loaded_root = get_container_transforms({"objectName": container}, @@ -320,13 +331,13 @@ def update_package(set_container, representation): "type": "representation" }) - current_file = api.get_representation_path(current_representation) + current_file = get_representation_path(current_representation) assert current_file.endswith(".json") with open(current_file, "r") as fp: current_data = json.load(fp) # Load the new package data - new_file = api.get_representation_path(representation) + new_file = get_representation_path(representation) assert new_file.endswith(".json") with open(new_file, "r") as fp: new_data = json.load(fp) @@ -460,12 +471,12 @@ def update_scene(set_container, containers, current_data, new_data, new_file): # considered as new element and added afterwards. processed_containers.pop() processed_namespaces.remove(container_ns) - api.remove(container) + remove_container(container) continue # Check whether the conversion can be done by the Loader. # They *must* use the same asset, subset and Loader for - # `api.update` to make sense. + # `update_container` to make sense. old = io.find_one({ "_id": io.ObjectId(representation_current) }) @@ -479,20 +490,21 @@ def update_scene(set_container, containers, current_data, new_data, new_file): continue new_version = new["context"]["version"] - api.update(container, version=new_version) + update_container(container, version=new_version) else: # Remove this container because it's not in the new data log.warning("Removing content: %s", container_ns) - api.remove(container) + remove_container(container) # Add new assets - all_loaders = api.discover(api.Loader) + all_loaders = discover_loader_plugins() for representation_id, instances in new_data.items(): # Find the compatible loaders - loaders = api.loaders_from_representation(all_loaders, - representation_id) + loaders = loaders_from_representation( + all_loaders, representation_id + ) for instance in instances: # Already processed in update functionality @@ -517,7 +529,7 @@ def update_scene(set_container, containers, current_data, new_data, new_file): def compare_representations(old, new): """Check if the old representation given can be updated - Due to limitations of the `api.update` function we cannot allow + Due to limitations of the `update_container` function we cannot allow differences in the following data: * Representation name (extension) diff --git a/openpype/hosts/maya/plugins/inventory/import_modelrender.py b/openpype/hosts/maya/plugins/inventory/import_modelrender.py index 119edccb7a..c5d3d0c8f4 100644 --- a/openpype/hosts/maya/plugins/inventory/import_modelrender.py +++ b/openpype/hosts/maya/plugins/inventory/import_modelrender.py @@ -1,5 +1,9 @@ import json -from avalon import api, io, pipeline +from avalon import api, io +from openpype.pipeline import ( + get_representation_context, + get_representation_path_from_context, +) from openpype.hosts.maya.api.lib import ( maintained_selection, apply_shaders @@ -73,11 +77,11 @@ class ImportModelRender(api.InventoryAction): "name": self.look_data_type, }) - context = pipeline.get_representation_context(look_repr["_id"]) - maya_file = pipeline.get_representation_path_from_context(context) + context = get_representation_context(look_repr["_id"]) + maya_file = get_representation_path_from_context(context) - context = pipeline.get_representation_context(json_repr["_id"]) - json_file = pipeline.get_representation_path_from_context(context) + context = get_representation_context(json_repr["_id"]) + json_file = get_representation_path_from_context(context) # Import the look file with maintained_selection(): diff --git a/openpype/hosts/maya/plugins/load/actions.py b/openpype/hosts/maya/plugins/load/actions.py index 1cb63c8a7a..483ad32402 100644 --- a/openpype/hosts/maya/plugins/load/actions.py +++ b/openpype/hosts/maya/plugins/load/actions.py @@ -2,14 +2,14 @@ """ -from avalon import api +from openpype.pipeline import load from openpype.hosts.maya.api.lib import ( maintained_selection, unique_namespace ) -class SetFrameRangeLoader(api.Loader): +class SetFrameRangeLoader(load.LoaderPlugin): """Specific loader of Alembic for the avalon.animation family""" families = ["animation", @@ -43,7 +43,7 @@ class SetFrameRangeLoader(api.Loader): animationEndTime=end) -class SetFrameRangeWithHandlesLoader(api.Loader): +class SetFrameRangeWithHandlesLoader(load.LoaderPlugin): """Specific loader of Alembic for the avalon.animation family""" families = ["animation", @@ -81,7 +81,7 @@ class SetFrameRangeWithHandlesLoader(api.Loader): animationEndTime=end) -class ImportMayaLoader(api.Loader): +class ImportMayaLoader(load.LoaderPlugin): """Import action for Maya (unmanaged) Warning: diff --git a/openpype/hosts/maya/plugins/load/load_ass.py b/openpype/hosts/maya/plugins/load/load_ass.py index 18b34d2233..18de4df3b1 100644 --- a/openpype/hosts/maya/plugins/load/load_ass.py +++ b/openpype/hosts/maya/plugins/load/load_ass.py @@ -1,8 +1,11 @@ import os import clique -from avalon import api from openpype.api import get_project_settings +from openpype.pipeline import ( + load, + get_representation_path +) import openpype.hosts.maya.api.plugin from openpype.hosts.maya.api.plugin import get_reference_node from openpype.hosts.maya.api.lib import ( @@ -106,7 +109,7 @@ class AssProxyLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): node = container["objectName"] representation["context"].pop("frame", None) - path = api.get_representation_path(representation) + path = get_representation_path(representation) print(path) # path = self.fname print(self.fname) @@ -164,7 +167,7 @@ class AssProxyLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): type="string") -class AssStandinLoader(api.Loader): +class AssStandinLoader(load.LoaderPlugin): """Load .ASS file as standin""" families = ["ass"] @@ -240,7 +243,7 @@ class AssStandinLoader(api.Loader): import pymel.core as pm - path = api.get_representation_path(representation) + path = get_representation_path(representation) files_in_path = os.listdir(os.path.split(path)[0]) sequence = 0 diff --git a/openpype/hosts/maya/plugins/load/load_assembly.py b/openpype/hosts/maya/plugins/load/load_assembly.py index 0151da7253..902f38695c 100644 --- a/openpype/hosts/maya/plugins/load/load_assembly.py +++ b/openpype/hosts/maya/plugins/load/load_assembly.py @@ -1,7 +1,10 @@ -from avalon import api +from openpype.pipeline import ( + load, + remove_container +) -class AssemblyLoader(api.Loader): +class AssemblyLoader(load.LoaderPlugin): families = ["assembly"] representations = ["json"] @@ -48,13 +51,11 @@ class AssemblyLoader(api.Loader): def update(self, container, representation): from openpype import setdress - return setdress.update_package(container, - representation) + return setdress.update_package(container, representation) def remove(self, container): """Remove all sub containers""" - from avalon import api from openpype import setdress import maya.cmds as cmds @@ -63,7 +64,7 @@ class AssemblyLoader(api.Loader): for member_container in member_containers: self.log.info("Removing container %s", member_container['objectName']) - api.remove(member_container) + remove_container(member_container) # Remove alembic hierarchy reference # TODO: Check whether removing all contained references is safe enough diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index 99f1f7c172..d8844ffea6 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -1,10 +1,14 @@ from maya import cmds, mel -from avalon import api, io +from avalon import io +from openpype.pipeline import ( + load, + get_representation_path +) from openpype.hosts.maya.api.pipeline import containerise from openpype.hosts.maya.api.lib import unique_namespace -class AudioLoader(api.Loader): +class AudioLoader(load.LoaderPlugin): """Specific loader of audio.""" families = ["audio"] @@ -51,7 +55,7 @@ class AudioLoader(api.Loader): assert audio_node is not None, "Audio node not found." - path = api.get_representation_path(representation) + path = get_representation_path(representation) audio_node.filename.set(path) cmds.setAttr( container["objectName"] + ".representation", diff --git a/openpype/hosts/maya/plugins/load/load_gpucache.py b/openpype/hosts/maya/plugins/load/load_gpucache.py index 2e0b7bb810..591e568e4c 100644 --- a/openpype/hosts/maya/plugins/load/load_gpucache.py +++ b/openpype/hosts/maya/plugins/load/load_gpucache.py @@ -1,9 +1,13 @@ import os -from avalon import api + +from openpype.pipeline import ( + load, + get_representation_path +) from openpype.api import get_project_settings -class GpuCacheLoader(api.Loader): +class GpuCacheLoader(load.LoaderPlugin): """Load model Alembic as gpuCache""" families = ["model"] @@ -73,7 +77,7 @@ class GpuCacheLoader(api.Loader): import maya.cmds as cmds - path = api.get_representation_path(representation) + path = get_representation_path(representation) # Update the cache members = cmds.sets(container['objectName'], query=True) diff --git a/openpype/hosts/maya/plugins/load/load_image_plane.py b/openpype/hosts/maya/plugins/load/load_image_plane.py index 8e33f51389..b250986489 100644 --- a/openpype/hosts/maya/plugins/load/load_image_plane.py +++ b/openpype/hosts/maya/plugins/load/load_image_plane.py @@ -1,6 +1,10 @@ from Qt import QtWidgets, QtCore -from avalon import api, io +from avalon import io +from openpype.pipeline import ( + load, + get_representation_path +) from openpype.hosts.maya.api.pipeline import containerise from openpype.hosts.maya.api.lib import unique_namespace @@ -74,7 +78,7 @@ class CameraWindow(QtWidgets.QDialog): self.close() -class ImagePlaneLoader(api.Loader): +class ImagePlaneLoader(load.LoaderPlugin): """Specific loader of plate for image planes on selected camera.""" families = ["image", "plate", "render"] @@ -203,7 +207,7 @@ class ImagePlaneLoader(api.Loader): assert image_plane_shape is not None, "Image plane not found." - path = api.get_representation_path(representation) + path = get_representation_path(representation) image_plane_shape.imageName.set(path) cmds.setAttr( container["objectName"] + ".representation", diff --git a/openpype/hosts/maya/plugins/load/load_look.py b/openpype/hosts/maya/plugins/load/load_look.py index 96c1ecbb20..8f02ed59b8 100644 --- a/openpype/hosts/maya/plugins/load/load_look.py +++ b/openpype/hosts/maya/plugins/load/load_look.py @@ -5,7 +5,8 @@ from collections import defaultdict from Qt import QtWidgets -from avalon import api, io +from avalon import io +from openpype.pipeline import get_representation_path import openpype.hosts.maya.api.plugin from openpype.hosts.maya.api import lib from openpype.widgets.message_window import ScrollMessageBox @@ -77,7 +78,7 @@ class LookLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): }) # Load relationships - shader_relation = api.get_representation_path(json_representation) + shader_relation = get_representation_path(json_representation) with open(shader_relation, "r") as f: json_data = json.load(f) diff --git a/openpype/hosts/maya/plugins/load/load_matchmove.py b/openpype/hosts/maya/plugins/load/load_matchmove.py index abc702cde8..ee3332bd09 100644 --- a/openpype/hosts/maya/plugins/load/load_matchmove.py +++ b/openpype/hosts/maya/plugins/load/load_matchmove.py @@ -1,8 +1,8 @@ -from avalon import api from maya import mel +from openpype.pipeline import load -class MatchmoveLoader(api.Loader): +class MatchmoveLoader(load.LoaderPlugin): """ This will run matchmove script to create track in scene. diff --git a/openpype/hosts/maya/plugins/load/load_redshift_proxy.py b/openpype/hosts/maya/plugins/load/load_redshift_proxy.py index fd2ae0f1d3..d93a9f02a2 100644 --- a/openpype/hosts/maya/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/maya/plugins/load/load_redshift_proxy.py @@ -5,8 +5,11 @@ import clique import maya.cmds as cmds -from avalon import api from openpype.api import get_project_settings +from openpype.pipeline import ( + load, + get_representation_path +) from openpype.hosts.maya.api.lib import ( namespaced, maintained_selection, @@ -15,7 +18,7 @@ from openpype.hosts.maya.api.lib import ( from openpype.hosts.maya.api.pipeline import containerise -class RedshiftProxyLoader(api.Loader): +class RedshiftProxyLoader(load.LoaderPlugin): """Load Redshift proxy""" families = ["redshiftproxy"] @@ -78,7 +81,7 @@ class RedshiftProxyLoader(api.Loader): rs_meshes = cmds.ls(members, type="RedshiftProxyMesh") assert rs_meshes, "Cannot find RedshiftProxyMesh in container" - filename = api.get_representation_path(representation) + filename = get_representation_path(representation) for rs_mesh in rs_meshes: cmds.setAttr("{}.fileName".format(rs_mesh), diff --git a/openpype/hosts/maya/plugins/load/load_rendersetup.py b/openpype/hosts/maya/plugins/load/load_rendersetup.py index efeff2f193..7a2d8b1002 100644 --- a/openpype/hosts/maya/plugins/load/load_rendersetup.py +++ b/openpype/hosts/maya/plugins/load/load_rendersetup.py @@ -7,10 +7,13 @@ instance. """ import json -import six import sys +import six -from avalon import api +from openpype.pipeline import ( + load, + get_representation_path +) from openpype.hosts.maya.api import lib from openpype.hosts.maya.api.pipeline import containerise @@ -18,7 +21,7 @@ from maya import cmds import maya.app.renderSetup.model.renderSetup as renderSetup -class RenderSetupLoader(api.Loader): +class RenderSetupLoader(load.LoaderPlugin): """Load json preset for RenderSetup overwriting current one.""" families = ["rendersetup"] @@ -87,7 +90,7 @@ class RenderSetupLoader(api.Loader): "Render setup setting will be overwritten by new version. All " "setting specified by user not included in loaded version " "will be lost.") - path = api.get_representation_path(representation) + path = get_representation_path(representation) with open(path, "r") as file: try: renderSetup.instance().decode( diff --git a/openpype/hosts/maya/plugins/load/load_vdb_to_redshift.py b/openpype/hosts/maya/plugins/load/load_vdb_to_redshift.py index 3e1d67ae9a..70bd9d22e2 100644 --- a/openpype/hosts/maya/plugins/load/load_vdb_to_redshift.py +++ b/openpype/hosts/maya/plugins/load/load_vdb_to_redshift.py @@ -1,9 +1,10 @@ import os -from avalon import api + from openpype.api import get_project_settings +from openpype.pipeline import load -class LoadVDBtoRedShift(api.Loader): +class LoadVDBtoRedShift(load.LoaderPlugin): """Load OpenVDB in a Redshift Volume Shape""" families = ["vdbcache"] diff --git a/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py b/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py index 6d5544103d..4f14235bfb 100644 --- a/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py +++ b/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py @@ -1,6 +1,10 @@ import os -from avalon import api + from openpype.api import get_project_settings +from openpype.pipeline import ( + load, + get_representation_path +) from maya import cmds @@ -69,7 +73,7 @@ def _fix_duplicate_vvg_callbacks(): matched.add(callback) -class LoadVDBtoVRay(api.Loader): +class LoadVDBtoVRay(load.LoaderPlugin): families = ["vdbcache"] representations = ["vdb"] @@ -252,7 +256,7 @@ class LoadVDBtoVRay(api.Loader): def update(self, container, representation): - path = api.get_representation_path(representation) + path = get_representation_path(representation) # Find VRayVolumeGrid members = cmds.sets(container['objectName'], query=True) diff --git a/openpype/hosts/maya/plugins/load/load_vrayproxy.py b/openpype/hosts/maya/plugins/load/load_vrayproxy.py index ac2fe635b3..5b79b1efb3 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayproxy.py +++ b/openpype/hosts/maya/plugins/load/load_vrayproxy.py @@ -9,8 +9,12 @@ import os import maya.cmds as cmds -from avalon import api, io +from avalon import io from openpype.api import get_project_settings +from openpype.pipeline import ( + load, + get_representation_path +) from openpype.hosts.maya.api.lib import ( maintained_selection, namespaced, @@ -19,7 +23,7 @@ from openpype.hosts.maya.api.lib import ( from openpype.hosts.maya.api.pipeline import containerise -class VRayProxyLoader(api.Loader): +class VRayProxyLoader(load.LoaderPlugin): """Load VRay Proxy with Alembic or VrayMesh.""" families = ["vrayproxy", "model", "pointcache", "animation"] @@ -100,7 +104,10 @@ class VRayProxyLoader(api.Loader): assert vraymeshes, "Cannot find VRayMesh in container" # get all representations for this version - filename = self._get_abc(representation["parent"]) or api.get_representation_path(representation) # noqa: E501 + filename = ( + self._get_abc(representation["parent"]) + or get_representation_path(representation) + ) for vray_mesh in vraymeshes: cmds.setAttr("{}.fileName".format(vray_mesh), @@ -185,7 +192,7 @@ class VRayProxyLoader(api.Loader): if abc_rep: self.log.debug("Found, we'll link alembic to vray proxy.") - file_name = api.get_representation_path(abc_rep) + file_name = get_representation_path(abc_rep) self.log.debug("File: {}".format(self.fname)) return file_name diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index dfe2b85edc..61132088cc 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -1,8 +1,11 @@ # -*- coding: utf-8 -*- import os import maya.cmds as cmds # noqa -from avalon import api from openpype.api import get_project_settings +from openpype.pipeline import ( + load, + get_representation_path +) from openpype.hosts.maya.api.lib import ( maintained_selection, namespaced, @@ -11,7 +14,7 @@ from openpype.hosts.maya.api.lib import ( from openpype.hosts.maya.api.pipeline import containerise -class VRaySceneLoader(api.Loader): +class VRaySceneLoader(load.LoaderPlugin): """Load Vray scene""" families = ["vrayscene_layer"] @@ -78,7 +81,7 @@ class VRaySceneLoader(api.Loader): vraymeshes = cmds.ls(members, type="VRayScene") assert vraymeshes, "Cannot find VRayScene in container" - filename = api.get_representation_path(representation) + filename = get_representation_path(representation) for vray_mesh in vraymeshes: cmds.setAttr("{}.FilePath".format(vray_mesh), diff --git a/openpype/hosts/maya/plugins/load/load_yeti_cache.py b/openpype/hosts/maya/plugins/load/load_yeti_cache.py index dfe75173ac..c64e1c540b 100644 --- a/openpype/hosts/maya/plugins/load/load_yeti_cache.py +++ b/openpype/hosts/maya/plugins/load/load_yeti_cache.py @@ -7,13 +7,17 @@ from pprint import pprint from maya import cmds -from avalon import api, io +from avalon import io from openpype.api import get_project_settings +from openpype.pipeline import ( + load, + get_representation_path +) from openpype.hosts.maya.api import lib from openpype.hosts.maya.api.pipeline import containerise -class YetiCacheLoader(api.Loader): +class YetiCacheLoader(load.LoaderPlugin): families = ["yeticache", "yetiRig"] representations = ["fur"] @@ -121,8 +125,8 @@ class YetiCacheLoader(api.Loader): "cannot find fursettings representation" ) - settings_fname = api.get_representation_path(fur_settings) - path = api.get_representation_path(representation) + settings_fname = get_representation_path(fur_settings) + path = get_representation_path(representation) # Get all node data with open(settings_fname, "r") as fp: settings = json.load(fp) diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index d98a951491..7011b3bed1 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -14,7 +14,11 @@ from openpype.api import ( BuildWorkfile, get_current_project_settings ) -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + register_loader_plugins_path, + deregister_loader_plugins_path, +) from openpype.tools.utils import host_tools from .command import viewer_update_and_undo_stop @@ -98,7 +102,7 @@ def install(): log.info("Registering Nuke plug-ins..") pyblish.api.register_plugin_path(PUBLISH_PATH) - avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) + register_loader_plugins_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) @@ -124,7 +128,7 @@ def uninstall(): log.info("Deregistering Nuke plug-ins..") pyblish.deregister_host("nuke") pyblish.api.deregister_plugin_path(PUBLISH_PATH) - avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) + deregister_loader_plugins_path(LOAD_PATH) avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) pyblish.api.deregister_callback( diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index ff186cd685..d0bb45a05d 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -4,10 +4,11 @@ import string import nuke -import avalon.api - from openpype.api import get_current_project_settings -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + LoaderPlugin, +) from .lib import ( Knobby, check_subsetname_exists, @@ -85,7 +86,7 @@ def get_review_presets_config(): return [str(name) for name, _prop in outputs.items()] -class NukeLoader(avalon.api.Loader): +class NukeLoader(LoaderPlugin): container_id_knob = "containerId" container_id = None diff --git a/openpype/hosts/nuke/plugins/load/actions.py b/openpype/hosts/nuke/plugins/load/actions.py index 07dcf2d8e1..81840b3a38 100644 --- a/openpype/hosts/nuke/plugins/load/actions.py +++ b/openpype/hosts/nuke/plugins/load/actions.py @@ -2,13 +2,13 @@ """ -from avalon import api from openpype.api import Logger +from openpype.pipeline import load log = Logger().get_logger(__name__) -class SetFrameRangeLoader(api.Loader): +class SetFrameRangeLoader(load.LoaderPlugin): """Specific loader of Alembic for the avalon.animation family""" families = ["animation", @@ -42,7 +42,7 @@ class SetFrameRangeLoader(api.Loader): lib.update_frame_range(start, end) -class SetFrameRangeWithHandlesLoader(api.Loader): +class SetFrameRangeWithHandlesLoader(load.LoaderPlugin): """Specific loader of Alembic for the avalon.animation family""" families = ["animation", diff --git a/openpype/hosts/nuke/plugins/load/load_backdrop.py b/openpype/hosts/nuke/plugins/load/load_backdrop.py index 6619cfb414..05ce4d08d3 100644 --- a/openpype/hosts/nuke/plugins/load/load_backdrop.py +++ b/openpype/hosts/nuke/plugins/load/load_backdrop.py @@ -1,7 +1,11 @@ -from avalon import api, style, io +from avalon import style, io import nuke import nukescripts +from openpype.pipeline import ( + load, + get_representation_path, +) from openpype.hosts.nuke.api.lib import ( find_free_space_to_paste_nodes, maintained_selection, @@ -14,7 +18,7 @@ from openpype.hosts.nuke.api.commands import viewer_update_and_undo_stop from openpype.hosts.nuke.api import containerise, update_container -class LoadBackdropNodes(api.Loader): +class LoadBackdropNodes(load.LoaderPlugin): """Loading Published Backdrop nodes (workfile, nukenodes)""" representations = ["nk"] @@ -191,7 +195,7 @@ class LoadBackdropNodes(api.Loader): # get corresponding node GN = nuke.toNode(container['objectName']) - file = api.get_representation_path(representation).replace("\\", "/") + file = get_representation_path(representation).replace("\\", "/") context = representation["context"] name = container['name'] version_data = version.get("data", {}) diff --git a/openpype/hosts/nuke/plugins/load/load_camera_abc.py b/openpype/hosts/nuke/plugins/load/load_camera_abc.py index 9610940619..fb5f7f8ede 100644 --- a/openpype/hosts/nuke/plugins/load/load_camera_abc.py +++ b/openpype/hosts/nuke/plugins/load/load_camera_abc.py @@ -1,6 +1,10 @@ import nuke -from avalon import api, io +from avalon import io +from openpype.pipeline import ( + load, + get_representation_path, +) from openpype.hosts.nuke.api import ( containerise, update_container, @@ -11,7 +15,7 @@ from openpype.hosts.nuke.api.lib import ( ) -class AlembicCameraLoader(api.Loader): +class AlembicCameraLoader(load.LoaderPlugin): """ This will load alembic camera into script. """ @@ -127,7 +131,7 @@ class AlembicCameraLoader(api.Loader): data_imprint.update({k: version_data[k]}) # getting file path - file = api.get_representation_path(representation).replace("\\", "/") + file = get_representation_path(representation).replace("\\", "/") with maintained_selection(): camera_node = nuke.toNode(object_name) diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py index a253ba4a9d..563a325a83 100644 --- a/openpype/hosts/nuke/plugins/load/load_clip.py +++ b/openpype/hosts/nuke/plugins/load/load_clip.py @@ -1,7 +1,8 @@ import nuke import qargparse -from avalon import api, io +from avalon import io +from openpype.pipeline import get_representation_path from openpype.hosts.nuke.api.lib import ( get_imageio_input_colorspace, maintained_selection @@ -186,7 +187,7 @@ class LoadClip(plugin.NukeLoader): is_sequence = len(representation["files"]) > 1 read_node = nuke.toNode(container['objectName']) - file = api.get_representation_path(representation).replace("\\", "/") + file = get_representation_path(representation).replace("\\", "/") start_at_workfile = bool("start at" in read_node['frame_mode'].value()) diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py index f636c6b510..2f8333e4f2 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects.py +++ b/openpype/hosts/nuke/plugins/load/load_effects.py @@ -1,7 +1,11 @@ import json from collections import OrderedDict import nuke -from avalon import api, style, io +from avalon import style, io +from openpype.pipeline import ( + load, + get_representation_path, +) from openpype.hosts.nuke.api import ( containerise, update_container, @@ -9,7 +13,7 @@ from openpype.hosts.nuke.api import ( ) -class LoadEffects(api.Loader): +class LoadEffects(load.LoaderPlugin): """Loading colorspace soft effect exported from nukestudio""" representations = ["effectJson"] @@ -149,7 +153,7 @@ class LoadEffects(api.Loader): # get corresponding node GN = nuke.toNode(container['objectName']) - file = api.get_representation_path(representation).replace("\\", "/") + file = get_representation_path(representation).replace("\\", "/") name = container['name'] version_data = version.get("data", {}) vname = version.get("name", None) diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py index 990bce54f1..b998eda69b 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py @@ -3,7 +3,11 @@ from collections import OrderedDict import nuke -from avalon import api, style, io +from avalon import style, io +from openpype.pipeline import ( + load, + get_representation_path, +) from openpype.hosts.nuke.api import lib from openpype.hosts.nuke.api import ( containerise, @@ -12,7 +16,7 @@ from openpype.hosts.nuke.api import ( ) -class LoadEffectsInputProcess(api.Loader): +class LoadEffectsInputProcess(load.LoaderPlugin): """Loading colorspace soft effect exported from nukestudio""" representations = ["effectJson"] @@ -156,7 +160,7 @@ class LoadEffectsInputProcess(api.Loader): # get corresponding node GN = nuke.toNode(container['objectName']) - file = api.get_representation_path(representation).replace("\\", "/") + file = get_representation_path(representation).replace("\\", "/") name = container['name'] version_data = version.get("data", {}) vname = version.get("name", None) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo.py b/openpype/hosts/nuke/plugins/load/load_gizmo.py index 659977d789..0eea6f784b 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo.py @@ -1,5 +1,9 @@ import nuke -from avalon import api, style, io +from avalon import style, io +from openpype.pipeline import ( + load, + get_representation_path, +) from openpype.hosts.nuke.api.lib import ( maintained_selection, get_avalon_knob_data, @@ -12,7 +16,7 @@ from openpype.hosts.nuke.api import ( ) -class LoadGizmo(api.Loader): +class LoadGizmo(load.LoaderPlugin): """Loading nuke Gizmo""" representations = ["gizmo"] @@ -103,7 +107,7 @@ class LoadGizmo(api.Loader): # get corresponding node GN = nuke.toNode(container['objectName']) - file = api.get_representation_path(representation).replace("\\", "/") + file = get_representation_path(representation).replace("\\", "/") name = container['name'] version_data = version.get("data", {}) vname = version.get("name", None) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py index 240bfd467d..8b3f35a29a 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py @@ -1,5 +1,9 @@ -from avalon import api, style, io +from avalon import style, io import nuke +from openpype.pipeline import ( + load, + get_representation_path, +) from openpype.hosts.nuke.api.lib import ( maintained_selection, create_backdrop, @@ -13,7 +17,7 @@ from openpype.hosts.nuke.api import ( ) -class LoadGizmoInputProcess(api.Loader): +class LoadGizmoInputProcess(load.LoaderPlugin): """Loading colorspace soft effect exported from nukestudio""" representations = ["gizmo"] @@ -109,7 +113,7 @@ class LoadGizmoInputProcess(api.Loader): # get corresponding node GN = nuke.toNode(container['objectName']) - file = api.get_representation_path(representation).replace("\\", "/") + file = get_representation_path(representation).replace("\\", "/") name = container['name'] version_data = version.get("data", {}) vname = version.get("name", None) diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index 27c634ec57..e04ccf3bf1 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -1,8 +1,12 @@ import nuke import qargparse -from avalon import api, io +from avalon import io +from openpype.pipeline import ( + load, + get_representation_path, +) from openpype.hosts.nuke.api.lib import ( get_imageio_input_colorspace ) @@ -13,7 +17,7 @@ from openpype.hosts.nuke.api import ( ) -class LoadImage(api.Loader): +class LoadImage(load.LoaderPlugin): """Load still image into Nuke""" families = [ @@ -161,7 +165,7 @@ class LoadImage(api.Loader): repr_cont = representation["context"] - file = api.get_representation_path(representation) + file = get_representation_path(representation) if not file: repr_id = representation["_id"] diff --git a/openpype/hosts/nuke/plugins/load/load_matchmove.py b/openpype/hosts/nuke/plugins/load/load_matchmove.py index 60d5dc026f..f5a90706c7 100644 --- a/openpype/hosts/nuke/plugins/load/load_matchmove.py +++ b/openpype/hosts/nuke/plugins/load/load_matchmove.py @@ -1,8 +1,8 @@ -from avalon import api import nuke +from openpype.pipeline import load -class MatchmoveLoader(api.Loader): +class MatchmoveLoader(load.LoaderPlugin): """ This will run matchmove script to create track in script. """ diff --git a/openpype/hosts/nuke/plugins/load/load_model.py b/openpype/hosts/nuke/plugins/load/load_model.py index 2b52bbf00f..e445beca05 100644 --- a/openpype/hosts/nuke/plugins/load/load_model.py +++ b/openpype/hosts/nuke/plugins/load/load_model.py @@ -1,5 +1,9 @@ import nuke -from avalon import api, io +from avalon import io +from openpype.pipeline import ( + load, + get_representation_path, +) from openpype.hosts.nuke.api.lib import maintained_selection from openpype.hosts.nuke.api import ( containerise, @@ -8,7 +12,7 @@ from openpype.hosts.nuke.api import ( ) -class AlembicModelLoader(api.Loader): +class AlembicModelLoader(load.LoaderPlugin): """ This will load alembic model into script. """ @@ -124,7 +128,7 @@ class AlembicModelLoader(api.Loader): data_imprint.update({k: version_data[k]}) # getting file path - file = api.get_representation_path(representation).replace("\\", "/") + file = get_representation_path(representation).replace("\\", "/") with maintained_selection(): model_node = nuke.toNode(object_name) diff --git a/openpype/hosts/nuke/plugins/load/load_script_precomp.py b/openpype/hosts/nuke/plugins/load/load_script_precomp.py index aa48b631c5..cd47a840ae 100644 --- a/openpype/hosts/nuke/plugins/load/load_script_precomp.py +++ b/openpype/hosts/nuke/plugins/load/load_script_precomp.py @@ -1,5 +1,9 @@ import nuke -from avalon import api, style, io +from avalon import style, io +from openpype.pipeline import ( + load, + get_representation_path, +) from openpype.hosts.nuke.api.lib import get_avalon_knob_data from openpype.hosts.nuke.api import ( containerise, @@ -8,7 +12,7 @@ from openpype.hosts.nuke.api import ( ) -class LinkAsGroup(api.Loader): +class LinkAsGroup(load.LoaderPlugin): """Copy the published file to be pasted at the desired location""" representations = ["nk"] @@ -108,7 +112,7 @@ class LinkAsGroup(api.Loader): """ node = nuke.toNode(container['objectName']) - root = api.get_representation_path(representation).replace("\\", "/") + root = get_representation_path(representation).replace("\\", "/") # Get start frame from version data version = io.find_one({ diff --git a/openpype/hosts/nuke/plugins/publish/precollect_writes.py b/openpype/hosts/nuke/plugins/publish/precollect_writes.py index 189f28f7c6..85e98db7ed 100644 --- a/openpype/hosts/nuke/plugins/publish/precollect_writes.py +++ b/openpype/hosts/nuke/plugins/publish/precollect_writes.py @@ -3,8 +3,9 @@ import re from pprint import pformat import nuke import pyblish.api +from avalon import io import openpype.api as pype -from avalon import io, api +from openpype.pipeline import get_representation_path @pyblish.api.log @@ -182,7 +183,7 @@ class CollectNukeWrites(pyblish.api.InstancePlugin): if repre_doc: instance.data["audio"] = [{ "offset": 0, - "filename": api.get_representation_path(repre_doc) + "filename": get_representation_path(repre_doc) }] self.log.debug("instance.data: {}".format(pformat(instance.data))) diff --git a/openpype/hosts/nuke/plugins/publish/validate_read_legacy.py b/openpype/hosts/nuke/plugins/publish/validate_read_legacy.py index 22a9b3678e..39fe011d85 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_read_legacy.py +++ b/openpype/hosts/nuke/plugins/publish/validate_read_legacy.py @@ -1,12 +1,16 @@ import os -import toml import nuke +import toml import pyblish.api -from avalon import api from bson.objectid import ObjectId +from openpype.pipeline import ( + discover_loader_plugins, + load_representation, +) + class RepairReadLegacyAction(pyblish.api.Action): @@ -49,13 +53,13 @@ class RepairReadLegacyAction(pyblish.api.Action): loader_name = "LoadMov" loader_plugin = None - for Loader in api.discover(api.Loader): + for Loader in discover_loader_plugins(): if Loader.__name__ != loader_name: continue loader_plugin = Loader - api.load( + load_representation( Loader=loader_plugin, representation=ObjectId(data["representation"]) ) diff --git a/openpype/hosts/photoshop/api/README.md b/openpype/hosts/photoshop/api/README.md index b958f53803..80792a4da0 100644 --- a/openpype/hosts/photoshop/api/README.md +++ b/openpype/hosts/photoshop/api/README.md @@ -195,11 +195,12 @@ class ExtractImage(openpype.api.Extractor): #### Loader Plugin ```python from avalon import api, photoshop +from openpype.pipeline import load, get_representation_path stub = photoshop.stub() -class ImageLoader(api.Loader): +class ImageLoader(load.LoaderPlugin): """Load images Stores the imported asset in a container named after the asset. @@ -227,7 +228,7 @@ class ImageLoader(api.Loader): with photoshop.maintained_selection(): stub.replace_smart_object( - layer, api.get_representation_path(representation) + layer, get_representation_path(representation) ) stub.imprint( @@ -245,7 +246,7 @@ https://community.adobe.com/t5/download-install/adobe-extension-debuger-problem/ Add --enable-blink-features=ShadowDOMV0,CustomElementsV0 when starting Chrome then localhost:8078 (port set in `photoshop\extension\.debug`) -Or use Visual Studio Code https://medium.com/adobetech/extendscript-debugger-for-visual-studio-code-public-release-a2ff6161fa01 +Or use Visual Studio Code https://medium.com/adobetech/extendscript-debugger-for-visual-studio-code-public-release-a2ff6161fa01 Or install CEF client from https://github.com/Adobe-CEP/CEP-Resources/tree/master/CEP_9.x ## Resources diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index 662e9dbebc..a7bd64585d 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -6,7 +6,11 @@ import avalon.api from avalon import pipeline, io from openpype.api import Logger -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + register_loader_plugins_path, + deregister_loader_plugins_path, +) import openpype.hosts.photoshop from . import lib @@ -67,7 +71,7 @@ def install(): pyblish.api.register_host("photoshop") pyblish.api.register_plugin_path(PUBLISH_PATH) - avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) + register_loader_plugins_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) log.info(PUBLISH_PATH) @@ -80,7 +84,7 @@ def install(): def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) - avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) + deregister_loader_plugins_path(LOAD_PATH) avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) diff --git a/openpype/hosts/photoshop/api/plugin.py b/openpype/hosts/photoshop/api/plugin.py index c577c67d82..c80e6bbd06 100644 --- a/openpype/hosts/photoshop/api/plugin.py +++ b/openpype/hosts/photoshop/api/plugin.py @@ -1,6 +1,6 @@ import re -import avalon.api +from openpype.pipeline import LoaderPlugin from .launch_logic import stub @@ -29,7 +29,7 @@ def get_unique_layer_name(layers, asset_name, subset_name): return "{}_{:0>3d}".format(name, occurrences + 1) -class PhotoshopLoader(avalon.api.Loader): +class PhotoshopLoader(LoaderPlugin): @staticmethod def get_stub(): return stub() diff --git a/openpype/hosts/photoshop/plugins/load/load_image.py b/openpype/hosts/photoshop/plugins/load/load_image.py index 3b1cfe9636..0a9421b8f2 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image.py +++ b/openpype/hosts/photoshop/plugins/load/load_image.py @@ -1,6 +1,6 @@ import re -from avalon import api +from openpype.pipeline import get_representation_path from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.api import get_unique_layer_name @@ -54,7 +54,7 @@ class ImageLoader(photoshop.PhotoshopLoader): else: # switching version - keep same name layer_name = container["namespace"] - path = api.get_representation_path(representation) + path = get_representation_path(representation) with photoshop.maintained_selection(): stub.replace_smart_object( layer, path, layer_name diff --git a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py index 12e0503dfc..5f39121ae1 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py +++ b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py @@ -1,8 +1,8 @@ import os import qargparse -from avalon.pipeline import get_representation_path_from_context +from openpype.pipeline import get_representation_path_from_context from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.api import get_unique_layer_name diff --git a/openpype/hosts/photoshop/plugins/load/load_reference.py b/openpype/hosts/photoshop/plugins/load/load_reference.py index 60142d4a1f..f5f0545d39 100644 --- a/openpype/hosts/photoshop/plugins/load/load_reference.py +++ b/openpype/hosts/photoshop/plugins/load/load_reference.py @@ -1,7 +1,6 @@ import re -from avalon import api - +from openpype.pipeline import get_representation_path from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.api import get_unique_layer_name @@ -55,7 +54,7 @@ class ReferenceLoader(photoshop.PhotoshopLoader): else: # switching version - keep same name layer_name = container["namespace"] - path = api.get_representation_path(representation) + path = get_representation_path(representation) with photoshop.maintained_selection(): stub.replace_smart_object( layer, path, layer_name diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py index c82545268b..829794dd41 100644 --- a/openpype/hosts/resolve/api/pipeline.py +++ b/openpype/hosts/resolve/api/pipeline.py @@ -9,7 +9,11 @@ from avalon import schema from avalon.pipeline import AVALON_CONTAINER_ID from pyblish import api as pyblish from openpype.api import Logger -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + register_loader_plugins_path, + deregister_loader_plugins_path, +) from . import lib from . import PLUGINS_DIR from openpype.tools.utils import host_tools @@ -42,7 +46,7 @@ def install(): pyblish.register_plugin_path(PUBLISH_PATH) log.info("Registering DaVinci Resovle plug-ins..") - avalon.register_plugin_path(avalon.Loader, LOAD_PATH) + register_loader_plugins_path(LOAD_PATH) avalon.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) @@ -67,7 +71,7 @@ def uninstall(): pyblish.deregister_plugin_path(PUBLISH_PATH) log.info("Deregistering DaVinci Resovle plug-ins..") - avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) + deregister_loader_plugins_path(LOAD_PATH) avalon.deregister_plugin_path(LegacyCreator, CREATE_PATH) avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH) diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index e7793d6e95..8e1436021c 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -4,14 +4,15 @@ import uuid import qargparse from Qt import QtWidgets, QtCore -from avalon import api import openpype.api as pype -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + LoaderPlugin, +) from openpype.hosts import resolve from . import lib - class CreatorWidget(QtWidgets.QDialog): # output items @@ -292,7 +293,7 @@ class ClipLoader: """ Initialize object Arguments: - cls (avalon.api.Loader): plugin object + cls (openpype.pipeline.load.LoaderPlugin): plugin object context (dict): loader plugin context options (dict)[optional]: possible keys: projectBinPath: "path/to/binItem" @@ -448,7 +449,7 @@ class ClipLoader: return timeline_item -class TimelineItemLoader(api.Loader): +class TimelineItemLoader(LoaderPlugin): """A basic SequenceLoader for Resolve This will implement the basic behavior for a loader to inherit from that diff --git a/openpype/hosts/resolve/plugins/load/load_clip.py b/openpype/hosts/resolve/plugins/load/load_clip.py index e20384ee6c..71850d95f6 100644 --- a/openpype/hosts/resolve/plugins/load/load_clip.py +++ b/openpype/hosts/resolve/plugins/load/load_clip.py @@ -1,11 +1,14 @@ -from avalon import io, api -from openpype.hosts import resolve from copy import deepcopy from importlib import reload + +from avalon import io +from openpype.hosts import resolve +from openpype.pipeline import get_representation_path from openpype.hosts.resolve.api import lib, plugin reload(plugin) reload(lib) + class LoadClip(resolve.TimelineItemLoader): """Load a subset to timeline as clip @@ -99,7 +102,7 @@ class LoadClip(resolve.TimelineItemLoader): version_name = version.get("name", None) colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) - self.fname = api.get_representation_path(representation) + self.fname = get_representation_path(representation) context["version"] = {"data": version_data} loader = resolve.ClipLoader(self, context) diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index f4599047b4..6a26446226 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -14,7 +14,11 @@ from avalon.pipeline import AVALON_CONTAINER_ID from openpype.hosts import tvpaint from openpype.api import get_current_project_settings -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + register_loader_plugins_path, + deregister_loader_plugins_path, +) from .lib import ( execute_george, @@ -76,7 +80,7 @@ def install(): pyblish.api.register_host("tvpaint") pyblish.api.register_plugin_path(PUBLISH_PATH) - avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) + register_loader_plugins_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) registered_callbacks = ( @@ -98,7 +102,7 @@ def uninstall(): log.info("OpenPype - Uninstalling TVPaint integration") pyblish.api.deregister_host("tvpaint") pyblish.api.deregister_plugin_path(PUBLISH_PATH) - avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) + deregister_loader_plugins_path(LOAD_PATH) avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) diff --git a/openpype/hosts/tvpaint/api/plugin.py b/openpype/hosts/tvpaint/api/plugin.py index 8510794f06..15ad8905e0 100644 --- a/openpype/hosts/tvpaint/api/plugin.py +++ b/openpype/hosts/tvpaint/api/plugin.py @@ -1,9 +1,10 @@ import re import uuid -import avalon.api - -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + LoaderPlugin, +) from openpype.hosts.tvpaint.api import ( pipeline, lib @@ -74,7 +75,7 @@ class Creator(LegacyCreator): self.write_instances(data) -class Loader(avalon.api.Loader): +class Loader(LoaderPlugin): hosts = ["tvpaint"] @staticmethod diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 8ab19bd697..7100ff3a83 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -7,7 +7,11 @@ import pyblish.api from avalon.pipeline import AVALON_CONTAINER_ID from avalon import api -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + register_loader_plugins_path, + deregister_loader_plugins_path, +) from openpype.tools.utils import host_tools import openpype.hosts.unreal @@ -44,7 +48,7 @@ def install(): print("-=" * 40) logger.info("installing OpenPype for Unreal") pyblish.api.register_plugin_path(str(PUBLISH_PATH)) - api.register_plugin_path(api.Loader, str(LOAD_PATH)) + register_loader_plugins_path(str(LOAD_PATH)) api.register_plugin_path(LegacyCreator, str(CREATE_PATH)) _register_callbacks() _register_events() @@ -53,7 +57,7 @@ def install(): def uninstall(): """Uninstall Unreal configuration for Avalon.""" pyblish.api.deregister_plugin_path(str(PUBLISH_PATH)) - api.deregister_plugin_path(api.Loader, str(LOAD_PATH)) + deregister_loader_plugins_path(str(LOAD_PATH)) api.deregister_plugin_path(LegacyCreator, str(CREATE_PATH)) diff --git a/openpype/hosts/unreal/api/plugin.py b/openpype/hosts/unreal/api/plugin.py index dd2e7750f0..b24bab831d 100644 --- a/openpype/hosts/unreal/api/plugin.py +++ b/openpype/hosts/unreal/api/plugin.py @@ -1,8 +1,10 @@ # -*- coding: utf-8 -*- from abc import ABC -from openpype.pipeline import LegacyCreator -import avalon.api +from openpype.pipeline import ( + LegacyCreator, + LoaderPlugin, +) class Creator(LegacyCreator): @@ -10,6 +12,6 @@ class Creator(LegacyCreator): defaults = ['Main'] -class Loader(avalon.api.Loader, ABC): +class Loader(LoaderPlugin, ABC): """This serves as skeleton for future OpenPype specific functionality""" pass diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py index 027e9f4cd3..3508fe5ed7 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py @@ -2,7 +2,8 @@ """Loader for published alembics.""" import os -from avalon import api, pipeline +from avalon import pipeline +from openpype.pipeline import get_representation_path from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline @@ -140,7 +141,7 @@ class PointCacheAlembicLoader(plugin.Loader): def update(self, container, representation): name = container["asset_name"] - source_path = api.get_representation_path(representation) + source_path = get_representation_path(representation) destination_path = container["namespace"] task = self.get_task(source_path, destination_path, name, True) diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py index 0236bab138..180942de51 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py @@ -2,7 +2,8 @@ """Load Skeletal Mesh alembics.""" import os -from avalon import api, pipeline +from avalon import pipeline +from openpype.pipeline import get_representation_path from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa @@ -104,7 +105,7 @@ class SkeletalMeshAlembicLoader(plugin.Loader): def update(self, container, representation): name = container["asset_name"] - source_path = api.get_representation_path(representation) + source_path = get_representation_path(representation) destination_path = container["namespace"] task = unreal.AssetImportTask() diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py index 3bcc8b476f..4e00af1d97 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py @@ -2,7 +2,8 @@ """Loader for Static Mesh alembics.""" import os -from avalon import api, pipeline +from avalon import pipeline +from openpype.pipeline import get_representation_path from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa @@ -123,7 +124,7 @@ class StaticMeshAlembicLoader(plugin.Loader): def update(self, container, representation): name = container["asset_name"] - source_path = api.get_representation_path(representation) + source_path = get_representation_path(representation) destination_path = container["namespace"] task = self.get_task(source_path, destination_path, name, True) diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py index 63c734b969..8ef81f7851 100644 --- a/openpype/hosts/unreal/plugins/load/load_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_animation.py @@ -3,7 +3,8 @@ import os import json -from avalon import api, pipeline +from avalon import pipeline +from openpype.pipeline import get_representation_path from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa @@ -173,7 +174,7 @@ class AnimationFBXLoader(plugin.Loader): def update(self, container, representation): name = container["asset_name"] - source_path = api.get_representation_path(representation) + source_path = get_representation_path(representation) destination_path = container["namespace"] task = unreal.AssetImportTask() diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index b802f5940a..b987a32a61 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -11,7 +11,13 @@ from unreal import AssetToolsHelpers from unreal import FBXImportType from unreal import MathLibrary as umath -from avalon import api, pipeline +from avalon.pipeline import AVALON_CONTAINER_ID +from openpype.pipeline import ( + discover_loader_plugins, + loaders_from_representation, + load_representation, + get_representation_path, +) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline @@ -205,7 +211,7 @@ class LayoutLoader(plugin.Loader): with open(lib_path, "r") as fp: data = json.load(fp) - all_loaders = api.discover(api.Loader) + all_loaders = discover_loader_plugins() if not loaded: loaded = [] @@ -235,7 +241,7 @@ class LayoutLoader(plugin.Loader): loaded.append(reference) family = element.get('family') - loaders = api.loaders_from_representation( + loaders = loaders_from_representation( all_loaders, reference) loader = None @@ -252,7 +258,7 @@ class LayoutLoader(plugin.Loader): "asset_dir": asset_dir } - assets = api.load( + assets = load_representation( loader, reference, namespace=instance_name, @@ -387,7 +393,7 @@ class LayoutLoader(plugin.Loader): data = { "schema": "openpype:container-2.0", - "id": pipeline.AVALON_CONTAINER_ID, + "id": AVALON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, @@ -411,9 +417,9 @@ class LayoutLoader(plugin.Loader): def update(self, container, representation): ar = unreal.AssetRegistryHelpers.get_asset_registry() - source_path = api.get_representation_path(representation) + source_path = get_representation_path(representation) destination_path = container["namespace"] - lib_path = Path(api.get_representation_path(representation)) + lib_path = Path(get_representation_path(representation)) self._remove_actors(destination_path) diff --git a/openpype/hosts/unreal/plugins/load/load_rig.py b/openpype/hosts/unreal/plugins/load/load_rig.py index a7ecb0ef7d..3d5616364c 100644 --- a/openpype/hosts/unreal/plugins/load/load_rig.py +++ b/openpype/hosts/unreal/plugins/load/load_rig.py @@ -2,7 +2,8 @@ """Load Skeletal Meshes form FBX.""" import os -from avalon import api, pipeline +from avalon import pipeline +from openpype.pipeline import get_representation_path from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa @@ -124,7 +125,7 @@ class SkeletalMeshFBXLoader(plugin.Loader): def update(self, container, representation): name = container["asset_name"] - source_path = api.get_representation_path(representation) + source_path = get_representation_path(representation) destination_path = container["namespace"] task = unreal.AssetImportTask() diff --git a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py index c8a6964ffb..587fc83a77 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py @@ -2,7 +2,8 @@ """Load Static meshes form FBX.""" import os -from avalon import api, pipeline +from avalon import pipeline +from openpype.pipeline import get_representation_path from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa @@ -118,7 +119,7 @@ class StaticMeshFBXLoader(plugin.Loader): def update(self, container, representation): name = container["asset_name"] - source_path = api.get_representation_path(representation) + source_path = get_representation_path(representation) destination_path = container["namespace"] task = self.get_task(source_path, destination_path, name, True) diff --git a/openpype/hosts/webpublisher/api/__init__.py b/openpype/hosts/webpublisher/api/__init__.py index 6ce8a58fc2..4542ddbba4 100644 --- a/openpype/hosts/webpublisher/api/__init__.py +++ b/openpype/hosts/webpublisher/api/__init__.py @@ -5,7 +5,6 @@ from avalon import api as avalon from avalon import io from pyblish import api as pyblish import openpype.hosts.webpublisher -from openpype.pipeline import LegacyCreator log = logging.getLogger("openpype.hosts.webpublisher") @@ -13,8 +12,6 @@ HOST_DIR = os.path.dirname(os.path.abspath( openpype.hosts.webpublisher.__file__)) PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") -LOAD_PATH = os.path.join(PLUGINS_DIR, "load") -CREATE_PATH = os.path.join(PLUGINS_DIR, "create") def application_launch(): @@ -25,8 +22,6 @@ def install(): print("Installing Pype config...") pyblish.register_plugin_path(PUBLISH_PATH) - avalon.register_plugin_path(avalon.Loader, LOAD_PATH) - avalon.register_plugin_path(LegacyCreator, CREATE_PATH) log.info(PUBLISH_PATH) io.install() @@ -35,8 +30,6 @@ def install(): def uninstall(): pyblish.deregister_plugin_path(PUBLISH_PATH) - avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) - avalon.deregister_plugin_path(LegacyCreator, CREATE_PATH) # to have required methods for interface diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 0bfd3f6de0..bd3fcba950 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -980,6 +980,8 @@ class BuildWorkfile: ... }] """ + from openpype.pipeline import discover_loader_plugins + # Get current asset name and entity current_asset_name = avalon.io.Session["AVALON_ASSET"] current_asset_entity = avalon.io.find_one({ @@ -996,7 +998,7 @@ class BuildWorkfile: # Prepare available loaders loaders_by_name = {} - for loader in avalon.api.discover(avalon.api.Loader): + for loader in discover_loader_plugins(): loader_name = loader.__name__ if loader_name in loaders_by_name: raise KeyError( @@ -1390,6 +1392,11 @@ class BuildWorkfile: Returns: (list) Objects of loaded containers. """ + from openpype.pipeline import ( + IncompatibleLoaderError, + load_representation, + ) + loaded_containers = [] # Get subset id order from build presets. @@ -1451,7 +1458,7 @@ class BuildWorkfile: if not loader: continue try: - container = avalon.api.load( + container = load_representation( loader, repre["_id"], name=subset_name @@ -1460,7 +1467,7 @@ class BuildWorkfile: is_loaded = True except Exception as exc: - if exc == avalon.pipeline.IncompatibleLoaderError: + if exc == IncompatibleLoaderError: self.log.info(( "Loader `{}` is not compatible with" " representation `{}`" diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py index 62bfdf774a..14e5fe59f8 100644 --- a/openpype/lib/path_templates.py +++ b/openpype/lib/path_templates.py @@ -187,6 +187,16 @@ class StringTemplate(object): result.validate() return result + @classmethod + def format_template(cls, template, data): + objected_template = cls(template) + return objected_template.format(data) + + @classmethod + def format_strict_template(cls, template, data): + objected_template = cls(template) + return objected_template.format_strict(data) + @staticmethod def find_optional_parts(parts): new_parts = [] diff --git a/openpype/lib/plugin_tools.py b/openpype/lib/plugin_tools.py index 19765a6f4a..f11ba56865 100644 --- a/openpype/lib/plugin_tools.py +++ b/openpype/lib/plugin_tools.py @@ -280,6 +280,7 @@ def set_plugin_attributes_from_settings( project_name (str): Name of project for which settings will be loaded. Value from environment `AVALON_PROJECT` is used if not entered. """ + from openpype.pipeline import LegacyCreator, LoaderPlugin # determine host application to use for finding presets if host_name is None: @@ -289,11 +290,11 @@ def set_plugin_attributes_from_settings( project_name = os.environ.get("AVALON_PROJECT") # map plugin superclass to preset json. Currently supported is load and - # create (avalon.api.Loader and avalon.api.Creator) + # create (LoaderPlugin and LegacyCreator) plugin_type = None - if superclass.__name__.split(".")[-1] in ("Loader", "SubsetLoader"): + if superclass is LoaderPlugin or issubclass(superclass, LoaderPlugin): plugin_type = "load" - elif superclass.__name__.split(".")[-1] in ("Creator", "LegacyCreator"): + elif superclass is LegacyCreator or issubclass(superclass, LegacyCreator): plugin_type = "create" if not host_name or not project_name or plugin_type is None: diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 1de1c37575..19d504b6c9 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -13,6 +13,8 @@ from avalon import api, io import pyblish.api +from openpype.pipeline import get_representation_path + def get_resources(version, extension=None): """Get the files from the specific version.""" @@ -23,7 +25,7 @@ def get_resources(version, extension=None): representation = io.find_one(query) assert representation, "This is a bug" - directory = api.get_representation_path(representation) + directory = get_representation_path(representation) print("Source: ", directory) resources = sorted( [ diff --git a/openpype/modules/ftrack/event_handlers_user/action_rv.py b/openpype/modules/ftrack/event_handlers_user/action_rv.py index 71d790f7e7..bdb0eaf250 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_rv.py +++ b/openpype/modules/ftrack/event_handlers_user/action_rv.py @@ -3,9 +3,10 @@ import subprocess import traceback import json -from openpype_modules.ftrack.lib import BaseAction, statics_icon import ftrack_api from avalon import io, api +from openpype.pipeline import get_representation_path +from openpype_modules.ftrack.lib import BaseAction, statics_icon class RVAction(BaseAction): @@ -307,7 +308,7 @@ class RVAction(BaseAction): "name": "preview" } ) - paths.append(api.get_representation_path(representation)) + paths.append(get_representation_path(representation)) return paths diff --git a/openpype/pipeline/create/legacy_create.py b/openpype/pipeline/create/legacy_create.py index d05cdff689..cf6629047e 100644 --- a/openpype/pipeline/create/legacy_create.py +++ b/openpype/pipeline/create/legacy_create.py @@ -21,6 +21,7 @@ class LegacyCreator(object): dynamic_subset_keys = [] log = logging.getLogger("LegacyCreator") + log.propagate = True def __init__(self, name, asset, options=None, data=None): self.name = name # For backwards compatibility diff --git a/openpype/plugins/load/add_site.py b/openpype/plugins/load/add_site.py index 09448d553c..95001691e2 100644 --- a/openpype/plugins/load/add_site.py +++ b/openpype/plugins/load/add_site.py @@ -1,8 +1,8 @@ -from avalon import api from openpype.modules import ModulesManager +from openpype.pipeline import load -class AddSyncSite(api.Loader): +class AddSyncSite(load.LoaderPlugin): """Add sync site to representation""" representations = ["*"] families = ["*"] diff --git a/openpype/plugins/load/copy_file.py b/openpype/plugins/load/copy_file.py index eaf5853035..c3c8e132d4 100644 --- a/openpype/plugins/load/copy_file.py +++ b/openpype/plugins/load/copy_file.py @@ -1,7 +1,9 @@ -from avalon import api, style +from avalon import style + +from openpype.pipeline import load -class CopyFile(api.Loader): +class CopyFile(load.LoaderPlugin): """Copy the published file to be pasted at the desired location""" representations = ["*"] diff --git a/openpype/plugins/load/copy_file_path.py b/openpype/plugins/load/copy_file_path.py index 2041c79f6d..565d8d1ff1 100644 --- a/openpype/plugins/load/copy_file_path.py +++ b/openpype/plugins/load/copy_file_path.py @@ -1,9 +1,9 @@ import os -from avalon import api +from openpype.pipeline import load -class CopyFilePath(api.Loader): +class CopyFilePath(load.LoaderPlugin): """Copy published file path to clipboard""" representations = ["*"] families = ["*"] diff --git a/openpype/plugins/load/delete_old_versions.py b/openpype/plugins/load/delete_old_versions.py index e8612745fb..7cadb8bb14 100644 --- a/openpype/plugins/load/delete_old_versions.py +++ b/openpype/plugins/load/delete_old_versions.py @@ -8,13 +8,14 @@ import ftrack_api import qargparse from Qt import QtWidgets, QtCore -from avalon import api, style +from avalon import style from avalon.api import AvalonMongoDB -import avalon.pipeline +from openpype.pipeline import load +from openpype.lib import StringTemplate from openpype.api import Anatomy -class DeleteOldVersions(api.SubsetLoader): +class DeleteOldVersions(load.SubsetLoaderPlugin): """Deletes specific number of old version""" is_multiple_contexts_compatible = True @@ -89,16 +90,12 @@ class DeleteOldVersions(api.SubsetLoader): try: context = representation["context"] context["root"] = anatomy.roots - path = avalon.pipeline.format_template_with_optional_keys( - context, template - ) + path = str(StringTemplate.format_template(template, context)) if "frame" in context: context["frame"] = self.sequence_splitter - sequence_path = os.path.normpath( - avalon.pipeline.format_template_with_optional_keys( - context, template - ) - ) + sequence_path = os.path.normpath(str( + StringTemplate.format_template(template, context) + )) except KeyError: # Template references unavailable data diff --git a/openpype/plugins/load/delivery.py b/openpype/plugins/load/delivery.py index 1037d6dc16..04080053e3 100644 --- a/openpype/plugins/load/delivery.py +++ b/openpype/plugins/load/delivery.py @@ -3,9 +3,9 @@ from collections import defaultdict from Qt import QtWidgets, QtCore, QtGui -from avalon import api from avalon.api import AvalonMongoDB +from openpype.pipeline import load from openpype.api import Anatomy, config from openpype import resources, style @@ -20,7 +20,7 @@ from openpype.lib.delivery import ( ) -class Delivery(api.SubsetLoader): +class Delivery(load.SubsetLoaderPlugin): """Export selected versions to folder structure from Template""" is_multiple_contexts_compatible = True diff --git a/openpype/plugins/load/open_djv.py b/openpype/plugins/load/open_djv.py index 4b0e8411c8..273c77c93f 100644 --- a/openpype/plugins/load/open_djv.py +++ b/openpype/plugins/load/open_djv.py @@ -1,6 +1,6 @@ import os -from avalon import api from openpype.api import ApplicationManager +from openpype.pipeline import load def existing_djv_path(): @@ -13,7 +13,8 @@ def existing_djv_path(): return djv_list -class OpenInDJV(api.Loader): + +class OpenInDJV(load.LoaderPlugin): """Open Image Sequence with system default""" djv_list = existing_djv_path() diff --git a/openpype/plugins/load/open_file.py b/openpype/plugins/load/open_file.py index 4133a64eb3..f21cd07c7f 100644 --- a/openpype/plugins/load/open_file.py +++ b/openpype/plugins/load/open_file.py @@ -2,7 +2,7 @@ import sys import os import subprocess -from avalon import api +from openpype.pipeline import load def open(filepath): @@ -15,7 +15,7 @@ def open(filepath): subprocess.call(('xdg-open', filepath)) -class Openfile(api.Loader): +class Openfile(load.LoaderPlugin): """Open Image Sequence with system default""" families = ["render2d"] diff --git a/openpype/plugins/load/remove_site.py b/openpype/plugins/load/remove_site.py index aedb5d1f2f..adffec9986 100644 --- a/openpype/plugins/load/remove_site.py +++ b/openpype/plugins/load/remove_site.py @@ -1,8 +1,8 @@ -from avalon import api from openpype.modules import ModulesManager +from openpype.pipeline import load -class RemoveSyncSite(api.Loader): +class RemoveSyncSite(load.LoaderPlugin): """Remove sync site and its files on representation""" representations = ["*"] families = ["*"] diff --git a/openpype/tools/loader/model.py b/openpype/tools/loader/model.py index baee569239..dc3cda1725 100644 --- a/openpype/tools/loader/model.py +++ b/openpype/tools/loader/model.py @@ -10,7 +10,7 @@ from avalon import ( from Qt import QtCore, QtGui import qtawesome -from avalon.lib import HeroVersionType +from openpype.pipeline import HeroVersionType from openpype.tools.utils.models import TreeModel, Item from openpype.tools.utils import lib diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index f145756cc5..b14bdd0e93 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -1,6 +1,5 @@ import os import sys -import inspect import datetime import pprint import traceback @@ -9,8 +8,19 @@ import collections from Qt import QtWidgets, QtCore, QtGui from avalon import api, pipeline -from avalon.lib import HeroVersionType +from openpype.pipeline import HeroVersionType +from openpype.pipeline.load import ( + discover_loader_plugins, + SubsetLoaderPlugin, + loaders_from_repre_context, + get_repres_contexts, + get_subset_contexts, + load_with_repre_context, + load_with_subset_context, + load_with_subset_contexts, + IncompatibleLoaderError, +) from openpype.tools.utils import ( ErrorMessageBox, lib as tools_lib @@ -425,7 +435,7 @@ class SubsetWidget(QtWidgets.QWidget): # Get all representation->loader combinations available for the # index under the cursor, so we can list the user the options. - available_loaders = api.discover(api.Loader) + available_loaders = discover_loader_plugins() if self.tool_name: available_loaders = lib.remove_tool_name_from_loaders( available_loaders, self.tool_name @@ -435,7 +445,7 @@ class SubsetWidget(QtWidgets.QWidget): subset_loaders = [] for loader in available_loaders: # Skip if its a SubsetLoader. - if api.SubsetLoader in inspect.getmro(loader): + if issubclass(loader, SubsetLoaderPlugin): subset_loaders.append(loader) else: repre_loaders.append(loader) @@ -459,7 +469,7 @@ class SubsetWidget(QtWidgets.QWidget): repre_docs = repre_docs_by_version_id[version_id] for repre_doc in repre_docs: repre_context = repre_context_by_id[repre_doc["_id"]] - for loader in pipeline.loaders_from_repre_context( + for loader in loaders_from_repre_context( repre_loaders, repre_context ): @@ -515,7 +525,7 @@ class SubsetWidget(QtWidgets.QWidget): action = lib.get_no_loader_action(menu, one_item_selected) menu.addAction(action) else: - repre_contexts = pipeline.get_repres_contexts( + repre_contexts = get_repres_contexts( repre_context_by_id.keys(), self.dbcon) menu = lib.add_representation_loaders_to_menu( @@ -532,7 +542,7 @@ class SubsetWidget(QtWidgets.QWidget): self.load_started.emit() - if api.SubsetLoader in inspect.getmro(loader): + if issubclass(loader, SubsetLoaderPlugin): subset_ids = [] subset_version_docs = {} for item in items: @@ -541,8 +551,7 @@ class SubsetWidget(QtWidgets.QWidget): subset_version_docs[subset_id] = item["version_document"] # get contexts only for selected menu option - subset_contexts_by_id = pipeline.get_subset_contexts(subset_ids, - self.dbcon) + subset_contexts_by_id = get_subset_contexts(subset_ids, self.dbcon) subset_contexts = list(subset_contexts_by_id.values()) options = lib.get_options(action, loader, self, subset_contexts) @@ -575,8 +584,7 @@ class SubsetWidget(QtWidgets.QWidget): repre_ids.append(representation["_id"]) # get contexts only for selected menu option - repre_contexts = pipeline.get_repres_contexts(repre_ids, - self.dbcon) + repre_contexts = get_repres_contexts(repre_ids, self.dbcon) options = lib.get_options(action, loader, self, list(repre_contexts.values())) @@ -1339,12 +1347,12 @@ class RepresentationWidget(QtWidgets.QWidget): selected_side = self._get_selected_side(point_index, rows) # Get all representation->loader combinations available for the # index under the cursor, so we can list the user the options. - available_loaders = api.discover(api.Loader) + available_loaders = discover_loader_plugins() filtered_loaders = [] for loader in available_loaders: # Skip subset loaders - if api.SubsetLoader in inspect.getmro(loader): + if issubclass(loader, SubsetLoaderPlugin): continue if ( @@ -1370,7 +1378,7 @@ class RepresentationWidget(QtWidgets.QWidget): for item in items: repre_context = repre_context_by_id[item["_id"]] - for loader in pipeline.loaders_from_repre_context( + for loader in loaders_from_repre_context( filtered_loaders, repre_context ): @@ -1426,7 +1434,7 @@ class RepresentationWidget(QtWidgets.QWidget): action = lib.get_no_loader_action(menu) menu.addAction(action) else: - repre_contexts = pipeline.get_repres_contexts( + repre_contexts = get_repres_contexts( repre_context_by_id.keys(), self.dbcon) menu = lib.add_representation_loaders_to_menu(loaders, menu, repre_contexts) @@ -1472,8 +1480,7 @@ class RepresentationWidget(QtWidgets.QWidget): repre_ids.append(item.get("_id")) - repre_contexts = pipeline.get_repres_contexts(repre_ids, - self.dbcon) + repre_contexts = get_repres_contexts(repre_ids, self.dbcon) options = lib.get_options(action, loader, self, list(repre_contexts.values())) @@ -1540,7 +1547,7 @@ def _load_representations_by_loader(loader, repre_contexts, """Loops through list of repre_contexts and loads them with one loader Args: - loader (cls of api.Loader) - not initialized yet + loader (cls of LoaderPlugin) - not initialized yet repre_contexts (dicts) - full info about selected representations (containing repre_doc, version_doc, subset_doc, project info) options (dict) - qargparse arguments to fill OptionDialog @@ -1558,12 +1565,12 @@ def _load_representations_by_loader(loader, repre_contexts, _id = repre_context["representation"]["_id"] data = data_by_repre_id.get(_id) options.update(data) - pipeline.load_with_repre_context( + load_with_repre_context( loader, repre_context, options=options ) - except pipeline.IncompatibleLoaderError as exc: + except IncompatibleLoaderError as exc: print(exc) error_info.append(( "Incompatible Loader", @@ -1612,7 +1619,7 @@ def _load_subsets_by_loader(loader, subset_contexts, options, context["version"] = subset_version_docs[context["subset"]["_id"]] try: - pipeline.load_with_subset_contexts( + load_with_subset_contexts( loader, subset_contexts, options=options @@ -1638,7 +1645,7 @@ def _load_subsets_by_loader(loader, subset_contexts, options, version_doc = subset_version_docs[subset_context["subset"]["_id"]] subset_context["version"] = version_doc try: - pipeline.load_with_subset_context( + load_with_subset_context( loader, subset_context, options=options diff --git a/openpype/tools/mayalookassigner/commands.py b/openpype/tools/mayalookassigner/commands.py index 96fc28243b..df72e41354 100644 --- a/openpype/tools/mayalookassigner/commands.py +++ b/openpype/tools/mayalookassigner/commands.py @@ -4,10 +4,11 @@ import os import maya.cmds as cmds -from openpype.hosts.maya.api import lib - from avalon import io, api +from openpype.pipeline import remove_container +from openpype.hosts.maya.api import lib + from .vray_proxies import get_alembic_ids_cache log = logging.getLogger(__name__) @@ -206,6 +207,6 @@ def remove_unused_looks(): for container in unused: log.info("Removing unused look container: %s", container['objectName']) - api.remove(container) + remove_container(container) log.info("Finished removing unused looks. (see log for details)") diff --git a/openpype/tools/mayalookassigner/vray_proxies.py b/openpype/tools/mayalookassigner/vray_proxies.py index b22ec95a4d..3179ba1445 100644 --- a/openpype/tools/mayalookassigner/vray_proxies.py +++ b/openpype/tools/mayalookassigner/vray_proxies.py @@ -12,6 +12,12 @@ from maya import cmds from avalon import io, api +from openpype.pipeline import ( + load_representation, + loaders_from_representation, + discover_loader_plugins, + get_representation_path, +) from openpype.hosts.maya.api import lib @@ -155,7 +161,7 @@ def get_look_relationships(version_id): "name": "json"}) # Load relationships - shader_relation = api.get_representation_path(json_representation) + shader_relation = get_representation_path(json_representation) with open(shader_relation, "r") as f: relationships = json.load(f) @@ -193,8 +199,8 @@ def load_look(version_id): log.info("Using look for the first time ...") # Load file - loaders = api.loaders_from_representation(api.discover(api.Loader), - representation_id) + all_loaders = discover_loader_plugins() + loaders = loaders_from_representation(all_loaders, representation_id) loader = next( (i for i in loaders if i.__name__ == "LookLoader"), None) if loader is None: @@ -202,7 +208,7 @@ def load_look(version_id): # Reference the look file with lib.maintained_selection(): - container_node = api.load(loader, look_representation) + container_node = load_representation(loader, look_representation) # Get container members shader_nodes = lib.get_container_members(container_node) diff --git a/openpype/tools/sceneinventory/model.py b/openpype/tools/sceneinventory/model.py index cba60be355..85bf18fbb7 100644 --- a/openpype/tools/sceneinventory/model.py +++ b/openpype/tools/sceneinventory/model.py @@ -7,8 +7,9 @@ from Qt import QtCore, QtGui import qtawesome from avalon import api, io, style, schema -from avalon.lib import HeroVersionType +from openpype.pipeline import HeroVersionType from openpype.tools.utils.models import TreeModel, Item +from openpype.modules import ModulesManager from .lib import ( get_site_icons, @@ -16,8 +17,6 @@ from .lib import ( get_progress_for_repre ) -from openpype.modules import ModulesManager - class InventoryModel(TreeModel): """The model for the inventory""" diff --git a/openpype/tools/sceneinventory/switch_dialog.py b/openpype/tools/sceneinventory/switch_dialog.py index 93ea68beb4..0e7b1b759a 100644 --- a/openpype/tools/sceneinventory/switch_dialog.py +++ b/openpype/tools/sceneinventory/switch_dialog.py @@ -3,7 +3,12 @@ import logging from Qt import QtWidgets, QtCore import qtawesome -from avalon import io, api, pipeline +from avalon import io, pipeline +from openpype.pipeline import ( + discover_loader_plugins, + switch_container, + get_repres_contexts, +) from .widgets import ( ButtonWithMenu, @@ -343,13 +348,13 @@ class SwitchAssetDialog(QtWidgets.QDialog): def _get_loaders(self, repre_ids): repre_contexts = None if repre_ids: - repre_contexts = pipeline.get_repres_contexts(repre_ids) + repre_contexts = get_repres_contexts(repre_ids) if not repre_contexts: return list() available_loaders = [] - for loader_plugin in api.discover(api.Loader): + for loader_plugin in discover_loader_plugins(): # Skip loaders without switch method if not hasattr(loader_plugin, "switch"): continue @@ -1352,7 +1357,7 @@ class SwitchAssetDialog(QtWidgets.QDialog): repre_doc = repres_by_name[container_repre_name] try: - api.switch(container, repre_doc, loader) + switch_container(container, repre_doc, loader) except Exception: msg = ( "Couldn't switch asset." diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py index 32c1883de6..fa61ec4384 100644 --- a/openpype/tools/sceneinventory/view.py +++ b/openpype/tools/sceneinventory/view.py @@ -6,8 +6,12 @@ from Qt import QtWidgets, QtCore import qtawesome from avalon import io, api, style -from avalon.lib import HeroVersionType +from openpype.pipeline import ( + HeroVersionType, + update_container, + remove_container, +) from openpype.modules import ModulesManager from openpype.tools.utils.lib import ( get_progress_for_repre, @@ -195,7 +199,7 @@ class SceneInventoryView(QtWidgets.QTreeView): version_name = version_name_by_id.get(version_id) if version_name is not None: try: - api.update(item, version_name) + update_container(item, version_name) except AssertionError: self._show_version_error_dialog( version_name, [item] @@ -223,7 +227,7 @@ class SceneInventoryView(QtWidgets.QTreeView): def _on_update_to_latest(items): for item in items: try: - api.update(item, -1) + update_container(item, -1) except AssertionError: self._show_version_error_dialog(None, [item]) log.warning("Update failed", exc_info=True) @@ -248,7 +252,7 @@ class SceneInventoryView(QtWidgets.QTreeView): def _on_update_to_hero(items): for item in items: try: - api.update(item, HeroVersionType(-1)) + update_container(item, HeroVersionType(-1)) except AssertionError: self._show_version_error_dialog('hero', [item]) log.warning("Update failed", exc_info=True) @@ -727,7 +731,7 @@ class SceneInventoryView(QtWidgets.QTreeView): version = versions_by_label[label] for item in items: try: - api.update(item, version) + update_container(item, version) except AssertionError: self._show_version_error_dialog(version, [item]) log.warning("Update failed", exc_info=True) @@ -758,7 +762,7 @@ class SceneInventoryView(QtWidgets.QTreeView): return for item in items: - api.remove(item) + remove_container(item) self.data_changed.emit() def _show_version_error_dialog(self, version, items): @@ -828,7 +832,7 @@ class SceneInventoryView(QtWidgets.QTreeView): # Trigger update to latest for item in outdated_items: try: - api.update(item, -1) + update_container(item, -1) except AssertionError: self._show_version_error_dialog(None, [item]) log.warning("Update failed", exc_info=True) diff --git a/openpype/tools/utils/delegates.py b/openpype/tools/utils/delegates.py index 4ec6079bb7..d3718b1734 100644 --- a/openpype/tools/utils/delegates.py +++ b/openpype/tools/utils/delegates.py @@ -6,7 +6,7 @@ import numbers import Qt from Qt import QtWidgets, QtGui, QtCore -from avalon.lib import HeroVersionType +from openpype.pipeline import HeroVersionType from .models import TreeModel from . import lib From d4434fd1b71e5e492119132e2b329f4f3e9dac52 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 11:42:02 +0100 Subject: [PATCH 442/483] use 'LegacyCreator' instead of 'avalon.api.Creator' --- openpype/lib/avalon_context.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 67a5515100..03ad69a5e6 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -1594,11 +1594,13 @@ def get_creator_by_name(creator_name, case_sensitive=False): Returns: Creator: Return first matching plugin or `None`. """ + from openpype.pipeline import LegacyCreator + # Lower input creator name if is not case sensitive if not case_sensitive: creator_name = creator_name.lower() - for creator_plugin in avalon.api.discover(avalon.api.Creator): + for creator_plugin in avalon.api.discover(LegacyCreator): _creator_name = creator_plugin.__name__ # Lower creator plugin name if is not case sensitive From a6fb84f3e185b90a8daeef56a344d99a97ddf475 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 14 Mar 2022 11:45:29 +0100 Subject: [PATCH 443/483] flame: single file in representation if mov or thumb --- .../hosts/flame/plugins/publish/extract_subset_resources.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index bfd723f5d8..2d1cbb951d 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -217,7 +217,11 @@ class ExtractSubsetResources(openpype.api.Extractor): # add files to represetation but add # imagesequence as list if ( - "movie_file" in preset_path + # first check if path in files is not mov extension + next([ + f for f in files if ".mov" in os.path.splitext(f)[-1] + ], None) + # then try if thumbnail is not in unique name or unique_name == "thumbnail" ): representation_data["files"] = files.pop() From 8adc26c27804a8cd200e72537e70a6014ee2fd27 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 14 Mar 2022 11:51:54 +0100 Subject: [PATCH 444/483] flame: fix multiple video files in list of repre files --- .../plugins/publish/extract_subset_resources.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 2d1cbb951d..5c3aed9672 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -218,9 +218,16 @@ class ExtractSubsetResources(openpype.api.Extractor): # imagesequence as list if ( # first check if path in files is not mov extension - next([ - f for f in files if ".mov" in os.path.splitext(f)[-1] - ], None) + next( + # iter all paths in files + # return only .mov positive test + iter([ + f for f in files + if ".mov" in os.path.splitext(f)[-1] + ]), + # if nothing return default + None + ) # then try if thumbnail is not in unique name or unique_name == "thumbnail" ): From 3d01ba27113eb82901b4a7aa5a550a55932125fa Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 13:00:27 +0100 Subject: [PATCH 445/483] define class attribute '_representations' loaded from settings --- openpype/hosts/nuke/plugins/load/load_clip.py | 3 +++ openpype/hosts/nuke/plugins/load/load_image.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py index 563a325a83..2b4315a830 100644 --- a/openpype/hosts/nuke/plugins/load/load_clip.py +++ b/openpype/hosts/nuke/plugins/load/load_clip.py @@ -42,6 +42,9 @@ class LoadClip(plugin.NukeLoader): icon = "file-video-o" color = "white" + # Loaded from settings + _representations = [] + script_start = int(nuke.root()["first_frame"].value()) # option gui diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index e04ccf3bf1..9a175a0cba 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -36,6 +36,9 @@ class LoadImage(load.LoaderPlugin): icon = "image" color = "white" + # Loaded from settings + _representations = [] + node_name_template = "{class_name}_{ext}" options = [ From 4e83ff6c27940fac9ca38d6c6a9df8d8b8712eed Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 14:03:51 +0100 Subject: [PATCH 446/483] moved avalon.api imports into functions using them --- openpype/pipeline/load/plugins.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/openpype/pipeline/load/plugins.py b/openpype/pipeline/load/plugins.py index d7e21e1248..ea92cf962b 100644 --- a/openpype/pipeline/load/plugins.py +++ b/openpype/pipeline/load/plugins.py @@ -1,13 +1,5 @@ import logging -from avalon.api import ( - discover, - register_plugin, - deregister_plugin, - register_plugin_path, - deregister_plugin_path, -) - from .utils import get_representation_path_from_context @@ -110,20 +102,29 @@ class SubsetLoaderPlugin(LoaderPlugin): def discover_loader_plugins(): - return discover(LoaderPlugin) + import avalon.api + + return avalon.api.discover(LoaderPlugin) def register_loader_plugin(plugin): - return register_plugin(LoaderPlugin, plugin) + import avalon.api + + return avalon.api.register_plugin(LoaderPlugin, plugin) def deregister_loader_plugins_path(path): - deregister_plugin_path(LoaderPlugin, path) + import avalon.api + + avalon.api.deregister_plugin_path(LoaderPlugin, path) def register_loader_plugins_path(path): - return register_plugin_path(LoaderPlugin, path) + import avalon.api + + return avalon.apiregister_plugin_path(LoaderPlugin, path) def deregister_loader_plugin(plugin): - deregister_plugin(LoaderPlugin, plugin) + import avalon.api + avalon.api.deregister_plugin(LoaderPlugin, plugin) From 105d794358e97dbba654b38383c91dc366ddc0d1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 14:04:37 +0100 Subject: [PATCH 447/483] fix typo --- openpype/pipeline/load/plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/load/plugins.py b/openpype/pipeline/load/plugins.py index ea92cf962b..5648236739 100644 --- a/openpype/pipeline/load/plugins.py +++ b/openpype/pipeline/load/plugins.py @@ -122,7 +122,7 @@ def deregister_loader_plugins_path(path): def register_loader_plugins_path(path): import avalon.api - return avalon.apiregister_plugin_path(LoaderPlugin, path) + return avalon.api.register_plugin_path(LoaderPlugin, path) def deregister_loader_plugin(plugin): From d5c35d18730ca35eee0d01e7d5e235380adde6c3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 15:20:20 +0100 Subject: [PATCH 448/483] use color hex instead of constans in nuke plugins --- openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py | 4 ++-- openpype/hosts/nuke/plugins/load/load_backdrop.py | 4 ++-- openpype/hosts/nuke/plugins/load/load_effects.py | 5 +++-- openpype/hosts/nuke/plugins/load/load_effects_ip.py | 5 +++-- openpype/hosts/nuke/plugins/load/load_gizmo.py | 5 +++-- openpype/hosts/nuke/plugins/load/load_gizmo_ip.py | 5 +++-- openpype/hosts/nuke/plugins/load/load_script_precomp.py | 5 +++-- 7 files changed, 19 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py b/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py index 49405fd213..5f834be557 100644 --- a/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py +++ b/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py @@ -1,4 +1,4 @@ -from avalon import api, style +from avalon import api from openpype.api import Logger from openpype.hosts.nuke.api.lib import set_avalon_knob_data @@ -7,7 +7,7 @@ class RepairOldLoaders(api.InventoryAction): label = "Repair Old Loaders" icon = "gears" - color = style.colors.alert + color = "#cc0000" log = Logger.get_logger(__name__) diff --git a/openpype/hosts/nuke/plugins/load/load_backdrop.py b/openpype/hosts/nuke/plugins/load/load_backdrop.py index 6619cfb414..58ebcc7d49 100644 --- a/openpype/hosts/nuke/plugins/load/load_backdrop.py +++ b/openpype/hosts/nuke/plugins/load/load_backdrop.py @@ -1,4 +1,4 @@ -from avalon import api, style, io +from avalon import api, io import nuke import nukescripts @@ -23,7 +23,7 @@ class LoadBackdropNodes(api.Loader): label = "Iport Nuke Nodes" order = 0 icon = "eye" - color = style.colors.light + color = "white" node_color = "0x7533c1ff" def load(self, context, name, namespace, data): diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py index f636c6b510..4d83da1a78 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects.py +++ b/openpype/hosts/nuke/plugins/load/load_effects.py @@ -1,7 +1,8 @@ import json from collections import OrderedDict import nuke -from avalon import api, style, io +from avalon import api, io + from openpype.hosts.nuke.api import ( containerise, update_container, @@ -18,7 +19,7 @@ class LoadEffects(api.Loader): label = "Load Effects - nodes" order = 0 icon = "cc" - color = style.colors.light + color = "white" ignore_attr = ["useLifetime"] diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py index 990bce54f1..4d30e0f93c 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py @@ -3,7 +3,8 @@ from collections import OrderedDict import nuke -from avalon import api, style, io +from avalon import api, io + from openpype.hosts.nuke.api import lib from openpype.hosts.nuke.api import ( containerise, @@ -21,7 +22,7 @@ class LoadEffectsInputProcess(api.Loader): label = "Load Effects - Input Process" order = 0 icon = "eye" - color = style.colors.alert + color = "#cc0000" ignore_attr = ["useLifetime"] def load(self, context, name, namespace, data): diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo.py b/openpype/hosts/nuke/plugins/load/load_gizmo.py index 659977d789..9c726d8fe6 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo.py @@ -1,5 +1,6 @@ import nuke -from avalon import api, style, io +from avalon import api, io + from openpype.hosts.nuke.api.lib import ( maintained_selection, get_avalon_knob_data, @@ -21,7 +22,7 @@ class LoadGizmo(api.Loader): label = "Load Gizmo" order = 0 icon = "dropbox" - color = style.colors.light + color = "white" node_color = "0x75338eff" def load(self, context, name, namespace, data): diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py index 240bfd467d..78d2625758 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py @@ -1,5 +1,6 @@ -from avalon import api, style, io +from avalon import api, io import nuke + from openpype.hosts.nuke.api.lib import ( maintained_selection, create_backdrop, @@ -22,7 +23,7 @@ class LoadGizmoInputProcess(api.Loader): label = "Load Gizmo - Input Process" order = 0 icon = "eye" - color = style.colors.alert + color = "#cc0000" node_color = "0x7533c1ff" def load(self, context, name, namespace, data): diff --git a/openpype/hosts/nuke/plugins/load/load_script_precomp.py b/openpype/hosts/nuke/plugins/load/load_script_precomp.py index aa48b631c5..48bf0b889f 100644 --- a/openpype/hosts/nuke/plugins/load/load_script_precomp.py +++ b/openpype/hosts/nuke/plugins/load/load_script_precomp.py @@ -1,5 +1,6 @@ import nuke -from avalon import api, style, io +from avalon import api, io + from openpype.hosts.nuke.api.lib import get_avalon_knob_data from openpype.hosts.nuke.api import ( containerise, @@ -17,7 +18,7 @@ class LinkAsGroup(api.Loader): label = "Load Precomp" order = 0 icon = "file" - color = style.colors.alert + color = "#cc0000" def load(self, context, name, namespace, data): # for k, v in context.items(): From 239badf4d32f9d1b2a8144957c9e47514562d833 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 15:24:40 +0100 Subject: [PATCH 449/483] added functions to get colors from style data --- openpype/style/__init__.py | 83 ++++++++++++++++++++++++++++++-------- openpype/style/data.json | 6 +++ 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/openpype/style/__init__.py b/openpype/style/__init__.py index ea88b342ee..d92e18c0cd 100644 --- a/openpype/style/__init__.py +++ b/openpype/style/__init__.py @@ -7,13 +7,30 @@ from openpype import resources from .color_defs import parse_color - -_STYLESHEET_CACHE = None -_FONT_IDS = None - current_dir = os.path.dirname(os.path.abspath(__file__)) +# Default colors +# - default color used in tool icons +_TOOLS_ICON_COLOR = "#ffffff" +# - entities icon color - use 'get_default_asset_icon_color' +_DEFAULT_ENTITY_ICON_COLOR = "#fb9c15" +# - disabled entitie +_DISABLED_ENTITY_ICON_ICON_COLOR = "#808080" +# - deprecated entity font color +_DEPRECATED_ENTITY_FONT_COLOR = "#666666" + + +class _Cache: + stylesheet = None + font_ids = None + + tools_icon_color = None + default_entity_icon_color = None + disabled_entity_icon_color = None + deprecated_entity_font_color = None + + def get_style_image_path(image_name): # All filenames are lowered image_name = image_name.lower() @@ -125,21 +142,19 @@ def _load_font(): """Load and register fonts into Qt application.""" from Qt import QtGui - global _FONT_IDS - # Check if font ids are still loaded - if _FONT_IDS is not None: - for font_id in tuple(_FONT_IDS): + if _Cache.font_ids is not None: + for font_id in tuple(_Cache.font_ids): font_families = QtGui.QFontDatabase.applicationFontFamilies( font_id ) # Reset font if font id is not available if not font_families: - _FONT_IDS = None + _Cache.font_ids = None break - if _FONT_IDS is None: - _FONT_IDS = [] + if _Cache.font_ids is None: + _Cache.font_ids = [] fonts_dirpath = os.path.join(current_dir, "fonts") font_dirs = [] font_dirs.append(os.path.join(fonts_dirpath, "Noto_Sans")) @@ -157,7 +172,7 @@ def _load_font(): continue full_path = os.path.join(font_dir, filename) font_id = QtGui.QFontDatabase.addApplicationFont(full_path) - _FONT_IDS.append(font_id) + _Cache.font_ids.append(font_id) font_families = QtGui.QFontDatabase.applicationFontFamilies( font_id ) @@ -167,11 +182,11 @@ def _load_font(): def load_stylesheet(): """Load and return OpenPype Qt stylesheet.""" - global _STYLESHEET_CACHE - if _STYLESHEET_CACHE is None: - _STYLESHEET_CACHE = _load_stylesheet() + + if _Cache.stylesheet is None: + _Cache.stylesheet = _load_stylesheet() _load_font() - return _STYLESHEET_CACHE + return _Cache.stylesheet def get_app_icon_path(): @@ -182,3 +197,39 @@ def get_app_icon_path(): def app_icon_path(): # Backwards compatibility return get_app_icon_path() + + +def get_default_tools_icon_color(): + if _Cache.tools_icon_color is None: + color_data = get_colors_data() + color = color_data.get("icon-tools") + _Cache.tools_icon_color = color or _TOOLS_ICON_COLOR + return _Cache.tools_icon_color + + +def get_default_entity_icon_color(): + if _Cache.default_entity_icon_color is None: + color_data = get_colors_data() + color = color_data.get("icon-entity-default") + _Cache.default_entity_icon_color = color or _DEFAULT_ENTITY_ICON_COLOR + return _Cache.default_entity_icon_color + + +def get_disabled_entity_icon_color(): + if _Cache.disabled_entity_icon_color is None: + color_data = get_colors_data() + color = color_data.get("icon-entity-disabled") + _Cache.disabled_entity_icon_color = ( + color or _DISABLED_ENTITY_ICON_ICON_COLOR + ) + return _Cache.disabled_entity_icon_color + + +def get_deprecated_entity_font_color(): + if _Cache.deprecated_entity_font_color is None: + color_data = get_colors_data() + color = color_data.get("font-entity-deprecated") + _Cache.deprecated_entity_font_color = ( + color or _DEPRECATED_ENTITY_FONT_COLOR + ) + return _Cache.deprecated_entity_font_color diff --git a/openpype/style/data.json b/openpype/style/data.json index b8ccef8bbd..2af23acd0d 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -56,6 +56,12 @@ "delete-btn-bg": "rgb(201, 54, 54)", "delete-btn-bg-disabled": "rgba(201, 54, 54, 64)", + "icon-tools": "#ffffff", + "icon-alert-tools": "#AA5050", + "icon-entity-default": "#fb9c15", + "icon-entity-disabled": "#808080", + "font-entity-deprecated": "#666666", + "tab-widget": { "bg": "#21252B", "bg-selected": "#434a56", From 8d81a91c1cce37c85f0e39f091f30013f2d2db34 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 15:24:56 +0100 Subject: [PATCH 450/483] use openpype style in delete old versions --- openpype/plugins/load/delete_old_versions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/load/delete_old_versions.py b/openpype/plugins/load/delete_old_versions.py index e8612745fb..fb8be0ed33 100644 --- a/openpype/plugins/load/delete_old_versions.py +++ b/openpype/plugins/load/delete_old_versions.py @@ -8,9 +8,10 @@ import ftrack_api import qargparse from Qt import QtWidgets, QtCore -from avalon import api, style +from avalon import api from avalon.api import AvalonMongoDB import avalon.pipeline +from openpype import style from openpype.api import Anatomy From 9bbaf42a3bb10cb56360f6f9a223c8428f855ce3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 15:26:08 +0100 Subject: [PATCH 451/483] use functions for colors in tools --- openpype/plugins/load/copy_file.py | 5 ++-- openpype/tools/loader/model.py | 19 ++++++++------- openpype/tools/mayalookassigner/models.py | 13 ++++++++--- openpype/tools/sceneinventory/model.py | 7 ++++-- .../standalonepublish/widgets/model_asset.py | 23 +++++++++++++++---- .../widgets/model_tasks_template.py | 6 +++-- .../standalonepublish/widgets/widget_asset.py | 6 +++-- openpype/tools/utils/assets_widget.py | 13 +++++++---- openpype/tools/utils/lib.py | 13 +++++++---- openpype/tools/utils/tasks_widget.py | 14 +++++++---- openpype/tools/workfiles/model.py | 16 ++++++++++--- 11 files changed, 93 insertions(+), 42 deletions(-) diff --git a/openpype/plugins/load/copy_file.py b/openpype/plugins/load/copy_file.py index eaf5853035..bdcb4fec79 100644 --- a/openpype/plugins/load/copy_file.py +++ b/openpype/plugins/load/copy_file.py @@ -1,4 +1,5 @@ -from avalon import api, style +from avalon import api +from openpype.style import get_default_entity_icon_color class CopyFile(api.Loader): @@ -10,7 +11,7 @@ class CopyFile(api.Loader): label = "Copy File" order = 10 icon = "copy" - color = style.colors.default + color = get_default_entity_icon_color() def load(self, context, name=None, namespace=None, data=None): self.log.info("Added copy to clipboard: {0}".format(self.fname)) diff --git a/openpype/tools/loader/model.py b/openpype/tools/loader/model.py index baee569239..1007355989 100644 --- a/openpype/tools/loader/model.py +++ b/openpype/tools/loader/model.py @@ -3,15 +3,13 @@ import re import math from uuid import uuid4 -from avalon import ( - style, - schema -) from Qt import QtCore, QtGui import qtawesome +from avalon import schema from avalon.lib import HeroVersionType +from openpype.style import get_default_entity_icon_color from openpype.tools.utils.models import TreeModel, Item from openpype.tools.utils import lib @@ -180,7 +178,10 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): self._sorter = None self._grouping = grouping self._icons = { - "subset": qtawesome.icon("fa.file-o", color=style.colors.default) + "subset": qtawesome.icon( + "fa.file-o", + color=get_default_entity_icon_color() + ) } self._items_by_id = {} @@ -1066,8 +1067,10 @@ class RepresentationModel(TreeModel, BaseRepresentationModel): self._docs = {} self._icons = lib.get_repre_icons() - self._icons["repre"] = qtawesome.icon("fa.file-o", - color=style.colors.default) + self._icons["repre"] = qtawesome.icon( + "fa.file-o", + color=get_default_entity_icon_color() + ) self._items_by_id = {} def set_version_ids(self, version_ids): @@ -1165,7 +1168,7 @@ class RepresentationModel(TreeModel, BaseRepresentationModel): "remote_site_name": self.remote_site, "icon": qtawesome.icon( "fa.folder", - color=style.colors.default + color=get_default_entity_icon_color() ) }) self._items_by_id[item_id] = group_item diff --git a/openpype/tools/mayalookassigner/models.py b/openpype/tools/mayalookassigner/models.py index 386b7d7e1e..77a3c8a590 100644 --- a/openpype/tools/mayalookassigner/models.py +++ b/openpype/tools/mayalookassigner/models.py @@ -3,14 +3,19 @@ from collections import defaultdict from Qt import QtCore import qtawesome -from avalon.style import colors from openpype.tools.utils import models +from openpype.style import get_default_entity_icon_color class AssetModel(models.TreeModel): Columns = ["label"] + def __init__(self, *args, **kwargs): + super(AssetModel, self).__init__(*args, **kwargs) + + self._icon_color = get_default_entity_icon_color() + def add_items(self, items): """ Add items to model with needed data @@ -65,8 +70,10 @@ class AssetModel(models.TreeModel): node = index.internalPointer() icon = node.get("icon") if icon: - return qtawesome.icon("fa.{0}".format(icon), - color=colors.default) + return qtawesome.icon( + "fa.{0}".format(icon), + color=self._icon_color + ) return super(AssetModel, self).data(index, role) diff --git a/openpype/tools/sceneinventory/model.py b/openpype/tools/sceneinventory/model.py index cba60be355..6ec3601705 100644 --- a/openpype/tools/sceneinventory/model.py +++ b/openpype/tools/sceneinventory/model.py @@ -6,8 +6,9 @@ from collections import defaultdict from Qt import QtCore, QtGui import qtawesome -from avalon import api, io, style, schema +from avalon import api, io, schema from avalon.lib import HeroVersionType +from openpype.style import get_default_entity_icon_color from openpype.tools.utils.models import TreeModel, Item from .lib import ( @@ -38,6 +39,8 @@ class InventoryModel(TreeModel): self._hierarchy_view = False + self._default_icon_color = get_default_entity_icon_color() + manager = ModulesManager() sync_server = manager.modules_by_name["sync_server"] self.sync_enabled = sync_server.enabled @@ -131,7 +134,7 @@ class InventoryModel(TreeModel): if role == QtCore.Qt.DecorationRole: if index.column() == 0: # Override color - color = item.get("color", style.colors.default) + color = item.get("color", self._default_icon_color) if item.get("isGroupNode"): # group-item return qtawesome.icon("fa.folder", color=color) if item.get("isNotSet"): diff --git a/openpype/tools/standalonepublish/widgets/model_asset.py b/openpype/tools/standalonepublish/widgets/model_asset.py index 6d764eff9f..7d93e7a943 100644 --- a/openpype/tools/standalonepublish/widgets/model_asset.py +++ b/openpype/tools/standalonepublish/widgets/model_asset.py @@ -1,10 +1,15 @@ import logging import collections + from Qt import QtCore, QtGui import qtawesome -from . import TreeModel, Node -from avalon import style +from openpype.style import ( + get_default_entity_icon_color, + get_deprecated_entity_font_color, +) + +from . import TreeModel, Node log = logging.getLogger(__name__) @@ -49,6 +54,14 @@ class AssetModel(TreeModel): def __init__(self, dbcon, parent=None): super(AssetModel, self).__init__(parent=parent) self.dbcon = dbcon + + self._default_asset_icon_color = QtGui.QColor( + get_default_entity_icon_color() + ) + self._deprecated_asset_font_color = QtGui.QColor( + get_deprecated_entity_font_color() + ) + self.refresh() def _add_hierarchy(self, assets, parent=None, silos=None): @@ -163,7 +176,7 @@ class AssetModel(TreeModel): icon = data.get("icon", None) if icon is None and node.get("type") == "silo": icon = "database" - color = data.get("color", style.colors.default) + color = data.get("color", self._default_asset_icon_color) if icon is None: # Use default icons if no custom one is specified. @@ -188,8 +201,8 @@ class AssetModel(TreeModel): return if role == QtCore.Qt.ForegroundRole: # font color - if "deprecated" in node.get("tags", []): - return QtGui.QColor(style.colors.light).darker(250) + # if "deprecated" in node.get("tags", []): + return QtGui.QColor(self._deprecated_asset_font_color) if role == self.ObjectIdRole: return node.get("_id", None) diff --git a/openpype/tools/standalonepublish/widgets/model_tasks_template.py b/openpype/tools/standalonepublish/widgets/model_tasks_template.py index 1f36eaa39d..648f7ed479 100644 --- a/openpype/tools/standalonepublish/widgets/model_tasks_template.py +++ b/openpype/tools/standalonepublish/widgets/model_tasks_template.py @@ -1,7 +1,9 @@ from Qt import QtCore import qtawesome + +from openpype.style import get_default_entity_icon_color + from . import Node, TreeModel -from avalon import style class TasksTemplateModel(TreeModel): @@ -14,7 +16,7 @@ class TasksTemplateModel(TreeModel): self.selectable = selectable self.icon = qtawesome.icon( 'fa.calendar-check-o', - color=style.colors.default + color=get_default_entity_icon_color() ) def set_tasks(self, tasks): diff --git a/openpype/tools/standalonepublish/widgets/widget_asset.py b/openpype/tools/standalonepublish/widgets/widget_asset.py index d929f227f9..e6b74f8f82 100644 --- a/openpype/tools/standalonepublish/widgets/widget_asset.py +++ b/openpype/tools/standalonepublish/widgets/widget_asset.py @@ -4,7 +4,7 @@ import qtawesome from openpype.tools.utils import PlaceholderLineEdit -from avalon import style +from openpype.style import get_default_tools_icon_color from . import RecursiveSortFilterProxyModel, AssetModel from . import TasksTemplateModel, DeselectableTreeView @@ -165,7 +165,9 @@ class AssetWidget(QtWidgets.QWidget): # Header header = QtWidgets.QHBoxLayout() - icon = qtawesome.icon("fa.refresh", color=style.colors.light) + icon = qtawesome.icon( + "fa.refresh", color=get_default_tools_icon_color() + ) refresh = QtWidgets.QPushButton(icon, "") refresh.setToolTip("Refresh items") diff --git a/openpype/tools/utils/assets_widget.py b/openpype/tools/utils/assets_widget.py index 4c77b81c0e..9beca69f12 100644 --- a/openpype/tools/utils/assets_widget.py +++ b/openpype/tools/utils/assets_widget.py @@ -5,9 +5,10 @@ import Qt from Qt import QtWidgets, QtCore, QtGui import qtawesome -from avalon import style - -from openpype.style import get_objected_colors +from openpype.style import ( + get_objected_colors, + get_default_tools_icon_color, +) from openpype.tools.flickcharm import FlickCharm from .views import ( @@ -589,7 +590,7 @@ class AssetsWidget(QtWidgets.QWidget): view.setModel(proxy) current_asset_icon = qtawesome.icon( - "fa.arrow-down", color=style.colors.light + "fa.arrow-down", color=get_default_tools_icon_color() ) current_asset_btn = QtWidgets.QPushButton(self) current_asset_btn.setIcon(current_asset_icon) @@ -597,7 +598,9 @@ class AssetsWidget(QtWidgets.QWidget): # Hide by default current_asset_btn.setVisible(False) - refresh_icon = qtawesome.icon("fa.refresh", color=style.colors.light) + refresh_icon = qtawesome.icon( + "fa.refresh", color=get_default_tools_icon_color() + ) refresh_btn = QtWidgets.QPushButton(self) refresh_btn.setIcon(refresh_icon) refresh_btn.setToolTip("Refresh items") diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 042ceaab88..829725dcf2 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -7,8 +7,8 @@ from Qt import QtWidgets, QtCore, QtGui import qtawesome import avalon.api -from avalon import style +from openpype.style import get_default_entity_icon_color from openpype.api import ( get_project_settings, Logger @@ -128,7 +128,7 @@ def get_qta_icon_by_name_and_color(icon_name, icon_color): def get_asset_icon(asset_doc, has_children=False): asset_data = asset_doc.get("data") or {} - icon_color = asset_data.get("color") or style.colors.default + icon_color = asset_data.get("color") or get_default_entity_icon_color() icon_name = asset_data.get("icon") if not icon_name: # Use default icons if no custom one is specified. @@ -149,7 +149,9 @@ def get_task_icon(): Icon should be defined by task type which is stored on project. """ - return get_qta_icon_by_name_and_color("fa.male", style.colors.default) + return get_qta_icon_by_name_and_color( + "fa.male", get_default_entity_icon_color() + ) def schedule(func, time, channel="default"): @@ -412,6 +414,7 @@ class GroupsConfig: def __init__(self, dbcon): self.dbcon = dbcon self.groups = {} + self._default_group_color = get_default_entity_icon_color() @classmethod def default_group_config(cls): @@ -419,7 +422,7 @@ class GroupsConfig: cls._default_group_config = { "icon": qtawesome.icon( "fa.object-group", - color=style.colors.default + color=get_default_entity_icon_color() ), "order": 0 } @@ -453,7 +456,7 @@ class GroupsConfig: for config in group_configs: name = config["name"] icon = "fa." + config.get("icon", "object-group") - color = config.get("color", style.colors.default) + color = config.get("color", self._default_group_color) order = float(config.get("order", 0)) self.groups[name] = { diff --git a/openpype/tools/utils/tasks_widget.py b/openpype/tools/utils/tasks_widget.py index 7619f59974..2c92b7228a 100644 --- a/openpype/tools/utils/tasks_widget.py +++ b/openpype/tools/utils/tasks_widget.py @@ -1,7 +1,10 @@ from Qt import QtWidgets, QtCore, QtGui import qtawesome -from avalon import style +from openpype.style import ( + get_default_entity_icon_color, + get_disabled_entity_icon_color, +) from .views import DeselectableTreeView @@ -21,13 +24,14 @@ class TasksModel(QtGui.QStandardItemModel): self.setHeaderData( 0, QtCore.Qt.Horizontal, "Tasks", QtCore.Qt.DisplayRole ) + default_color = get_default_entity_icon_color() + self._default_color = default_color self._default_icon = qtawesome.icon( - "fa.male", - color=style.colors.default + "fa.male", color=default_color ) self._no_tasks_icon = qtawesome.icon( "fa.exclamation-circle", - color=style.colors.mid + color=get_disabled_entity_icon_color() ) self._cached_icons = {} self._project_task_types = {} @@ -62,7 +66,7 @@ class TasksModel(QtGui.QStandardItemModel): try: icon = qtawesome.icon( "fa.{}".format(icon_name), - color=style.colors.default + color=self._default_color ) except Exception: diff --git a/openpype/tools/workfiles/model.py b/openpype/tools/workfiles/model.py index b3cf5063e7..e9184842fc 100644 --- a/openpype/tools/workfiles/model.py +++ b/openpype/tools/workfiles/model.py @@ -4,7 +4,11 @@ import logging from Qt import QtCore import qtawesome -from avalon import style +from openpype.style import ( + get_default_entity_icon_color, + get_disabled_entity_icon_color, +) + from openpype.tools.utils.models import TreeModel, Item log = logging.getLogger(__name__) @@ -25,7 +29,10 @@ class FilesModel(TreeModel): self._root = None self._file_extensions = file_extensions self._icons = { - "file": qtawesome.icon("fa.file-o", color=style.colors.default) + "file": qtawesome.icon( + "fa.file-o", + color=get_default_entity_icon_color() + ) } def set_root(self, root): @@ -64,7 +71,10 @@ class FilesModel(TreeModel): "date": None, "filepath": None, "enabled": False, - "icon": qtawesome.icon("fa.times", color=style.colors.mid) + "icon": qtawesome.icon( + "fa.times", + color=get_disabled_entity_icon_color() + ) }) self.add_child(item) self.endResetModel() From 7c63ef68df3a10d31655dd9d8a0b3a8de21c1d3a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 15:45:03 +0100 Subject: [PATCH 452/483] change asset colorchange entity icon color --- openpype/style/data.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/style/data.json b/openpype/style/data.json index 2af23acd0d..a76a77015b 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -58,7 +58,7 @@ "icon-tools": "#ffffff", "icon-alert-tools": "#AA5050", - "icon-entity-default": "#fb9c15", + "icon-entity-default": "#bfccd6", "icon-entity-disabled": "#808080", "font-entity-deprecated": "#666666", From bf3b9407cb8c8b05abde6a2df2145b4f86198a07 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 16:18:23 +0100 Subject: [PATCH 453/483] remove avalon style from maya look assigner --- openpype/tools/mayalookassigner/__init__.py | 4 +- openpype/tools/mayalookassigner/app.py | 55 ++++++++++++--------- openpype/tools/mayalookassigner/views.py | 3 -- openpype/tools/mayalookassigner/widgets.py | 36 +++++++------- openpype/tools/utils/host_tools.py | 6 +-- 5 files changed, 55 insertions(+), 49 deletions(-) diff --git a/openpype/tools/mayalookassigner/__init__.py b/openpype/tools/mayalookassigner/__init__.py index 616a3e94d0..5e40777741 100644 --- a/openpype/tools/mayalookassigner/__init__.py +++ b/openpype/tools/mayalookassigner/__init__.py @@ -1,9 +1,9 @@ from .app import ( - App, + MayaLookAssignerWindow, show ) __all__ = [ - "App", + "MayaLookAssignerWindow", "show"] diff --git a/openpype/tools/mayalookassigner/app.py b/openpype/tools/mayalookassigner/app.py index 31bb455f95..da9f06f3f0 100644 --- a/openpype/tools/mayalookassigner/app.py +++ b/openpype/tools/mayalookassigner/app.py @@ -4,10 +4,10 @@ import logging from Qt import QtWidgets, QtCore -from openpype.hosts.maya.api.lib import assign_look_by_version - -from avalon import style, io +from avalon import io +from openpype import style from openpype.tools.utils.lib import qt_app_context +from openpype.hosts.maya.api.lib import assign_look_by_version from maya import cmds # old api for MFileIO @@ -28,10 +28,10 @@ module = sys.modules[__name__] module.window = None -class App(QtWidgets.QWidget): +class MayaLookAssignerWindow(QtWidgets.QWidget): def __init__(self, parent=None): - QtWidgets.QWidget.__init__(self, parent=parent) + super(MayaLookAssignerWindow, self).__init__(parent=parent) self.log = logging.getLogger(__name__) @@ -56,30 +56,41 @@ class App(QtWidgets.QWidget): def setup_ui(self): """Build the UI""" + main_splitter = QtWidgets.QSplitter(self) + # Assets (left) - asset_outliner = AssetOutliner() + asset_outliner = AssetOutliner(main_splitter) # Looks (right) - looks_widget = QtWidgets.QWidget() - looks_layout = QtWidgets.QVBoxLayout(looks_widget) + looks_widget = QtWidgets.QWidget(main_splitter) - look_outliner = LookOutliner() # Database look overview + look_outliner = LookOutliner(looks_widget) # Database look overview - assign_selected = QtWidgets.QCheckBox("Assign to selected only") + assign_selected = QtWidgets.QCheckBox( + "Assign to selected only", looks_widget + ) assign_selected.setToolTip("Whether to assign only to selected nodes " "or to the full asset") - remove_unused_btn = QtWidgets.QPushButton("Remove Unused Looks") + remove_unused_btn = QtWidgets.QPushButton( + "Remove Unused Looks", looks_widget + ) + looks_layout = QtWidgets.QVBoxLayout(looks_widget) looks_layout.addWidget(look_outliner) looks_layout.addWidget(assign_selected) looks_layout.addWidget(remove_unused_btn) + main_splitter.addWidget(asset_outliner) + main_splitter.addWidget(looks_widget) + main_splitter.setSizes([350, 200]) + # Footer - status = QtWidgets.QStatusBar() + status = QtWidgets.QStatusBar(self) status.setSizeGripEnabled(False) status.setFixedHeight(25) - warn_layer = QtWidgets.QLabel("Current Layer is not " - "defaultRenderLayer") + warn_layer = QtWidgets.QLabel( + "Current Layer is not defaultRenderLayer", self + ) warn_layer.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) warn_layer.setStyleSheet("color: #DD5555; font-weight: bold;") warn_layer.setFixedHeight(25) @@ -92,11 +103,6 @@ class App(QtWidgets.QWidget): # Build up widgets main_layout = QtWidgets.QVBoxLayout(self) main_layout.setSpacing(0) - main_splitter = QtWidgets.QSplitter() - main_splitter.setStyleSheet("QSplitter{ border: 0px; }") - main_splitter.addWidget(asset_outliner) - main_splitter.addWidget(looks_widget) - main_splitter.setSizes([350, 200]) main_layout.addWidget(main_splitter) main_layout.addLayout(footer) @@ -124,6 +130,8 @@ class App(QtWidgets.QWidget): self.remove_unused = remove_unused_btn self.assign_selected = assign_selected + self._first_show = True + def setup_connections(self): """Connect interactive widgets with actions""" if self._connections_set_up: @@ -147,11 +155,14 @@ class App(QtWidgets.QWidget): def showEvent(self, event): self.setup_connections() - super(App, self).showEvent(event) + super(MayaLookAssignerWindow, self).showEvent(event) + if self._first_show: + self._first_show = False + self.setStyleSheet(style.load_stylesheet()) def closeEvent(self, event): self.remove_connection() - super(App, self).closeEvent(event) + super(MayaLookAssignerWindow, self).closeEvent(event) def _on_renderlayer_switch(self, *args): """Callback that updates on Maya renderlayer switch""" @@ -267,7 +278,7 @@ def show(): if widget.objectName() == "MayaWindow") with qt_app_context(): - window = App(parent=mainwindow) + window = MayaLookAssignerWindow(parent=mainwindow) window.setStyleSheet(style.load_stylesheet()) window.show() diff --git a/openpype/tools/mayalookassigner/views.py b/openpype/tools/mayalookassigner/views.py index 993023bb45..8e676ebc7f 100644 --- a/openpype/tools/mayalookassigner/views.py +++ b/openpype/tools/mayalookassigner/views.py @@ -1,9 +1,6 @@ from Qt import QtWidgets, QtCore -DEFAULT_COLOR = "#fb9c15" - - class View(QtWidgets.QTreeView): data_changed = QtCore.Signal() diff --git a/openpype/tools/mayalookassigner/widgets.py b/openpype/tools/mayalookassigner/widgets.py index e546ee705d..10e573342a 100644 --- a/openpype/tools/mayalookassigner/widgets.py +++ b/openpype/tools/mayalookassigner/widgets.py @@ -14,7 +14,7 @@ from .models import ( LookModel ) from . import commands -from . import views +from .views import View from maya import cmds @@ -24,25 +24,28 @@ class AssetOutliner(QtWidgets.QWidget): selection_changed = QtCore.Signal() def __init__(self, parent=None): - QtWidgets.QWidget.__init__(self, parent) + super(AssetOutliner, self).__init__(parent) - layout = QtWidgets.QVBoxLayout() - - title = QtWidgets.QLabel("Assets") + title = QtWidgets.QLabel("Assets", self) title.setAlignment(QtCore.Qt.AlignCenter) title.setStyleSheet("font-weight: bold; font-size: 12px") model = AssetModel() - view = views.View() + view = View(self) view.setModel(model) view.customContextMenuRequested.connect(self.right_mouse_menu) view.setSortingEnabled(False) view.setHeaderHidden(True) view.setIndentation(10) - from_all_asset_btn = QtWidgets.QPushButton("Get All Assets") - from_selection_btn = QtWidgets.QPushButton("Get Assets From Selection") + from_all_asset_btn = QtWidgets.QPushButton( + "Get All Assets", self + ) + from_selection_btn = QtWidgets.QPushButton( + "Get Assets From Selection", self + ) + layout = QtWidgets.QVBoxLayout(self) layout.addWidget(title) layout.addWidget(from_all_asset_btn) layout.addWidget(from_selection_btn) @@ -58,8 +61,6 @@ class AssetOutliner(QtWidgets.QWidget): self.view = view self.model = model - self.setLayout(layout) - self.log = logging.getLogger(__name__) def clear(self): @@ -188,15 +189,10 @@ class LookOutliner(QtWidgets.QWidget): menu_apply_action = QtCore.Signal() def __init__(self, parent=None): - QtWidgets.QWidget.__init__(self, parent) - - # look manager layout - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(10) + super(LookOutliner, self).__init__(parent) # Looks from database - title = QtWidgets.QLabel("Looks") + title = QtWidgets.QLabel("Looks", self) title.setAlignment(QtCore.Qt.AlignCenter) title.setStyleSheet("font-weight: bold; font-size: 12px") title.setAlignment(QtCore.Qt.AlignCenter) @@ -207,13 +203,17 @@ class LookOutliner(QtWidgets.QWidget): proxy = QtCore.QSortFilterProxyModel() proxy.setSourceModel(model) - view = views.View() + view = View(self) view.setModel(proxy) view.setMinimumHeight(180) view.setToolTip("Use right mouse button menu for direct actions") view.customContextMenuRequested.connect(self.right_mouse_menu) view.sortByColumn(0, QtCore.Qt.AscendingOrder) + # look manager layout + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(10) layout.addWidget(title) layout.addWidget(view) diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index 6ce9e818d9..2d9733ec94 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -224,20 +224,18 @@ class HostToolsHelper: def get_look_assigner_tool(self, parent): """Create, cache and return look assigner tool window.""" if self._look_assigner_tool is None: - import mayalookassigner + from openpype.tools.mayalookassigner import MayaLookAssignerWindow - mayalookassigner_window = mayalookassigner.App(parent) + mayalookassigner_window = MayaLookAssignerWindow(parent) self._look_assigner_tool = mayalookassigner_window return self._look_assigner_tool def show_look_assigner(self, parent=None): """Look manager is Maya specific tool for look management.""" - from avalon import style with qt_app_context(): look_assigner_tool = self.get_look_assigner_tool(parent) look_assigner_tool.show() - look_assigner_tool.setStyleSheet(style.load_stylesheet()) def get_experimental_tools_dialog(self, parent=None): """Dialog of experimental tools. From cd1e764da6299d328fe5d9895d4b6552f48fdc2e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 16:19:08 +0100 Subject: [PATCH 454/483] cleanup --- .../project_manager/project_manager/multiselection_combobox.py | 2 +- openpype/tools/utils/__init__.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/multiselection_combobox.py b/openpype/tools/project_manager/project_manager/multiselection_combobox.py index 890567de6d..f776831298 100644 --- a/openpype/tools/project_manager/project_manager/multiselection_combobox.py +++ b/openpype/tools/project_manager/project_manager/multiselection_combobox.py @@ -1,4 +1,4 @@ -from Qt import QtCore, QtGui, QtWidgets +from Qt import QtCore, QtWidgets class ComboItemDelegate(QtWidgets.QStyledItemDelegate): diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 6ab9e75b52..ea1133c442 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -42,6 +42,7 @@ __all__ = ( "set_style_property", "DynamicQThread", "qt_app_context", + "get_asset_icon", "RecursiveSortFilterProxyModel", ) From 4f8129acfae2061211f00c4d8061dc8bb8626199 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 16:40:05 +0100 Subject: [PATCH 455/483] move spinner svg into openpype --- openpype/resources/images/spinner-200.svg | 6 ++++++ openpype/tools/utils/views.py | 9 +++------ 2 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 openpype/resources/images/spinner-200.svg diff --git a/openpype/resources/images/spinner-200.svg b/openpype/resources/images/spinner-200.svg new file mode 100644 index 0000000000..73d8ae2890 --- /dev/null +++ b/openpype/resources/images/spinner-200.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/openpype/tools/utils/views.py b/openpype/tools/utils/views.py index 97aaf622a4..a2f1f15b95 100644 --- a/openpype/tools/utils/views.py +++ b/openpype/tools/utils/views.py @@ -1,5 +1,5 @@ import os -from avalon import style +from openpype.resources import get_image_path from Qt import QtWidgets, QtCore, QtGui, QtSvg @@ -24,11 +24,8 @@ class TreeViewSpinner(QtWidgets.QTreeView): def __init__(self, parent=None): super(TreeViewSpinner, self).__init__(parent=parent) - loading_image_path = os.path.join( - os.path.dirname(os.path.abspath(style.__file__)), - "svg", - "spinner-200.svg" - ) + loading_image_path = get_image_path("spinner-200.svg") + self.spinner = QtSvg.QSvgRenderer(loading_image_path) self.is_loading = False From e1dd07c936c65a4e34c68135425e0ffd54ab94db Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 16:40:32 +0100 Subject: [PATCH 456/483] remove backwards compatibility of 'application' context function --- openpype/tools/utils/lib.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 829725dcf2..3ad2d12883 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -92,10 +92,6 @@ def qt_app_context(): yield app -# Backwards compatibility -application = qt_app_context - - class SharedObjects: jobs = {} icons = {} From 2b062e98a0a3076d56d3f91756570db67ea17e0c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 16:51:37 +0100 Subject: [PATCH 457/483] added icon option for projects --- openpype/tools/launcher/models.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index 9036c9cbd5..08bf43a451 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -14,7 +14,10 @@ from openpype.lib.applications import ( CUSTOM_LAUNCH_APP_GROUPS, ApplicationManager ) -from openpype.tools.utils.lib import DynamicQThread +from openpype.tools.utils.lib import ( + DynamicQThread, + get_qta_icon_by_name_and_color, +) from openpype.tools.utils.assets_widget import ( AssetModel, ASSET_NAME_ROLE @@ -400,6 +403,7 @@ class LauncherModel(QtCore.QObject): self._dbcon = dbcon # Available project names self._project_names = set() + self._project_icons_by_name = {} # Context data self._asset_docs = [] @@ -460,6 +464,9 @@ class LauncherModel(QtCore.QObject): """Available project names.""" return self._project_names + def get_icon_for_project(self, project_name): + return self._project_icons_by_name.get(project_name) + @property def asset_filter_data_by_id(self): """Prepared filter data by asset id.""" @@ -516,9 +523,15 @@ class LauncherModel(QtCore.QObject): """Refresh projects.""" current_project = self.project_name project_names = set() + project_icons_by_name = {} for project_doc in self._dbcon.projects(only_active=True): - project_names.add(project_doc["name"]) + project_name = project_doc["name"] + project_names.add(project_name) + project_icons_by_name[project_name] = ( + project_doc.get("data", {}).get("icon") + ) + self._project_icons_by_name = project_icons_by_name self._project_names = project_names self.projects_refreshed.emit() if ( @@ -718,7 +731,6 @@ class AssetRecursiveSortFilterModel(QtCore.QSortFilterProxyModel): self._assignee_filter = self._launcher_model.assignee_filters self.invalidateFilter() - """Filters to the regex if any of the children matches allow parent""" def filterAcceptsRow(self, row, parent): if ( not self._name_filter @@ -863,7 +875,14 @@ class ProjectModel(QtGui.QStandardItemModel): for row in reversed(sorted(row_counts.keys())): items = [] for project_name in row_counts[row]: - item = QtGui.QStandardItem(self.project_icon, project_name) + icon_name = self._launcher_model.get_icon_for_project( + project_name + ) + icon = get_qta_icon_by_name_and_color(icon_name, "white") + if not icon: + icon = self.project_icon + + item = QtGui.QStandardItem(icon, project_name) items.append(item) self.invisibleRootItem().insertRows(row, items) From b48460d579e275dae09248168ae3748e2eb77716 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 17:17:33 +0100 Subject: [PATCH 458/483] added function for getting a project icon --- openpype/tools/launcher/models.py | 24 ++++++---------- openpype/tools/utils/lib.py | 46 +++++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index 08bf43a451..8dbc45aadb 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -16,7 +16,7 @@ from openpype.lib.applications import ( ) from openpype.tools.utils.lib import ( DynamicQThread, - get_qta_icon_by_name_and_color, + get_project_icon, ) from openpype.tools.utils.assets_widget import ( AssetModel, @@ -403,7 +403,7 @@ class LauncherModel(QtCore.QObject): self._dbcon = dbcon # Available project names self._project_names = set() - self._project_icons_by_name = {} + self._project_docs_by_name = {} # Context data self._asset_docs = [] @@ -464,8 +464,8 @@ class LauncherModel(QtCore.QObject): """Available project names.""" return self._project_names - def get_icon_for_project(self, project_name): - return self._project_icons_by_name.get(project_name) + def get_project_doc(self, project_name): + return self._project_docs_by_name.get(project_name) @property def asset_filter_data_by_id(self): @@ -523,15 +523,13 @@ class LauncherModel(QtCore.QObject): """Refresh projects.""" current_project = self.project_name project_names = set() - project_icons_by_name = {} + project_docs_by_name = {} for project_doc in self._dbcon.projects(only_active=True): project_name = project_doc["name"] project_names.add(project_name) - project_icons_by_name[project_name] = ( - project_doc.get("data", {}).get("icon") - ) + project_docs_by_name[project_name] = project_doc - self._project_icons_by_name = project_icons_by_name + self._project_docs_by_name = project_docs_by_name self._project_names = project_names self.projects_refreshed.emit() if ( @@ -830,7 +828,6 @@ class ProjectModel(QtGui.QStandardItemModel): super(ProjectModel, self).__init__(parent=parent) self._launcher_model = launcher_model - self.project_icon = qtawesome.icon("fa.map", color="white") self._project_names = set() launcher_model.projects_refreshed.connect(self._on_refresh) @@ -875,13 +872,10 @@ class ProjectModel(QtGui.QStandardItemModel): for row in reversed(sorted(row_counts.keys())): items = [] for project_name in row_counts[row]: - icon_name = self._launcher_model.get_icon_for_project( + project_doc = self._launcher_model.get_project_doc( project_name ) - icon = get_qta_icon_by_name_and_color(icon_name, "white") - if not icon: - icon = self.project_icon - + icon = get_project_icon(project_doc) item = QtGui.QStandardItem(icon, project_name) items.append(item) diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 3ad2d12883..4754a85bf1 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -122,18 +122,42 @@ def get_qta_icon_by_name_and_color(icon_name, icon_color): return icon +def get_project_icon(project_doc): + if project_doc: + icon_name = project_doc.get("data", {}).get("icon") + icon = get_qta_icon_by_name_and_color(icon_name, "white") + if icon: + return icon + + return get_qta_icon_by_name_and_color( + "fa.map", get_default_entity_icon_color() + ) + + +def get_asset_icon_name(asset_doc, has_children=True): + if asset_doc: + asset_data = asset_doc.get("data") or {} + icon_name = asset_data.get("icon") + if icon_name: + return icon_name + + if has_children: + return "folder" + return "folder-o" + + +def get_asset_icon_color(asset_doc): + if asset_doc: + asset_data = asset_doc.get("data") or {} + icon_color = asset_data.get("color") + if icon_color: + return icon_color + return get_default_entity_icon_color() + + def get_asset_icon(asset_doc, has_children=False): - asset_data = asset_doc.get("data") or {} - icon_color = asset_data.get("color") or get_default_entity_icon_color() - icon_name = asset_data.get("icon") - if not icon_name: - # Use default icons if no custom one is specified. - # If it has children show a full folder, otherwise - # show an open folder - if has_children: - icon_name = "folder" - else: - icon_name = "folder-o" + icon_name = get_asset_icon_name(asset_doc, has_children) + icon_color = get_asset_icon_color(asset_doc) return get_qta_icon_by_name_and_color(icon_name, icon_color) From 8f998f06e88fa410c1603b40616911f83a3ac7b1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 14 Mar 2022 17:24:57 +0100 Subject: [PATCH 459/483] use openpype style on scene inventory error dialog --- openpype/tools/sceneinventory/view.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py index 32c1883de6..fb93faefd6 100644 --- a/openpype/tools/sceneinventory/view.py +++ b/openpype/tools/sceneinventory/view.py @@ -5,9 +5,10 @@ from functools import partial from Qt import QtWidgets, QtCore import qtawesome -from avalon import io, api, style +from avalon import io, api from avalon.lib import HeroVersionType +from openpype import style from openpype.modules import ModulesManager from openpype.tools.utils.lib import ( get_progress_for_repre, From 16a24d03daaf0eec01fb7121e48e40c6f0f1fa1f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 15 Mar 2022 09:56:58 +0100 Subject: [PATCH 460/483] removed unnecessary style set --- openpype/tools/mayalookassigner/app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/tools/mayalookassigner/app.py b/openpype/tools/mayalookassigner/app.py index da9f06f3f0..0e633a21e3 100644 --- a/openpype/tools/mayalookassigner/app.py +++ b/openpype/tools/mayalookassigner/app.py @@ -279,7 +279,6 @@ def show(): with qt_app_context(): window = MayaLookAssignerWindow(parent=mainwindow) - window.setStyleSheet(style.load_stylesheet()) window.show() module.window = window From c5b3098acd35c439628d12750d521dfe5b36e598 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 15 Mar 2022 10:24:12 +0100 Subject: [PATCH 461/483] fix function typo --- openpype/hosts/houdini/api/pipeline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 7d4e58efb7..eb1bdafbb0 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -13,7 +13,7 @@ from avalon.lib import find_submodule from openpype.pipeline import ( LegacyCreator, - register_loader_plugin_path, + register_loader_plugins_path, ) import openpype.hosts.houdini from openpype.hosts.houdini.api import lib @@ -53,7 +53,7 @@ def install(): pyblish.api.register_host("hpython") pyblish.api.register_plugin_path(PUBLISH_PATH) - register_loader_plugin_path(LOAD_PATH) + register_loader_plugins_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) log.info("Installing callbacks ... ") From 96f55916dd3cd000aad6901f842653123313285f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 15 Mar 2022 10:26:07 +0100 Subject: [PATCH 462/483] remove constants --- openpype/style/__init__.py | 55 +++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/openpype/style/__init__.py b/openpype/style/__init__.py index d92e18c0cd..b2a1a4ce6c 100644 --- a/openpype/style/__init__.py +++ b/openpype/style/__init__.py @@ -10,17 +10,6 @@ from .color_defs import parse_color current_dir = os.path.dirname(os.path.abspath(__file__)) -# Default colors -# - default color used in tool icons -_TOOLS_ICON_COLOR = "#ffffff" -# - entities icon color - use 'get_default_asset_icon_color' -_DEFAULT_ENTITY_ICON_COLOR = "#fb9c15" -# - disabled entitie -_DISABLED_ENTITY_ICON_ICON_COLOR = "#808080" -# - deprecated entity font color -_DEPRECATED_ENTITY_FONT_COLOR = "#666666" - - class _Cache: stylesheet = None font_ids = None @@ -200,36 +189,60 @@ def app_icon_path(): def get_default_tools_icon_color(): + """Default color used in tool icons. + + Color must be possible to parse using QColor. + + Returns: + str: Color as a string. + """ if _Cache.tools_icon_color is None: color_data = get_colors_data() - color = color_data.get("icon-tools") - _Cache.tools_icon_color = color or _TOOLS_ICON_COLOR + _Cache.tools_icon_color = color_data["icon-tools"] return _Cache.tools_icon_color def get_default_entity_icon_color(): + """Default color of entities icons. + + Color must be possible to parse using QColor. + + Returns: + str: Color as a string. + """ if _Cache.default_entity_icon_color is None: color_data = get_colors_data() - color = color_data.get("icon-entity-default") - _Cache.default_entity_icon_color = color or _DEFAULT_ENTITY_ICON_COLOR + _Cache.default_entity_icon_color = color_data["icon-entity-default"] return _Cache.default_entity_icon_color def get_disabled_entity_icon_color(): + """Default color of entities icons. + + TODO: Find more suitable function name. + + Color must be possible to parse using QColor. + + Returns: + str: Color as a string. + """ if _Cache.disabled_entity_icon_color is None: color_data = get_colors_data() - color = color_data.get("icon-entity-disabled") - _Cache.disabled_entity_icon_color = ( - color or _DISABLED_ENTITY_ICON_ICON_COLOR - ) + _Cache.disabled_entity_icon_color = color_data["icon-entity-disabled"] return _Cache.disabled_entity_icon_color def get_deprecated_entity_font_color(): + """Font color for deprecated entities. + + Color must be possible to parse using QColor. + + Returns: + str: Color as a string. + """ if _Cache.deprecated_entity_font_color is None: color_data = get_colors_data() - color = color_data.get("font-entity-deprecated") _Cache.deprecated_entity_font_color = ( - color or _DEPRECATED_ENTITY_FONT_COLOR + color_data["font-entity-deprecated"] ) return _Cache.deprecated_entity_font_color From 58d31a11b9c89eef71d784947b19d9c9d78a8eae Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 15 Mar 2022 10:49:51 +0100 Subject: [PATCH 463/483] implemented function to receive task icon --- openpype/tools/launcher/models.py | 5 ++ openpype/tools/utils/lib.py | 39 +++++++++++--- openpype/tools/utils/tasks_widget.py | 76 +++++++--------------------- 3 files changed, 56 insertions(+), 64 deletions(-) diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index 8dbc45aadb..85d553fca4 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -705,6 +705,11 @@ class LauncherTaskModel(TasksModel): self._launcher_model = launcher_model super(LauncherTaskModel, self).__init__(*args, **kwargs) + def _refresh_project_doc(self): + self._project_doc = self._launcher_model.get_project_doc( + self._launcher_model.project_name + ) + def set_asset_id(self, asset_id): asset_doc = None if self._context_is_valid(): diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 4754a85bf1..00cec20b2a 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -162,16 +162,43 @@ def get_asset_icon(asset_doc, has_children=False): return get_qta_icon_by_name_and_color(icon_name, icon_color) -def get_task_icon(): - """Get icon for a task. +def get_default_task_icon(color=None): + if color is None: + color = get_default_entity_icon_color() + return get_qta_icon_by_name_and_color("fa.male", color) - TODO: Get task icon based on data in database. + +def get_task_icon(project_doc, asset_doc, task_name): + """Get icon for a task. Icon should be defined by task type which is stored on project. """ - return get_qta_icon_by_name_and_color( - "fa.male", get_default_entity_icon_color() - ) + + color = get_default_entity_icon_color() + + tasks_info = asset_doc.get("data", {}).get("tasks") or {} + task_info = tasks_info.get(task_name) or {} + task_icon = task_info.get("icon") + if task_icon: + icon = get_qta_icon_by_name_and_color(task_icon, color) + if icon is not None: + return icon + + task_type = task_info.get("type") + if "config" not in project_doc: + print(10*"*") + print(project_doc) + task_types = {} + else: + task_types = project_doc["config"].get("tasks") or {} + + task_type_info = task_types.get(task_type) or {} + task_type_icon = task_type_info.get("icon") + if task_type_icon: + icon = get_qta_icon_by_name_and_color(task_icon, color) + if icon is not None: + return icon + return get_default_task_icon(color) def schedule(func, time, channel="default"): diff --git a/openpype/tools/utils/tasks_widget.py b/openpype/tools/utils/tasks_widget.py index 2c92b7228a..eab183d5f3 100644 --- a/openpype/tools/utils/tasks_widget.py +++ b/openpype/tools/utils/tasks_widget.py @@ -1,10 +1,8 @@ from Qt import QtWidgets, QtCore, QtGui import qtawesome -from openpype.style import ( - get_default_entity_icon_color, - get_disabled_entity_icon_color, -) +from openpype.style import get_disabled_entity_icon_color +from openpype.tools.utils.lib import get_task_icon from .views import DeselectableTreeView @@ -24,54 +22,35 @@ class TasksModel(QtGui.QStandardItemModel): self.setHeaderData( 0, QtCore.Qt.Horizontal, "Tasks", QtCore.Qt.DisplayRole ) - default_color = get_default_entity_icon_color() - self._default_color = default_color - self._default_icon = qtawesome.icon( - "fa.male", color=default_color - ) + self._no_tasks_icon = qtawesome.icon( "fa.exclamation-circle", color=get_disabled_entity_icon_color() ) self._cached_icons = {} - self._project_task_types = {} + self._project_doc = {} self._empty_tasks_item = None self._last_asset_id = None self._loaded_project_name = None def _context_is_valid(self): - if self.dbcon.Session.get("AVALON_PROJECT"): + if self._get_current_project(): return True return False def refresh(self): - self._refresh_task_types() + self._refresh_project_doc() self.set_asset_id(self._last_asset_id) - def _refresh_task_types(self): + def _refresh_project_doc(self): # Get the project configured icons from database - task_types = {} + project_doc = {} if self._context_is_valid(): - project = self.dbcon.find_one( - {"type": "project"}, - {"config.tasks"} - ) - task_types = project["config"].get("tasks") or task_types - self._project_task_types = task_types + project_doc = self.dbcon.find_one({"type": "project"}) - def _try_get_awesome_icon(self, icon_name): - icon = None - if icon_name: - try: - icon = qtawesome.icon( - "fa.{}".format(icon_name), - color=self._default_color - ) - - except Exception: - pass - return icon + self._loaded_project_name = self._get_current_project() + self._project_doc = project_doc def headerData(self, section, orientation, role=None): if role is None: @@ -86,28 +65,8 @@ class TasksModel(QtGui.QStandardItemModel): return super(TasksModel, self).headerData(section, orientation, role) - def _get_icon(self, task_icon, task_type_icon): - if task_icon in self._cached_icons: - return self._cached_icons[task_icon] - - icon = self._try_get_awesome_icon(task_icon) - if icon is not None: - self._cached_icons[task_icon] = icon - return icon - - if task_type_icon in self._cached_icons: - icon = self._cached_icons[task_type_icon] - self._cached_icons[task_icon] = icon - return icon - - icon = self._try_get_awesome_icon(task_type_icon) - if icon is None: - icon = self._default_icon - - self._cached_icons[task_icon] = icon - self._cached_icons[task_type_icon] = icon - - return icon + def _get_current_project(self): + return self.dbcon.Session.get("AVALON_PROJECT") def set_asset_id(self, asset_id): asset_doc = None @@ -132,6 +91,9 @@ class TasksModel(QtGui.QStandardItemModel): Arguments: asset_doc (dict): Asset document from MongoDB. """ + if self._loaded_project_name != self._get_current_project(): + self._refresh_project_doc() + asset_tasks = {} self._last_asset_id = None if asset_doc: @@ -142,13 +104,11 @@ class TasksModel(QtGui.QStandardItemModel): root_item.removeRows(0, root_item.rowCount()) items = [] + for task_name, task_info in asset_tasks.items(): - task_icon = task_info.get("icon") task_type = task_info.get("type") task_order = task_info.get("order") - task_type_info = self._project_task_types.get(task_type) or {} - task_type_icon = task_type_info.get("icon") - icon = self._get_icon(task_icon, task_type_icon) + icon = get_task_icon(self._project_doc, asset_doc, task_name) task_assignees = set() assignees_data = task_info.get("assignees") or [] From 8a0005e93dd4eec57be0e92f3cf1741bc88e260c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 15 Mar 2022 10:50:00 +0100 Subject: [PATCH 464/483] use default task icon in publisher --- openpype/tools/publisher/widgets/tasks_widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/publisher/widgets/tasks_widget.py b/openpype/tools/publisher/widgets/tasks_widget.py index 8a913b7114..aa239f6334 100644 --- a/openpype/tools/publisher/widgets/tasks_widget.py +++ b/openpype/tools/publisher/widgets/tasks_widget.py @@ -1,7 +1,7 @@ from Qt import QtCore, QtGui from openpype.tools.utils.tasks_widget import TasksWidget, TASK_NAME_ROLE -from openpype.tools.utils.lib import get_task_icon +from openpype.tools.utils.lib import get_default_task_icon class TasksModel(QtGui.QStandardItemModel): @@ -120,7 +120,7 @@ class TasksModel(QtGui.QStandardItemModel): item = QtGui.QStandardItem(task_name) item.setData(task_name, TASK_NAME_ROLE) if task_name: - item.setData(get_task_icon(), QtCore.Qt.DecorationRole) + item.setData(get_default_task_icon(), QtCore.Qt.DecorationRole) self._items_by_name[task_name] = item new_items.append(item) From 001f096f4e7861d8065304af521d848d417265cd Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 15 Mar 2022 10:55:27 +0100 Subject: [PATCH 465/483] fix deprecated font color in standalone publisher --- openpype/tools/standalonepublish/widgets/model_asset.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/standalonepublish/widgets/model_asset.py b/openpype/tools/standalonepublish/widgets/model_asset.py index 7d93e7a943..a7316a2aa7 100644 --- a/openpype/tools/standalonepublish/widgets/model_asset.py +++ b/openpype/tools/standalonepublish/widgets/model_asset.py @@ -201,8 +201,8 @@ class AssetModel(TreeModel): return if role == QtCore.Qt.ForegroundRole: # font color - # if "deprecated" in node.get("tags", []): - return QtGui.QColor(self._deprecated_asset_font_color) + if "deprecated" in node.get("tags", []): + return QtGui.QColor(self._deprecated_asset_font_color) if role == self.ObjectIdRole: return node.get("_id", None) From 10333be88310781deffe118e276201c13ea5a4ae Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 15 Mar 2022 10:55:40 +0100 Subject: [PATCH 466/483] remove asset document check --- openpype/tools/utils/assets_widget.py | 2 +- openpype/tools/utils/lib.py | 16 ++++++---------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/openpype/tools/utils/assets_widget.py b/openpype/tools/utils/assets_widget.py index 9beca69f12..3d4efcdd4d 100644 --- a/openpype/tools/utils/assets_widget.py +++ b/openpype/tools/utils/assets_widget.py @@ -513,7 +513,7 @@ class AssetModel(QtGui.QStandardItemModel): item.setData(asset_label, ASSET_LABEL_ROLE) has_children = item.rowCount() > 0 - icon = get_asset_icon(asset_data, has_children) + icon = get_asset_icon(asset_doc, has_children) item.setData(icon, QtCore.Qt.DecorationRole) def _threaded_fetch(self): diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 00cec20b2a..d66b636b2a 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -135,11 +135,9 @@ def get_project_icon(project_doc): def get_asset_icon_name(asset_doc, has_children=True): - if asset_doc: - asset_data = asset_doc.get("data") or {} - icon_name = asset_data.get("icon") - if icon_name: - return icon_name + icon_name = asset_doc["data"].get("icon") + if icon_name: + return icon_name if has_children: return "folder" @@ -147,11 +145,9 @@ def get_asset_icon_name(asset_doc, has_children=True): def get_asset_icon_color(asset_doc): - if asset_doc: - asset_data = asset_doc.get("data") or {} - icon_color = asset_data.get("color") - if icon_color: - return icon_color + icon_color = asset_doc["data"].get("color") + if icon_color: + return icon_color return get_default_entity_icon_color() From 43239740119e2456b8d7a26a28f3a4eaab05af47 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 15 Mar 2022 11:12:39 +0100 Subject: [PATCH 467/483] remove debug prints --- openpype/tools/utils/lib.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index d66b636b2a..93b156bef8 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -181,12 +181,7 @@ def get_task_icon(project_doc, asset_doc, task_name): return icon task_type = task_info.get("type") - if "config" not in project_doc: - print(10*"*") - print(project_doc) - task_types = {} - else: - task_types = project_doc["config"].get("tasks") or {} + task_types = project_doc["config"]["tasks"] task_type_info = task_types.get(task_type) or {} task_type_icon = task_type_info.get("icon") From f4ece8fd5ea844149c37c2cad0c9edebe258b4dc Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 15 Mar 2022 11:17:31 +0100 Subject: [PATCH 468/483] flame: removing testing code --- openpype/hosts/flame/hooks/pre_flame_setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index 5db5757d50..ad2b0dc897 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -63,7 +63,7 @@ class FlamePrelaunch(PreLaunchHook): _db_p_data = project_doc["data"] width = _db_p_data["resolutionWidth"] height = _db_p_data["resolutionHeight"] - fps = float(_db_p_data["fps_string"]) + fps = float(_db_p_data["fps"]) project_data = { "Name": project_doc["name"], From 5b835d87e7707fd625cba05c7e8e587d766c75fa Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 15 Mar 2022 11:40:59 +0100 Subject: [PATCH 469/483] renamed 'load_representation' to 'load_container' --- openpype/hosts/blender/plugins/load/load_layout_json.py | 4 ++-- openpype/hosts/maya/api/lib.py | 4 ++-- openpype/hosts/maya/api/setdress.py | 4 ++-- openpype/hosts/nuke/plugins/publish/validate_read_legacy.py | 4 ++-- openpype/hosts/unreal/plugins/load/load_layout.py | 4 ++-- openpype/lib/avalon_context.py | 4 ++-- openpype/pipeline/__init__.py | 4 ++-- openpype/pipeline/load/__init__.py | 4 ++-- openpype/pipeline/load/utils.py | 2 +- openpype/tools/mayalookassigner/vray_proxies.py | 4 ++-- 10 files changed, 19 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_layout_json.py b/openpype/hosts/blender/plugins/load/load_layout_json.py index 499d2c49f3..0693937fec 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_json.py +++ b/openpype/hosts/blender/plugins/load/load_layout_json.py @@ -10,7 +10,7 @@ import bpy from openpype.pipeline import ( discover_loader_plugins, remove_container, - load_representation, + load_container, get_representation_path, loaders_from_representation, ) @@ -108,7 +108,7 @@ class JsonLayoutLoader(plugin.AssetLoader): # at this time it will not return anything. The assets will be # loaded in the next Blender cycle, so we use the options to # set the transform, parent and assign the action, if there is one. - load_representation( + load_container( loader, reference, namespace=instance_name, diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 94efbb7a07..9f97eef2f1 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -25,7 +25,7 @@ from openpype.pipeline import ( discover_loader_plugins, loaders_from_representation, get_representation_path, - load_representation, + load_container, ) from .commands import reset_frame_range @@ -1594,7 +1594,7 @@ def assign_look_by_version(nodes, version_id): # Reference the look file with maintained_selection(): - container_node = load_representation(Loader, look_representation) + container_node = load_container(Loader, look_representation) # Get container members shader_nodes = get_container_members(container_node) diff --git a/openpype/hosts/maya/api/setdress.py b/openpype/hosts/maya/api/setdress.py index 74ee292eb2..96a9700b88 100644 --- a/openpype/hosts/maya/api/setdress.py +++ b/openpype/hosts/maya/api/setdress.py @@ -12,7 +12,7 @@ from avalon import io from openpype.pipeline import ( discover_loader_plugins, loaders_from_representation, - load_representation, + load_container, update_container, remove_container, get_representation_path, @@ -189,7 +189,7 @@ def _add(instance, representation_id, loaders, namespace, root="|"): instance['loader'], instance) raise RuntimeError("Loader is missing.") - container = load_representation( + container = load_container( Loader, representation_id, namespace=instance['namespace'] diff --git a/openpype/hosts/nuke/plugins/publish/validate_read_legacy.py b/openpype/hosts/nuke/plugins/publish/validate_read_legacy.py index 39fe011d85..2bf1ff81f8 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_read_legacy.py +++ b/openpype/hosts/nuke/plugins/publish/validate_read_legacy.py @@ -8,7 +8,7 @@ from bson.objectid import ObjectId from openpype.pipeline import ( discover_loader_plugins, - load_representation, + load_container, ) @@ -59,7 +59,7 @@ class RepairReadLegacyAction(pyblish.api.Action): loader_plugin = Loader - load_representation( + load_container( Loader=loader_plugin, representation=ObjectId(data["representation"]) ) diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index b987a32a61..19ee179d20 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -15,7 +15,7 @@ from avalon.pipeline import AVALON_CONTAINER_ID from openpype.pipeline import ( discover_loader_plugins, loaders_from_representation, - load_representation, + load_container, get_representation_path, ) from openpype.hosts.unreal.api import plugin @@ -258,7 +258,7 @@ class LayoutLoader(plugin.Loader): "asset_dir": asset_dir } - assets = load_representation( + assets = load_container( loader, reference, namespace=instance_name, diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index d7f17d8eed..c88e72c46a 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -1394,7 +1394,7 @@ class BuildWorkfile: """ from openpype.pipeline import ( IncompatibleLoaderError, - load_representation, + load_container, ) loaded_containers = [] @@ -1458,7 +1458,7 @@ class BuildWorkfile: if not loader: continue try: - container = load_representation( + container = load_container( loader, repre["_id"], name=subset_name diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index d582ef1d07..3ff3638a23 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -24,7 +24,7 @@ from .load import ( register_loader_plugins_path, deregister_loader_plugin, - load_representation, + load_container, remove_container, update_container, switch_container, @@ -68,7 +68,7 @@ __all__ = ( "register_loader_plugins_path", "deregister_loader_plugin", - "load_representation", + "load_container", "remove_container", "update_container", "switch_container", diff --git a/openpype/pipeline/load/__init__.py b/openpype/pipeline/load/__init__.py index 2af15e8705..eac303c10c 100644 --- a/openpype/pipeline/load/__init__.py +++ b/openpype/pipeline/load/__init__.py @@ -10,7 +10,7 @@ from .utils import ( load_with_subset_context, load_with_subset_contexts, - load_representation, + load_container, remove_container, update_container, switch_container, @@ -51,7 +51,7 @@ __all__ = ( "load_with_subset_context", "load_with_subset_contexts", - "load_representation", + "load_container", "remove_container", "update_container", "switch_container", diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index 4ef0f099d7..ae47cb9ce9 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -333,7 +333,7 @@ def load_with_subset_contexts( return loader.load(subset_contexts, name, namespace, options) -def load_representation( +def load_container( Loader, representation, namespace=None, name=None, options=None, **kwargs ): """Use Loader to load a representation. diff --git a/openpype/tools/mayalookassigner/vray_proxies.py b/openpype/tools/mayalookassigner/vray_proxies.py index 3179ba1445..6a9347449a 100644 --- a/openpype/tools/mayalookassigner/vray_proxies.py +++ b/openpype/tools/mayalookassigner/vray_proxies.py @@ -13,7 +13,7 @@ from maya import cmds from avalon import io, api from openpype.pipeline import ( - load_representation, + load_container, loaders_from_representation, discover_loader_plugins, get_representation_path, @@ -208,7 +208,7 @@ def load_look(version_id): # Reference the look file with lib.maintained_selection(): - container_node = load_representation(loader, look_representation) + container_node = load_container(loader, look_representation) # Get container members shader_nodes = lib.get_container_members(container_node) From 4f7d99babeee9a5a309b009e805877d70feec8a6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 15 Mar 2022 14:15:35 +0100 Subject: [PATCH 470/483] remove plural from de/register_loader_plugins_path --- openpype/__init__.py | 10 +++++----- openpype/hosts/aftereffects/api/pipeline.py | 8 ++++---- openpype/hosts/blender/api/pipeline.py | 8 ++++---- openpype/hosts/flame/api/pipeline.py | 8 ++++---- openpype/hosts/fusion/api/pipeline.py | 8 ++++---- openpype/hosts/harmony/api/pipeline.py | 8 ++++---- openpype/hosts/hiero/api/pipeline.py | 8 ++++---- openpype/hosts/houdini/api/pipeline.py | 4 ++-- openpype/hosts/maya/api/pipeline.py | 8 ++++---- openpype/hosts/nuke/api/pipeline.py | 8 ++++---- openpype/hosts/photoshop/api/pipeline.py | 8 ++++---- openpype/hosts/resolve/api/pipeline.py | 8 ++++---- openpype/hosts/tvpaint/api/pipeline.py | 8 ++++---- openpype/hosts/unreal/api/pipeline.py | 8 ++++---- openpype/pipeline/__init__.py | 8 ++++---- openpype/pipeline/load/__init__.py | 8 ++++---- openpype/pipeline/load/plugins.py | 4 ++-- 17 files changed, 65 insertions(+), 65 deletions(-) diff --git a/openpype/__init__.py b/openpype/__init__.py index 0df1b7270f..99629a4257 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -77,7 +77,7 @@ def install(): from openpype.modules import load_modules from openpype.pipeline import ( LegacyCreator, - register_loader_plugins_path, + register_loader_plugin_path, ) from avalon import pipeline @@ -94,7 +94,7 @@ def install(): log.info("Registering global plug-ins..") pyblish.register_plugin_path(PUBLISH_PATH) pyblish.register_discovery_filter(filter_pyblish_plugins) - register_loader_plugins_path(LOAD_PATH) + register_loader_plugin_path(LOAD_PATH) project_name = os.environ.get("AVALON_PROJECT") @@ -122,7 +122,7 @@ def install(): continue pyblish.register_plugin_path(path) - register_loader_plugins_path(path) + register_loader_plugin_path(path) avalon.register_plugin_path(LegacyCreator, path) avalon.register_plugin_path(avalon.InventoryAction, path) @@ -142,12 +142,12 @@ def _on_task_change(): @import_wrapper def uninstall(): """Uninstall Pype from Avalon.""" - from openpype.pipeline import deregister_loader_plugins_path + from openpype.pipeline import deregister_loader_plugin_path log.info("Deregistering global plug-ins..") pyblish.deregister_plugin_path(PUBLISH_PATH) pyblish.deregister_discovery_filter(filter_pyblish_plugins) - deregister_loader_plugins_path(LOAD_PATH) + deregister_loader_plugin_path(LOAD_PATH) log.info("Global plug-ins unregistred") # restore original discover diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 8961599149..681f1c51a7 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -11,8 +11,8 @@ from openpype import lib from openpype.api import Logger from openpype.pipeline import ( LegacyCreator, - register_loader_plugins_path, - deregister_loader_plugins_path, + register_loader_plugin_path, + deregister_loader_plugin_path, ) import openpype.hosts.aftereffects from openpype.lib import register_event_callback @@ -71,7 +71,7 @@ def install(): pyblish.api.register_host("aftereffects") pyblish.api.register_plugin_path(PUBLISH_PATH) - register_loader_plugins_path(LOAD_PATH) + register_loader_plugin_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) log.info(PUBLISH_PATH) @@ -84,7 +84,7 @@ def install(): def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) - deregister_loader_plugins_path(LOAD_PATH) + deregister_loader_plugin_path(LOAD_PATH) avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 64fb135d89..07a7509dd7 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -16,8 +16,8 @@ from avalon.pipeline import AVALON_CONTAINER_ID from openpype.pipeline import ( LegacyCreator, - register_loader_plugins_path, - deregister_loader_plugins_path, + register_loader_plugin_path, + deregister_loader_plugin_path, ) from openpype.api import Logger from openpype.lib import ( @@ -54,7 +54,7 @@ def install(): pyblish.api.register_host("blender") pyblish.api.register_plugin_path(str(PUBLISH_PATH)) - register_loader_plugins_path(str(LOAD_PATH)) + register_loader_plugin_path(str(LOAD_PATH)) avalon.api.register_plugin_path(LegacyCreator, str(CREATE_PATH)) lib.append_user_scripts() @@ -76,7 +76,7 @@ def uninstall(): pyblish.api.deregister_host("blender") pyblish.api.deregister_plugin_path(str(PUBLISH_PATH)) - deregister_loader_plugins_path(str(LOAD_PATH)) + deregister_loader_plugin_path(str(LOAD_PATH)) avalon.api.deregister_plugin_path(LegacyCreator, str(CREATE_PATH)) if not IS_HEADLESS: diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 6a045214c3..930c6abe29 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -9,8 +9,8 @@ from pyblish import api as pyblish from openpype.api import Logger from openpype.pipeline import ( LegacyCreator, - register_loader_plugins_path, - deregister_loader_plugins_path, + register_loader_plugin_path, + deregister_loader_plugin_path, ) from .lib import ( set_segment_data_marker, @@ -37,7 +37,7 @@ def install(): pyblish.register_host("flame") pyblish.register_plugin_path(PUBLISH_PATH) - register_loader_plugins_path(LOAD_PATH) + register_loader_plugin_path(LOAD_PATH) avalon.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) log.info("OpenPype Flame plug-ins registred ...") @@ -52,7 +52,7 @@ def uninstall(): log.info("Deregistering Flame plug-ins..") pyblish.deregister_plugin_path(PUBLISH_PATH) - deregister_loader_plugins_path(LOAD_PATH) + deregister_loader_plugin_path(LOAD_PATH) avalon.deregister_plugin_path(LegacyCreator, CREATE_PATH) avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH) diff --git a/openpype/hosts/fusion/api/pipeline.py b/openpype/hosts/fusion/api/pipeline.py index 3f5da7fcc7..92e54ad6f5 100644 --- a/openpype/hosts/fusion/api/pipeline.py +++ b/openpype/hosts/fusion/api/pipeline.py @@ -13,8 +13,8 @@ from avalon.pipeline import AVALON_CONTAINER_ID from openpype.api import Logger from openpype.pipeline import ( LegacyCreator, - register_loader_plugins_path, - deregister_loader_plugins_path, + register_loader_plugin_path, + deregister_loader_plugin_path, ) import openpype.hosts.fusion @@ -67,7 +67,7 @@ def install(): pyblish.api.register_plugin_path(PUBLISH_PATH) log.info("Registering Fusion plug-ins..") - register_loader_plugins_path(LOAD_PATH) + register_loader_plugin_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) @@ -91,7 +91,7 @@ def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) log.info("Deregistering Fusion plug-ins..") - deregister_loader_plugins_path(LOAD_PATH) + deregister_loader_plugin_path(LOAD_PATH) avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) avalon.api.deregister_plugin_path( avalon.api.InventoryAction, INVENTORY_PATH diff --git a/openpype/hosts/harmony/api/pipeline.py b/openpype/hosts/harmony/api/pipeline.py index b9d2e78bce..f967da15ca 100644 --- a/openpype/hosts/harmony/api/pipeline.py +++ b/openpype/hosts/harmony/api/pipeline.py @@ -12,8 +12,8 @@ from openpype import lib from openpype.lib import register_event_callback from openpype.pipeline import ( LegacyCreator, - register_loader_plugins_path, - deregister_loader_plugins_path, + register_loader_plugin_path, + deregister_loader_plugin_path, ) import openpype.hosts.harmony import openpype.hosts.harmony.api as harmony @@ -184,7 +184,7 @@ def install(): pyblish.api.register_host("harmony") pyblish.api.register_plugin_path(PUBLISH_PATH) - register_loader_plugins_path(LOAD_PATH) + register_loader_plugin_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) log.info(PUBLISH_PATH) @@ -198,7 +198,7 @@ def install(): def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) - deregister_loader_plugins_path(LOAD_PATH) + deregister_loader_plugin_path(LOAD_PATH) avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) diff --git a/openpype/hosts/hiero/api/pipeline.py b/openpype/hosts/hiero/api/pipeline.py index f27b7a4f81..eff126c0b6 100644 --- a/openpype/hosts/hiero/api/pipeline.py +++ b/openpype/hosts/hiero/api/pipeline.py @@ -11,8 +11,8 @@ from pyblish import api as pyblish from openpype.api import Logger from openpype.pipeline import ( LegacyCreator, - register_loader_plugins_path, - deregister_loader_plugins_path, + register_loader_plugin_path, + deregister_loader_plugin_path, ) from openpype.tools.utils import host_tools from . import lib, menu, events @@ -49,7 +49,7 @@ def install(): log.info("Registering Hiero plug-ins..") pyblish.register_host("hiero") pyblish.register_plugin_path(PUBLISH_PATH) - register_loader_plugins_path(LOAD_PATH) + register_loader_plugin_path(LOAD_PATH) avalon.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) @@ -71,7 +71,7 @@ def uninstall(): log.info("Deregistering Hiero plug-ins..") pyblish.deregister_host("hiero") pyblish.deregister_plugin_path(PUBLISH_PATH) - deregister_loader_plugins_path(LOAD_PATH) + deregister_loader_plugin_path(LOAD_PATH) avalon.deregister_plugin_path(LegacyCreator, CREATE_PATH) # register callback for switching publishable diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index eb1bdafbb0..7d4e58efb7 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -13,7 +13,7 @@ from avalon.lib import find_submodule from openpype.pipeline import ( LegacyCreator, - register_loader_plugins_path, + register_loader_plugin_path, ) import openpype.hosts.houdini from openpype.hosts.houdini.api import lib @@ -53,7 +53,7 @@ def install(): pyblish.api.register_host("hpython") pyblish.api.register_plugin_path(PUBLISH_PATH) - register_loader_plugins_path(LOAD_PATH) + register_loader_plugin_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) log.info("Installing callbacks ... ") diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index ae8b36f9d3..5cdc3ff4fd 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -22,8 +22,8 @@ from openpype.lib import ( from openpype.lib.path_tools import HostDirmap from openpype.pipeline import ( LegacyCreator, - register_loader_plugins_path, - deregister_loader_plugins_path, + register_loader_plugin_path, + deregister_loader_plugin_path, ) from openpype.hosts.maya.lib import copy_workspace_mel from . import menu, lib @@ -57,7 +57,7 @@ def install(): pyblish.api.register_host("mayapy") pyblish.api.register_host("maya") - register_loader_plugins_path(LOAD_PATH) + register_loader_plugin_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) log.info(PUBLISH_PATH) @@ -186,7 +186,7 @@ def uninstall(): pyblish.api.deregister_host("mayapy") pyblish.api.deregister_host("maya") - deregister_loader_plugins_path(LOAD_PATH) + deregister_loader_plugin_path(LOAD_PATH) avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) avalon.api.deregister_plugin_path( avalon.api.InventoryAction, INVENTORY_PATH diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index cecd129eac..fd2e16b8d3 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -17,8 +17,8 @@ from openpype.api import ( from openpype.lib import register_event_callback from openpype.pipeline import ( LegacyCreator, - register_loader_plugins_path, - deregister_loader_plugins_path, + register_loader_plugin_path, + deregister_loader_plugin_path, ) from openpype.tools.utils import host_tools @@ -103,7 +103,7 @@ def install(): log.info("Registering Nuke plug-ins..") pyblish.api.register_plugin_path(PUBLISH_PATH) - register_loader_plugins_path(LOAD_PATH) + register_loader_plugin_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) @@ -129,7 +129,7 @@ def uninstall(): log.info("Deregistering Nuke plug-ins..") pyblish.deregister_host("nuke") pyblish.api.deregister_plugin_path(PUBLISH_PATH) - deregister_loader_plugins_path(LOAD_PATH) + deregister_loader_plugin_path(LOAD_PATH) avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) pyblish.api.deregister_callback( diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index 85155f45d6..e814e1ca4d 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -9,8 +9,8 @@ from openpype.api import Logger from openpype.lib import register_event_callback from openpype.pipeline import ( LegacyCreator, - register_loader_plugins_path, - deregister_loader_plugins_path, + register_loader_plugin_path, + deregister_loader_plugin_path, ) import openpype.hosts.photoshop @@ -72,7 +72,7 @@ def install(): pyblish.api.register_host("photoshop") pyblish.api.register_plugin_path(PUBLISH_PATH) - register_loader_plugins_path(LOAD_PATH) + register_loader_plugin_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) log.info(PUBLISH_PATH) @@ -85,7 +85,7 @@ def install(): def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) - deregister_loader_plugins_path(LOAD_PATH) + deregister_loader_plugin_path(LOAD_PATH) avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py index 829794dd41..fa309e3503 100644 --- a/openpype/hosts/resolve/api/pipeline.py +++ b/openpype/hosts/resolve/api/pipeline.py @@ -11,8 +11,8 @@ from pyblish import api as pyblish from openpype.api import Logger from openpype.pipeline import ( LegacyCreator, - register_loader_plugins_path, - deregister_loader_plugins_path, + register_loader_plugin_path, + deregister_loader_plugin_path, ) from . import lib from . import PLUGINS_DIR @@ -46,7 +46,7 @@ def install(): pyblish.register_plugin_path(PUBLISH_PATH) log.info("Registering DaVinci Resovle plug-ins..") - register_loader_plugins_path(LOAD_PATH) + register_loader_plugin_path(LOAD_PATH) avalon.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) @@ -71,7 +71,7 @@ def uninstall(): pyblish.deregister_plugin_path(PUBLISH_PATH) log.info("Deregistering DaVinci Resovle plug-ins..") - deregister_loader_plugins_path(LOAD_PATH) + deregister_loader_plugin_path(LOAD_PATH) avalon.deregister_plugin_path(LegacyCreator, CREATE_PATH) avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH) diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index 46981851f4..46c9d3a1dd 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -17,8 +17,8 @@ from openpype.api import get_current_project_settings from openpype.lib import register_event_callback from openpype.pipeline import ( LegacyCreator, - register_loader_plugins_path, - deregister_loader_plugins_path, + register_loader_plugin_path, + deregister_loader_plugin_path, ) from .lib import ( @@ -81,7 +81,7 @@ def install(): pyblish.api.register_host("tvpaint") pyblish.api.register_plugin_path(PUBLISH_PATH) - register_loader_plugins_path(LOAD_PATH) + register_loader_plugin_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) registered_callbacks = ( @@ -103,7 +103,7 @@ def uninstall(): log.info("OpenPype - Uninstalling TVPaint integration") pyblish.api.deregister_host("tvpaint") pyblish.api.deregister_plugin_path(PUBLISH_PATH) - deregister_loader_plugins_path(LOAD_PATH) + deregister_loader_plugin_path(LOAD_PATH) avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 7100ff3a83..9ec11b942d 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -9,8 +9,8 @@ from avalon import api from openpype.pipeline import ( LegacyCreator, - register_loader_plugins_path, - deregister_loader_plugins_path, + register_loader_plugin_path, + deregister_loader_plugin_path, ) from openpype.tools.utils import host_tools import openpype.hosts.unreal @@ -48,7 +48,7 @@ def install(): print("-=" * 40) logger.info("installing OpenPype for Unreal") pyblish.api.register_plugin_path(str(PUBLISH_PATH)) - register_loader_plugins_path(str(LOAD_PATH)) + register_loader_plugin_path(str(LOAD_PATH)) api.register_plugin_path(LegacyCreator, str(CREATE_PATH)) _register_callbacks() _register_events() @@ -57,7 +57,7 @@ def install(): def uninstall(): """Uninstall Unreal configuration for Avalon.""" pyblish.api.deregister_plugin_path(str(PUBLISH_PATH)) - deregister_loader_plugins_path(str(LOAD_PATH)) + deregister_loader_plugin_path(str(LOAD_PATH)) api.deregister_plugin_path(LegacyCreator, str(CREATE_PATH)) diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index 3ff3638a23..e204eea239 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -20,8 +20,8 @@ from .load import ( discover_loader_plugins, register_loader_plugin, - deregister_loader_plugins_path, - register_loader_plugins_path, + deregister_loader_plugin_path, + register_loader_plugin_path, deregister_loader_plugin, load_container, @@ -64,8 +64,8 @@ __all__ = ( "discover_loader_plugins", "register_loader_plugin", - "deregister_loader_plugins_path", - "register_loader_plugins_path", + "deregister_loader_plugin_path", + "register_loader_plugin_path", "deregister_loader_plugin", "load_container", diff --git a/openpype/pipeline/load/__init__.py b/openpype/pipeline/load/__init__.py index eac303c10c..6e7612d4c1 100644 --- a/openpype/pipeline/load/__init__.py +++ b/openpype/pipeline/load/__init__.py @@ -32,8 +32,8 @@ from .plugins import ( discover_loader_plugins, register_loader_plugin, - deregister_loader_plugins_path, - register_loader_plugins_path, + deregister_loader_plugin_path, + register_loader_plugin_path, deregister_loader_plugin, ) @@ -72,7 +72,7 @@ __all__ = ( "discover_loader_plugins", "register_loader_plugin", - "deregister_loader_plugins_path", - "register_loader_plugins_path", + "deregister_loader_plugin_path", + "register_loader_plugin_path", "deregister_loader_plugin", ) diff --git a/openpype/pipeline/load/plugins.py b/openpype/pipeline/load/plugins.py index 5648236739..601ad3b258 100644 --- a/openpype/pipeline/load/plugins.py +++ b/openpype/pipeline/load/plugins.py @@ -113,13 +113,13 @@ def register_loader_plugin(plugin): return avalon.api.register_plugin(LoaderPlugin, plugin) -def deregister_loader_plugins_path(path): +def deregister_loader_plugin_path(path): import avalon.api avalon.api.deregister_plugin_path(LoaderPlugin, path) -def register_loader_plugins_path(path): +def register_loader_plugin_path(path): import avalon.api return avalon.api.register_plugin_path(LoaderPlugin, path) From 372d686024ffb53be947441be48dd014e5608feb Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 15 Mar 2022 14:24:36 +0100 Subject: [PATCH 471/483] Fix - Harmony creator issue Creator failed with 'str' object does not support item assignment --- openpype/hosts/harmony/api/TB_sceneOpened.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/harmony/api/TB_sceneOpened.js b/openpype/hosts/harmony/api/TB_sceneOpened.js index 5a3fe9ce82..6a403fa65e 100644 --- a/openpype/hosts/harmony/api/TB_sceneOpened.js +++ b/openpype/hosts/harmony/api/TB_sceneOpened.js @@ -272,8 +272,8 @@ function Client() { app.avalonClient.send( { - 'module': 'avalon.api', - 'method': 'emit', + 'module': 'openpype.lib', + 'method': 'emit_event', 'args': ['application.launched'] }, false); }; From 0f67d46ae0c7899f33b165600898e6743b1782e1 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Wed, 16 Mar 2022 03:37:11 +0000 Subject: [PATCH 472/483] [Automated] Bump version --- CHANGELOG.md | 17 ++++++++++++++++- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5acb161bf9..7790894b7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,23 @@ # Changelog +## [3.9.1-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.0...HEAD) + +**🐛 Bug fixes** + +- Harmony - fixed creator issue [\#2891](https://github.com/pypeclub/OpenPype/pull/2891) +- General: Remove forgotten use of avalon Creator [\#2885](https://github.com/pypeclub/OpenPype/pull/2885) +- General: Avoid circular import [\#2884](https://github.com/pypeclub/OpenPype/pull/2884) +- Fixes for attaching loaded containers \(\#2837\) [\#2874](https://github.com/pypeclub/OpenPype/pull/2874) + +**🔀 Refactored code** + +- General: Reduce style usage to OpenPype repository [\#2889](https://github.com/pypeclub/OpenPype/pull/2889) + ## [3.9.0](https://github.com/pypeclub/OpenPype/tree/3.9.0) (2022-03-14) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...3.9.0) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.9.0-nightly.9...3.9.0) **Deprecated:** diff --git a/openpype/version.py b/openpype/version.py index d2182ac7da..17e514642d 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.9.0" +__version__ = "3.9.1-nightly.1" diff --git a/pyproject.toml b/pyproject.toml index 681702560a..128d1cd615 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.9.0" # OpenPype +version = "3.9.1-nightly.1" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 47cb270532a3651099a9f86899ad23b2c70c9f5f Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 16 Mar 2022 09:56:46 +0100 Subject: [PATCH 473/483] add integrations and users --- .../src/components/BadgesSection/badges.js | 61 +++++----- website/src/pages/index.js | 111 ++++++++++-------- .../static/img/LUMINE_LogoMaster_black_2k.png | Bin 0 -> 287334 bytes website/static/img/app_aquarium.png | Bin 0 -> 74514 bytes website/static/img/app_flame.png | Bin 0 -> 74845 bytes website/static/img/app_shotgrid.png | Bin 0 -> 3794 bytes 6 files changed, 98 insertions(+), 74 deletions(-) create mode 100644 website/static/img/LUMINE_LogoMaster_black_2k.png create mode 100644 website/static/img/app_aquarium.png create mode 100644 website/static/img/app_flame.png create mode 100644 website/static/img/app_shotgrid.png diff --git a/website/src/components/BadgesSection/badges.js b/website/src/components/BadgesSection/badges.js index 4bc85df2ef..5b179d066d 100644 --- a/website/src/components/BadgesSection/badges.js +++ b/website/src/components/BadgesSection/badges.js @@ -1,58 +1,63 @@ export default { upper: [ - { - title: "License", - src: - "https://img.shields.io/github/license/pypeclub/pype?labelColor=303846", - href: "https://github.com/pypeclub/pype", - }, - { - title: "Release", - src: - "https://img.shields.io/github/v/release/pypeclub/pype?labelColor=303846", - href: "https://github.com/pypeclub/pype", - }, - { - title: "Requirements State", - src: - "https://img.shields.io/requires/github/pypeclub/pype?labelColor=303846", - href: - "https://requires.io/github/pypeclub/pype/requirements/?branch=main", - }, { title: "VFX Platform", src: "https://img.shields.io/badge/vfx%20platform-2021-lightgrey?labelColor=303846", href: "https://vfxplatform.com", }, + { + title: "License", + src: + "https://img.shields.io/github/license/pypeclub/openpype?labelColor=303846", + href: "https://github.com/pypeclub/openpype", + }, + { + title: "Release", + src: + "https://img.shields.io/github/v/release/pypeclub/openpype?labelColor=303846", + href: "https://github.com/pypeclub/openpype", + }, { title: "GitHub last commit", src: - "https://img.shields.io/github/last-commit/pypeclub/pype/develop?labelColor=303846", - href: "https://github.com/pypeclub/pype", + "https://img.shields.io/github/last-commit/pypeclub/openpype/develop?labelColor=303846", + href: "https://github.com/pypeclub/openpype", }, { title: "GitHub commit activity", src: - "https://img.shields.io/github/commit-activity/y/pypeclub/pype?labelColor=303846", - href: "https://github.com/pypeclub/pype", + "https://img.shields.io/github/commit-activity/y/pypeclub/openpype?labelColor=303846", + href: "https://github.com/pypeclub/openpype", }, { title: "Repository Size", src: - "https://img.shields.io/github/repo-size/pypeclub/pype?labelColor=303846", - href: "https://github.com/pypeclub/pype", + "https://img.shields.io/github/repo-size/pypeclub/openpype?labelColor=303846", + href: "https://github.com/pypeclub/openpype", + }, + { + title: "Repository Size", + src: + "https://img.shields.io/github/contributors/pypeclub/openpype?labelColor=303846", + href: "https://github.com/pypeclub/openpype", + }, + { + title: "Stars", + src: + "https://img.shields.io/github/stars/pypeclub?labelColor=303846", + href: "https://github.com/pypeclub/openpype", }, { title: "Forks", src: - "https://img.shields.io/github/forks/pypeclub/pype?style=social&labelColor=303846", - href: "https://github.com/pypeclub/pype", + "https://img.shields.io/github/forks/pypeclub/openpype?labelColor=303846", + href: "https://github.com/pypeclub/openpype", }, { title: "Discord", src: - "https://img.shields.io/discord/517362899170230292?label=discord&logo=discord&logoColor=white&labelColor=303846", + "https://img.shields.io/discord/517362899170230292?label=discord&logo=discord&logoColor=white&labelColor=303846", href: "https://discord.gg/sFNPWXG", }, ], diff --git a/website/src/pages/index.js b/website/src/pages/index.js index 29b81e973f..e01ffc60e1 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -129,6 +129,11 @@ const studios = [ title: "Moonrock Animation Studio", image: "/img/moonrock_logo.png", infoLink: "https://www.moonrock.eu/", + }, + { + title: "Lumine Studio", + image: "/img/LUMINE_LogoMaster_black_2k.png", + infoLink: "https://www.luminestudio.com/", } ]; @@ -275,107 +280,121 @@ function Home() { diff --git a/website/static/img/LUMINE_LogoMaster_black_2k.png b/website/static/img/LUMINE_LogoMaster_black_2k.png new file mode 100644 index 0000000000000000000000000000000000000000..37ef01486dccb2fcb9f17818a8de58bf23b32a9b GIT binary patch literal 287334 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

Ch{idEs);7-#$f9MA8J*$p|0dfp1S-2 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

01#Ztw} zI`MNO1;6+>x`?~-Z{A4-HU1BF;E;>h79baQt z?%xYoE6a{$A+fdEIO?m`xCT~cpp3BJKHTR&)YjNdz(MC;00=gi{oC7O)oMac` zefsk?gOZ@VfRjJN{uD1`6~lHu2+;s3>ts5CHJT@0X;4FdiViZb@i72;XYf9V5Ij46 z@Ho7sk}>kpKwfd-4Z3>gSPdxH*>VhX=yKo#iOo6Ivtz*vDh1PTCeW30FDnFN?C1u3 zuOhIDy%bCMv2~{WM{uh)g=$5|Q=}LQRprwQKxdr(MU1S@E0pu)$qzbrSKJHELC8?P zKLVJ<)?eba>^r;~4&3~nO)%3xFM~*~^_n8lMLue1RcqHB!*$I^x0?gR2b8)3r6(RP z9=p>q0MCYB5Ub%LuOPp0<6PPp${85g8(u$vx4R|xeBFdomxczpGUg2G)O z^n@Ftm20$bRp{bz^=h|dc>QJBq-23UnG5KZ0FJHUi_`Pbrn9OP=ZkDOoVizqR_!9U zSEnMisN&Ctaf6N{Fi6~{Du^p^yIz|RlXx8f+yRWe`H3cH6{AIiIQ)+wz@_40X+pf@ zytnfCswekxvmB{iiY@g9P|`Q<^Dat*1~P0T z2IX~mg6L;oX^9f&F4KdcY1cFBR1Wl1sMo zG`>2tT;v*S~;GasLC0w3))+uWE5O8uwO7f2uT|a8Q0X>yeKx_l~Cm$ius_ zh$=&_*^xDxYuVUaN(Kf)h0{tPSE_sjT|pf{O6Q;lP|6uDb>`)Hg?KN54L%_Ma|Hqq zSj)yB$fw;n6h)^R_A`AdTgqHE+UXjqKTiU|Yt8|#7wt18⁢p#9x@6a}j%u=1M<} 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

yZbi}dm-n#1QA$Qya%*hVHYY9XcWIQCU5=u7 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 Bw$=au literal 0 HcmV?d00001 diff --git a/website/static/img/app_flame.png b/website/static/img/app_flame.png new file mode 100644 index 0000000000000000000000000000000000000000..ba9b69e45fa73d3f9298e77ec4a167a38f7ecf7b GIT binary patch literal 74845 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 u_1V@IZCsr_eG=!1bAOm;|0UAW6F{ Date: Wed, 16 Mar 2022 09:57:08 +0100 Subject: [PATCH 474/483] add kitsu --- website/static/img/app_kitsu.png | Bin 0 -> 5999 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 website/static/img/app_kitsu.png diff --git a/website/static/img/app_kitsu.png b/website/static/img/app_kitsu.png new file mode 100644 index 0000000000000000000000000000000000000000..b4e3d00ebd953a38b70a48e80a5112136f912104 GIT binary patch literal 5999 zcmV-#7m(BwN@M_P1-DtVR-7STLwOKdh<&n=F^fiJUN0J zJ?uFMgdWfhK*NEA5fqmGB~5#P5QRIL6&iuRf#!Y*_L>SW! zfp7!R2BR+{J0qorWg9(}Of^&P~2;HT1F-;Jt z&Jlv-258II^1dA|{*fufe7N^YAHu#_v<|D@9gjOQ6Y#u<62Pv^$0zKK7krhuRDgEJ z6PCjDI0YhTC5I2z4M_^NF)aF;_y;FSg!ucz3L1Ky3K8B~u!_*^ju&+9T`F*A#uGUa zD*y%=@q&*0RfN7mh+AbT-U9~$&?r)fSz~_Mm%{&%RD@Z)iJ9@OG#PhfuOO;2mI}Ii z{da-?*^w)NespCoC+fO ze&+zcU3(H(0rbIjFp;Re41w}q4ltus6fN z5|}cP{UQKh1@a`pJ!8e6vZNXJ!OEUv;60X#_%}aKASyiMvjPZC01Pq!IV%V`Y@%Ev zQ!vz*5lAuq>#^bw)csup$ah^4V$E5Jc&(??_eTKB1X-*AMixPLRsvpAk`&{R^`+Mo zOs7~i^R{OJGMOI>oqva?-ZLwE>oLQDMBLm*zMld(#0tQ-2)Yg>;Pj>S7)|zO0ZB4H z{k980gq>ir64UGzt>G&_U5dA4^fr5b3t%ZL0Ds}ymmqMSOTx9iYXuIan80pZ?N8;u zB!w+(c`}ajmg6NrjSOEly&sN~_ zViBILeGUJ{4qYlz3H(wLmUAc(Z(&s*EZv_VXxlS7_wNGiX9W->G9Z9oEGH<~KTyfU_(f>SE`MDh#rP#yioS9o30EIX z6gUHJ_`I)rcEfJqK2`uh;Gglh`@|YtUey@{*< z0zw90-|K|^f)LX%hedi0lzy{d)*{N2v74~bQwAakNdWueiM^}<0w6zY8KHy|w^ks+ z1wgkOB`LTRwtAgGXMi|7lNEqJ!FnBc=dZv>IBna`1b(c51XMst_+lAe8Mp{Q010>o zD}a&VKCp~%o?VO8$W!okPrV<5AXEA6U#=nKnac!Sfd~Lf$R1Vz!vIbWyR#DssziwC zfqYNOAFCjH4uEu=TaW1v!FdKoxPA#>DJuZ4wI0^UTaT~AD5(%{Bt4B@cmSrO-B*)w zOYRDyX16~Ah-U>bWYh}~tg`9OujgA}M74wg>0Yye1U9P*lZYBv>%W#UVg?}o$qJy4 z{CEM%Sw*N}o^2z09~%p2K<2uBc^%(;AOWw=7)Ah@aX2dg58?$kPprnOBtp#TY4pYq z+~p$NnX{ab?|I#e0G8qZhh!Vin^|S7+5aCCB%P=oD2gZn zkQ?~1;IvxGlHUhSWC=io{T!S&kSy_i{XlKCn0sHu2%tjDe^Hu(IU}9;0Wd&3W=4zv zz}c1HbT^v;{spUaBmT395r9NYY>~1vfPSRlPU!~hm53l8U7mCYG=Lwlqkj;t%ToBF zh!B8U9PsdZD)PW4km(a7)UHzyn!&Y=>Tc=7gp7`cvbY{MKu&_nR!{ z&uELeMfK5QS}Mfckxo7X?dR+CMrW(bWHzObYXMlAEVGPeW4o!T3F*l~9l7DrTaiva zx%KfTqt(&~0|OZ?#&Z_Cg+D&0e+W%5SxoCd0EmI9*?C=?m86lT5bmH;XUb4~&18-8 zH6`nLNH6_LVX|7031R{Q=r&nR&yGt0I652?X$xHoBIpqSD4wz!P0|gs+ky&UDuzz4 z)p&1F%p5LkUcFnS692Kq@e9a4Mu;9o~nu^pJOUob| z-eTg>2N1vql!f}vxD$ZMWcpbz`Tek3P`&K*C28^_0R>MX#?kJVV$h!+Qa0`UQWBZL0c?H`G3!Aq{%UU2T+wpAmLLyPw-zI zfR`6?(dK8Spz`EFZy9$>x6Eo+>{xgU$VGl(TWp&=?RzqE$ZOtTUch6nGYBuna#7}z z82EVrqkkO_)m=PT4*t8m#RQtcKO%(g7&qiQoz90L{d*MdRRok?Cru(cU}X3y033;* zibhKo4JP9yAmfc^qfz;w3_v*LTjrRRI~a2P#tr%ASq(5t8BqVRq2zn87_PbhkB|5- zfaIx2@&2!B>Bj0?Udiu?QL)Wo`_}jw{TAc4e)7Ge=F~@WXo~y_q8v)Vp+Chy2Ixuq zAOslwJzszR8kB%RN3H~BNa?i!kdL5LxE1NMufzZ87+3=S`ur3a@%`6i1Q+)N7y1Cc zf8>LpD#U^|y`+GK#;2Aetr+kA7eoxUvpIZ#_2!CH{!# zAzt|NXq5ezm_Qmom*&741NeLmeD?T0To!F$z`) zn<3r$ErP=3e3T#0N2lNu4w?>0#aNeY^Ymurj)mO;W&oxSutM{wA9}sgWDK21;G+V# z=DF3F|EXWdNAhh?I1QEM4a~I9S3DqVUU%A^;eSd9$S(v%yL<`&R1Z$KYW6O(K$8^6 zvv-i5nNP1jYbVnm)qnsPVgtm&o3c$apak%JT+=_mtl~yZ-rKb%o4wm7`TaA3m0?Z3 z*<_v%0-*XG^O*&ZL0by-`Uwk+m{E8zQ_!w_BUTY$1Ly%G8glb&3;drJaunVtB;Wp@ z?M7R(i6Pcs0Zu z-vvOy!sNKtuG#(PN?0KbGW|o{-8Ns{PiT;Rz}DH7P>$MzTJK*Qh4dv~D45S(NY+jcc-vOGak{j({)YsZMKqBc0xU(I zC1k?hs4BLBkli82!D6BSzX+GwKM8I&x8H#fVQlPFefV3MB<6px<0QM&PM6=br5^lWPlg2|=zpGJm)dX9{ z*8@32sNA|xHyQ%)G?CCuO8spFp=Xi5^Hg19jm&1Wm`7(2Sd2#K1AY^3i$IJBT1O=S zCi;4H?ly(L`vem4v_ZRnp<}*G>eovuL7-y%W^K_&R+HV)IZ9cu9tOd4oNzRQ(U3r! zX~$^v0){H7Q})(>nEg3CBHuFe(r5}mwPQgWnMPdqqxQSiAHPV$K^OBVWj`%s)2BIL z<M3ng?Rvp36~?%SeLJTR>ymF> z&^o#Tfa&1Y*%y5Xz^y(kRGS?wH$ox5cN6FgjQmGA~6)@**?V}NyT-2 zn>~u1&(>27)opMu!PfkpZT7Z_oJh7tsxcZG!vnn{a0fbV(w+RUvSRf^NFw3`v9p|z zs=J1aTEq}5+dKm){`L!o z&Fskx+ZPNKKsUKzW=|OqPjl3lsi zU_@?6GFzLz#bK@ATI+KkHtLHH2Rx6qm|eO{Sx$)%mjmfmCDe9l`dp;m8z<3}6qak% z%IjKTBKB{ zkV>iYA%-fXtal58pMD zIyR+(VnkqUfWY~Ggu1Swp+#M#>j2`t6A3B55`LyuX;4}56?FWIgXqoWFQa#pmZGA9 zqexO#hO}BByRv$CF;vzfZB5;ch6c6HdffqTkQH54|++LG z`_U^4C!_S&UqFXsEctB&_-s&1toyWsv2~m@FZIE++%&jkBC=dC!y89 zT8Q+Grr-r&ZANr`F*2Nf4pB!ZGvtFWJPn66-$ov|?{32pUJ-}@Q4I2;5q04YP)GHQ z2!a#9>95fG-#m$yK&l6lePZ47KLi07gA;(&j_A6}h&uB$+&kCf1NhS2c#QWC4r~4y zl^yjimB;Jy2%rxaevgb)B@_6uD*)zu`pYo#e*)x3p#k@Q(|9E6Sq^Ld8T0_Zf}q(3 z2_W|&M4f|7U{ElP;n);_z62Rg{SZMTJCJ+?qra8In*T;0C+>v|P|XBy5OVHA^!dfe z*dSp-?qko+Mc;Qo%oT3}|#;Vp*Xb^d!m zqhVTgKcb4BMzjuyXma|T29}qUpuO*HMIUTfg+AE0+9xvKP4_tsgfj^}4WDK|qE0*t zu?O-D^+*o;q9jB#gcSWU^lyDgbXn2mnrR#O7Sr=Fy!ZZ8b0sH;hpBN7M#H}s*nld@Zciq)b|lxm4oW)>p=htAgLNq_3r>; z1A?Z?E3|3_YN$SkX!$3|P%wkJhvDy!DOtyOuW?ul&gg>ZI2r=!Qv_edB3;>sNTY!D zd`0=!ib}<0)KHuZH6I(1e5Uz>GKQK?PM*wREjVjCG4;E?1;BvI)L_7*ZwQlp-jk>M z&*xrroZ_i+hE(c zhAOyyBi#Voy|8gr45D1+{4$!S`)u;vbk3AJIjntiMjz!)2Qt#BT&@-baudku`kF|0 z13F=os?BgDs)WwtEpN=`KFncl>{!zY?##wK-UdT9?<$Z*0op?%JJ0eq%69OgZ5I3C4L;l`suD#xI4`zZ)v z4NTQ?VWelllI?3i`je&tE<4gA#k#(XouCK--!zsq5bwf}Dq4oCn1000F2f8CEz d0RR91jshTtZNd52z4-tD002ovPDHLkV1h4aI~V`} literal 0 HcmV?d00001 From b0a8809cbfceaa58a11171315d9579479885f02f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 16 Mar 2022 10:19:22 +0100 Subject: [PATCH 475/483] dnxhd is adding bitrate in case of dnxhd profile --- openpype/lib/transcoding.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index 6181ff6d13..6bab6a8160 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -705,6 +705,12 @@ def _ffmpeg_dnxhd_codec_args(stream_data, source_ffmpeg_cmd): profile = stream_data.get("profile") or "" # Lower profile and replace space with underscore cleaned_profile = profile.lower().replace(" ", "_") + + # TODO validate this statement + # Looks like using 'dnxhd' profile must have set bit rate and in that case + # should be used bitrate from source. + # - related attributes 'bit_rate_defined', 'bit_rate_must_be_defined' + bit_rate_must_be_defined = True dnx_profiles = { "dnxhd", "dnxhr_lb", @@ -714,6 +720,8 @@ def _ffmpeg_dnxhd_codec_args(stream_data, source_ffmpeg_cmd): "dnxhr_444" } if cleaned_profile in dnx_profiles: + if cleaned_profile != "dnxhd": + bit_rate_must_be_defined = False output.extend(["-profile:v", cleaned_profile]) pix_fmt = stream_data.get("pix_fmt") @@ -721,15 +729,28 @@ def _ffmpeg_dnxhd_codec_args(stream_data, source_ffmpeg_cmd): output.extend(["-pix_fmt", pix_fmt]) # Use arguments from source if are available source arguments + bit_rate_defined = False if source_ffmpeg_cmd: - copy_args = ( - "-b:v", "-vb", - ) + # Define bitrate arguments + bit_rate_args = ("-b:v", "-vb",) + # Seprate the two variables in case something else should be copied + # from source command + copy_args = [] + copy_args.extend(bit_rate_args) + args = source_ffmpeg_cmd.split(" ") for idx, arg in enumerate(args): if arg in copy_args: + if arg in bit_rate_args: + bit_rate_defined = True output.extend([arg, args[idx + 1]]) + # Add bitrate if needed + if bit_rate_must_be_defined and not bit_rate_defined: + src_bit_rate = stream_data.get("bit_rate") + if src_bit_rate: + output.extend(["-b:v", src_bit_rate]) + output.extend(["-g", "1"]) return output From fedde965c1ab77093b915231db34fad2831e4a0f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 16 Mar 2022 10:19:38 +0100 Subject: [PATCH 476/483] pass source ffmpeg argument if available in extract review slate --- openpype/plugins/publish/extract_review_slate.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 460d546340..505ae75169 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -360,11 +360,14 @@ class ExtractReviewSlate(openpype.api.Extractor): ) return codec_args + source_ffmpeg_cmd = repre.get("ffmpeg_cmd") codec_args.extend( - get_ffmpeg_format_args(ffprobe_data) + get_ffmpeg_format_args(ffprobe_data, source_ffmpeg_cmd) ) codec_args.extend( - get_ffmpeg_codec_args(ffprobe_data, logger=self.log) + get_ffmpeg_codec_args( + ffprobe_data, source_ffmpeg_cmd, logger=self.log + ) ) return codec_args From 30788b6d382deaacb0de08d0768313ec585a6067 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 16 Mar 2022 10:20:36 +0100 Subject: [PATCH 477/483] add overmind studios --- website/src/pages/index.js | 5 +++++ website/static/img/OMS_logo_black_color.png | Bin 0 -> 541863 bytes 2 files changed, 5 insertions(+) create mode 100644 website/static/img/OMS_logo_black_color.png diff --git a/website/src/pages/index.js b/website/src/pages/index.js index e01ffc60e1..902505e134 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -134,6 +134,11 @@ const studios = [ title: "Lumine Studio", image: "/img/LUMINE_LogoMaster_black_2k.png", infoLink: "https://www.luminestudio.com/", + }, + { + title: "Overmind Studios", + image: "/img/OMS_logo_black_color.png", + infoLink: "https://www.overmind-studios.de/", } ]; diff --git a/website/static/img/OMS_logo_black_color.png b/website/static/img/OMS_logo_black_color.png new file mode 100644 index 0000000000000000000000000000000000000000..9046927e32cc2c70ab5bb337defeaa447f5a3ce1 GIT binary patch literal 541863 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 BOOQ-;JnIS$z&K$fv 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

    #_`d35o;^6Y!k9=Y$)og*#-mZVug9r$(v*dx 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�b?2^6 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�+{vA(qxh{2{7cnIqun4b^B3Gbyn1 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 literal 0 HcmV?d00001 From 256286c55fb22dc1bfad6032c4d10b75be1ce274 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 16 Mar 2022 10:37:51 +0100 Subject: [PATCH 478/483] fix tooltip of buttons in scene inventory --- openpype/tools/sceneinventory/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/sceneinventory/window.py b/openpype/tools/sceneinventory/window.py index 83e4435015..b40fbb69e4 100644 --- a/openpype/tools/sceneinventory/window.py +++ b/openpype/tools/sceneinventory/window.py @@ -61,7 +61,7 @@ class SceneInventoryWindow(QtWidgets.QDialog): icon = qtawesome.icon("fa.refresh", color="white") refresh_button = QtWidgets.QPushButton(self) - update_all_button.setToolTip("Refresh") + refresh_button.setToolTip("Refresh") refresh_button.setIcon(icon) control_layout = QtWidgets.QHBoxLayout() From f130e30a2cd6188a265513663c3548268760c9de Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 16 Mar 2022 10:45:11 +0100 Subject: [PATCH 479/483] fix missing import of 'get_repres_contexts' --- openpype/pipeline/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index e204eea239..26970e4edc 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -31,6 +31,7 @@ from .load import ( loaders_from_representation, get_representation_path, + get_repres_contexts, ) from .publish import ( @@ -75,6 +76,7 @@ __all__ = ( "loaders_from_representation", "get_representation_path", + "get_repres_contexts", # --- Publish --- "PublishValidationError", From 819e44976ce9338dfd2f835410b512404b2b2e1f Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 16 Mar 2022 14:03:35 +0100 Subject: [PATCH 480/483] add ember light --- website/src/pages/index.js | 7 ++++++- website/static/img/EmberLight_black.png | Bin 0 -> 59148 bytes website/static/img/Ember_Light.jpg | Bin 0 -> 338284 bytes 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 website/static/img/EmberLight_black.png create mode 100644 website/static/img/Ember_Light.jpg diff --git a/website/src/pages/index.js b/website/src/pages/index.js index 902505e134..791b309bbc 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -139,6 +139,11 @@ const studios = [ title: "Overmind Studios", image: "/img/OMS_logo_black_color.png", infoLink: "https://www.overmind-studios.de/", + }, + { + title: "Ember Light", + image: "/img/EmberLight_black.png", + infoLink: "https://emberlight.se/", } ]; @@ -433,7 +438,7 @@ function Home() { {studios && studios.length && (

    -

    Studios using openPYPE

    +

    Studios using openPype

    {studios.map((props, idx) => ( diff --git a/website/static/img/EmberLight_black.png b/website/static/img/EmberLight_black.png new file mode 100644 index 0000000000000000000000000000000000000000..e8a9f95d06844081674f497e8c75625d5fe41a33 GIT binary patch literal 59148 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%0Aur1;kkT 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 literal 0 HcmV?d00001 diff --git a/website/static/img/Ember_Light.jpg b/website/static/img/Ember_Light.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9081bc7ceac5ff2a307de12e26f7e2d14e596692 GIT binary patch literal 338284 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%0De7HRMH@RI&3UlX? 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 R+%O^wmIH8%4u*i-|JiSp?~VWf literal 0 HcmV?d00001 From 8b3d32c5b26733cf2da44cedc164f478f8045fca Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 16 Mar 2022 15:42:49 +0100 Subject: [PATCH 481/483] global: fixing order of output resolution flow --- openpype/plugins/publish/extract_review.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index f046194c0d..cbe1924408 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -1188,8 +1188,8 @@ class ExtractReview(pyblish.api.InstancePlugin): # NOTE Setting only one of `width` or `heigth` is not allowed # - settings value can't have None but has value of 0 - output_width = output_width or output_def.get("width") or None - output_height = output_height or output_def.get("height") or None + output_width = output_def.get("width") or output_width or None + output_height = output_def.get("height") or output_height or None # Overscal color overscan_color_value = "black" From 2563a7ce607db3e2eddd83a70832a02b6e6065b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Wed, 16 Mar 2022 16:36:46 +0100 Subject: [PATCH 482/483] Update openpype/hosts/flame/plugins/publish/extract_subset_resources.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../plugins/publish/extract_subset_resources.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 5c3aed9672..3b1466925f 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -218,16 +218,10 @@ class ExtractSubsetResources(openpype.api.Extractor): # imagesequence as list if ( # first check if path in files is not mov extension - next( - # iter all paths in files - # return only .mov positive test - iter([ - f for f in files - if ".mov" in os.path.splitext(f)[-1] - ]), - # if nothing return default - None - ) + [ + f for f in files + if os.path.splitext(f)[-1] == ".mov" + ] # then try if thumbnail is not in unique name or unique_name == "thumbnail" ): From 3d426d1d8f90764d35b56316dd81522bb8e6e39d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 17 Mar 2022 00:14:00 +0100 Subject: [PATCH 483/483] Fix #2834 - ensure current state is correct when entering new group order --- openpype/tools/pyblish_pype/control.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/tools/pyblish_pype/control.py b/openpype/tools/pyblish_pype/control.py index 6f89952c22..f657936b79 100644 --- a/openpype/tools/pyblish_pype/control.py +++ b/openpype/tools/pyblish_pype/control.py @@ -389,6 +389,9 @@ class Controller(QtCore.QObject): new_current_group_order ) + # Force update to the current state + self._set_state_by_order() + if self.collect_state == 0: self.collect_state = 1 self._current_state = (