From b7f6e2dd55c24ef5534495e4adc1287d5f49571f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 16:50:25 +0100 Subject: [PATCH 01/23] 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 02/23] 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 03/23] 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 04/23] 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 05/23] 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 06/23] 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 07/23] 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 08/23] 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 09/23] 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 10/23] 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 11/23] 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 12/23] 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 13/23] 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 14/23] 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 15/23] 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 16/23] 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 70537ecfd441d30d12df3ecde4b4f2fba0b14337 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 19:00:50 +0100 Subject: [PATCH 17/23] 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 18/23] 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 19/23] 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 bc86cd279c40933c6f9ed84a5724bd2997df1196 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Feb 2022 18:47:09 +0100 Subject: [PATCH 20/23] 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 21/23] 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 4e9131ccd8153cc3bb1f2aaa683c5819f55894f0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 11:49:44 +0100 Subject: [PATCH 22/23] 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 3015c998db50bf3540487f9a392a0fee64d09105 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 12:24:16 +0100 Subject: [PATCH 23/23] 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):