Merge branch 'develop' into enhancement/product-name-template-settings

This commit is contained in:
Jakub Trllo 2025-12-17 14:50:24 +01:00 committed by GitHub
commit 4051d679dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 251 additions and 51 deletions

View file

@ -29,6 +29,7 @@ from .lib import (
get_publish_template_name, get_publish_template_name,
publish_plugins_discover, publish_plugins_discover,
filter_crashed_publish_paths,
load_help_content_from_plugin, load_help_content_from_plugin,
load_help_content_from_filepath, load_help_content_from_filepath,
@ -87,6 +88,7 @@ __all__ = (
"get_publish_template_name", "get_publish_template_name",
"publish_plugins_discover", "publish_plugins_discover",
"filter_crashed_publish_paths",
"load_help_content_from_plugin", "load_help_content_from_plugin",
"load_help_content_from_filepath", "load_help_content_from_filepath",

View file

@ -1,6 +1,8 @@
"""Library functions for publishing.""" """Library functions for publishing."""
from __future__ import annotations from __future__ import annotations
import os import os
import platform
import re
import sys import sys
import inspect import inspect
import copy import copy
@ -8,19 +10,19 @@ import warnings
import hashlib import hashlib
import xml.etree.ElementTree import xml.etree.ElementTree
from typing import TYPE_CHECKING, Optional, Union, List, Any from typing import TYPE_CHECKING, Optional, Union, List, Any
import clique
import speedcopy
import logging import logging
import pyblish.util
import pyblish.plugin
import pyblish.api
from ayon_api import ( from ayon_api import (
get_server_api_connection, get_server_api_connection,
get_representations, get_representations,
get_last_version_by_product_name get_last_version_by_product_name
) )
import clique
import pyblish.util
import pyblish.plugin
import pyblish.api
import speedcopy
from ayon_core.lib import ( from ayon_core.lib import (
import_filepath, import_filepath,
Logger, Logger,
@ -246,6 +248,67 @@ def load_help_content_from_plugin(
return load_help_content_from_filepath(filepath) return load_help_content_from_filepath(filepath)
def filter_crashed_publish_paths(
project_name: str,
crashed_paths: set[str],
*,
project_settings: Optional[dict[str, Any]] = None,
) -> set[str]:
"""Filter crashed paths happened during plugins discovery.
Check if plugins discovery has enabled strict mode and filter crashed
paths that happened during discover based on regexes from settings.
Publishing should not start if any paths are returned.
Args:
project_name (str): Project name in which context plugins discovery
happened.
crashed_paths (set[str]): Crashed paths from plugins discovery report.
project_settings (Optional[dict[str, Any]]): Project settings.
Returns:
set[str]: Filtered crashed paths.
"""
filtered_paths = set()
# Nothing crashed all good...
if not crashed_paths:
return filtered_paths
if project_settings is None:
project_settings = get_project_settings(project_name)
discover_validation = (
project_settings["core"]["tools"]["publish"]["discover_validation"]
)
# Strict mode is not enabled.
if not discover_validation["enabled"]:
return filtered_paths
regexes = [
re.compile(value, re.IGNORECASE)
for value in discover_validation["ignore_paths"]
if value
]
is_windows = platform.system().lower() == "windows"
# Fitler path with regexes from settings
for path in crashed_paths:
# Normalize paths to use forward slashes on windows
if is_windows:
path = path.replace("\\", "/")
is_invalid = True
for regex in regexes:
if regex.match(path):
is_invalid = False
break
if is_invalid:
filtered_paths.add(path)
return filtered_paths
def publish_plugins_discover( def publish_plugins_discover(
paths: Optional[list[str]] = None) -> DiscoverResult: paths: Optional[list[str]] = None) -> DiscoverResult:
"""Find and return available pyblish plug-ins. """Find and return available pyblish plug-ins.
@ -1099,14 +1162,16 @@ def main_cli_publish(
except ValueError: except ValueError:
pass pass
context = get_global_context()
project_settings = get_project_settings(context["project_name"])
install_ayon_plugins() install_ayon_plugins()
if addons_manager is None: if addons_manager is None:
addons_manager = AddonsManager() addons_manager = AddonsManager(project_settings)
applications_addon = addons_manager.get_enabled_addon("applications") applications_addon = addons_manager.get_enabled_addon("applications")
if applications_addon is not None: if applications_addon is not None:
context = get_global_context()
env = applications_addon.get_farm_publish_environment_variables( env = applications_addon.get_farm_publish_environment_variables(
context["project_name"], context["project_name"],
context["folder_path"], context["folder_path"],
@ -1129,17 +1194,33 @@ def main_cli_publish(
log.info("Running publish ...") log.info("Running publish ...")
discover_result = publish_plugins_discover() discover_result = publish_plugins_discover()
publish_plugins = discover_result.plugins
print(discover_result.get_report(only_errors=False)) print(discover_result.get_report(only_errors=False))
filtered_crashed_paths = filter_crashed_publish_paths(
context["project_name"],
set(discover_result.crashed_file_paths),
project_settings=project_settings,
)
if filtered_crashed_paths:
joined_paths = "\n".join([
f"- {path}"
for path in filtered_crashed_paths
])
log.error(
"Plugin discovery strict mode is enabled."
" Crashed plugin paths that prevent from publishing:"
f"\n{joined_paths}"
)
sys.exit(1)
publish_plugins = discover_result.plugins
# Error exit as soon as any error occurs. # Error exit as soon as any error occurs.
error_format = ("Failed {plugin.__name__}: " error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}"
"{error} -- {error.traceback}")
for result in pyblish.util.publish_iter(plugins=publish_plugins): for result in pyblish.util.publish_iter(plugins=publish_plugins):
if result["error"]: if result["error"]:
log.error(error_format.format(**result)) log.error(error_format.format(**result))
# uninstall()
sys.exit(1) sys.exit(1)
log.info("Publish finished.") log.info("Publish finished.")

View file

@ -21,6 +21,7 @@ from ayon_core.pipeline.plugin_discover import DiscoverResult
from ayon_core.pipeline.publish import ( from ayon_core.pipeline.publish import (
get_publish_instance_label, get_publish_instance_label,
PublishError, PublishError,
filter_crashed_publish_paths,
) )
from ayon_core.tools.publisher.abstract import AbstractPublisherBackend from ayon_core.tools.publisher.abstract import AbstractPublisherBackend
@ -107,11 +108,14 @@ class PublishReportMaker:
creator_discover_result: Optional[DiscoverResult] = None, creator_discover_result: Optional[DiscoverResult] = None,
convertor_discover_result: Optional[DiscoverResult] = None, convertor_discover_result: Optional[DiscoverResult] = None,
publish_discover_result: Optional[DiscoverResult] = None, publish_discover_result: Optional[DiscoverResult] = None,
blocking_crashed_paths: Optional[list[str]] = None,
): ):
self._create_discover_result: Union[DiscoverResult, None] = None self._create_discover_result: Union[DiscoverResult, None] = None
self._convert_discover_result: Union[DiscoverResult, None] = None self._convert_discover_result: Union[DiscoverResult, None] = None
self._publish_discover_result: Union[DiscoverResult, None] = None self._publish_discover_result: Union[DiscoverResult, None] = None
self._blocking_crashed_paths: list[str] = []
self._all_instances_by_id: Dict[str, pyblish.api.Instance] = {} self._all_instances_by_id: Dict[str, pyblish.api.Instance] = {}
self._plugin_data_by_id: Dict[str, Any] = {} self._plugin_data_by_id: Dict[str, Any] = {}
self._current_plugin_id: Optional[str] = None self._current_plugin_id: Optional[str] = None
@ -120,6 +124,7 @@ class PublishReportMaker:
creator_discover_result, creator_discover_result,
convertor_discover_result, convertor_discover_result,
publish_discover_result, publish_discover_result,
blocking_crashed_paths,
) )
def reset( def reset(
@ -127,12 +132,14 @@ class PublishReportMaker:
creator_discover_result: Union[DiscoverResult, None], creator_discover_result: Union[DiscoverResult, None],
convertor_discover_result: Union[DiscoverResult, None], convertor_discover_result: Union[DiscoverResult, None],
publish_discover_result: Union[DiscoverResult, None], publish_discover_result: Union[DiscoverResult, None],
blocking_crashed_paths: list[str],
): ):
"""Reset report and clear all data.""" """Reset report and clear all data."""
self._create_discover_result = creator_discover_result self._create_discover_result = creator_discover_result
self._convert_discover_result = convertor_discover_result self._convert_discover_result = convertor_discover_result
self._publish_discover_result = publish_discover_result self._publish_discover_result = publish_discover_result
self._blocking_crashed_paths = blocking_crashed_paths
self._all_instances_by_id = {} self._all_instances_by_id = {}
self._plugin_data_by_id = {} self._plugin_data_by_id = {}
@ -242,9 +249,10 @@ class PublishReportMaker:
"instances": instances_details, "instances": instances_details,
"context": self._extract_context_data(publish_context), "context": self._extract_context_data(publish_context),
"crashed_file_paths": crashed_file_paths, "crashed_file_paths": crashed_file_paths,
"blocking_crashed_paths": list(self._blocking_crashed_paths),
"id": uuid.uuid4().hex, "id": uuid.uuid4().hex,
"created_at": now.isoformat(), "created_at": now.isoformat(),
"report_version": "1.1.0", "report_version": "1.1.1",
} }
def _add_plugin_data_item(self, plugin: pyblish.api.Plugin): def _add_plugin_data_item(self, plugin: pyblish.api.Plugin):
@ -959,11 +967,16 @@ class PublishModel:
self._publish_plugins_proxy = PublishPluginsProxy( self._publish_plugins_proxy = PublishPluginsProxy(
publish_plugins publish_plugins
) )
blocking_crashed_paths = filter_crashed_publish_paths(
create_context.get_current_project_name(),
set(create_context.publish_discover_result.crashed_file_paths),
project_settings=create_context.get_current_project_settings(),
)
self._publish_report.reset( self._publish_report.reset(
create_context.creator_discover_result, create_context.creator_discover_result,
create_context.convertor_discover_result, create_context.convertor_discover_result,
create_context.publish_discover_result, create_context.publish_discover_result,
blocking_crashed_paths,
) )
for plugin in create_context.publish_plugins_mismatch_targets: for plugin in create_context.publish_plugins_mismatch_targets:
self._publish_report.set_plugin_skipped(plugin.id) self._publish_report.set_plugin_skipped(plugin.id)

View file

@ -139,3 +139,6 @@ class PublishReport:
self.logs = logs self.logs = logs
self.crashed_plugin_paths = report_data["crashed_file_paths"] self.crashed_plugin_paths = report_data["crashed_file_paths"]
self.blocking_crashed_paths = report_data.get(
"blocking_crashed_paths", []
)

View file

@ -7,6 +7,7 @@ from ayon_core.tools.utils import (
SeparatorWidget, SeparatorWidget,
IconButton, IconButton,
paint_image_with_color, paint_image_with_color,
get_qt_icon,
) )
from ayon_core.resources import get_image_path from ayon_core.resources import get_image_path
from ayon_core.style import get_objected_colors from ayon_core.style import get_objected_colors
@ -46,10 +47,13 @@ def get_pretty_milliseconds(value):
class PluginLoadReportModel(QtGui.QStandardItemModel): class PluginLoadReportModel(QtGui.QStandardItemModel):
_blocking_icon = None
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._traceback_by_filepath = {} self._traceback_by_filepath = {}
self._items_by_filepath = {} self._items_by_filepath = {}
self._blocking_crashed_paths = set()
self._is_active = True self._is_active = True
self._need_refresh = False self._need_refresh = False
@ -75,6 +79,7 @@ class PluginLoadReportModel(QtGui.QStandardItemModel):
for filepath in to_remove: for filepath in to_remove:
self._traceback_by_filepath.pop(filepath) self._traceback_by_filepath.pop(filepath)
self._blocking_crashed_paths = set(report.blocking_crashed_paths)
self._update_items() self._update_items()
def _update_items(self): def _update_items(self):
@ -83,6 +88,7 @@ class PluginLoadReportModel(QtGui.QStandardItemModel):
parent = self.invisibleRootItem() parent = self.invisibleRootItem()
if not self._traceback_by_filepath: if not self._traceback_by_filepath:
parent.removeRows(0, parent.rowCount()) parent.removeRows(0, parent.rowCount())
self._items_by_filepath = {}
return return
new_items = [] new_items = []
@ -91,12 +97,18 @@ class PluginLoadReportModel(QtGui.QStandardItemModel):
set(self._items_by_filepath) - set(self._traceback_by_filepath) set(self._items_by_filepath) - set(self._traceback_by_filepath)
) )
for filepath in self._traceback_by_filepath: for filepath in self._traceback_by_filepath:
if filepath in self._items_by_filepath: item = self._items_by_filepath.get(filepath)
continue if item is None:
item = QtGui.QStandardItem(filepath) item = QtGui.QStandardItem(filepath)
new_items.append(item) new_items.append(item)
new_items_by_filepath[filepath] = item new_items_by_filepath[filepath] = item
self._items_by_filepath[filepath] = item self._items_by_filepath[filepath] = item
icon = None
if filepath.replace("\\", "/") in self._blocking_crashed_paths:
icon = self._get_blocking_icon()
item.setData(icon, QtCore.Qt.DecorationRole)
if new_items: if new_items:
parent.appendRows(new_items) parent.appendRows(new_items)
@ -113,6 +125,16 @@ class PluginLoadReportModel(QtGui.QStandardItemModel):
item = self._items_by_filepath.pop(filepath) item = self._items_by_filepath.pop(filepath)
parent.removeRow(item.row()) parent.removeRow(item.row())
@classmethod
def _get_blocking_icon(cls):
if cls._blocking_icon is None:
cls._blocking_icon = get_qt_icon({
"type": "material-symbols",
"name": "block",
"color": "red",
})
return cls._blocking_icon
class DetailWidget(QtWidgets.QTextEdit): class DetailWidget(QtWidgets.QTextEdit):
def __init__(self, text, *args, **kwargs): def __init__(self, text, *args, **kwargs):
@ -856,7 +878,7 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
report = PublishReport(report_data) report = PublishReport(report_data)
self.set_report(report) self.set_report(report)
def set_report(self, report): def set_report(self, report: PublishReport) -> None:
self._ignore_selection_changes = True self._ignore_selection_changes = True
self._report_item = report self._report_item = report
@ -866,6 +888,10 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
self._logs_text_widget.set_report(report) self._logs_text_widget.set_report(report)
self._plugin_load_report_widget.set_report(report) self._plugin_load_report_widget.set_report(report)
self._plugins_details_widget.set_report(report) self._plugins_details_widget.set_report(report)
if report.blocking_crashed_paths:
self._details_tab_widget.setCurrentWidget(
self._plugin_load_report_widget
)
self._ignore_selection_changes = False self._ignore_selection_changes = False

View file

@ -1,9 +1,11 @@
from __future__ import annotations
import os import os
import json import json
import time import time
import collections import collections
import copy import copy
from typing import Optional from typing import Optional, Any
from qtpy import QtWidgets, QtCore, QtGui from qtpy import QtWidgets, QtCore, QtGui
@ -393,6 +395,9 @@ class PublisherWindow(QtWidgets.QDialog):
self._publish_frame_visible = None self._publish_frame_visible = None
self._tab_on_reset = None self._tab_on_reset = None
self._create_context_valid: bool = True
self._blocked_by_crashed_paths: bool = False
self._error_messages_to_show = collections.deque() self._error_messages_to_show = collections.deque()
self._errors_dialog_message_timer = errors_dialog_message_timer self._errors_dialog_message_timer = errors_dialog_message_timer
@ -406,6 +411,8 @@ class PublisherWindow(QtWidgets.QDialog):
self._show_counter = 0 self._show_counter = 0
self._window_is_visible = False self._window_is_visible = False
self._update_footer_state()
@property @property
def controller(self) -> AbstractPublisherFrontend: def controller(self) -> AbstractPublisherFrontend:
"""Kept for compatibility with traypublisher.""" """Kept for compatibility with traypublisher."""
@ -664,11 +671,33 @@ class PublisherWindow(QtWidgets.QDialog):
self._tab_on_reset = tab self._tab_on_reset = tab
def _update_publish_details_widget(self, force=False): def set_current_tab(self, tab):
if not force and not self._is_on_details_tab(): if tab == "create":
self._go_to_create_tab()
elif tab == "publish":
self._go_to_publish_tab()
elif tab == "report":
self._go_to_report_tab()
elif tab == "details":
self._go_to_details_tab()
if not self._window_is_visible:
self.set_tab_on_reset(tab)
def _update_publish_details_widget(
self,
force: bool = False,
report_data: Optional[dict[str, Any]] = None,
) -> None:
if (
report_data is None
and not force
and not self._is_on_details_tab()
):
return return
report_data = self._controller.get_publish_report() if report_data is None:
report_data = self._controller.get_publish_report()
self._publish_details_widget.set_report_data(report_data) self._publish_details_widget.set_report_data(report_data)
def _on_help_click(self): def _on_help_click(self):
@ -752,19 +781,6 @@ class PublisherWindow(QtWidgets.QDialog):
def _set_current_tab(self, identifier): def _set_current_tab(self, identifier):
self._tabs_widget.set_current_tab(identifier) self._tabs_widget.set_current_tab(identifier)
def set_current_tab(self, tab):
if tab == "create":
self._go_to_create_tab()
elif tab == "publish":
self._go_to_publish_tab()
elif tab == "report":
self._go_to_report_tab()
elif tab == "details":
self._go_to_details_tab()
if not self._window_is_visible:
self.set_tab_on_reset(tab)
def _is_current_tab(self, identifier): def _is_current_tab(self, identifier):
return self._tabs_widget.is_current_tab(identifier) return self._tabs_widget.is_current_tab(identifier)
@ -865,26 +881,56 @@ class PublisherWindow(QtWidgets.QDialog):
# Reset style # Reset style
self._comment_input.setStyleSheet("") self._comment_input.setStyleSheet("")
def _set_footer_enabled(self, enabled): def _set_create_context_valid(self, valid: bool) -> None:
self._save_btn.setEnabled(True) self._create_context_valid = valid
self._update_footer_state()
def _set_blocked(self, blocked: bool) -> None:
self._blocked_by_crashed_paths = blocked
self._overview_widget.setEnabled(not blocked)
self._update_footer_state()
if not blocked:
return
self.set_tab_on_reset("details")
self._go_to_details_tab()
QtWidgets.QMessageBox.critical(
self,
"Failed to load plugins",
(
"Failed to load plugins that do prevent you from"
" using publish tool.\n"
"Please contact your TD or administrator."
)
)
def _update_footer_state(self) -> None:
enabled = (
not self._blocked_by_crashed_paths
and self._create_context_valid
)
save_enabled = not self._blocked_by_crashed_paths
self._save_btn.setEnabled(save_enabled)
self._reset_btn.setEnabled(True) self._reset_btn.setEnabled(True)
if enabled: self._stop_btn.setEnabled(False)
self._stop_btn.setEnabled(False) self._validate_btn.setEnabled(enabled)
self._validate_btn.setEnabled(True) self._publish_btn.setEnabled(enabled)
self._publish_btn.setEnabled(True)
else:
self._stop_btn.setEnabled(enabled)
self._validate_btn.setEnabled(enabled)
self._publish_btn.setEnabled(enabled)
def _on_publish_reset(self): def _on_publish_reset(self):
self._create_tab.setEnabled(True) self._create_tab.setEnabled(True)
self._set_comment_input_visiblity(True) self._set_comment_input_visiblity(True)
self._set_publish_overlay_visibility(False) self._set_publish_overlay_visibility(False)
self._set_publish_visibility(False) self._set_publish_visibility(False)
self._update_publish_details_widget()
report_data = self._controller.get_publish_report()
blocked = bool(report_data["blocking_crashed_paths"])
self._set_blocked(blocked)
self._update_publish_details_widget(report_data=report_data)
def _on_controller_reset(self): def _on_controller_reset(self):
self._update_publish_details_widget(force=True)
self._first_reset, first_reset = False, self._first_reset self._first_reset, first_reset = False, self._first_reset
if self._tab_on_reset is not None: if self._tab_on_reset is not None:
self._tab_on_reset, new_tab = None, self._tab_on_reset self._tab_on_reset, new_tab = None, self._tab_on_reset
@ -952,7 +998,7 @@ class PublisherWindow(QtWidgets.QDialog):
def _validate_create_instances(self): def _validate_create_instances(self):
if not self._controller.is_host_valid(): if not self._controller.is_host_valid():
self._set_footer_enabled(True) self._set_create_context_valid(True)
return return
active_instances_by_id = { active_instances_by_id = {
@ -973,7 +1019,7 @@ class PublisherWindow(QtWidgets.QDialog):
if all_valid is None: if all_valid is None:
all_valid = True all_valid = True
self._set_footer_enabled(bool(all_valid)) self._set_create_context_valid(bool(all_valid))
def _on_create_model_reset(self): def _on_create_model_reset(self):
self._validate_create_instances() self._validate_create_instances()

View file

@ -356,6 +356,27 @@ class CustomStagingDirProfileModel(BaseSettingsModel):
) )
class DiscoverValidationModel(BaseSettingsModel):
"""Strictly validate publish plugins discovery.
Artist won't be able to publish if path to publish plugin fails to be
imported.
"""
_isGroup = True
enabled: bool = SettingsField(
False,
description="Enable strict mode of plugins discovery",
)
ignore_paths: list[str] = SettingsField(
default_factory=list,
title="Ignored paths (regex)",
description=(
"Paths that do match regex will be skipped in validation."
),
)
class PublishToolModel(BaseSettingsModel): class PublishToolModel(BaseSettingsModel):
template_name_profiles: list[PublishTemplateNameProfile] = SettingsField( template_name_profiles: list[PublishTemplateNameProfile] = SettingsField(
default_factory=list, default_factory=list,
@ -373,6 +394,10 @@ class PublishToolModel(BaseSettingsModel):
title="Custom Staging Dir Profiles" title="Custom Staging Dir Profiles"
) )
) )
discover_validation: DiscoverValidationModel = SettingsField(
default_factory=DiscoverValidationModel,
title="Validate plugins discovery",
)
comment_minimum_required_chars: int = SettingsField( comment_minimum_required_chars: int = SettingsField(
0, 0,
title="Publish comment minimum required characters", title="Publish comment minimum required characters",
@ -705,6 +730,10 @@ DEFAULT_TOOLS_VALUES = {
"template_name": "simpleUnrealTextureHero" "template_name": "simpleUnrealTextureHero"
} }
], ],
"discover_validation": {
"enabled": False,
"ignore_paths": [],
},
"comment_minimum_required_chars": 0, "comment_minimum_required_chars": 0,
} }
} }