Merge pull request #142 from ynput/enhancement/AY-1411_add_plug_in_details_tab

Publisher: Add plugin details widget
This commit is contained in:
Jakub Trllo 2024-08-21 17:45:27 +02:00 committed by GitHub
commit 200e04c622
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 489 additions and 20 deletions

View file

@ -1245,6 +1245,15 @@ ValidationArtistMessage QLabel {
background: transparent;
}
#PluginDetailsContent {
background: {color:bg-inputs};
border-radius: 0.2em;
}
#PluginDetailsContent #PluginLabel {
font-size: 14pt;
font-weight: bold;
}
CreateNextPageOverlay {
font-size: 32pt;
}

View file

@ -172,7 +172,7 @@ class PublishReportMaker:
"crashed_file_paths": crashed_file_paths,
"id": uuid.uuid4().hex,
"created_at": now.isoformat(),
"report_version": "1.0.1",
"report_version": "1.1.0",
}
def _add_plugin_data_item(self, plugin: pyblish.api.Plugin):
@ -194,11 +194,23 @@ class PublishReportMaker:
if hasattr(plugin, "label"):
label = plugin.label
plugin_type = "instance" if plugin.__instanceEnabled__ else "context"
# Get docstring
# NOTE we do care only about docstring from the plugin so we can't
# use 'inspect.getdoc' which also looks for docstring in parent
# classes.
docstring = getattr(plugin, "__doc__", None)
if docstring:
docstring = inspect.cleandoc(docstring)
return {
"id": plugin.id,
"name": plugin.__name__,
"label": label,
"order": plugin.order,
"filepath": inspect.getfile(plugin),
"docstring": docstring,
"plugin_type": plugin_type,
"families": list(plugin.families),
"targets": list(plugin.targets),
"instances_data": [],
"actions_data": [],

View file

@ -13,8 +13,16 @@ class PluginItem:
self.skipped = plugin_data["skipped"]
self.passed = plugin_data["passed"]
# Introduced in report '1.1.0'
self.docstring = plugin_data.get("docstring")
self.filepath = plugin_data.get("filepath")
self.plugin_type = plugin_data.get("plugin_type")
self.families = plugin_data.get("families")
errored = False
process_time = 0.0
for instance_data in plugin_data["instances_data"]:
process_time += instance_data["process_time"]
for log_item in instance_data["logs"]:
errored = log_item["type"] == "error"
if errored:
@ -22,6 +30,7 @@ class PluginItem:
if errored:
break
self.process_time = process_time
self.errored = errored
@property

View file

@ -3,6 +3,8 @@ from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.tools.utils import (
NiceCheckbox,
ElideLabel,
SeparatorWidget,
IconButton,
paint_image_with_color,
)
@ -28,33 +30,89 @@ TRACEBACK_ROLE = QtCore.Qt.UserRole + 2
IS_DETAIL_ITEM_ROLE = QtCore.Qt.UserRole + 3
class PluginLoadReportModel(QtGui.QStandardItemModel):
def set_report(self, report):
parent = self.invisibleRootItem()
parent.removeRows(0, parent.rowCount())
def get_pretty_milliseconds(value):
if value < 1000:
return f"{value:.3f}ms"
value /= 1000
if value < 60:
return f"{value:.2f}s"
seconds = int(value % 60)
value /= 60
if value < 60:
return f"{value:.2f}m {seconds:.2f}s"
minutes = int(value % 60)
value /= 60
return f"{value:.2f}h {minutes:.2f}m"
class PluginLoadReportModel(QtGui.QStandardItemModel):
def __init__(self):
super().__init__()
self._traceback_by_filepath = {}
self._items_by_filepath = {}
self._is_active = True
self._need_refresh = False
def set_active(self, is_active):
if self._is_active is is_active:
return
self._is_active = is_active
self._update_items()
def set_report(self, report):
self._need_refresh = True
if report is None:
self._traceback_by_filepath.clear()
self._update_items()
return
filepaths = set(report.crashed_plugin_paths.keys())
to_remove = set(self._traceback_by_filepath) - filepaths
for filepath in filepaths:
self._traceback_by_filepath[filepath] = (
report.crashed_plugin_paths[filepath]
)
for filepath in to_remove:
self._traceback_by_filepath.pop(filepath)
self._update_items()
def _update_items(self):
if not self._is_active or not self._need_refresh:
return
parent = self.invisibleRootItem()
if not self._traceback_by_filepath:
parent.removeRows(0, parent.rowCount())
return
new_items = []
new_items_by_filepath = {}
for filepath in report.crashed_plugin_paths.keys():
to_remove = (
set(self._items_by_filepath) - set(self._traceback_by_filepath)
)
for filepath in self._traceback_by_filepath:
if filepath in self._items_by_filepath:
continue
item = QtGui.QStandardItem(filepath)
new_items.append(item)
new_items_by_filepath[filepath] = item
self._items_by_filepath[filepath] = item
if not new_items:
return
if new_items:
parent.appendRows(new_items)
parent.appendRows(new_items)
for filepath, item in new_items_by_filepath.items():
traceback_txt = report.crashed_plugin_paths[filepath]
traceback_txt = self._traceback_by_filepath[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)
for filepath in to_remove:
item = self._items_by_filepath.pop(filepath)
parent.removeRow(item.row())
class DetailWidget(QtWidgets.QTextEdit):
def __init__(self, text, *args, **kwargs):
@ -101,10 +159,12 @@ class PluginLoadReportWidget(QtWidgets.QWidget):
self._model = model
self._widgets_by_filepath = {}
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)
def set_active(self, is_active):
self._model.set_active(is_active)
def set_report(self, report):
self._widgets_by_filepath = {}
self._model.set_report(report)
def showEvent(self, event):
super().showEvent(event)
@ -114,6 +174,11 @@ class PluginLoadReportWidget(QtWidgets.QWidget):
super().resizeEvent(event)
self._update_widgets_size_hints()
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)
def _update_widgets_size_hints(self):
for item in self._widgets_by_filepath.values():
widget, index = item
@ -142,10 +207,6 @@ class PluginLoadReportWidget(QtWidgets.QWidget):
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 ZoomPlainText(QtWidgets.QPlainTextEdit):
min_point_size = 1.0
@ -235,6 +296,8 @@ class DetailsWidget(QtWidgets.QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(output_widget)
self._is_active = True
self._need_refresh = False
self._output_widget = output_widget
self._report_item = None
self._instance_filter = set()
@ -243,21 +306,33 @@ class DetailsWidget(QtWidgets.QWidget):
def clear(self):
self._output_widget.setPlainText("")
def set_active(self, is_active):
if self._is_active is is_active:
return
self._is_active = is_active
self._update_logs()
def set_report(self, report):
self._report_item = report
self._plugin_filter = set()
self._instance_filter = set()
self._need_refresh = True
self._update_logs()
def set_plugin_filter(self, plugin_filter):
self._plugin_filter = plugin_filter
self._need_refresh = True
self._update_logs()
def set_instance_filter(self, instance_filter):
self._instance_filter = instance_filter
self._need_refresh = True
self._update_logs()
def _update_logs(self):
if not self._is_active or not self._need_refresh:
return
if not self._report_item:
self._output_widget.setPlainText("")
return
@ -300,6 +375,242 @@ class DetailsWidget(QtWidgets.QWidget):
self._output_widget.setPlainText(text)
class PluginDetailsWidget(QtWidgets.QWidget):
def __init__(self, plugin_item, parent):
super().__init__(parent)
content_widget = QtWidgets.QFrame(self)
content_widget.setObjectName("PluginDetailsContent")
plugin_label_widget = QtWidgets.QLabel(content_widget)
plugin_label_widget.setObjectName("PluginLabel")
plugin_doc_widget = QtWidgets.QLabel(content_widget)
plugin_doc_widget.setWordWrap(True)
form_separator = SeparatorWidget(parent=content_widget)
plugin_class_label = QtWidgets.QLabel("Class:")
plugin_class_widget = QtWidgets.QLabel(content_widget)
plugin_order_label = QtWidgets.QLabel("Order:")
plugin_order_widget = QtWidgets.QLabel(content_widget)
plugin_families_label = QtWidgets.QLabel("Families:")
plugin_families_widget = QtWidgets.QLabel(content_widget)
plugin_families_widget.setWordWrap(True)
plugin_path_label = QtWidgets.QLabel("File Path:")
plugin_path_widget = ElideLabel(content_widget)
plugin_path_widget.set_elide_mode(QtCore.Qt.ElideLeft)
plugin_time_label = QtWidgets.QLabel("Time:")
plugin_time_widget = QtWidgets.QLabel(content_widget)
# Set interaction flags
for label_widget in (
plugin_label_widget,
plugin_doc_widget,
plugin_class_widget,
plugin_order_widget,
plugin_families_widget,
plugin_time_widget,
):
label_widget.setTextInteractionFlags(
QtCore.Qt.TextBrowserInteraction
)
# Change style of form labels
for label_widget in (
plugin_class_label,
plugin_order_label,
plugin_families_label,
plugin_path_label,
plugin_time_label,
):
label_widget.setObjectName("PluginFormLabel")
plugin_label = plugin_item.label or plugin_item.name
if plugin_item.plugin_type:
plugin_label += " ({})".format(
plugin_item.plugin_type.capitalize()
)
time_label = "Not started"
if plugin_item.passed:
time_label = get_pretty_milliseconds(plugin_item.process_time)
elif plugin_item.skipped:
time_label = "Skipped plugin"
families = "N/A"
if plugin_item.families:
families = ", ".join(plugin_item.families)
order = "N/A"
if plugin_item.order is not None:
order = str(plugin_item.order)
plugin_label_widget.setText(plugin_label)
plugin_doc_widget.setText(plugin_item.docstring or "N/A")
plugin_class_widget.setText(plugin_item.name or "N/A")
plugin_order_widget.setText(order)
plugin_families_widget.setText(families)
plugin_path_widget.setText(plugin_item.filepath or "N/A")
plugin_path_widget.setToolTip(plugin_item.filepath or None)
plugin_time_widget.setText(time_label)
content_layout = QtWidgets.QGridLayout(content_widget)
content_layout.setContentsMargins(8, 8, 8, 8)
content_layout.setColumnStretch(0, 0)
content_layout.setColumnStretch(1, 1)
row = 0
content_layout.addWidget(plugin_label_widget, row, 0, 1, 2)
row += 1
# Hide docstring if it is empty
if plugin_item.docstring:
content_layout.addWidget(plugin_doc_widget, row, 0, 1, 2)
row += 1
else:
plugin_doc_widget.setVisible(False)
content_layout.addWidget(form_separator, row, 0, 1, 2)
row += 1
for label_widget, value_widget in (
(plugin_class_label, plugin_class_widget),
(plugin_order_label, plugin_order_widget),
(plugin_families_label, plugin_families_widget),
(plugin_path_label, plugin_path_widget),
(plugin_time_label, plugin_time_widget),
):
content_layout.addWidget(label_widget, row, 0)
content_layout.addWidget(value_widget, row, 1)
row += 1
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(content_widget, 0)
class PluginsDetailsWidget(QtWidgets.QWidget):
def __init__(self, parent):
super().__init__(parent)
scroll_area = QtWidgets.QScrollArea(self)
scroll_area.setWidgetResizable(True)
scroll_content_widget = QtWidgets.QWidget(scroll_area)
scroll_area.setWidget(scroll_content_widget)
empty_label = QtWidgets.QLabel(
"<br/><br/>Select plugins to view more information...",
scroll_content_widget
)
empty_label.setAlignment(QtCore.Qt.AlignCenter)
content_widget = QtWidgets.QWidget(scroll_content_widget)
content_layout = QtWidgets.QVBoxLayout(content_widget)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.setSpacing(10)
scroll_content_layout = QtWidgets.QVBoxLayout(scroll_content_widget)
scroll_content_layout.setContentsMargins(0, 0, 0, 0)
scroll_content_layout.addWidget(empty_label, 0)
scroll_content_layout.addWidget(content_widget, 0)
scroll_content_layout.addStretch(1)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(scroll_area, 1)
content_widget.setVisible(False)
self._scroll_area = scroll_area
self._empty_label = empty_label
self._content_layout = content_layout
self._content_widget = content_widget
self._widgets_by_plugin_id = {}
self._stretch_item_index = 0
self._is_active = True
self._need_refresh = False
self._report_item = None
self._plugin_filter = set()
self._plugin_ids = None
def set_active(self, is_active):
if self._is_active is is_active:
return
self._is_active = is_active
self._update_widgets()
def set_plugin_filter(self, plugin_filter):
self._need_refresh = True
self._plugin_filter = plugin_filter
self._update_widgets()
def set_report(self, report):
self._plugin_ids = None
self._plugin_filter = set()
self._need_refresh = True
self._report_item = report
self._update_widgets()
def _get_plugin_ids(self):
if self._plugin_ids is not None:
return self._plugin_ids
# Clear layout and clear widgets
while self._content_layout.count():
item = self._content_layout.takeAt(0)
widget = item.widget()
if widget:
widget.setVisible(False)
widget.deleteLater()
self._widgets_by_plugin_id.clear()
plugin_ids = []
if self._report_item is not None:
plugin_ids = list(self._report_item.plugins_id_order)
self._plugin_ids = plugin_ids
return plugin_ids
def _update_widgets(self):
if not self._is_active or not self._need_refresh:
return
self._need_refresh = False
# Hide content widget before updating
# - add widgets to layout can happen without recalculating
# the layout and widget size hints
self._content_widget.setVisible(False)
any_visible = False
for plugin_id in self._get_plugin_ids():
widget = self._widgets_by_plugin_id.get(plugin_id)
if widget is None:
plugin_item = self._report_item.plugins_items_by_id[plugin_id]
widget = PluginDetailsWidget(plugin_item, self._content_widget)
self._widgets_by_plugin_id[plugin_id] = widget
self._content_layout.addWidget(widget, 0)
is_visible = plugin_id in self._plugin_filter
widget.setVisible(is_visible)
if is_visible:
any_visible = True
self._content_widget.setVisible(any_visible)
self._empty_label.setVisible(not any_visible)
class DeselectableTreeView(QtWidgets.QTreeView):
"""A tree view that deselects on clicking on an empty area in the view"""
@ -446,9 +757,16 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
logs_text_widget = DetailsWidget(details_tab_widget)
plugin_load_report_widget = PluginLoadReportWidget(details_tab_widget)
plugins_details_widget = PluginsDetailsWidget(details_tab_widget)
plugin_load_report_widget.set_active(False)
plugins_details_widget.set_active(False)
details_tab_widget.addTab(logs_text_widget, "Logs")
details_tab_widget.addTab(plugin_load_report_widget, "Crashed plugins")
details_tab_widget.addTab(plugins_details_widget, "Plugins Details")
details_tab_widget.addTab(
plugin_load_report_widget, "Crashed plugins"
)
middle_widget = QtWidgets.QWidget(self)
middle_layout = QtWidgets.QGridLayout(middle_widget)
@ -465,6 +783,7 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
layout.addWidget(middle_widget, 0)
layout.addWidget(details_widget, 1)
details_tab_widget.currentChanged.connect(self._on_tab_change)
instances_view.selectionModel().selectionChanged.connect(
self._on_instance_change
)
@ -483,10 +802,12 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
details_popup_btn.clicked.connect(self._on_details_popup)
details_popup.closed.connect(self._on_popup_close)
self._current_tab_idx = 0
self._ignore_selection_changes = False
self._report_item = None
self._logs_text_widget = logs_text_widget
self._plugin_load_report_widget = plugin_load_report_widget
self._plugins_details_widget = plugins_details_widget
self._removed_instances_check = removed_instances_check
self._instances_view = instances_view
@ -523,6 +844,14 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
else:
self._plugins_view.expand(index)
def set_active(self, active):
for idx in range(self._details_tab_widget.count()):
widget = self._details_tab_widget.widget(idx)
widget.set_active(active and idx == self._current_tab_idx)
if not active:
self.close_details_popup()
def set_report_data(self, report_data):
report = PublishReport(report_data)
self.set_report(report)
@ -536,12 +865,22 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
self._plugins_model.set_report(report)
self._logs_text_widget.set_report(report)
self._plugin_load_report_widget.set_report(report)
self._plugins_details_widget.set_report(report)
self._ignore_selection_changes = False
self._instances_view.expandAll()
self._plugins_view.expandAll()
def _on_tab_change(self, new_idx):
if self._current_tab_idx == new_idx:
return
old_widget = self._details_tab_widget.widget(self._current_tab_idx)
new_widget = self._details_tab_widget.widget(new_idx)
self._current_tab_idx = new_idx
old_widget.set_active(False)
new_widget.set_active(True)
def _on_instance_change(self, *_args):
if self._ignore_selection_changes:
return
@ -563,6 +902,7 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
plugin_ids.add(index.data(ITEM_ID_ROLE))
self._logs_text_widget.set_plugin_filter(plugin_ids)
self._plugins_details_widget.set_plugin_filter(plugin_ids)
def _on_skipped_plugin_check(self):
self._plugins_proxy.set_ignore_skipped(

View file

@ -687,13 +687,14 @@ class PublisherWindow(QtWidgets.QDialog):
def _on_tab_change(self, old_tab, new_tab):
if old_tab == "details":
self._publish_details_widget.close_details_popup()
self._publish_details_widget.set_active(False)
if new_tab == "details":
self._content_stacked_layout.setCurrentWidget(
self._publish_details_widget
)
self._update_publish_details_widget()
self._publish_details_widget.set_active(True)
elif new_tab == "report":
self._content_stacked_layout.setCurrentWidget(

View file

@ -5,6 +5,7 @@ from .widgets import (
ComboBox,
CustomTextComboBox,
PlaceholderLineEdit,
ElideLabel,
HintedLineEdit,
ExpandingTextEdit,
BaseClickableFrame,
@ -89,6 +90,7 @@ __all__ = (
"ComboBox",
"CustomTextComboBox",
"PlaceholderLineEdit",
"ElideLabel",
"HintedLineEdit",
"ExpandingTextEdit",
"BaseClickableFrame",

View file

@ -105,6 +105,102 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit):
self.setPalette(filter_palette)
class ElideLabel(QtWidgets.QLabel):
"""Label which elide text.
By default, elide happens on right side. Can be changed with
'set_elide_mode' method.
It is not possible to use other features of QLabel like word wrap or
interactive text. This is a simple label which elide text.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setSizePolicy(
QtWidgets.QSizePolicy.Expanding,
QtWidgets.QSizePolicy.Preferred
)
# Store text set during init
self._text = self.text()
# Define initial elide mode
self._elide_mode = QtCore.Qt.ElideRight
# Make sure that text of QLabel is empty
super().setText("")
def setText(self, text):
# Update private text attribute and force update
self._text = text
self.update()
def setWordWrap(self, word_wrap):
# Word wrap is not supported in 'ElideLabel'
if word_wrap:
raise ValueError("Word wrap is not supported in 'ElideLabel'.")
def contextMenuEvent(self, event):
menu = self.create_context_menu(event.pos())
if menu is None:
event.ignore()
return
event.accept()
menu.setAttribute(QtCore.Qt.WA_DeleteOnClose)
menu.popup(event.globalPos())
def create_context_menu(self, pos):
if not self._text:
return None
menu = QtWidgets.QMenu(self)
# Copy text action
copy_action = menu.addAction("Copy")
copy_action.setObjectName("edit-copy")
icon = QtGui.QIcon.fromTheme("edit-copy")
if not icon.isNull():
copy_action.setIcon(icon)
copy_action.triggered.connect(self._on_copy_text)
return menu
def set_set(self, text):
self.setText(text)
def set_elide_mode(self, elide_mode):
"""Change elide type.
Args:
elide_mode: Possible elide type. Available in 'QtCore.Qt'
'ElideLeft', 'ElideRight' and 'ElideMiddle'.
"""
if elide_mode == QtCore.Qt.ElideNone:
raise ValueError(
"Invalid elide type. 'ElideNone' is not supported."
)
if elide_mode not in (
QtCore.Qt.ElideLeft,
QtCore.Qt.ElideRight,
QtCore.Qt.ElideMiddle,
):
raise ValueError(f"Unknown value '{elide_mode}'")
self._elide_mode = elide_mode
self.update()
def paintEvent(self, event):
super().paintEvent(event)
painter = QtGui.QPainter(self)
fm = painter.fontMetrics()
elided_line = fm.elidedText(
self._text, self._elide_mode, self.width()
)
painter.drawText(QtCore.QPoint(0, fm.ascent()), elided_line)
def _on_copy_text(self):
clipboard = QtWidgets.QApplication.clipboard()
clipboard.setText(self._text)
class _LocalCache:
down_arrow_icon = None