diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index cba926fc87..3caa459764 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -1,6 +1,8 @@ import os +import sys import copy import logging +import traceback import collections import inspect from uuid import uuid4 @@ -22,6 +24,7 @@ from .creator_plugins import ( Creator, AutoCreator, discover_creator_plugins, + CreatorError, ) UpdateData = collections.namedtuple("UpdateData", ["instance", "changes"]) @@ -67,6 +70,77 @@ class HostMissRequiredMethod(Exception): super(HostMissRequiredMethod, self).__init__(msg) +class CreatorsOperationFailed(Exception): + """Raised when a creator process crashes in 'CreateContext'. + + The exception contains information about the creator and error. The data + are prepared using 'prepare_failed_creator_operation_info' and can be + serialized using json. + + Usage is for UI purposes which may not have access to exceptions directly + and would not have ability to catch exceptions 'per creator'. + + Args: + msg (str): General error message. + failed_info (list[dict[str, Any]]): List of failed creators with + exception message and optionally formatted traceback. + """ + + def __init__(self, msg, failed_info): + super(CreatorsOperationFailed, self).__init__(msg) + self.failed_info = failed_info + + +class CreatorsCollectionFailed(CreatorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed to collect instances" + super(CreatorsCollectionFailed, self).__init__( + msg, failed_info + ) + + +class CreatorsSaveFailed(CreatorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed update instance changes" + super(CreatorsSaveFailed, self).__init__( + msg, failed_info + ) + + +class CreatorsRemoveFailed(CreatorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed to remove instances" + super(CreatorsRemoveFailed, self).__init__( + msg, failed_info + ) + + +class CreatorsCreateFailed(CreatorsOperationFailed): + def __init__(self, failed_info): + msg = "Faled to create instances" + super(CreatorsCreateFailed, self).__init__( + msg, failed_info + ) + + +def prepare_failed_creator_operation_info( + identifier, label, exc_info, add_traceback=True +): + formatted_traceback = None + exc_type, exc_value, exc_traceback = exc_info + if add_traceback: + formatted_traceback = "".join(traceback.format_exception( + exc_type, exc_value, exc_traceback + )) + + return { + "creator_identifier": identifier, + "creator_label": label, + "message": str(exc_value), + "traceback": formatted_traceback + } + + class InstanceMember: """Representation of instance member. @@ -1212,7 +1286,65 @@ class CreateContext: with self.bulk_instances_collection(): self._bulk_instances_to_process.append(instance) + def create(self, identifier, *args, **kwargs): + """Wrapper for creators to trigger created. + + Different types of creators may expect different arguments thus the + hints for args are blind. + + Args: + identifier (str): Creator's identifier. + *args (Tuple[Any]): Arguments for create method. + **kwargs (Dict[Any, Any]): Keyword argument for create method. + """ + + error_message = "Failed to run Creator with identifier \"{}\". {}" + creator = self.creators.get(identifier) + label = getattr(creator, "label", None) + failed = False + add_traceback = False + exc_info = None + try: + # Fake CreatorError (Could be maybe specific exception?) + if creator is None: + raise CreatorError( + "Creator {} was not found".format(identifier) + ) + + creator.create(*args, **kwargs) + + except CreatorError: + failed = True + exc_info = sys.exc_info() + self.log.warning(error_message.format(identifier, exc_info[1])) + + except: + failed = True + add_traceback = True + exc_info = sys.exc_info() + self.log.warning( + error_message.format(identifier, ""), + exc_info=True + ) + + if failed: + raise CreatorsCreateFailed([ + prepare_failed_creator_operation_info( + identifier, label, exc_info, add_traceback + ) + ]) + def creator_removed_instance(self, instance): + """When creator removes instance context should be acknowledged. + + If creator removes instance conext should know about it to avoid + possible issues in the session. + + Args: + instance (CreatedInstance): Object of instance which was removed + from scene metadata. + """ + self._instances_by_id.pop(instance.id, None) @contextmanager @@ -1247,24 +1379,81 @@ class CreateContext: self._instances_by_id = {} # Collect instances + error_message = "Collection of instances for creator {} failed. {}" + failed_info = [] for creator in self.creators.values(): - creator.collect_instances() + label = creator.label + identifier = creator.identifier + failed = False + add_traceback = False + exc_info = None + try: + creator.collect_instances() + + except CreatorError: + failed = True + exc_info = sys.exc_info() + self.log.warning(error_message.format(identifier, exc_info[1])) + + except: + failed = True + add_traceback = True + exc_info = sys.exc_info() + self.log.warning( + error_message.format(identifier, ""), + exc_info=True + ) + + if failed: + failed_info.append( + prepare_failed_creator_operation_info( + identifier, label, exc_info, add_traceback + ) + ) + + if failed_info: + raise CreatorsCollectionFailed(failed_info) def execute_autocreators(self): """Execute discovered AutoCreator plugins. Reset instances if any autocreator executed properly. """ + + error_message = "Failed to run AutoCreator with identifier \"{}\". {}" + failed_info = [] for identifier, creator in self.autocreators.items(): + label = creator.label + failed = False + add_traceback = False try: creator.create() - except Exception: - # TODO raise report exception if any crashed - msg = ( - "Failed to run AutoCreator with identifier \"{}\" ({})." - ).format(identifier, inspect.getfile(creator.__class__)) - self.log.warning(msg, exc_info=True) + except CreatorError: + failed = True + exc_info = sys.exc_info() + self.log.warning(error_message.format(identifier, exc_info[1])) + + # Use bare except because some hosts raise their exceptions that + # do not inherit from python's `BaseException` + except: + failed = True + add_traceback = True + exc_info = sys.exc_info() + self.log.warning( + error_message.format(identifier, ""), + exc_info=True + ) + + if failed: + failed_info.append( + prepare_failed_creator_operation_info( + identifier, label, exc_info, add_traceback + ) + ) + + if failed_info: + raise CreatorsCreateFailed(failed_info) def validate_instances_context(self, instances=None): """Validate 'asset' and 'task' instance context.""" @@ -1341,17 +1530,48 @@ class CreateContext: identifier = instance.creator_identifier instances_by_identifier[identifier].append(instance) - for identifier, cretor_instances in instances_by_identifier.items(): + error_message = "Instances update of creator \"{}\" failed. {}" + failed_info = [] + for identifier, creator_instances in instances_by_identifier.items(): update_list = [] - for instance in cretor_instances: + for instance in creator_instances: instance_changes = instance.changes() if instance_changes: update_list.append(UpdateData(instance, instance_changes)) creator = self.creators[identifier] - if update_list: + if not update_list: + continue + + label = creator.label + failed = False + add_traceback = False + exc_info = None + try: creator.update_instances(update_list) + except CreatorError: + failed = True + exc_info = sys.exc_info() + self.log.warning(error_message.format(identifier, exc_info[1])) + + except: + failed = True + add_traceback = True + exc_info = sys.exc_info() + self.log.warning( + error_message.format(identifier, ""), exc_info=True) + + if failed: + failed_info.append( + prepare_failed_creator_operation_info( + identifier, label, exc_info, add_traceback + ) + ) + + if failed_info: + raise CreatorsSaveFailed(failed_info) + def remove_instances(self, instances): """Remove instances from context. @@ -1359,14 +1579,48 @@ class CreateContext: instances(list): Instances that should be removed from context. """ + instances_by_identifier = collections.defaultdict(list) for instance in instances: identifier = instance.creator_identifier instances_by_identifier[identifier].append(instance) + error_message = "Instances removement of creator \"{}\" failed. {}" + failed_info = [] for identifier, creator_instances in instances_by_identifier.items(): creator = self.creators.get(identifier) - creator.remove_instances(creator_instances) + label = creator.label + failed = False + add_traceback = False + exc_info = None + try: + creator.remove_instances(creator_instances) + + except CreatorError: + failed = True + exc_info = sys.exc_info() + self.log.warning( + error_message.format(identifier, exc_info[1]) + ) + + except: + failed = True + add_traceback = True + exc_info = sys.exc_info() + self.log.warning( + error_message.format(identifier, ""), + exc_info=True + ) + + if failed: + failed_info.append( + prepare_failed_creator_operation_info( + identifier, label, exc_info, add_traceback + ) + ) + + if failed_info: + raise CreatorsRemoveFailed(failed_info) def _get_publish_plugins_with_attr_for_family(self, family): """Publish plugin attributes for passed family. diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index d2d01e7921..1c732bf3a7 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -31,6 +31,9 @@ from openpype.pipeline.create import ( HiddenCreator, Creator, ) +from openpype.pipeline.create.context import ( + CreatorsOperationFailed, +) # Define constant for plugin orders offset PLUGIN_ORDER_OFFSET = 0.5 @@ -299,8 +302,11 @@ class PublishReport: } def _extract_context_data(self, context): + context_label = "Context" + if context is not None: + context_label = context.data.get("label") return { - "label": context.data.get("label") + "label": context_label } def _extract_instance_data(self, instance, exists): @@ -1101,6 +1107,8 @@ class AbstractPublisherController(object): options (Dict[str, Any]): Data from pre-create attributes. """ + pass + def save_changes(self): """Save changes in create context.""" @@ -1662,11 +1670,8 @@ class PublisherController(BasePublisherController): def reset(self): """Reset everything related to creation and publishing.""" - # Stop publishing self.stop_publish() - self.save_changes() - self.host_is_valid = self._create_context.host_is_valid self._create_context.reset_preparation() @@ -1715,8 +1720,28 @@ class PublisherController(BasePublisherController): self._create_context.reset_context_data() with self._create_context.bulk_instances_collection(): - self._create_context.reset_instances() - self._create_context.execute_autocreators() + try: + self._create_context.reset_instances() + except CreatorsOperationFailed as exc: + self._emit_event( + "instances.collection.failed", + { + "title": "Instance collection failed", + "failed_info": exc.failed_info + } + ) + + try: + self._create_context.execute_autocreators() + + except CreatorsOperationFailed as exc: + self._emit_event( + "instances.create.failed", + { + "title": "AutoCreation failed", + "failed_info": exc.failed_info + } + ) self._resetting_instances = False @@ -1845,16 +1870,42 @@ class PublisherController(BasePublisherController): self, creator_identifier, subset_name, instance_data, options ): """Trigger creation and refresh of instances in UI.""" - creator = self._creators[creator_identifier] - creator.create(subset_name, instance_data, options) + + success = True + try: + self._create_context.create( + creator_identifier, subset_name, instance_data, options + ) + except CreatorsOperationFailed as exc: + success = False + self._emit_event( + "instances.create.failed", + { + "title": "Creation failed", + "failed_info": exc.failed_info + } + ) self._on_create_instance_change() + return success def save_changes(self): """Save changes happened during creation.""" - if self._create_context.host_is_valid: + if not self._create_context.host_is_valid: + return + + try: self._create_context.save_changes() + except CreatorsOperationFailed as exc: + self._emit_event( + "instances.save.failed", + { + "title": "Instances save failed", + "failed_info": exc.failed_info + } + ) + def remove_instances(self, instance_ids): """Remove instances based on instance ids. @@ -1876,7 +1927,16 @@ class PublisherController(BasePublisherController): instances_by_id[instance_id] for instance_id in instance_ids ] - self._create_context.remove_instances(instances) + try: + self._create_context.remove_instances(instances) + except CreatorsOperationFailed as exc: + self._emit_event( + "instances.remove.failed", + { + "title": "Instance removement failed", + "failed_info": exc.failed_info + } + ) def _on_create_instance_change(self): self._emit_event("instances.refresh.finished") diff --git a/openpype/tools/publisher/widgets/create_widget.py b/openpype/tools/publisher/widgets/create_widget.py index 10cf39675e..910b2adfc7 100644 --- a/openpype/tools/publisher/widgets/create_widget.py +++ b/openpype/tools/publisher/widgets/create_widget.py @@ -9,7 +9,6 @@ from openpype.pipeline.create import ( SUBSET_NAME_ALLOWED_SYMBOLS, TaskNotSetError, ) -from openpype.tools.utils import ErrorMessageBox from .widgets import ( IconValuePixmapLabel, @@ -35,79 +34,6 @@ class VariantInputsWidget(QtWidgets.QWidget): self.resized.emit() -class CreateErrorMessageBox(ErrorMessageBox): - def __init__( - self, - creator_label, - subset_name, - asset_name, - exc_msg, - formatted_traceback, - parent - ): - self._creator_label = creator_label - self._subset_name = subset_name - self._asset_name = asset_name - self._exc_msg = exc_msg - self._formatted_traceback = formatted_traceback - super(CreateErrorMessageBox, self).__init__("Creation failed", parent) - - def _create_top_widget(self, parent_widget): - label_widget = QtWidgets.QLabel(parent_widget) - label_widget.setText( - "Failed to create" - ) - return label_widget - - def _get_report_data(self): - report_message = ( - "{creator}: Failed to create Subset: \"{subset}\"" - " in Asset: \"{asset}\"" - "\n\nError: {message}" - ).format( - creator=self._creator_label, - subset=self._subset_name, - asset=self._asset_name, - message=self._exc_msg, - ) - if self._formatted_traceback: - report_message += "\n\n{}".format(self._formatted_traceback) - return [report_message] - - def _create_content(self, content_layout): - item_name_template = ( - "Creator: {}
" - "Subset: {}
" - "Asset: {}
" - ) - exc_msg_template = "{}" - - line = self._create_line() - content_layout.addWidget(line) - - item_name_widget = QtWidgets.QLabel(self) - item_name_widget.setText( - item_name_template.format( - self._creator_label, self._subset_name, self._asset_name - ) - ) - content_layout.addWidget(item_name_widget) - - message_label_widget = QtWidgets.QLabel(self) - message_label_widget.setText( - exc_msg_template.format(self.convert_text_for_html(self._exc_msg)) - ) - content_layout.addWidget(message_label_widget) - - if self._formatted_traceback: - line_widget = self._create_line() - tb_widget = self._create_traceback_widget( - self._formatted_traceback - ) - content_layout.addWidget(line_widget) - content_layout.addWidget(tb_widget) - - # TODO add creator identifier/label to details class CreatorShortDescWidget(QtWidgets.QWidget): def __init__(self, parent=None): @@ -178,8 +104,6 @@ class CreateWidget(QtWidgets.QWidget): self._prereq_available = False - self._message_dialog = None - name_pattern = "^[{}]*$".format(SUBSET_NAME_ALLOWED_SYMBOLS) self._name_pattern = name_pattern self._compiled_name_pattern = re.compile(name_pattern) @@ -769,7 +693,6 @@ class CreateWidget(QtWidgets.QWidget): return index = indexes[0] - creator_label = index.data(QtCore.Qt.DisplayRole) creator_identifier = index.data(CREATOR_IDENTIFIER_ROLE) family = index.data(FAMILY_ROLE) variant = self.variant_input.text() @@ -792,40 +715,13 @@ class CreateWidget(QtWidgets.QWidget): "family": family } - error_msg = None - formatted_traceback = None - try: - self._controller.create( - creator_identifier, - subset_name, - instance_data, - pre_create_data - ) + success = self._controller.create( + creator_identifier, + subset_name, + instance_data, + pre_create_data + ) - except CreatorError as exc: - error_msg = str(exc) - - # Use bare except because some hosts raise their exceptions that - # do not inherit from python's `BaseException` - except: - exc_type, exc_value, exc_traceback = sys.exc_info() - formatted_traceback = "".join(traceback.format_exception( - exc_type, exc_value, exc_traceback - )) - error_msg = str(exc_value) - - if error_msg is None: + if success: self._set_creator(self._selected_creator) self._controller.emit_card_message("Creation finished...") - else: - box = CreateErrorMessageBox( - creator_label, - subset_name, - asset_name, - error_msg, - formatted_traceback, - parent=self - ) - box.show() - # Store dialog so is not garbage collected before is shown - self._message_dialog = box diff --git a/openpype/tools/publisher/widgets/overview_widget.py b/openpype/tools/publisher/widgets/overview_widget.py index 5bd3017c2a..4cf8ae0eed 100644 --- a/openpype/tools/publisher/widgets/overview_widget.py +++ b/openpype/tools/publisher/widgets/overview_widget.py @@ -93,8 +93,8 @@ class OverviewWidget(QtWidgets.QFrame): main_layout.addWidget(subset_content_widget, 1) change_anim = QtCore.QVariantAnimation() - change_anim.setStartValue(0) - change_anim.setEndValue(self.anim_end_value) + change_anim.setStartValue(float(0)) + change_anim.setEndValue(float(self.anim_end_value)) change_anim.setDuration(self.anim_duration) change_anim.setEasingCurve(QtCore.QEasingCurve.InOutQuad) @@ -264,9 +264,10 @@ class OverviewWidget(QtWidgets.QFrame): + (self._subset_content_layout.spacing() * 2) ) ) - subset_attrs_width = int(float(width) / self.anim_end_value) * value + subset_attrs_width = int((float(width) / self.anim_end_value) * value) if subset_attrs_width > width: subset_attrs_width = width + create_width = width - subset_attrs_width self._create_widget.setMinimumWidth(create_width) diff --git a/openpype/tools/publisher/widgets/publish_frame.py b/openpype/tools/publisher/widgets/publish_frame.py index e6333a104f..00597451a9 100644 --- a/openpype/tools/publisher/widgets/publish_frame.py +++ b/openpype/tools/publisher/widgets/publish_frame.py @@ -248,13 +248,13 @@ class PublishFrame(QtWidgets.QWidget): hint = self._top_content_widget.minimumSizeHint() end = hint.height() - self._shrunk_anim.setStartValue(start) - self._shrunk_anim.setEndValue(end) + self._shrunk_anim.setStartValue(float(start)) + self._shrunk_anim.setEndValue(float(end)) if not anim_is_running: self._shrunk_anim.start() def _on_shrunk_anim(self, value): - diff = self._top_content_widget.height() - value + diff = self._top_content_widget.height() - int(value) if not self._top_content_widget.isVisible(): diff -= self._content_layout.spacing() diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index 39075d2489..b6bd506c18 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -1,3 +1,4 @@ +import collections from Qt import QtWidgets, QtCore, QtGui from openpype import ( @@ -5,6 +6,7 @@ from openpype import ( style ) from openpype.tools.utils import ( + ErrorMessageBox, PlaceholderLineEdit, MessageOverlayObject, PixmapLabel, @@ -222,6 +224,12 @@ class PublisherWindow(QtWidgets.QDialog): # Floating publish frame publish_frame = PublishFrame(controller, self.footer_border, self) + creators_dialog_message_timer = QtCore.QTimer() + creators_dialog_message_timer.setInterval(100) + creators_dialog_message_timer.timeout.connect( + self._on_creators_message_timeout + ) + help_btn.clicked.connect(self._on_help_click) tabs_widget.tab_changed.connect(self._on_tab_change) overview_widget.active_changed.connect( @@ -259,6 +267,18 @@ class PublisherWindow(QtWidgets.QDialog): controller.event_system.add_callback( "show.card.message", self._on_overlay_message ) + controller.event_system.add_callback( + "instances.collection.failed", self._instance_collection_failed + ) + controller.event_system.add_callback( + "instances.save.failed", self._instance_save_failed + ) + controller.event_system.add_callback( + "instances.remove.failed", self._instance_remove_failed + ) + controller.event_system.add_callback( + "instances.create.failed", self._instance_create_failed + ) # Store extra header widget for TrayPublisher # - can be used to add additional widgets to header between context @@ -298,10 +318,16 @@ class PublisherWindow(QtWidgets.QDialog): self._controller = controller self._first_show = True - self._reset_on_show = reset_on_show + # This is a little bit confusing but 'reset_on_first_show' is too long + # forin init + self._reset_on_first_show = reset_on_show + self._reset_on_show = True self._restart_timer = None self._publish_frame_visible = None + self._creators_messages_to_show = collections.deque() + self._creators_dialog_message_timer = creators_dialog_message_timer + self._set_publish_visibility(False) @property @@ -314,6 +340,18 @@ class PublisherWindow(QtWidgets.QDialog): self._first_show = False self._on_first_show() + if not self._reset_on_show: + return + + self._reset_on_show = False + # Detach showing - give OS chance to draw the window + timer = QtCore.QTimer() + timer.setSingleShot(True) + timer.setInterval(1) + timer.timeout.connect(self._on_show_restart_timer) + self._restart_timer = timer + timer.start() + def resizeEvent(self, event): super(PublisherWindow, self).resizeEvent(event) self._update_publish_frame_rect() @@ -324,16 +362,7 @@ class PublisherWindow(QtWidgets.QDialog): def _on_first_show(self): self.resize(self.default_width, self.default_height) self.setStyleSheet(style.load_stylesheet()) - if not self._reset_on_show: - return - - # Detach showing - give OS chance to draw the window - timer = QtCore.QTimer() - timer.setSingleShot(True) - timer.setInterval(1) - timer.timeout.connect(self._on_show_restart_timer) - self._restart_timer = timer - timer.start() + self._reset_on_show = self._reset_on_first_show def _on_show_restart_timer(self): """Callback for '_restart_timer' timer.""" @@ -342,9 +371,13 @@ class PublisherWindow(QtWidgets.QDialog): self.reset() def closeEvent(self, event): - self._controller.save_changes() + self.save_changes() + self._reset_on_show = True super(PublisherWindow, self).closeEvent(event) + def save_changes(self): + self._controller.save_changes() + def reset(self): self._controller.reset() @@ -436,7 +469,8 @@ class PublisherWindow(QtWidgets.QDialog): self._update_publish_frame_rect() def _on_reset_clicked(self): - self._controller.reset() + self.save_changes() + self.reset() def _on_stop_clicked(self): self._controller.stop_publish() @@ -472,7 +506,7 @@ class PublisherWindow(QtWidgets.QDialog): self._update_publish_details_widget() if ( not self._tabs_widget.is_current_tab("create") - or not self._tabs_widget.is_current_tab("publish") + and not self._tabs_widget.is_current_tab("publish") ): self._tabs_widget.set_current_tab("publish") @@ -569,3 +603,129 @@ class PublisherWindow(QtWidgets.QDialog): self._publish_frame.move( 0, window_size.height() - height ) + + def add_message_dialog(self, title, failed_info): + self._creators_messages_to_show.append((title, failed_info)) + self._creators_dialog_message_timer.start() + + def _on_creators_message_timeout(self): + if not self._creators_messages_to_show: + self._creators_dialog_message_timer.stop() + return + + item = self._creators_messages_to_show.popleft() + title, failed_info = item + dialog = CreatorsErrorMessageBox(title, failed_info, self) + dialog.exec_() + dialog.deleteLater() + + def _instance_collection_failed(self, event): + self.add_message_dialog(event["title"], event["failed_info"]) + + def _instance_save_failed(self, event): + self.add_message_dialog(event["title"], event["failed_info"]) + + def _instance_remove_failed(self, event): + self.add_message_dialog(event["title"], event["failed_info"]) + + def _instance_create_failed(self, event): + self.add_message_dialog(event["title"], event["failed_info"]) + + +class CreatorsErrorMessageBox(ErrorMessageBox): + def __init__(self, error_title, failed_info, parent): + self._failed_info = failed_info + self._info_with_id = [ + # Id must be string when used in tab widget + {"id": str(idx), "info": info} + for idx, info in enumerate(failed_info) + ] + self._widgets_by_id = {} + self._tabs_widget = None + self._stack_layout = None + + super(CreatorsErrorMessageBox, self).__init__(error_title, parent) + + layout = self.layout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + footer_layout = self._footer_widget.layout() + footer_layout.setContentsMargins(5, 5, 5, 5) + + def _create_top_widget(self, parent_widget): + return None + + def _get_report_data(self): + output = [] + for info in self._failed_info: + creator_label = info["creator_label"] + creator_identifier = info["creator_identifier"] + report_message = "Creator:" + if creator_label: + report_message += " {} ({})".format( + creator_label, creator_identifier) + else: + report_message += " {}".format(creator_identifier) + + report_message += "\n\nError: {}".format(info["message"]) + formatted_traceback = info["traceback"] + if formatted_traceback: + report_message += "\n\n{}".format(formatted_traceback) + output.append(report_message) + return output + + def _create_content(self, content_layout): + tabs_widget = PublisherTabsWidget(self) + + stack_widget = QtWidgets.QFrame(self._content_widget) + stack_layout = QtWidgets.QStackedLayout(stack_widget) + + first = True + for item in self._info_with_id: + item_id = item["id"] + info = item["info"] + message = info["message"] + formatted_traceback = info["traceback"] + creator_label = info["creator_label"] + creator_identifier = info["creator_identifier"] + if not creator_label: + creator_label = creator_identifier + + msg_widget = QtWidgets.QWidget(stack_widget) + msg_layout = QtWidgets.QVBoxLayout(msg_widget) + + exc_msg_template = "{}" + message_label_widget = QtWidgets.QLabel(msg_widget) + message_label_widget.setText( + exc_msg_template.format(self.convert_text_for_html(message)) + ) + msg_layout.addWidget(message_label_widget, 0) + + if formatted_traceback: + line_widget = self._create_line(msg_widget) + tb_widget = self._create_traceback_widget(formatted_traceback) + msg_layout.addWidget(line_widget, 0) + msg_layout.addWidget(tb_widget, 0) + + msg_layout.addStretch(1) + + tabs_widget.add_tab(creator_label, item_id) + stack_layout.addWidget(msg_widget) + if first: + first = False + stack_layout.setCurrentWidget(msg_widget) + + self._widgets_by_id[item_id] = msg_widget + + content_layout.addWidget(tabs_widget, 0) + content_layout.addWidget(stack_widget, 1) + + tabs_widget.tab_changed.connect(self._on_tab_change) + + self._tabs_widget = tabs_widget + self._stack_layout = stack_layout + + def _on_tab_change(self, old_identifier, identifier): + widget = self._widgets_by_id[identifier] + self._stack_layout.setCurrentWidget(widget) diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 5ccc1b40b3..019ea16391 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -7,6 +7,7 @@ from .widgets import ( ExpandBtn, PixmapLabel, IconButton, + SeparatorWidget, ) from .views import DeselectableTreeView from .error_dialog import ErrorMessageBox @@ -37,6 +38,7 @@ __all__ = ( "ExpandBtn", "PixmapLabel", "IconButton", + "SeparatorWidget", "DeselectableTreeView", diff --git a/openpype/tools/utils/error_dialog.py b/openpype/tools/utils/error_dialog.py index f7b12bb69f..5fe49a53af 100644 --- a/openpype/tools/utils/error_dialog.py +++ b/openpype/tools/utils/error_dialog.py @@ -1,9 +1,9 @@ from Qt import QtWidgets, QtCore -from .widgets import ClickableFrame, ExpandBtn +from .widgets import ClickableFrame, ExpandBtn, SeparatorWidget -def convert_text_for_html(text): +def escape_text_for_html(text): return ( text .replace("<", "<") @@ -19,7 +19,7 @@ class TracebackWidget(QtWidgets.QWidget): # Modify text to match html # - add more replacements when needed - tb_text = convert_text_for_html(tb_text) + tb_text = escape_text_for_html(tb_text) expand_btn = ExpandBtn(self) clickable_frame = ClickableFrame(self) @@ -85,17 +85,20 @@ class ErrorMessageBox(QtWidgets.QDialog): copy_report_btn = QtWidgets.QPushButton("Copy report", self) ok_btn = QtWidgets.QPushButton("OK", self) - footer_layout = QtWidgets.QHBoxLayout() + footer_widget = QtWidgets.QWidget(self) + footer_layout = QtWidgets.QHBoxLayout(footer_widget) + footer_layout.setContentsMargins(0, 0, 0, 0) footer_layout.addWidget(copy_report_btn, 0) footer_layout.addStretch(1) footer_layout.addWidget(ok_btn, 0) bottom_line = self._create_line() - body_layout = QtWidgets.QVBoxLayout(self) - body_layout.addWidget(top_widget, 0) - body_layout.addWidget(content_scroll, 1) - body_layout.addWidget(bottom_line, 0) - body_layout.addLayout(footer_layout, 0) + main_layout = QtWidgets.QVBoxLayout(self) + if top_widget is not None: + main_layout.addWidget(top_widget, 0) + main_layout.addWidget(content_scroll, 1) + main_layout.addWidget(bottom_line, 0) + main_layout.addWidget(footer_widget, 0) copy_report_btn.clicked.connect(self._on_copy_report) ok_btn.clicked.connect(self._on_ok_clicked) @@ -106,11 +109,13 @@ class ErrorMessageBox(QtWidgets.QDialog): if not report_data: copy_report_btn.setVisible(False) + self._content_scroll = content_scroll + self._footer_widget = footer_widget self._report_data = report_data @staticmethod def convert_text_for_html(text): - return convert_text_for_html(text) + return escape_text_for_html(text) def _create_top_widget(self, parent_widget): label_widget = QtWidgets.QLabel(parent_widget) @@ -131,7 +136,8 @@ class ErrorMessageBox(QtWidgets.QDialog): self.close() def _on_copy_report(self): - report_text = (10 * "*").join(self._report_data) + sep = "\n{}\n".format(10 * "*") + report_text = sep.join(self._report_data) mime_data = QtCore.QMimeData() mime_data.setText(report_text) @@ -139,12 +145,10 @@ class ErrorMessageBox(QtWidgets.QDialog): mime_data ) - def _create_line(self): - line = QtWidgets.QFrame(self) - line.setObjectName("Separator") - line.setMinimumHeight(2) - line.setMaximumHeight(2) - return line + def _create_line(self, parent=None): + if parent is None: + parent = self + return SeparatorWidget(2, parent=parent) def _create_traceback_widget(self, traceback_text, parent=None): if parent is None: diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index c8133b3359..ca65182124 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -448,3 +448,57 @@ class OptionDialog(QtWidgets.QDialog): def parse(self): return self._options.copy() + + +class SeparatorWidget(QtWidgets.QFrame): + """Prepared widget that can be used as separator with predefined color. + + Args: + size (int): Size of separator (width or height). + orientation (Qt.Horizontal|Qt.Vertical): Orintation of widget. + parent (QtWidgets.QWidget): Parent widget. + """ + + def __init__(self, size=2, orientation=QtCore.Qt.Horizontal, parent=None): + super(SeparatorWidget, self).__init__(parent) + + self.setObjectName("Separator") + + maximum_width = self.maximumWidth() + maximum_height = self.maximumHeight() + + self._size = None + self._orientation = orientation + self._maximum_width = maximum_width + self._maximum_height = maximum_height + self.set_size(size) + + def set_size(self, size): + if size == self._size: + return + if self._orientation == QtCore.Qt.Vertical: + self.setMinimumWidth(size) + self.setMaximumWidth(size) + else: + self.setMinimumHeight(size) + self.setMaximumHeight(size) + + self._size = size + + def set_orientation(self, orientation): + if self._orientation == orientation: + return + + # Reset min/max sizes in opossite direction + if self._orientation == QtCore.Qt.Vertical: + self.setMinimumHeight(0) + self.setMaximumHeight(self._maximum_height) + else: + self.setMinimumWidth(0) + self.setMaximumWidth(self._maximum_width) + + self._orientation = orientation + + size = self._size + self._size = None + self.set_size(size)