From f1aed92d795b92ac2ec15a52e91a9ae37535da87 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 5 Jul 2024 15:21:41 +0200 Subject: [PATCH 001/203] Enable asset contributions to write AYON Entity URIs --- .../publish/extract_usd_layer_contributions.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index 162b7d3d41..58cffcd59a 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -561,6 +561,8 @@ class ExtractUSDLayerContribution(publish.Extractor): label = "Extract USD Layer Contributions (Asset/Shot)" order = pyblish.api.ExtractorOrder + 0.45 + use_ayon_entity_uri = True + def process(self, instance): folder_path = instance.data["folderPath"] @@ -578,7 +580,8 @@ class ExtractUSDLayerContribution(publish.Extractor): contributions = instance.data.get("usd_contributions", []) for contribution in sorted(contributions, key=attrgetter("order")): - path = get_instance_uri_path(contribution.instance) + path = get_instance_uri_path(contribution.instance, + resolve=not self.use_ayon_entity_uri) if isinstance(contribution, VariantContribution): # Add contribution as a reference inside a variant self.log.debug(f"Adding variant: {contribution}") @@ -720,6 +723,8 @@ class ExtractUSDAssetContribution(publish.Extractor): label = "Extract USD Asset/Shot Contributions" order = ExtractUSDLayerContribution.order + 0.01 + use_ayon_entity_uri = True + def process(self, instance): folder_path = instance.data["folderPath"] @@ -795,15 +800,15 @@ class ExtractUSDAssetContribution(publish.Extractor): layer_id = layer_instance.data["usd_layer_id"] order = layer_instance.data["usd_layer_order"] - path = get_instance_uri_path(instance=layer_instance) + path = get_instance_uri_path(instance=layer_instance, + resolve=not self.use_ayon_entity_uri) add_ordered_sublayer(target_layer, contribution_path=path, layer_id=layer_id, order=order, # Add the sdf argument metadata which allows # us to later detect whether another path - # has the same layer id, so we can replace it - # it. + # has the same layer id, so we can replace it. add_sdf_arguments_metadata=True) # Save the file From 104e03b13d2185b97ac926992abb4f13ac9756b8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 5 Jul 2024 15:59:55 +0200 Subject: [PATCH 002/203] Add toggle to settings --- server/settings/publish_plugins.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 36bb3f7340..930fa9d0a3 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -84,6 +84,17 @@ class CollectUSDLayerContributionsModel(BaseSettingsModel): return value +class AyonEntityURIModel(BaseSettingsModel): + use_ayon_entity_uri: bool = SettingsField( + title="Use AYON Entity URI", + description=( + "When enabled the USD paths written using the contribution " + "workflow will use ayon entity URIs instead of resolved published " + "paths. You can only load these if you use the AYON USD Resolver." + ) + ) + + class PluginStateByHostModelProfile(BaseSettingsModel): _layout = "expanded" # Filtering @@ -857,6 +868,14 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=ExtractBurninModel, title="Extract Burnin" ) + ExtractUSDAssetContribution: AyonEntityURIModel = SettingsField( + default_factory=AyonEntityURIModel, + title="Extract USD Asset Contribution", + ) + ExtractUSDLayerContribution: AyonEntityURIModel = SettingsField( + default_factory=AyonEntityURIModel, + title="Extract USD Layer Contribution", + ) PreIntegrateThumbnails: PreIntegrateThumbnailsModel = SettingsField( default_factory=PreIntegrateThumbnailsModel, title="Override Integrate Thumbnail Representations" @@ -1161,6 +1180,12 @@ DEFAULT_PUBLISH_VALUES = { } ] }, + "ExtractUSDAssetContribution": { + "use_ayon_entity_uri": True, + }, + "ExtractUSDLayerContribution": { + "use_ayon_entity_uri": True, + }, "PreIntegrateThumbnails": { "enabled": True, "integrate_profiles": [] From 86066660e854011d839a2412f1868b32a0e3b8cf Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 5 Jul 2024 16:00:15 +0200 Subject: [PATCH 003/203] Change default state to False --- server/settings/publish_plugins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 930fa9d0a3..c1c6bc42a5 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -1181,10 +1181,10 @@ DEFAULT_PUBLISH_VALUES = { ] }, "ExtractUSDAssetContribution": { - "use_ayon_entity_uri": True, + "use_ayon_entity_uri": False, }, "ExtractUSDLayerContribution": { - "use_ayon_entity_uri": True, + "use_ayon_entity_uri": False, }, "PreIntegrateThumbnails": { "enabled": True, From ec419f556906c4e3a804b02b1c139cb82e1b91ee Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Jul 2024 14:12:26 +0200 Subject: [PATCH 004/203] added 'ensure_is_process_ready' method to addon base class --- client/ayon_core/addon/base.py | 72 ++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index b9ecff4233..c85f38b32a 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -67,6 +67,56 @@ MOVED_ADDON_MILESTONE_VERSIONS = { } +class ProcessPreparationError(Exception): + """Exception that can be used when process preparation failed. + + The message is showed to user (either as UI dialog or printed). If + different error is raised a "generic" error message is showed to user + with option to copy error message to clipboard. + + """ + pass + + +class ProcessContext: + """Context of child process. + + Notes: + This class is used to pass context to child process. It can be used + to use different behavior of addon based on information in + the context. + The context can be enhanced in future versions. + + Args: + addon_name (Optional[str]): Addon name which triggered process. + addon_version (Optional[str]): Addon version which triggered process. + project_name (Optional[str]): Project name. Can be filled in case + process is triggered for specific project. Some addons can have + different behavior based on project. + headless (Optional[bool]): Is process running in headless mode. + + """ + def __init__( + self, + addon_name: Optional[str] = None, + addon_version: Optional[str] = None, + project_name: Optional[str] = None, + headless: Optional[bool] = None, + **kwargs, + ): + if headless is None: + # TODO use lib function to get headless mode + headless = os.getenv("AYON_HEADLESS_MODE") == "1" + self.addon_name: Optional[str] = addon_name + self.addon_version: Optional[str] = addon_version + self.project_name: Optional[str] = project_name + self.headless: bool = headless + + if kwargs: + unknown_keys = ", ".join([f'"{key}"' for key in kwargs.keys()]) + print(f"Unknown keys in ProcessContext: {unknown_keys}") + + # Inherit from `object` for Python 2 hosts class _ModuleClass(object): """Fake module class for storing AYON addons. @@ -588,7 +638,29 @@ class AYONAddon(object): Args: enabled_addons (list[AYONAddon]): Addons that are enabled. """ + pass + def ensure_is_process_ready( + self, process_context: ProcessContext + ): + """Make sure addon is prepared for a process. + + This method is called when some action makes sure that addon has set + necessary data. For example if user should be logged in + and filled credentials in environment variables this method should + ask user for credentials. + + Implementation of this method is optional. + + Note: + The logic can be similar to logic in tray, but in tray not require + to be logged in. + + Args: + process_context (ProcessContext): Context of child + process. + + """ pass def get_global_environments(self): From 9de1f236f16166f51fdfed836284cdf1fd0ed3ed Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Jul 2024 14:12:52 +0200 Subject: [PATCH 005/203] prepare utils to run process preparation --- client/ayon_core/addon/__init__.py | 8 +++ client/ayon_core/addon/utils.py | 112 +++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 client/ayon_core/addon/utils.py diff --git a/client/ayon_core/addon/__init__.py b/client/ayon_core/addon/__init__.py index fe8865c730..27af2955ed 100644 --- a/client/ayon_core/addon/__init__.py +++ b/client/ayon_core/addon/__init__.py @@ -9,12 +9,17 @@ from .interfaces import ( ) from .base import ( + ProcessPreparationError, AYONAddon, AddonsManager, TrayAddonsManager, load_addons, ) +from .utils import ( + ensure_addons_are_process_ready, +) + __all__ = ( "click_wrap", @@ -25,8 +30,11 @@ __all__ = ( "ITrayService", "IHostAddon", + "ProcessPreparationError", "AYONAddon", "AddonsManager", "TrayAddonsManager", "load_addons", + + "ensure_addons_are_process_ready", ) diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py new file mode 100644 index 0000000000..092ce502af --- /dev/null +++ b/client/ayon_core/addon/utils.py @@ -0,0 +1,112 @@ +import os +import sys +import contextlib +import tempfile +import json +from io import StringIO +from typing import Optional + +from ayon_core.lib import run_ayon_launcher_process + +from .base import AddonsManager, ProcessContext, ProcessPreparationError + + +def _handle_error( + process_context: ProcessContext, + message: str, + detail: Optional[str], +): + """Handle error in process ready preparation. + + Shows UI to inform user about the error, or prints the message + to stdout if running in headless mode. + + Args: + process_context (ProcessContext): The context in which the + error occurred. + message (str): The message to show. + detail (Optional[str]): The detail message to show (usually + traceback). + + """ + if process_context.headless: + print(detail) + print(f"{10*'*'}\n{message}\n{10*'*'}") + return + + current_dir = os.path.dirname(os.path.abspath(__file__)) + script_path = os.path.join(current_dir, "ui", "process_ready_error.py") + with tempfile.NamedTemporaryFile("w", delete=False) as tmp: + tmp_path = tmp.name + + try: + with open(tmp_path, "w") as stream: + json.dump( + {"message": message, "detail": detail}, + stream + ) + + run_ayon_launcher_process( + "run", script_path, tmp_path + ) + + finally: + os.remove(tmp_path) + + +def ensure_addons_are_process_ready( + process_context: ProcessContext, + addons_manager: Optional[AddonsManager] = None, + exit_on_failure: bool = True, +): + """Ensure all enabled addons are ready to be used in the given context. + + Call this method only in AYON launcher process and as first thing + to avoid possible clashes with preparation. For example 'QApplication' + should not be created. + + Args: + process_context (ProcessContext): The context in which the + addons should be prepared. + addons_manager (Optional[AddonsManager]): The addons + manager to use. If not provided, a new one will be created. + exit_on_failure (bool, optional): If True, the process will exit + if an error occurs. Defaults to True. + + Returns: + Optional[Exception]: The exception that occurred during the + preparation, if any. + + """ + if addons_manager is None: + addons_manager = AddonsManager() + + exc = None + message = None + failed = False + use_detail = False + with contextlib.redirect_stdout(StringIO()) as stdout: + for addon in addons_manager.get_enabled_addons(): + failed = True + try: + addon.ensure_is_process_ready(process_context) + failed = False + except ProcessPreparationError as exc: + message = str(exc) + + except BaseException as exc: + message = "An unexpected error occurred." + use_detail = True + + if failed: + break + + if failed: + detail = None + if use_detail: + detail = stdout.get_value() + + _handle_error(process_context, message, detail) + if not exit_on_failure: + return exc + sys.exit(1) From 01983de888db8118437c6feb104b576cbffc362f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Jul 2024 14:13:31 +0200 Subject: [PATCH 006/203] implemented dialog showing error --- .../ayon_core/addon/ui/process_ready_error.py | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 client/ayon_core/addon/ui/process_ready_error.py diff --git a/client/ayon_core/addon/ui/process_ready_error.py b/client/ayon_core/addon/ui/process_ready_error.py new file mode 100644 index 0000000000..78e2b9b9bb --- /dev/null +++ b/client/ayon_core/addon/ui/process_ready_error.py @@ -0,0 +1,132 @@ +import sys +import json +from typing import Optional + +from qtpy import QtWidgets, QtCore + +from ayon_core.style import load_stylesheet +from ayon_core.tools.utils import get_ayon_qt_app + + +class DetailDialog(QtWidgets.QDialog): + def __init__(self, detail, parent): + super().__init__(parent) + + self.setWindowTitle("Detail") + + detail_input = QtWidgets.QPlainTextEdit(self) + detail_input.setPlainText(detail) + detail_input.setReadOnly(True) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(detail_input, 1) + + def showEvent(self, event): + self.resize(600, 400) + super().showEvent(event) + + +class ErrorDialog(QtWidgets.QDialog): + def __init__( + self, + message: str, + detail: Optional[str], + parent: Optional[QtWidgets.QWidget] = None + ): + super().__init__(parent) + + self.setWindowTitle("Preparation failed") + self.setWindowFlags( + self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint + ) + + message_label = QtWidgets.QLabel(self) + + detail_wrapper = QtWidgets.QWidget(self) + + detail_label = QtWidgets.QLabel(detail_wrapper) + + detail_layout = QtWidgets.QVBoxLayout(detail_wrapper) + detail_layout.setContentsMargins(0, 0, 0, 0) + detail_layout.addWidget(detail_label) + + btns_wrapper = QtWidgets.QWidget(self) + + copy_detail_btn = QtWidgets.QPushButton("Copy detail", btns_wrapper) + show_detail_btn = QtWidgets.QPushButton("Show detail", btns_wrapper) + confirm_btn = QtWidgets.QPushButton("Close", btns_wrapper) + + btns_layout = QtWidgets.QHBoxLayout(btns_wrapper) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(copy_detail_btn, 0) + btns_layout.addWidget(show_detail_btn, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(confirm_btn, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(message_label, 0) + layout.addWidget(detail_wrapper, 1) + layout.addWidget(btns_wrapper, 0) + + copy_detail_btn.clicked.connect(self._on_copy_clicked) + show_detail_btn.clicked.connect(self._on_show_detail_clicked) + confirm_btn.clicked.connect(self._on_confirm_clicked) + + self._message_label = message_label + self._detail_wrapper = detail_wrapper + self._detail_label = detail_label + + self._copy_detail_btn = copy_detail_btn + self._show_detail_btn = show_detail_btn + self._confirm_btn = confirm_btn + + self._detail_dialog = None + + self._detail = detail + + self.set_message(message, detail) + + def showEvent(self, event): + self.setStyleSheet(load_stylesheet()) + self.resize(320, 140) + super().showEvent(event) + + def set_message(self, message, detail): + self._message_label.setText(message) + self._detail = detail + + for widget in ( + self._copy_detail_btn, + self._show_detail_btn, + ): + widget.setVisible(bool(detail)) + + def _on_copy_clicked(self): + if self._detail: + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(self._detail) + + def _on_show_detail_clicked(self): + if self._detail_dialog is None: + self._detail_dialog = DetailDialog(self._detail, self) + self._detail_dialog.show() + + def _on_confirm_clicked(self): + self.accept() + + +def main(): + json_path = sys.argv[-1] + with open(json_path, "r") as stream: + data = json.load(stream) + + message = data["message"] + detail = data["detail"] + app = get_ayon_qt_app() + dialog = ErrorDialog(message, detail) + dialog.show() + app.exec_() + + +if __name__ == "__main__": + main() From db069448df569a165fd3521a4a4353d83bf4862d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Jul 2024 16:59:43 +0200 Subject: [PATCH 007/203] python module tools do not support python 2 anymore --- client/ayon_core/lib/python_module_tools.py | 137 ++++++-------------- 1 file changed, 38 insertions(+), 99 deletions(-) diff --git a/client/ayon_core/lib/python_module_tools.py b/client/ayon_core/lib/python_module_tools.py index cb6e4c14c4..d146e069a9 100644 --- a/client/ayon_core/lib/python_module_tools.py +++ b/client/ayon_core/lib/python_module_tools.py @@ -5,43 +5,30 @@ import importlib import inspect import logging -import six - log = logging.getLogger(__name__) def import_filepath(filepath, module_name=None): """Import python file as python module. - Python 2 and Python 3 compatibility. - Args: - filepath(str): Path to python file. - module_name(str): Name of loaded module. Only for Python 3. By default + filepath (str): Path to python file. + module_name (str): Name of loaded module. Only for Python 3. By default is filled with filename of filepath. + """ if module_name is None: module_name = os.path.splitext(os.path.basename(filepath))[0] - # Make sure it is not 'unicode' in Python 2 - module_name = str(module_name) - # Prepare module object where content of file will be parsed module = types.ModuleType(module_name) module.__file__ = filepath - if six.PY3: - # Use loader so module has full specs - module_loader = importlib.machinery.SourceFileLoader( - module_name, filepath - ) - module_loader.exec_module(module) - else: - # Execute module code and store content to module - with open(filepath) as _stream: - # Execute content and store it to module object - six.exec_(_stream.read(), module.__dict__) - + # Use loader so module has full specs + module_loader = importlib.machinery.SourceFileLoader( + module_name, filepath + ) + module_loader.exec_module(module) return module @@ -139,35 +126,31 @@ def classes_from_module(superclass, module): return classes -def _import_module_from_dirpath_py2(dirpath, module_name, dst_module_name): - """Import passed dirpath as python module using `imp`.""" +def import_module_from_dirpath(dirpath, folder_name, dst_module_name=None): + """Import passed directory as a python module. + + Imported module can be assigned as a child attribute of already loaded + module from `sys.modules` if has support of `setattr`. That is not default + behavior of python modules so parent module must be a custom module with + that ability. + + It is not possible to reimport already cached module. If you need to + reimport module you have to remove it from caches manually. + + Args: + dirpath (str): Parent directory path of loaded folder. + folder_name (str): Folder name which should be imported inside passed + directory. + dst_module_name (str): Parent module name under which can be loaded + module added. + + """ + # Import passed dirpath as python module if dst_module_name: - full_module_name = "{}.{}".format(dst_module_name, module_name) + full_module_name = "{}.{}".format(dst_module_name, folder_name) dst_module = sys.modules[dst_module_name] else: - full_module_name = module_name - dst_module = None - - if full_module_name in sys.modules: - return sys.modules[full_module_name] - - import imp - - fp, pathname, description = imp.find_module(module_name, [dirpath]) - module = imp.load_module(full_module_name, fp, pathname, description) - if dst_module is not None: - setattr(dst_module, module_name, module) - - return module - - -def _import_module_from_dirpath_py3(dirpath, module_name, dst_module_name): - """Import passed dirpath as python module using Python 3 modules.""" - if dst_module_name: - full_module_name = "{}.{}".format(dst_module_name, module_name) - dst_module = sys.modules[dst_module_name] - else: - full_module_name = module_name + full_module_name = folder_name dst_module = None # Skip import if is already imported @@ -191,7 +174,7 @@ def _import_module_from_dirpath_py3(dirpath, module_name, dst_module_name): # Store module to destination module and `sys.modules` # WARNING this mus be done before module execution if dst_module is not None: - setattr(dst_module, module_name, module) + setattr(dst_module, folder_name, module) sys.modules[full_module_name] = module @@ -201,37 +184,6 @@ def _import_module_from_dirpath_py3(dirpath, module_name, dst_module_name): return module -def import_module_from_dirpath(dirpath, folder_name, dst_module_name=None): - """Import passed directory as a python module. - - Python 2 and 3 compatible. - - Imported module can be assigned as a child attribute of already loaded - module from `sys.modules` if has support of `setattr`. That is not default - behavior of python modules so parent module must be a custom module with - that ability. - - It is not possible to reimport already cached module. If you need to - reimport module you have to remove it from caches manually. - - Args: - dirpath(str): Parent directory path of loaded folder. - folder_name(str): Folder name which should be imported inside passed - directory. - dst_module_name(str): Parent module name under which can be loaded - module added. - """ - if six.PY3: - module = _import_module_from_dirpath_py3( - dirpath, folder_name, dst_module_name - ) - else: - module = _import_module_from_dirpath_py2( - dirpath, folder_name, dst_module_name - ) - return module - - def is_func_signature_supported(func, *args, **kwargs): """Check if a function signature supports passed args and kwargs. @@ -275,25 +227,12 @@ def is_func_signature_supported(func, *args, **kwargs): Returns: bool: Function can pass in arguments. + """ - - if hasattr(inspect, "signature"): - # Python 3 using 'Signature' object where we try to bind arg - # or kwarg. Using signature is recommended approach based on - # documentation. - sig = inspect.signature(func) - try: - sig.bind(*args, **kwargs) - return True - except TypeError: - pass - - else: - # In Python 2 'signature' is not available so 'getcallargs' is used - # - 'getcallargs' is marked as deprecated since Python 3.0 - try: - inspect.getcallargs(func, *args, **kwargs) - return True - except TypeError: - pass + sig = inspect.signature(func) + try: + sig.bind(*args, **kwargs) + return True + except TypeError: + pass return False From 6e7d6201c969b8f3147db9f724efd03c836cf17a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Jul 2024 17:00:15 +0200 Subject: [PATCH 008/203] use WeakMethod from weakref --- client/ayon_core/lib/events.py | 3 +- client/ayon_core/lib/python_2_comp.py | 53 +++++++-------------------- 2 files changed, 14 insertions(+), 42 deletions(-) diff --git a/client/ayon_core/lib/events.py b/client/ayon_core/lib/events.py index 774790b80a..9a3d1edfd4 100644 --- a/client/ayon_core/lib/events.py +++ b/client/ayon_core/lib/events.py @@ -8,7 +8,6 @@ import logging import weakref from uuid import uuid4 -from .python_2_comp import WeakMethod from .python_module_tools import is_func_signature_supported @@ -18,7 +17,7 @@ class MissingEventSystem(Exception): def _get_func_ref(func): if inspect.ismethod(func): - return WeakMethod(func) + return weakref.WeakMethod(func) return weakref.ref(func) diff --git a/client/ayon_core/lib/python_2_comp.py b/client/ayon_core/lib/python_2_comp.py index 091c51a6f6..900db59062 100644 --- a/client/ayon_core/lib/python_2_comp.py +++ b/client/ayon_core/lib/python_2_comp.py @@ -1,44 +1,17 @@ +# Deprecated file +# - the file container 'WeakMethod' implementation for Python 2 which is not +# needed anymore. +import warnings import weakref -WeakMethod = getattr(weakref, "WeakMethod", None) +WeakMethod = weakref.WeakMethod -if WeakMethod is None: - class _WeakCallable: - def __init__(self, obj, func): - self.im_self = obj - self.im_func = func - - def __call__(self, *args, **kws): - if self.im_self is None: - return self.im_func(*args, **kws) - else: - return self.im_func(self.im_self, *args, **kws) - - - class WeakMethod: - """ Wraps a function or, more importantly, a bound method in - a way that allows a bound method's object to be GCed, while - providing the same interface as a normal weak reference. """ - - def __init__(self, fn): - try: - self._obj = weakref.ref(fn.im_self) - self._meth = fn.im_func - except AttributeError: - # It's not a bound method - self._obj = None - self._meth = fn - - def __call__(self): - if self._dead(): - return None - return _WeakCallable(self._getobj(), self._meth) - - def _dead(self): - return self._obj is not None and self._obj() is None - - def _getobj(self): - if self._obj is None: - return None - return self._obj() +warnings.warn( + ( + "'ayon_core.lib.python_2_comp' is deprecated." + "Please use 'weakref.WeakMethod'." + ), + DeprecationWarning, + stacklevel=2 +) From e731dd7064a52035cfec4f8d233344507e2b3a6c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Jul 2024 17:00:30 +0200 Subject: [PATCH 009/203] don't handle py2 vs. py3 imports --- client/ayon_core/lib/local_settings.py | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 54432265d9..00e551d119 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -3,26 +3,10 @@ import os import json import platform +import configparser from datetime import datetime from abc import ABC, abstractmethod - -# disable lru cache in Python 2 -try: - from functools import lru_cache -except ImportError: - def lru_cache(maxsize): - def max_size(func): - def wrapper(*args, **kwargs): - value = func(*args, **kwargs) - return value - return wrapper - return max_size - -# ConfigParser was renamed in python3 to configparser -try: - import configparser -except ImportError: - import ConfigParser as configparser +from functools import lru_cache import appdirs import ayon_api From 11641c996e880d555e12d6320462d8ed11350b68 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Jul 2024 17:05:22 +0200 Subject: [PATCH 010/203] do not inherit from object by default --- client/ayon_core/lib/attribute_definitions.py | 2 +- client/ayon_core/lib/events.py | 7 +++---- client/ayon_core/lib/file_transaction.py | 2 +- client/ayon_core/lib/path_templates.py | 4 ++-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 360d47ea17..7e022f6dba 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -577,7 +577,7 @@ class BoolDef(AbstractAttrDef): return self.default -class FileDefItem(object): +class FileDefItem: def __init__( self, directory, filenames, frames=None, template=None ): diff --git a/client/ayon_core/lib/events.py b/client/ayon_core/lib/events.py index 9a3d1edfd4..2601bc1cf4 100644 --- a/client/ayon_core/lib/events.py +++ b/client/ayon_core/lib/events.py @@ -122,7 +122,7 @@ class weakref_partial: ) -class EventCallback(object): +class EventCallback: """Callback registered to a topic. The callback function is registered to a topic. Topic is a string which @@ -379,8 +379,7 @@ class EventCallback(object): self._partial_func = None -# Inherit from 'object' for Python 2 hosts -class Event(object): +class Event: """Base event object. Can be used for any event because is not specific. Only required argument @@ -487,7 +486,7 @@ class Event(object): return obj -class EventSystem(object): +class EventSystem: """Encapsulate event handling into an object. System wraps registered callbacks and triggered events into single object, diff --git a/client/ayon_core/lib/file_transaction.py b/client/ayon_core/lib/file_transaction.py index 47b10dd994..a502403958 100644 --- a/client/ayon_core/lib/file_transaction.py +++ b/client/ayon_core/lib/file_transaction.py @@ -22,7 +22,7 @@ class DuplicateDestinationError(ValueError): """ -class FileTransaction(object): +class FileTransaction: """File transaction with rollback options. The file transaction is a three-step process. diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index 01a6985a25..ccd36796c1 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -38,7 +38,7 @@ class TemplateUnsolved(Exception): ) -class StringTemplate(object): +class StringTemplate: """String that can be formatted.""" def __init__(self, template): if not isinstance(template, str): @@ -410,7 +410,7 @@ class TemplatePartResult: self._invalid_types[key] = type(value) -class FormatObject(object): +class FormatObject: """Object that can be used for formatting. This is base that is valid for to be used in 'StringTemplate' value. From 03317229fa0073f8624357ac04ad1706bcff574d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Jul 2024 17:10:35 +0200 Subject: [PATCH 011/203] add missing import --- client/ayon_core/addon/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index c85f38b32a..4c27cca9d7 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -10,6 +10,7 @@ import threading import collections from uuid import uuid4 from abc import ABCMeta, abstractmethod +from typing import Optional import six import appdirs From c86613922f035162ed801c9dd95c8698022112a0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Jul 2024 15:15:05 +0200 Subject: [PATCH 012/203] better handling of the error --- client/ayon_core/addon/__init__.py | 2 + client/ayon_core/addon/utils.py | 63 ++++++++++++++++++------------ 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/client/ayon_core/addon/__init__.py b/client/ayon_core/addon/__init__.py index 27af2955ed..44f495b2a1 100644 --- a/client/ayon_core/addon/__init__.py +++ b/client/ayon_core/addon/__init__.py @@ -10,6 +10,7 @@ from .interfaces import ( from .base import ( ProcessPreparationError, + ProcessContext, AYONAddon, AddonsManager, TrayAddonsManager, @@ -31,6 +32,7 @@ __all__ = ( "IHostAddon", "ProcessPreparationError", + "ProcessContext", "AYONAddon", "AddonsManager", "TrayAddonsManager", diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index 092ce502af..77dc5a26f6 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -3,6 +3,7 @@ import sys import contextlib import tempfile import json +import traceback from io import StringIO from typing import Optional @@ -21,6 +22,10 @@ def _handle_error( Shows UI to inform user about the error, or prints the message to stdout if running in headless mode. + Todos: + Make this functionality with the dialog as unified function, so it can + be used elsewhere. + Args: process_context (ProcessContext): The context in which the error occurred. @@ -30,7 +35,8 @@ def _handle_error( """ if process_context.headless: - print(detail) + if detail: + print(detail) print(f"{10*'*'}\n{message}\n{10*'*'}") return @@ -38,16 +44,15 @@ def _handle_error( script_path = os.path.join(current_dir, "ui", "process_ready_error.py") with tempfile.NamedTemporaryFile("w", delete=False) as tmp: tmp_path = tmp.name + json.dump( + {"message": message, "detail": detail}, + tmp.file + ) try: - with open(tmp_path, "w") as stream: - json.dump( - {"message": message, "detail": detail}, - stream - ) - run_ayon_launcher_process( - "run", script_path, tmp_path + "run", script_path, tmp_path, + creationflags=0 ) finally: @@ -85,27 +90,35 @@ def ensure_addons_are_process_ready( message = None failed = False use_detail = False - with contextlib.redirect_stdout(StringIO()) as stdout: - for addon in addons_manager.get_enabled_addons(): - failed = True - try: - addon.ensure_is_process_ready(process_context) - failed = False - except ProcessPreparationError as exc: - message = str(exc) + output = StringIO() + with contextlib.redirect_stdout(output): + with contextlib.redirect_stderr(output): + for addon in addons_manager.get_enabled_addons(): + failed = True + try: + addon.ensure_is_process_ready(process_context) + failed = False + except ProcessPreparationError as exc: + message = str(exc) + print(f"Addon preparation failed: '{addon.name}'") + print(message) - except BaseException as exc: - message = "An unexpected error occurred." - use_detail = True + except BaseException as exc: + message = "An unexpected error occurred." + print(f"Addon preparation failed: '{addon.name}'") + print(message) + # Print the traceback so it is in the output + traceback.print_exception(*sys.exc_info()) + use_detail = True - if failed: - break + if failed: + break + output_str = output.getvalue() + # Print stdout/stderr to console as it was redirected + print(output_str) if failed: - detail = None - if use_detail: - detail = stdout.get_value() - + detail = output_str if use_detail else None _handle_error(process_context, message, detail) if not exit_on_failure: return exc From 54f22696a964154c9a1a113cc24bd486ed7ec58d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Jul 2024 15:27:39 +0200 Subject: [PATCH 013/203] 'run_ayon_launcher_process' can add sys path to python path --- client/ayon_core/lib/execute.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index e89c8f22ee..f61d892324 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -179,7 +179,7 @@ def clean_envs_for_ayon_process(env=None): return env -def run_ayon_launcher_process(*args, **kwargs): +def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs): """Execute AYON process with passed arguments and wait. Wrapper for 'run_process' which prepends AYON executable arguments @@ -209,6 +209,14 @@ def run_ayon_launcher_process(*args, **kwargs): # - fill more if you find more env = clean_envs_for_ayon_process(os.environ) + if add_sys_paths: + new_pythonpath = list(sys.path) + for path in env.get("PYTHONPATH", "").split(os.pathsep): + if not path or path in new_pythonpath: + continue + new_pythonpath.append(path) + env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) + return run_subprocess(args, env=env, **kwargs) From 909e88baa799dc6f226c8bca746fffa697a0fa1a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Jul 2024 15:28:09 +0200 Subject: [PATCH 014/203] run the UI by adding sys path and skipping bootstrap --- client/ayon_core/addon/utils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index 77dc5a26f6..3ad112b562 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -51,8 +51,11 @@ def _handle_error( try: run_ayon_launcher_process( - "run", script_path, tmp_path, - creationflags=0 + "--skip-bootstrap", + script_path, + tmp_path, + add_sys_paths=True, + creationflags=0, ) finally: From 4fefd1e8e5270e77b9a66778373873e99513c526 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Jul 2024 15:53:50 +0200 Subject: [PATCH 015/203] Fix grammar Co-authored-by: Roy Nieterau --- client/ayon_core/addon/base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 4c27cca9d7..0c173ee6f6 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -71,8 +71,8 @@ MOVED_ADDON_MILESTONE_VERSIONS = { class ProcessPreparationError(Exception): """Exception that can be used when process preparation failed. - The message is showed to user (either as UI dialog or printed). If - different error is raised a "generic" error message is showed to user + The message is shown to user (either as UI dialog or printed). If + different error is raised a "generic" error message is shown to user with option to copy error message to clipboard. """ @@ -654,7 +654,7 @@ class AYONAddon(object): Implementation of this method is optional. Note: - The logic can be similar to logic in tray, but in tray not require + The logic can be similar to logic in tray, but tray does not require to be logged in. Args: From 49a0ccc0d5506f49335c886ca5f56dc9ca2de7ec Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 18 Jul 2024 23:28:17 +0200 Subject: [PATCH 016/203] Allow passing CollectSceneVersion but with a logged error so validators can catch a nicer error report instead --- client/ayon_core/plugins/publish/collect_scene_version.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_scene_version.py b/client/ayon_core/plugins/publish/collect_scene_version.py index ea4823d62a..8d643062bc 100644 --- a/client/ayon_core/plugins/publish/collect_scene_version.py +++ b/client/ayon_core/plugins/publish/collect_scene_version.py @@ -47,8 +47,9 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): return if not context.data.get('currentFile'): - raise KnownPublishError("Cannot get current workfile path. " - "Make sure your scene is saved.") + self.log.error("Cannot get current workfile path. " + "Make sure your scene is saved.") + return filename = os.path.basename(context.data.get('currentFile')) From 412cc595cce900d1697146e30fdc3fb3203881f3 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 18 Jul 2024 23:31:29 +0200 Subject: [PATCH 017/203] Use regular `dict.get()` access on `instance.data.get("version")` for less confusion --- .../ayon_core/plugins/publish/collect_anatomy_instance_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py index b6636696c1..97bc47dc11 100644 --- a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py +++ b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py @@ -321,7 +321,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): use_context_version = instance.data["followWorkfileVersion"] if use_context_version: - version_number = context.data("version") + version_number = context.data.get("version") # Even if 'follow_workfile_version' is enabled, it may not be set # because workfile version was not collected to 'context.data' From 30c32b8fa7a0dce477206ade54ecfa06940b8da4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 19 Jul 2024 00:23:12 +0200 Subject: [PATCH 018/203] Improve Validate File Saved report and provide repair actions --- .../plugins/publish/validate_file_saved.py | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/validate_file_saved.py b/client/ayon_core/plugins/publish/validate_file_saved.py index d459ba7ed4..976f78aa2f 100644 --- a/client/ayon_core/plugins/publish/validate_file_saved.py +++ b/client/ayon_core/plugins/publish/validate_file_saved.py @@ -1,6 +1,30 @@ +import inspect + import pyblish.api from ayon_core.pipeline.publish import PublishValidationError +from ayon_core.tools.utils.host_tools import show_workfiles +from ayon_core.pipeline.context_tools import version_up_current_workfile + + +class SaveByVersionUpAction(pyblish.api.Action): + """Save Workfile.""" + label = "Save Workfile" + on = "failed" + icon = "save" + + def process(self, context, plugin): + version_up_current_workfile() + + +class ShowWorkfilesAction(pyblish.api.Action): + """Save Workfile.""" + label = "Show Workfiles Tool..." + on = "failed" + icon = "files-o" + + def process(self, context, plugin): + show_workfiles() class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): @@ -8,10 +32,26 @@ class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): label = "Validate File Saved" order = pyblish.api.ValidatorOrder - 0.1 - hosts = ["maya", "houdini", "nuke"] + hosts = ["maya", "houdini", "nuke", "fusion"] + actions = [SaveByVersionUpAction, ShowWorkfilesAction] def process(self, context): current_file = context.data["currentFile"] if not current_file: - raise PublishValidationError("File not saved") + raise PublishValidationError( + "File not saved", + title="File not saved", + description=self.get_description()) + + def get_description(self): + return inspect.cleandoc(""" + ### File not saved + + Your workfile must be saved to continue publishing. + + Please save your scene. + + The **Save Workfile** action will save it for you with the first + available workfile version number in your current context. + """) From 6b7ece95e69e753dcefe46061b558b0ab960a606 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 19 Jul 2024 00:25:19 +0200 Subject: [PATCH 019/203] Improve Validate File Saved report and provide repair actions --- client/ayon_core/plugins/publish/validate_file_saved.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/validate_file_saved.py b/client/ayon_core/plugins/publish/validate_file_saved.py index 976f78aa2f..bff835fd33 100644 --- a/client/ayon_core/plugins/publish/validate_file_saved.py +++ b/client/ayon_core/plugins/publish/validate_file_saved.py @@ -40,7 +40,7 @@ class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): current_file = context.data["currentFile"] if not current_file: raise PublishValidationError( - "File not saved", + "Workfile is not saved. Please save your scene to continue.", title="File not saved", description=self.get_description()) @@ -50,8 +50,6 @@ class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): Your workfile must be saved to continue publishing. - Please save your scene. - The **Save Workfile** action will save it for you with the first available workfile version number in your current context. """) From 86b1b4d208261bf987668b7d3655e46c832efaac Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 19 Jul 2024 00:31:12 +0200 Subject: [PATCH 020/203] Improve docstring --- client/ayon_core/plugins/publish/validate_file_saved.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/validate_file_saved.py b/client/ayon_core/plugins/publish/validate_file_saved.py index bff835fd33..e4f009615a 100644 --- a/client/ayon_core/plugins/publish/validate_file_saved.py +++ b/client/ayon_core/plugins/publish/validate_file_saved.py @@ -28,7 +28,11 @@ class ShowWorkfilesAction(pyblish.api.Action): class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): - """File must be saved before publishing""" + """File must be saved before publishing + + This does not validate for unsaved changes. It only validates whether + the current context was able to identify any 'currentFile'. + """ label = "Validate File Saved" order = pyblish.api.ValidatorOrder - 0.1 From 39cc1f2877f125ce47155be52837019f7ba36003 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 19 Jul 2024 12:51:06 +0200 Subject: [PATCH 021/203] base of upload logic in integrate plugin --- client/ayon_core/plugins/publish/integrate.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index a2cf910fa6..8ab48ce9e5 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -6,6 +6,7 @@ import copy import clique import pyblish.api from ayon_api import ( + get_server_api_connection, get_attributes_for_type, get_product_by_name, get_version_by_name, @@ -25,6 +26,7 @@ from ayon_core.lib.file_transaction import ( DuplicateDestinationError ) from ayon_core.pipeline.publish import ( + get_publish_repre_path, KnownPublishError, get_publish_template_name, ) @@ -348,6 +350,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): self.log.debug("{}".format(op_session.to_data())) op_session.commit() + self._upload_reviewable(project_name, version_entity["id"], instance) + # Backwards compatibility used in hero integration. # todo: can we avoid the need to store this? instance.data["published_representations"] = { @@ -984,6 +988,57 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "hash_type": "op3", } + def _upload_reviewable(self, project_name, version_id, instance): + uploaded_labels = set() + ayon_con = get_server_api_connection() + base_headers = ayon_con.get_headers() + endpoint = ( + f"/api/projects/{project_name}/versions/{version_id}/reviewables" + ) + for repre in instance.data["representations"]: + repre_tags = repre.get("tags") or [] + # Ignore representations that are not reviewable + if "webreview" not in repre_tags: + continue + + # exclude representations with are going to be published on farm + if "publish_on_farm" in repre_tags: + continue + + # Skip thumbnails + if repre.get("thumbnail") or "thumbnail" in repre_tags: + continue + + # include only thumbnail representations + repre_path = get_publish_repre_path( + instance, repre, False + ) + repre_ext = os.path.splitext(repre_path)[-1].lower() + + label = repre.get("outputName") + if not label: + # TODO how to label the reviewable if there is no output name? + label = "Main" + + # Make sure label is unique + orig_label = label + idx = 0 + while label in uploaded_labels: + idx += 1 + label = f"{orig_label}_{idx}" + + uploaded_labels.add(label) + + # Upload the reviewable + self.log.info(f"Uploading reviewable '{label}' ...") + headers = copy.deepcopy(base_headers) + ayon_con.upload_file( + f"/api/projects/{project_name}/versions/{version_id}/reviewables", + repre_path, + headers=headers + ) + + def _validate_path_in_project_roots(self, anatomy, file_path): """Checks if 'file_path' starts with any of the roots. From facf2425ecbb01883b6bd80f5b80e153e64c8df2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 19 Jul 2024 12:51:22 +0200 Subject: [PATCH 022/203] mark openpype keys --- client/ayon_core/plugins/publish/integrate.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 8ab48ce9e5..3c29438f78 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -116,18 +116,19 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # the database even if not used by the destination template db_representation_context_keys = [ "project", - "asset", "hierarchy", "folder", "task", "product", - "subset", - "family", "version", "representation", "username", "user", - "output" + "output", + # OpenPype keys - should be removed + "asset", # folder[name] + "subset", # product[name] + "family", # product[type] ] def process(self, instance): From 3268240c6bd002b447ef96c36ff569aafab57ad1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 19 Jul 2024 15:11:42 +0200 Subject: [PATCH 023/203] implemented 'get_media_mime_type' --- client/ayon_core/lib/__init__.py | 2 + client/ayon_core/lib/transcoding.py | 87 +++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 1f864284cd..12c391d867 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -109,6 +109,7 @@ from .transcoding import ( convert_ffprobe_fps_value, convert_ffprobe_fps_to_float, get_rescaled_command_arguments, + get_media_mime_type, ) from .plugin_tools import ( @@ -209,6 +210,7 @@ __all__ = [ "convert_ffprobe_fps_value", "convert_ffprobe_fps_to_float", "get_rescaled_command_arguments", + "get_media_mime_type", "compile_list_of_regexes", diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index bff28614ea..7ccd2ce819 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -6,6 +6,7 @@ import collections import tempfile import subprocess import platform +from typing import Optional import xml.etree.ElementTree @@ -1455,3 +1456,89 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None): input_arg += ":ch={}".format(input_channels_str) return input_arg, channels_arg + + +def _get_media_mime_type_from_ftyp(content): + if content[8:10] == b"qt": + return "video/quicktime" + + if content[8:12] == b"isom": + return "video/mp4" + if content[8:12] in (b"M4V\x20", b"mp42"): + return "video/mp4v" + if content[8:13] in (b"3gpis"): + return "video/3gpp" + # ( + # b"avc1", b"iso2", b"isom", b"mmp4", b"mp41", b"mp71", + # b"msnv", b"ndas", b"ndsc", b"ndsh", b"ndsm", b"ndsp", b"ndss", + # b"ndxc", b"ndxh", b"ndxm", b"ndxp", b"ndxs" + # ) + return None + + +def get_media_mime_type(filepath: str) -> Optional[str]: + """Determine Mime-Type of a file. + + Args: + filepath (str): Path to file. + + Returns: + Optional[str]: Mime type or None if is unknown mime type. + + """ + if not filepath or not os.path.exists(filepath): + return None + + with open(filepath, "rb") as stream: + content = stream.read() + + content_len = len(content) + # Pre-validation (largest definition check) + # - hopefully there cannot be media defined in less than 12 bytes + if content_len < 12: + return None + + # FTYP + if content[4:8] == b"ftyp": + return _get_media_mime_type_from_ftyp(content) + + # BMP + if content[0:2] == b"BM": + return "image/bmp" + + # Tiff + if content[0:2] in (b"MM", b"II"): + return "tiff" + + # PNG + if content[0:4] == b"\211PNG": + return "image/png" + + # SVG + if b'xmlns="http://www.w3.org/2000/svg"' in content: + return "image/svg+xml" + + # JPEG, JFIF or Exif + if ( + content[0:4] == b"\xff\xd8\xff\xdb" + or content[6:10] in (b"JFIF", b"Exif") + ): + return "image/jpeg" + + # Webp + if content[0:4] == b"RIFF" and content[8:12] == b"WEBP": + return "image/webp" + + # Gif + if content[0:6] in (b"GIF87a", b"GIF89a"): + return "gif" + + # Adobe PhotoShop file (8B > Adobe, PS > PhotoShop) + if content[0:4] == b"8BPS": + return "image/vnd.adobe.photoshop" + + # Windows ICO > this might be wild guess as multiple files can start + # with this header + if content[0:4] == b"\x00\x00\x01\x00": + return "image/x-icon" + return None From b87b9cc35493920af6a4da4bfb53bb6cf7002df0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 19 Jul 2024 15:12:05 +0200 Subject: [PATCH 024/203] properly implement upload --- client/ayon_core/plugins/publish/integrate.py | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 3c29438f78..ababbd88fc 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -20,7 +20,7 @@ from ayon_api.operations import ( ) from ayon_api.utils import create_entity_id -from ayon_core.lib import source_hash +from ayon_core.lib import source_hash, get_media_mime_type from ayon_core.lib.file_transaction import ( FileTransaction, DuplicateDestinationError @@ -990,12 +990,10 @@ class IntegrateAsset(pyblish.api.InstancePlugin): } def _upload_reviewable(self, project_name, version_id, instance): - uploaded_labels = set() ayon_con = get_server_api_connection() base_headers = ayon_con.get_headers() - endpoint = ( - f"/api/projects/{project_name}/versions/{version_id}/reviewables" - ) + + uploaded_labels = set() for repre in instance.data["representations"]: repre_tags = repre.get("tags") or [] # Ignore representations that are not reviewable @@ -1014,13 +1012,25 @@ class IntegrateAsset(pyblish.api.InstancePlugin): repre_path = get_publish_repre_path( instance, repre, False ) - repre_ext = os.path.splitext(repre_path)[-1].lower() + if not repre_path or not os.path.exists(repre_path): + # TODO log skipper path + continue + content_type = get_media_mime_type(repre_path) + if not content_type: + self.log.warning("Could not determine Content-Type") + continue + + # Use output name as label if available label = repre.get("outputName") - if not label: - # TODO how to label the reviewable if there is no output name? - label = "Main" + query = "" + if label: + query = f"?label={label}" + endpoint = ( + f"/api/projects/{project_name}" + f"/versions/{version_id}/reviewables{query}" + ) # Make sure label is unique orig_label = label idx = 0 @@ -1033,13 +1043,15 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # Upload the reviewable self.log.info(f"Uploading reviewable '{label}' ...") headers = copy.deepcopy(base_headers) + headers["Content-Type"] = content_type + headers["x-file-name"] = os.path.basename(repre_path) + self.log.info(f"Uploading reviewable {repre_path}") ayon_con.upload_file( - f"/api/projects/{project_name}/versions/{version_id}/reviewables", + endpoint, repre_path, headers=headers ) - def _validate_path_in_project_roots(self, anatomy, file_path): """Checks if 'file_path' starts with any of the roots. From 8bac6f0277a10967a7636b37181781c351e066dc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 19 Jul 2024 17:15:16 +0200 Subject: [PATCH 025/203] fix upload method --- client/ayon_core/plugins/publish/integrate.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index ababbd88fc..18cd3e5cdd 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -11,6 +11,7 @@ from ayon_api import ( get_product_by_name, get_version_by_name, get_representations, + RequestTypes, ) from ayon_api.operations import ( OperationsSession, @@ -1028,7 +1029,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): query = f"?label={label}" endpoint = ( - f"/api/projects/{project_name}" + f"/projects/{project_name}" f"/versions/{version_id}/reviewables{query}" ) # Make sure label is unique @@ -1049,7 +1050,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): ayon_con.upload_file( endpoint, repre_path, - headers=headers + headers=headers, + request_type=RequestTypes.post, ) def _validate_path_in_project_roots(self, anatomy, file_path): From 595aae2f416f1ffb67830efc473c9890de9803c0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 19 Jul 2024 17:15:37 +0200 Subject: [PATCH 026/203] modified default extract review settings --- server/settings/publish_plugins.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 36bb3f7340..987eefb39e 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -1011,7 +1011,8 @@ DEFAULT_PUBLISH_VALUES = { "ext": "png", "tags": [ "ftrackreview", - "kitsureview" + "kitsureview", + "webreview" ], "burnins": [], "ffmpeg_args": { @@ -1051,7 +1052,8 @@ DEFAULT_PUBLISH_VALUES = { "tags": [ "burnin", "ftrackreview", - "kitsureview" + "kitsureview", + "webreview" ], "burnins": [], "ffmpeg_args": { @@ -1063,7 +1065,10 @@ DEFAULT_PUBLISH_VALUES = { "output": [ "-pix_fmt yuv420p", "-crf 18", - "-intra" + "-c:a acc", + "-b:a 192k", + "-g 1", + "-movflags faststart" ] }, "filter": { From 194944e86c8f7a6a543025285a64d2031a9ea1ed Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 19 Jul 2024 17:35:39 +0200 Subject: [PATCH 027/203] validate supported version --- client/ayon_core/plugins/publish/integrate.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 18cd3e5cdd..ce8a955911 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -992,6 +992,14 @@ class IntegrateAsset(pyblish.api.InstancePlugin): def _upload_reviewable(self, project_name, version_id, instance): ayon_con = get_server_api_connection() + major, minor, _, _, _ = ayon_con.get_server_version_tuple() + if (major, minor) < (1, 3): + self.log.info( + "Skipping reviewable upload, supported from server 1.3.x." + f" User server version {ayon_con.get_server_version()}" + ) + return + base_headers = ayon_con.get_headers() uploaded_labels = set() From bb93a744bc19b0285e80f90c72df9de215ecc4d1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 19 Jul 2024 18:34:08 +0200 Subject: [PATCH 028/203] fix possible clash of content type --- client/ayon_core/plugins/publish/integrate.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index ce8a955911..ebea0447b2 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -1000,8 +1000,6 @@ class IntegrateAsset(pyblish.api.InstancePlugin): ) return - base_headers = ayon_con.get_headers() - uploaded_labels = set() for repre in instance.data["representations"]: repre_tags = repre.get("tags") or [] @@ -1051,8 +1049,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # Upload the reviewable self.log.info(f"Uploading reviewable '{label}' ...") - headers = copy.deepcopy(base_headers) - headers["Content-Type"] = content_type + + headers = ayon_con.get_headers(content_type) headers["x-file-name"] = os.path.basename(repre_path) self.log.info(f"Uploading reviewable {repre_path}") ayon_con.upload_file( From 432ead8178e7f75229c67924e411f2ac6d3055e3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 19 Jul 2024 18:54:12 +0200 Subject: [PATCH 029/203] removed invalid 3gpp --- client/ayon_core/lib/transcoding.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 7ccd2ce819..ead8b621b9 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1466,8 +1466,6 @@ def _get_media_mime_type_from_ftyp(content): return "video/mp4" if content[8:12] in (b"M4V\x20", b"mp42"): return "video/mp4v" - if content[8:13] in (b"3gpis"): - return "video/3gpp" # ( # b"avc1", b"iso2", b"isom", b"mmp4", b"mp41", b"mp71", # b"msnv", b"ndas", b"ndsc", b"ndsh", b"ndsm", b"ndsp", b"ndss", From 7b01a73e7eb7716a411ef3a1bc10dd7ca2454f91 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 22 Jul 2024 11:08:11 +0200 Subject: [PATCH 030/203] support older ayon api --- client/ayon_core/plugins/publish/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index ebea0447b2..ab86855b10 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -11,8 +11,8 @@ from ayon_api import ( get_product_by_name, get_version_by_name, get_representations, - RequestTypes, ) +from ayon_api.server_api import RequestTypes from ayon_api.operations import ( OperationsSession, new_product_entity, From a6a65418e3a039a4b85995edaa4372d511ce1647 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 22 Jul 2024 11:12:36 +0200 Subject: [PATCH 031/203] duplicated code to integrate hero version --- .../plugins/publish/integrate_hero_version.py | 77 ++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate_hero_version.py b/client/ayon_core/plugins/publish/integrate_hero_version.py index 4fb8b886a9..3e85226f75 100644 --- a/client/ayon_core/plugins/publish/integrate_hero_version.py +++ b/client/ayon_core/plugins/publish/integrate_hero_version.py @@ -10,10 +10,12 @@ from ayon_api.operations import ( OperationsSession, new_version_entity, ) +from ayon_api.server_api import RequestTypes from ayon_api.utils import create_entity_id -from ayon_core.lib import create_hard_link, source_hash +from ayon_core.lib import create_hard_link, source_hash, get_media_mime_type from ayon_core.pipeline.publish import ( + get_publish_repre_path, get_publish_template_name, OptionalPyblishPluginMixin, ) @@ -475,6 +477,9 @@ class IntegrateHeroVersion( ) op_session.commit() + self._upload_reviewable( + project_name, new_hero_version["id"], instance + ) # Remove backuped previous hero if ( @@ -672,3 +677,73 @@ class IntegrateHeroVersion( file_name = os.path.basename(value) file_name, _ = os.path.splitext(file_name) return file_name + + def _upload_reviewable(self, project_name, version_id, instance): + ayon_con = ayon_api.get_server_api_connection() + major, minor, _, _, _ = ayon_con.get_server_version_tuple() + if (major, minor) < (1, 3): + self.log.info( + "Skipping reviewable upload, supported from server 1.3.x." + f" User server version {ayon_con.get_server_version()}" + ) + return + + uploaded_labels = set() + for repre in instance.data["representations"]: + repre_tags = repre.get("tags") or [] + # Ignore representations that are not reviewable + if "webreview" not in repre_tags: + continue + + # exclude representations with are going to be published on farm + if "publish_on_farm" in repre_tags: + continue + + # Skip thumbnails + if repre.get("thumbnail") or "thumbnail" in repre_tags: + continue + + # include only thumbnail representations + repre_path = get_publish_repre_path( + instance, repre, False + ) + if not repre_path or not os.path.exists(repre_path): + # TODO log skipper path + continue + + content_type = get_media_mime_type(repre_path) + if not content_type: + self.log.warning("Could not determine Content-Type") + continue + + # Use output name as label if available + label = repre.get("outputName") + query = "" + if label: + query = f"?label={label}" + + endpoint = ( + f"/projects/{project_name}" + f"/versions/{version_id}/reviewables{query}" + ) + # Make sure label is unique + orig_label = label + idx = 0 + while label in uploaded_labels: + idx += 1 + label = f"{orig_label}_{idx}" + + uploaded_labels.add(label) + + # Upload the reviewable + self.log.info(f"Uploading reviewable '{label}' ...") + + headers = ayon_con.get_headers(content_type) + headers["x-file-name"] = os.path.basename(repre_path) + self.log.info(f"Uploading reviewable {repre_path}") + ayon_con.upload_file( + endpoint, + repre_path, + headers=headers, + request_type=RequestTypes.post, + ) From f61675b10e85c309c33a794fd5911c45af788425 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 22 Jul 2024 15:05:24 +0200 Subject: [PATCH 032/203] separate upload review to separated plugin --- client/ayon_core/plugins/publish/integrate.py | 75 +------------- .../plugins/publish/integrate_hero_version.py | 80 +-------------- .../plugins/publish/integrate_review.py | 98 +++++++++++++++++++ 3 files changed, 103 insertions(+), 150 deletions(-) create mode 100644 client/ayon_core/plugins/publish/integrate_review.py diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index ab86855b10..6837145f5d 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -6,13 +6,11 @@ import copy import clique import pyblish.api from ayon_api import ( - get_server_api_connection, get_attributes_for_type, get_product_by_name, get_version_by_name, get_representations, ) -from ayon_api.server_api import RequestTypes from ayon_api.operations import ( OperationsSession, new_product_entity, @@ -21,13 +19,12 @@ from ayon_api.operations import ( ) from ayon_api.utils import create_entity_id -from ayon_core.lib import source_hash, get_media_mime_type +from ayon_core.lib import source_hash from ayon_core.lib.file_transaction import ( FileTransaction, DuplicateDestinationError ) from ayon_core.pipeline.publish import ( - get_publish_repre_path, KnownPublishError, get_publish_template_name, ) @@ -990,76 +987,6 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "hash_type": "op3", } - def _upload_reviewable(self, project_name, version_id, instance): - ayon_con = get_server_api_connection() - major, minor, _, _, _ = ayon_con.get_server_version_tuple() - if (major, minor) < (1, 3): - self.log.info( - "Skipping reviewable upload, supported from server 1.3.x." - f" User server version {ayon_con.get_server_version()}" - ) - return - - uploaded_labels = set() - for repre in instance.data["representations"]: - repre_tags = repre.get("tags") or [] - # Ignore representations that are not reviewable - if "webreview" not in repre_tags: - continue - - # exclude representations with are going to be published on farm - if "publish_on_farm" in repre_tags: - continue - - # Skip thumbnails - if repre.get("thumbnail") or "thumbnail" in repre_tags: - continue - - # include only thumbnail representations - repre_path = get_publish_repre_path( - instance, repre, False - ) - if not repre_path or not os.path.exists(repre_path): - # TODO log skipper path - continue - - content_type = get_media_mime_type(repre_path) - if not content_type: - self.log.warning("Could not determine Content-Type") - continue - - # Use output name as label if available - label = repre.get("outputName") - query = "" - if label: - query = f"?label={label}" - - endpoint = ( - f"/projects/{project_name}" - f"/versions/{version_id}/reviewables{query}" - ) - # Make sure label is unique - orig_label = label - idx = 0 - while label in uploaded_labels: - idx += 1 - label = f"{orig_label}_{idx}" - - uploaded_labels.add(label) - - # Upload the reviewable - self.log.info(f"Uploading reviewable '{label}' ...") - - headers = ayon_con.get_headers(content_type) - headers["x-file-name"] = os.path.basename(repre_path) - self.log.info(f"Uploading reviewable {repre_path}") - ayon_con.upload_file( - endpoint, - repre_path, - headers=headers, - request_type=RequestTypes.post, - ) - def _validate_path_in_project_roots(self, anatomy, file_path): """Checks if 'file_path' starts with any of the roots. diff --git a/client/ayon_core/plugins/publish/integrate_hero_version.py b/client/ayon_core/plugins/publish/integrate_hero_version.py index 3e85226f75..2163596864 100644 --- a/client/ayon_core/plugins/publish/integrate_hero_version.py +++ b/client/ayon_core/plugins/publish/integrate_hero_version.py @@ -10,12 +10,10 @@ from ayon_api.operations import ( OperationsSession, new_version_entity, ) -from ayon_api.server_api import RequestTypes from ayon_api.utils import create_entity_id -from ayon_core.lib import create_hard_link, source_hash, get_media_mime_type +from ayon_core.lib import create_hard_link, source_hash from ayon_core.pipeline.publish import ( - get_publish_repre_path, get_publish_template_name, OptionalPyblishPluginMixin, ) @@ -267,6 +265,9 @@ class IntegrateHeroVersion( project_name, "version", new_hero_version ) + # Store hero entity to 'instance.data' + instance.data["heroVersionEntity"] = new_hero_version + # Separate old representations into `to replace` and `to delete` old_repres_to_replace = {} old_repres_to_delete = {} @@ -477,9 +478,6 @@ class IntegrateHeroVersion( ) op_session.commit() - self._upload_reviewable( - project_name, new_hero_version["id"], instance - ) # Remove backuped previous hero if ( @@ -677,73 +675,3 @@ class IntegrateHeroVersion( file_name = os.path.basename(value) file_name, _ = os.path.splitext(file_name) return file_name - - def _upload_reviewable(self, project_name, version_id, instance): - ayon_con = ayon_api.get_server_api_connection() - major, minor, _, _, _ = ayon_con.get_server_version_tuple() - if (major, minor) < (1, 3): - self.log.info( - "Skipping reviewable upload, supported from server 1.3.x." - f" User server version {ayon_con.get_server_version()}" - ) - return - - uploaded_labels = set() - for repre in instance.data["representations"]: - repre_tags = repre.get("tags") or [] - # Ignore representations that are not reviewable - if "webreview" not in repre_tags: - continue - - # exclude representations with are going to be published on farm - if "publish_on_farm" in repre_tags: - continue - - # Skip thumbnails - if repre.get("thumbnail") or "thumbnail" in repre_tags: - continue - - # include only thumbnail representations - repre_path = get_publish_repre_path( - instance, repre, False - ) - if not repre_path or not os.path.exists(repre_path): - # TODO log skipper path - continue - - content_type = get_media_mime_type(repre_path) - if not content_type: - self.log.warning("Could not determine Content-Type") - continue - - # Use output name as label if available - label = repre.get("outputName") - query = "" - if label: - query = f"?label={label}" - - endpoint = ( - f"/projects/{project_name}" - f"/versions/{version_id}/reviewables{query}" - ) - # Make sure label is unique - orig_label = label - idx = 0 - while label in uploaded_labels: - idx += 1 - label = f"{orig_label}_{idx}" - - uploaded_labels.add(label) - - # Upload the reviewable - self.log.info(f"Uploading reviewable '{label}' ...") - - headers = ayon_con.get_headers(content_type) - headers["x-file-name"] = os.path.basename(repre_path) - self.log.info(f"Uploading reviewable {repre_path}") - ayon_con.upload_file( - endpoint, - repre_path, - headers=headers, - request_type=RequestTypes.post, - ) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py new file mode 100644 index 0000000000..1c62f25d94 --- /dev/null +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -0,0 +1,98 @@ +import os + +import pyblish.api +import ayon_api +from ayon_api.server_api import RequestTypes + +from ayon_core.lib import get_media_mime_type +from ayon_core.pipeline.publish import get_publish_repre_path + + +class IntegrateAYONReview(pyblish.api.InstancePlugin): + label = "Integrate AYON Review" + # Must happen after IntegrateNew + order = pyblish.api.IntegratorOrder + 0.15 + + def process(self, instance): + project_name = instance.context.data["projectName"] + src_version_entity = instance.data.get("versionEntity") + src_hero_version_entity = instance.data.get("heroVersionEntity") + for version_entity in ( + src_version_entity, + src_hero_version_entity, + ): + if not version_entity: + continue + + version_id = version_entity["id"] + self._upload_reviewable(project_name, version_id, instance) + + def _upload_reviewable(self, project_name, version_id, instance): + ayon_con = ayon_api.get_server_api_connection() + major, minor, _, _, _ = ayon_con.get_server_version_tuple() + if (major, minor) < (1, 3): + self.log.info( + "Skipping reviewable upload, supported from server 1.3.x." + f" User server version {ayon_con.get_server_version()}" + ) + return + + uploaded_labels = set() + for repre in instance.data["representations"]: + repre_tags = repre.get("tags") or [] + # Ignore representations that are not reviewable + if "webreview" not in repre_tags: + continue + + # exclude representations with are going to be published on farm + if "publish_on_farm" in repre_tags: + continue + + # Skip thumbnails + if repre.get("thumbnail") or "thumbnail" in repre_tags: + continue + + # include only thumbnail representations + repre_path = get_publish_repre_path( + instance, repre, False + ) + if not repre_path or not os.path.exists(repre_path): + # TODO log skipper path + continue + + content_type = get_media_mime_type(repre_path) + if not content_type: + self.log.warning("Could not determine Content-Type") + continue + + # Use output name as label if available + label = repre.get("outputName") + query = "" + if label: + query = f"?label={label}" + + endpoint = ( + f"/projects/{project_name}" + f"/versions/{version_id}/reviewables{query}" + ) + # Make sure label is unique + orig_label = label + idx = 0 + while label in uploaded_labels: + idx += 1 + label = f"{orig_label}_{idx}" + + uploaded_labels.add(label) + + # Upload the reviewable + self.log.info(f"Uploading reviewable '{label}' ...") + + headers = ayon_con.get_headers(content_type) + headers["x-file-name"] = os.path.basename(repre_path) + self.log.info(f"Uploading reviewable {repre_path}") + ayon_con.upload_file( + endpoint, + repre_path, + headers=headers, + request_type=RequestTypes.post, + ) \ No newline at end of file From d060244d49f6185195fcca04737419b2dbb06639 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 09:44:42 +0200 Subject: [PATCH 033/203] remove unecessary comment --- client/ayon_core/plugins/publish/integrate_review.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index 1c62f25d94..a5979e3b1c 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -52,7 +52,6 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): if repre.get("thumbnail") or "thumbnail" in repre_tags: continue - # include only thumbnail representations repre_path = get_publish_repre_path( instance, repre, False ) From 722b3a5d63a623ed46700528692c1dad020edfd9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 09:53:21 +0200 Subject: [PATCH 034/203] fix label calculation --- .../plugins/publish/integrate_review.py | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index a5979e3b1c..6a79e72c1e 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -64,8 +64,7 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): self.log.warning("Could not determine Content-Type") continue - # Use output name as label if available - label = repre.get("outputName") + label = self._get_review_label(repre, uploaded_labels) query = "" if label: query = f"?label={label}" @@ -74,14 +73,6 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): f"/projects/{project_name}" f"/versions/{version_id}/reviewables{query}" ) - # Make sure label is unique - orig_label = label - idx = 0 - while label in uploaded_labels: - idx += 1 - label = f"{orig_label}_{idx}" - - uploaded_labels.add(label) # Upload the reviewable self.log.info(f"Uploading reviewable '{label}' ...") @@ -94,4 +85,16 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): repre_path, headers=headers, request_type=RequestTypes.post, - ) \ No newline at end of file + ) + + def _get_review_label(self, repre, uploaded_labels): + # Use output name as label if available + label = repre.get("outputName") + if not label: + return None + orig_label = label + idx = 0 + while label in uploaded_labels: + idx += 1 + label = f"{orig_label}_{idx}" + return label From 754f2d0e5f010cc4bcc95a6506db87b1f7bd4587 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 09:53:30 +0200 Subject: [PATCH 035/203] log representation path --- client/ayon_core/plugins/publish/integrate_review.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index 6a79e72c1e..1adac420a6 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -61,7 +61,9 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): content_type = get_media_mime_type(repre_path) if not content_type: - self.log.warning("Could not determine Content-Type") + self.log.warning( + f"Could not determine Content-Type for {repre_path}" + ) continue label = self._get_review_label(repre, uploaded_labels) From e7f816795314a931e80f28a26c6a5eb4efa70d31 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 10:55:18 +0200 Subject: [PATCH 036/203] store tracebacks in case stdout is not available --- client/ayon_core/addon/utils.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index 3ad112b562..66c2d81a79 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -93,35 +93,50 @@ def ensure_addons_are_process_ready( message = None failed = False use_detail = False + # Wrap the output in StringIO to capture it for details on fail + # - but in case stdout was invalid on start of process also store + # the tracebacks + tracebacks = [] output = StringIO() with contextlib.redirect_stdout(output): with contextlib.redirect_stderr(output): for addon in addons_manager.get_enabled_addons(): - failed = True + addon_failed = True try: addon.ensure_is_process_ready(process_context) - failed = False + addon_failed = False except ProcessPreparationError as exc: message = str(exc) print(f"Addon preparation failed: '{addon.name}'") print(message) except BaseException as exc: + use_detail = True message = "An unexpected error occurred." + formatted_traceback = "".join(traceback.format_exception( + *sys.exc_info() + )) + tracebacks.append(formatted_traceback) print(f"Addon preparation failed: '{addon.name}'") print(message) - # Print the traceback so it is in the output - traceback.print_exception(*sys.exc_info()) - use_detail = True + # Print the traceback so it is in the stdout + print(formatted_traceback) - if failed: + if addon_failed: + failed = True break output_str = output.getvalue() # Print stdout/stderr to console as it was redirected print(output_str) if failed: - detail = output_str if use_detail else None + detail = None + if use_detail: + # In case stdout was not captured, use the tracebacks + if not output_str: + output_str = "\n".join(tracebacks) + detail = output_str + _handle_error(process_context, message, detail) if not exit_on_failure: return exc From e15a2b275f4373025a18c6d9b7a9fe72d2a81edd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 11:00:30 +0200 Subject: [PATCH 037/203] fix ruff linting --- client/ayon_core/addon/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index 66c2d81a79..8b7740c89c 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -89,7 +89,7 @@ def ensure_addons_are_process_ready( if addons_manager is None: addons_manager = AddonsManager() - exc = None + exception = None message = None failed = False use_detail = False @@ -106,11 +106,13 @@ def ensure_addons_are_process_ready( addon.ensure_is_process_ready(process_context) addon_failed = False except ProcessPreparationError as exc: + exception = exc message = str(exc) print(f"Addon preparation failed: '{addon.name}'") print(message) except BaseException as exc: + exception = exc use_detail = True message = "An unexpected error occurred." formatted_traceback = "".join(traceback.format_exception( @@ -139,5 +141,5 @@ def ensure_addons_are_process_ready( _handle_error(process_context, message, detail) if not exit_on_failure: - return exc + return exception sys.exit(1) From b281d5be049406574efa9d6ff3632ca775824e3a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 17:21:45 +0200 Subject: [PATCH 038/203] don't crash the plugin file because of missing functions --- .../extract_usd_layer_contributions.py | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index 162b7d3d41..dbd26c24c9 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -4,7 +4,10 @@ import os from typing import Dict import pyblish.api -from pxr import Sdf +try: + from pxr import Sdf +except ImportError: + Sdf = None from ayon_core.lib import ( TextDef, @@ -13,21 +16,24 @@ from ayon_core.lib import ( UILabelDef, EnumDef ) -from ayon_core.pipeline.usdlib import ( - get_or_define_prim_spec, - add_ordered_reference, - variant_nested_prim_path, - setup_asset_layer, - add_ordered_sublayer, - set_layer_defaults -) +try: + from ayon_core.pipeline.usdlib import ( + get_or_define_prim_spec, + add_ordered_reference, + variant_nested_prim_path, + setup_asset_layer, + add_ordered_sublayer, + set_layer_defaults + ) +except ImportError: + pass from ayon_core.pipeline.entity_uri import ( construct_ayon_entity_uri, parse_ayon_entity_uri ) from ayon_core.pipeline.load.utils import get_representation_path_by_names from ayon_core.pipeline.publish.lib import get_instance_expected_output_path -from ayon_core.pipeline import publish +from ayon_core.pipeline import publish, KnownPublishError # This global toggle is here mostly for debugging purposes and should usually @@ -555,6 +561,16 @@ class CollectUSDLayerContributionsHoudiniLook(CollectUSDLayerContributions): return defs +class ValidateUSDDependencies(pyblish.api.InstancePlugin): + families = ["usdLayer"] + + order = pyblish.api.ValidatorOrder + + def process(self, instance): + if Sdf is None: + raise KnownPublishError("USD library 'Sdf' is not available.") + + class ExtractUSDLayerContribution(publish.Extractor): families = ["usdLayer"] @@ -652,14 +668,14 @@ class ExtractUSDLayerContribution(publish.Extractor): ) def remove_previous_reference_contribution(self, - prim_spec: Sdf.PrimSpec, + prim_spec: "Sdf.PrimSpec", instance: pyblish.api.Instance): # Remove existing contributions of the same product - ignoring # the picked version and representation. We assume there's only ever # one version of a product you want to have referenced into a Prim. remove_indices = set() for index, ref in enumerate(prim_spec.referenceList.prependedItems): - ref: Sdf.Reference # type hint + ref: "Sdf.Reference" uri = ref.customData.get("ayon_uri") if uri and self.instance_match_ayon_uri(instance, uri): @@ -674,8 +690,8 @@ class ExtractUSDLayerContribution(publish.Extractor): ] def add_reference_contribution(self, - layer: Sdf.Layer, - prim_path: Sdf.Path, + layer: "Sdf.Layer", + prim_path: "Sdf.Path", filepath: str, contribution: VariantContribution): instance = contribution.instance From 5f7dd573197e8acc9048f87c9f57586851e732cf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 18:10:33 +0200 Subject: [PATCH 039/203] use filename if label is empty --- client/ayon_core/plugins/publish/integrate_review.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index 1adac420a6..790c7da9c6 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -75,12 +75,12 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): f"/projects/{project_name}" f"/versions/{version_id}/reviewables{query}" ) - + filename = os.path.basename(repre_path) # Upload the reviewable - self.log.info(f"Uploading reviewable '{label}' ...") + self.log.info(f"Uploading reviewable '{label or filename}' ...") headers = ayon_con.get_headers(content_type) - headers["x-file-name"] = os.path.basename(repre_path) + headers["x-file-name"] = filename self.log.info(f"Uploading reviewable {repre_path}") ayon_con.upload_file( endpoint, From f5abf6e981821819752e94c193154609781ce95e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:47:50 +0200 Subject: [PATCH 040/203] Enhance comments and logs Co-authored-by: Roy Nieterau --- client/ayon_core/plugins/publish/integrate_review.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index 790c7da9c6..0a6b24adb4 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -10,7 +10,7 @@ from ayon_core.pipeline.publish import get_publish_repre_path class IntegrateAYONReview(pyblish.api.InstancePlugin): label = "Integrate AYON Review" - # Must happen after IntegrateNew + # Must happen after IntegrateAsset order = pyblish.api.IntegratorOrder + 0.15 def process(self, instance): @@ -33,7 +33,7 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): if (major, minor) < (1, 3): self.log.info( "Skipping reviewable upload, supported from server 1.3.x." - f" User server version {ayon_con.get_server_version()}" + f" Current server version {ayon_con.get_server_version()}" ) return From b5336f2e483912b05b884375aee0073f76a1b0cf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 13:51:40 +0200 Subject: [PATCH 041/203] removed upload reviewable from integrate plugin --- client/ayon_core/plugins/publish/integrate.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 6837145f5d..69c14465eb 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -349,8 +349,6 @@ class IntegrateAsset(pyblish.api.InstancePlugin): self.log.debug("{}".format(op_session.to_data())) op_session.commit() - self._upload_reviewable(project_name, version_entity["id"], instance) - # Backwards compatibility used in hero integration. # todo: can we avoid the need to store this? instance.data["published_representations"] = { From 073ab83e78279d4bc51a09e452ff64403193fd1b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 26 Jul 2024 15:39:59 +0200 Subject: [PATCH 042/203] Write the version instead of "latest" into the AYON URI --- .../plugins/publish/extract_usd_layer_contributions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index 58cffcd59a..7ed129a127 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -138,13 +138,14 @@ def get_instance_uri_path( folder_path = instance.data["folderPath"] product_name = instance.data["productName"] project_name = context.data["projectName"] + version_name = instance.data["version"] # Get the layer's published path path = construct_ayon_entity_uri( project_name=project_name, folder_path=folder_path, product=product_name, - version="latest", + version=version_name, representation_name="usd" ) From ddd5313a415b67c4d1af4ece9d178932f73a79fc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 30 Jul 2024 13:42:23 +0200 Subject: [PATCH 043/203] add optional output of ensude function --- client/ayon_core/addon/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index 8b7740c89c..caa6b70a85 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -66,7 +66,7 @@ def ensure_addons_are_process_ready( process_context: ProcessContext, addons_manager: Optional[AddonsManager] = None, exit_on_failure: bool = True, -): +) -> Optional[Exception]: """Ensure all enabled addons are ready to be used in the given context. Call this method only in AYON launcher process and as first thing From f8e1b6eeb106666c1904eb27418a2c3cc789f605 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 30 Jul 2024 14:59:28 +0200 Subject: [PATCH 044/203] added one more ensure function for easier approach. --- client/ayon_core/addon/__init__.py | 2 ++ client/ayon_core/addon/utils.py | 33 ++++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/addon/__init__.py b/client/ayon_core/addon/__init__.py index 497987fa22..6a7ce8a3cb 100644 --- a/client/ayon_core/addon/__init__.py +++ b/client/ayon_core/addon/__init__.py @@ -17,6 +17,7 @@ from .base import ( ) from .utils import ( + ensure_addons_are_process_context_ready, ensure_addons_are_process_ready, ) @@ -36,5 +37,6 @@ __all__ = ( "AddonsManager", "load_addons", + "ensure_addons_are_process_context_ready", "ensure_addons_are_process_ready", ) diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index caa6b70a85..d96809de49 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -62,7 +62,7 @@ def _handle_error( os.remove(tmp_path) -def ensure_addons_are_process_ready( +def ensure_addons_are_process_context_ready( process_context: ProcessContext, addons_manager: Optional[AddonsManager] = None, exit_on_failure: bool = True, @@ -134,7 +134,7 @@ def ensure_addons_are_process_ready( if failed: detail = None if use_detail: - # In case stdout was not captured, use the tracebacks + # In case stdout was not captured, use the tracebacks as detail if not output_str: output_str = "\n".join(tracebacks) detail = output_str @@ -143,3 +143,32 @@ def ensure_addons_are_process_ready( if not exit_on_failure: return exception sys.exit(1) + + +def ensure_addons_are_process_ready( + addons_manager: Optional[AddonsManager] = None, + exit_on_failure: bool = True, + **kwargs, +) -> Optional[Exception]: + """Ensure all enabled addons are ready to be used in the given context. + + Call this method only in AYON launcher process and as first thing + to avoid possible clashes with preparation. For example 'QApplication' + should not be created. + + Args: + addons_manager (Optional[AddonsManager]): The addons + manager to use. If not provided, a new one will be created. + exit_on_failure (bool, optional): If True, the process will exit + if an error occurs. Defaults to True. + kwargs: The keyword arguments to pass to the ProcessContext. + + Returns: + Optional[Exception]: The exception that occurred during the + preparation, if any. + + """ + context: ProcessContext = ProcessContext(**kwargs) + return ensure_addons_are_process_context_ready( + context, addons_manager, exit_on_failure + ) From 943245b698557f1bd8fa36aab30d6c2fc8547e3f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 30 Jul 2024 15:05:20 +0200 Subject: [PATCH 045/203] start tray if is not running --- client/ayon_core/addon/utils.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index d96809de49..b6f665b677 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -62,6 +62,12 @@ def _handle_error( os.remove(tmp_path) +def _start_tray(process_context): + from ayon_core.tools.tray import make_sure_tray_is_running + + make_sure_tray_is_running() + + def ensure_addons_are_process_context_ready( process_context: ProcessContext, addons_manager: Optional[AddonsManager] = None, @@ -131,18 +137,22 @@ def ensure_addons_are_process_context_ready( output_str = output.getvalue() # Print stdout/stderr to console as it was redirected print(output_str) - if failed: - detail = None - if use_detail: - # In case stdout was not captured, use the tracebacks as detail - if not output_str: - output_str = "\n".join(tracebacks) - detail = output_str + if not failed: + if not process_context.headless: + _start_tray(process_context) + return None - _handle_error(process_context, message, detail) - if not exit_on_failure: - return exception - sys.exit(1) + detail = None + if use_detail: + # In case stdout was not captured, use the tracebacks as detail + if not output_str: + output_str = "\n".join(tracebacks) + detail = output_str + + _handle_error(process_context, message, detail) + if not exit_on_failure: + return exception + sys.exit(1) def ensure_addons_are_process_ready( From fa729cdcdd3b04c2e03fa15b157ec5869f4872d5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 30 Jul 2024 15:29:25 +0200 Subject: [PATCH 046/203] don't require process context to start tray function --- client/ayon_core/addon/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index b6f665b677..ac5ff25984 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -62,7 +62,7 @@ def _handle_error( os.remove(tmp_path) -def _start_tray(process_context): +def _start_tray(): from ayon_core.tools.tray import make_sure_tray_is_running make_sure_tray_is_running() @@ -139,7 +139,7 @@ def ensure_addons_are_process_context_ready( print(output_str) if not failed: if not process_context.headless: - _start_tray(process_context) + _start_tray() return None detail = None From aebc9a90d65bc1fd1697543ac7d14e2f9fc20f42 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 30 Jul 2024 15:35:18 +0200 Subject: [PATCH 047/203] Update opencolorio version to ^2.3.2 in pyproject.toml Bump opencolorio version from 2.2.1 to ^2.3.2 in the pyproject.toml file for compatibility and potential enhancements with other dependencies. --- client/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/pyproject.toml b/client/pyproject.toml index ca88a37125..a0be9605b6 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -15,6 +15,6 @@ qtawesome = "0.7.3" aiohttp-middlewares = "^2.0.0" Click = "^8" OpenTimelineIO = "0.16.0" -opencolorio = "2.2.1" +opencolorio = "^2.3.2" Pillow = "9.5.0" websocket-client = ">=0.40.0,<2" From 3b942cefbe384042abc7781fec93e3c5a1a48788 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 31 Jul 2024 13:46:25 +0200 Subject: [PATCH 048/203] bump version to '0.4.3' --- client/ayon_core/version.py | 2 +- package.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index a8c42ec80a..85f0814bfe 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON core addon version.""" -__version__ = "0.4.3-dev.1" +__version__ = "0.4.3" diff --git a/package.py b/package.py index 4f2d2b16b4..0902df7618 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "0.4.3-dev.1" +version = "0.4.3" client_dir = "ayon_core" From b6fba133226af8a267eff0aa7028db414c064e7b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 31 Jul 2024 13:47:02 +0200 Subject: [PATCH 049/203] bump version to '0.4.4-dev.1' --- client/ayon_core/version.py | 2 +- package.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 85f0814bfe..55a14ba567 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON core addon version.""" -__version__ = "0.4.3" +__version__ = "0.4.4-dev.1" diff --git a/package.py b/package.py index 0902df7618..ca4006425d 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "0.4.3" +version = "0.4.4-dev.1" client_dir = "ayon_core" From 8cd6a2f342431d70afa9433b75d026ec7bb78e0d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 31 Jul 2024 17:26:34 +0200 Subject: [PATCH 050/203] wrap tray info into object --- client/ayon_core/tools/tray/lib.py | 311 +++++++++++++++++++++-------- 1 file changed, 233 insertions(+), 78 deletions(-) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index ad190482a8..e6de884cb5 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -144,17 +144,6 @@ def get_tray_storage_dir() -> str: return get_ayon_appdirs("tray") -def _get_tray_information(tray_url: str) -> Optional[Dict[str, Any]]: - if not tray_url: - return None - try: - response = requests.get(f"{tray_url}/tray") - response.raise_for_status() - return response.json() - except (requests.HTTPError, requests.ConnectionError): - return None - - def _get_tray_info_filepath( server_url: Optional[str] = None, variant: Optional[str] = None @@ -165,6 +154,51 @@ def _get_tray_info_filepath( return os.path.join(hash_dir, filename) +def _get_tray_file_info( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> Tuple[Optional[Dict[str, Any]], Optional[float]]: + filepath = _get_tray_info_filepath(server_url, variant) + if not os.path.exists(filepath): + return None, None + file_modified = os.path.getmtime(filepath) + try: + with open(filepath, "r") as stream: + data = json.load(stream) + except Exception: + return None, file_modified + + return data, file_modified + + +def _remove_tray_server_url( + server_url: Optional[str], + variant: Optional[str], + file_modified: Optional[float], +): + """Remove tray information file. + + Called from tray logic, do not use on your own. + + Args: + server_url (Optional[str]): AYON server url. + variant (Optional[str]): Settings variant. + file_modified (Optional[float]): File modified timestamp. Is validated + against current state of file. + + """ + filepath = _get_tray_info_filepath(server_url, variant) + if not os.path.exists(filepath): + return + + if ( + file_modified is not None + and os.path.getmtime(filepath) != file_modified + ): + return + os.remove(filepath) + + def get_tray_file_info( server_url: Optional[str] = None, variant: Optional[str] = None @@ -182,15 +216,156 @@ def get_tray_file_info( Optional[Dict[str, Any]]: Tray information. """ - filepath = _get_tray_info_filepath(server_url, variant) - if not os.path.exists(filepath): + file_info, _ = _get_tray_file_info(server_url, variant) + return file_info + + +def _get_tray_rest_information(tray_url: str) -> Optional[Dict[str, Any]]: + if not tray_url: return None try: - with open(filepath, "r") as stream: - data = json.load(stream) - except Exception: + response = requests.get(f"{tray_url}/tray") + response.raise_for_status() + return response.json() + except (requests.HTTPError, requests.ConnectionError): return None - return data + + +class TrayInfo: + def __init__( + self, + server_url: str, + variant: str, + timeout: Optional[int] = None + ): + self.server_url = server_url + self.variant = variant + + if timeout is None: + timeout = 10 + + self._timeout = timeout + + self._file_modified = None + self._file_info = None + self._file_info_cached = False + self._tray_info = None + self._tray_info_cached = False + self._file_state = None + self._state = None + + @classmethod + def new( + cls, + server_url: Optional[str] = None, + variant: Optional[str] = None, + timeout: Optional[int] = None, + wait_to_start: Optional[bool] = True + ) -> "TrayInfo": + server_url, variant = _get_server_and_variant(server_url, variant) + obj = cls(server_url, variant, timeout=timeout) + if wait_to_start: + obj.wait_to_start() + return obj + + def get_pid(self) -> Optional[int]: + file_info = self.get_file_info() + if file_info: + return file_info.get("pid") + return None + + def reset(self): + self._file_modified = None + self._file_info = None + self._file_info_cached = False + self._tray_info = None + self._tray_info_cached = False + self._state = None + self._file_state = None + + def get_file_info(self) -> Optional[Dict[str, Any]]: + if not self._file_info_cached: + file_info, file_modified = _get_tray_file_info( + self.server_url, self.variant + ) + self._file_info = file_info + self._file_modified = file_modified + self._file_info_cached = True + return self._file_info + + def get_file_url(self) -> Optional[str]: + file_info = self.get_file_info() + if file_info: + return file_info.get("url") + return None + + def get_tray_url(self) -> Optional[str]: + info = self.get_tray_info() + if info: + return self.get_file_url() + return None + + def get_tray_info(self) -> Optional[Dict[str, Any]]: + if self._tray_info_cached: + return self._tray_info + + tray_url = self.get_file_url() + tray_info = None + if tray_url: + tray_info = _get_tray_rest_information(tray_url) + + self._tray_info = tray_info + self._tray_info_cached = True + return self._tray_info + + def get_file_state(self) -> int: + if self._file_state is not None: + return self._file_state + + state = TrayState.NOT_RUNNING + file_info = self.get_file_info() + if file_info: + state = TrayState.STARTING + if file_info.get("started") is True: + state = TrayState.RUNNING + self._file_state = state + return self._file_state + + def get_state(self) -> int: + if self._state is not None: + return self._state + + state = self.get_file_state() + if state == TrayState.RUNNING and not self.get_tray_info(): + state = TrayState.NOT_RUNNING + pid = self.pid + if pid: + _kill_tray_process(pid) + # Remove the file as tray is not running anymore and update + # the state of this object. + _remove_tray_server_url( + self.server_url, self.variant, self._file_modified + ) + self.reset() + + self._state = state + return self._state + + def get_ayon_username(self) -> Optional[str]: + tray_info = self.get_tray_info() + if tray_info: + return tray_info.get("username") + return None + + def wait_to_start(self) -> bool: + _wait_for_starting_tray( + self.server_url, self.variant, self._timeout + ) + self.reset() + return self.get_file_state() == TrayState.RUNNING + + pid = property(get_pid) + state = property(get_state) def get_tray_server_url( @@ -214,25 +389,12 @@ def get_tray_server_url( Optional[str]: Tray server url. """ - data = get_tray_file_info(server_url, variant) - if data is None: - return None - - if data.get("started") is False: - data = _wait_for_starting_tray(server_url, variant, timeout) - if data is None: - return None - - url = data.get("url") - if not url: - return None - - if not validate: - return url - - if _get_tray_information(url): - return url - return None + tray_info = TrayInfo.new( + server_url, variant, timeout, wait_to_start=True + ) + if validate: + return tray_info.get_tray_url() + return tray_info.get_file_url() def set_tray_server_url(tray_url: Optional[str], started: bool): @@ -246,10 +408,13 @@ def set_tray_server_url(tray_url: Optional[str], started: bool): that tray is starting up. """ - file_info = get_tray_file_info() - if file_info and file_info["pid"] != os.getpid(): - if not file_info["started"] or _get_tray_information(file_info["url"]): - raise TrayIsRunningError("Tray is already running.") + info = TrayInfo.new(wait_to_start=False) + if ( + info.pid + and info.pid != os.getpid() + and info.state in (TrayState.RUNNING, TrayState.STARTING) + ): + raise TrayIsRunningError("Tray is already running.") filepath = _get_tray_info_filepath() os.makedirs(os.path.dirname(filepath), exist_ok=True) @@ -292,20 +457,21 @@ def remove_tray_server_url(force: Optional[bool] = False): def get_tray_information( server_url: Optional[str] = None, - variant: Optional[str] = None -) -> Optional[Dict[str, Any]]: + variant: Optional[str] = None, + timeout: Optional[int] = None, +) -> TrayInfo: """Get information about tray. Args: server_url (Optional[str]): AYON server url. variant (Optional[str]): Settings variant. + timeout (Optional[int]): Timeout for tray start-up. Returns: - Optional[Dict[str, Any]]: Tray information. + TrayInfo: Tray information. """ - tray_url = get_tray_server_url(server_url, variant) - return _get_tray_information(tray_url) + return TrayInfo.new(server_url, variant, timeout) def get_tray_state( @@ -322,20 +488,8 @@ def get_tray_state( int: Tray state. """ - file_info = get_tray_file_info(server_url, variant) - if file_info is None: - return TrayState.NOT_RUNNING - - if file_info.get("started") is False: - return TrayState.STARTING - - tray_url = file_info.get("url") - info = _get_tray_information(tray_url) - if not info: - # Remove the information as the tray is not running - remove_tray_server_url(force=True) - return TrayState.NOT_RUNNING - return TrayState.RUNNING + tray_info = get_tray_information(server_url, variant) + return tray_info.state def is_tray_running( @@ -435,38 +589,39 @@ def main(force=False): Logger.set_process_name("Tray") - state = get_tray_state() - if force and state in (TrayState.RUNNING, TrayState.STARTING): - file_info = get_tray_file_info() or {} - pid = file_info.get("pid") + tray_info = TrayInfo.new(wait_to_start=False) + file_state = tray_info.get_file_state() + if force and file_state in (TrayState.RUNNING, TrayState.STARTING): + pid = tray_info.pid if pid is not None: _kill_tray_process(pid) remove_tray_server_url(force=True) - state = TrayState.NOT_RUNNING + file_state = TrayState.NOT_RUNNING - if state == TrayState.RUNNING: - show_message_in_tray( - "Tray is already running", - "Your AYON tray application is already running." - ) - print("Tray is already running.") - return + if file_state == TrayState.RUNNING: + if tray_info.get_state() == TrayState.RUNNING: + show_message_in_tray( + "Tray is already running", + "Your AYON tray application is already running." + ) + print("Tray is already running.") + return + file_state = tray_info.get_file_state() - if state == TrayState.STARTING: + if file_state == TrayState.STARTING: print("Tray is starting. Waiting for it to start.") - _wait_for_starting_tray() - state = get_tray_state() - if state == TrayState.RUNNING: + tray_info.wait_to_start() + file_state = tray_info.get_file_state() + if file_state == TrayState.RUNNING: print("Tray started. Exiting.") return - if state == TrayState.STARTING: + if file_state == TrayState.STARTING: print( "Tray did not start in expected time." " Killing the process and starting new." ) - file_info = get_tray_file_info() or {} - pid = file_info.get("pid") + pid = tray_info.pid if pid is not None: _kill_tray_process(pid) remove_tray_server_url(force=True) From 041b93d09a264800c6fc34f4a32c6b15036dd4df Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 1 Aug 2024 16:26:16 +0200 Subject: [PATCH 051/203] use power of sets --- client/ayon_core/lib/execute.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index f61d892324..1c73a97731 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -210,11 +210,12 @@ def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs): env = clean_envs_for_ayon_process(os.environ) if add_sys_paths: - new_pythonpath = list(sys.path) - for path in env.get("PYTHONPATH", "").split(os.pathsep): - if not path or path in new_pythonpath: - continue - new_pythonpath.append(path) + pp_set = frozenset(sys.path) + new_pythonpath = list(pp_set) + pythonpath = env.get("PYTHONPATH") or "" + for path in frozenset(pythonpath.split(os.pathsep)): + if path and path not in pp_set: + new_pythonpath.append(path) env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) return run_subprocess(args, env=env, **kwargs) From 3e0d422ed5b7d9243fc85b33825e816d03d0e3e5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 1 Aug 2024 17:03:58 +0200 Subject: [PATCH 052/203] fix paths ordering --- client/ayon_core/lib/execute.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index 1c73a97731..23da34b6fa 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -210,8 +210,8 @@ def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs): env = clean_envs_for_ayon_process(os.environ) if add_sys_paths: - pp_set = frozenset(sys.path) - new_pythonpath = list(pp_set) + new_pythonpath = list(sys.path) + pp_set = set(new_pythonpath) pythonpath = env.get("PYTHONPATH") or "" for path in frozenset(pythonpath.split(os.pathsep)): if path and path not in pp_set: From a90c7a0f9029a7587d9bc35182615ce31028bda5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 1 Aug 2024 17:05:48 +0200 Subject: [PATCH 053/203] update lookup set --- client/ayon_core/lib/execute.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index 23da34b6fa..bc55c27bd8 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -211,11 +211,11 @@ def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs): if add_sys_paths: new_pythonpath = list(sys.path) - pp_set = set(new_pythonpath) - pythonpath = env.get("PYTHONPATH") or "" - for path in frozenset(pythonpath.split(os.pathsep)): - if path and path not in pp_set: + lookup_set = set(new_pythonpath) + for path in (env.get("PYTHONPATH") or "").split(os.pathsep): + if path and path not in lookup_set: new_pythonpath.append(path) + lookup_set.add(path) env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) return run_subprocess(args, env=env, **kwargs) From c63f50197c90128334cf79afe77d029c1d95abed Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 6 Aug 2024 11:03:56 +0200 Subject: [PATCH 054/203] use outdated color if container is not from latest version --- client/ayon_core/tools/sceneinventory/model.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/ayon_core/tools/sceneinventory/model.py b/client/ayon_core/tools/sceneinventory/model.py index a40d110476..b7f79986ac 100644 --- a/client/ayon_core/tools/sceneinventory/model.py +++ b/client/ayon_core/tools/sceneinventory/model.py @@ -217,10 +217,7 @@ class InventoryModel(QtGui.QStandardItemModel): version_item = version_items[repre_info.version_id] version_label = format_version(version_item.version) is_hero = version_item.version < 0 - is_latest = version_item.is_latest - # TODO maybe use different colors for last approved and last - # version? Or don't care about color at all? - if not is_latest and not version_item.is_last_approved: + if not version_item.is_latest: version_color = self.OUTDATED_COLOR status_name = version_item.status From e10a78d761210ae8f9484c653b049d02a16444bc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 6 Aug 2024 11:22:49 +0200 Subject: [PATCH 055/203] shutdown existing tray if is running under different user --- client/ayon_core/tools/tray/lib.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index e6de884cb5..f09f400dfa 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -13,7 +13,12 @@ from typing import Optional, Dict, Tuple, Any import ayon_api import requests -from ayon_core.lib import Logger, get_ayon_launcher_args, run_detached_process +from ayon_core.lib import ( + Logger, + get_ayon_launcher_args, + run_detached_process, + get_ayon_username, +) from ayon_core.lib.local_settings import get_ayon_appdirs @@ -590,6 +595,7 @@ def main(force=False): Logger.set_process_name("Tray") tray_info = TrayInfo.new(wait_to_start=False) + file_state = tray_info.get_file_state() if force and file_state in (TrayState.RUNNING, TrayState.STARTING): pid = tray_info.pid @@ -598,6 +604,17 @@ def main(force=False): remove_tray_server_url(force=True) file_state = TrayState.NOT_RUNNING + if file_state in (TrayState.RUNNING, TrayState.STARTING): + expected_username = get_ayon_username() + username = tray_info.get_ayon_username() + # TODO probably show some message to the user??? + if expected_username != username: + pid = tray_info.pid + if pid is not None: + _kill_tray_process(pid) + remove_tray_server_url(force=True) + file_state = TrayState.NOT_RUNNING + if file_state == TrayState.RUNNING: if tray_info.get_state() == TrayState.RUNNING: show_message_in_tray( From d7ab1328cfeab813e55ca0c0c5dd5f590e03e6cc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 6 Aug 2024 16:42:22 +0200 Subject: [PATCH 056/203] 'make_sure_tray_is_running' is also handling username --- client/ayon_core/tools/tray/lib.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index f09f400dfa..0d0ead85d4 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -551,6 +551,7 @@ def show_message_in_tray( def make_sure_tray_is_running( ayon_url: Optional[str] = None, variant: Optional[str] = None, + username: Optional[str] = None, env: Optional[Dict[str, str]] = None ): """Make sure that tray for AYON url and variant is running. @@ -558,19 +559,26 @@ def make_sure_tray_is_running( Args: ayon_url (Optional[str]): AYON server url. variant (Optional[str]): Settings variant. + username (Optional[str]): Username under which should be tray running. env (Optional[Dict[str, str]]): Environment variables for the process. """ - state = get_tray_state(ayon_url, variant) - if state == TrayState.RUNNING: - return + tray_info = TrayInfo.new( + ayon_url, variant, wait_to_start=False + ) + if tray_info.state == TrayState.STARTING: + tray_info.wait_to_start() - if state == TrayState.STARTING: - _wait_for_starting_tray(ayon_url, variant) - state = get_tray_state(ayon_url, variant) - if state == TrayState.RUNNING: + if tray_info.state == TrayState.RUNNING: + if not username: + username = get_ayon_username() + if tray_info.get_ayon_username() == username: return + pid = tray_info.pid + if pid is not None: + _kill_tray_process(pid) + args = get_ayon_launcher_args("tray", "--force") if env is None: env = os.environ.copy() From 0a05731413b411a0fa554b727288e74b13f08aeb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 6 Aug 2024 16:43:15 +0200 Subject: [PATCH 057/203] don't kill tray in make sure function --- client/ayon_core/tools/tray/lib.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 0d0ead85d4..7fec47ee56 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -575,10 +575,6 @@ def make_sure_tray_is_running( if tray_info.get_ayon_username() == username: return - pid = tray_info.pid - if pid is not None: - _kill_tray_process(pid) - args = get_ayon_launcher_args("tray", "--force") if env is None: env = os.environ.copy() From 60c919cd3386de4b7b79261806a29ca972e52745 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 7 Aug 2024 11:24:18 +0200 Subject: [PATCH 058/203] use 'get_default_settings_variant' from utils --- client/ayon_core/tools/tray/lib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 7fec47ee56..fd84a9bd10 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -10,8 +10,8 @@ import signal import locale from typing import Optional, Dict, Tuple, Any -import ayon_api import requests +from ayon_api.utils import get_default_settings_variant from ayon_core.lib import ( Logger, @@ -39,7 +39,7 @@ def _get_default_server_url() -> str: def _get_default_variant() -> str: """Get default settings variant.""" - return ayon_api.get_default_settings_variant() + return get_default_settings_variant() def _get_server_and_variant( From 8a20416e492a8301c8bcdb264b9f6b1bd3d00dc6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 8 Aug 2024 10:10:21 +0200 Subject: [PATCH 059/203] don't validate extension of dropped file --- .../ayon_core/tools/publisher/publish_report_viewer/window.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/window.py b/client/ayon_core/tools/publisher/publish_report_viewer/window.py index aedc3b9e31..d5742d73e0 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/window.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/window.py @@ -576,8 +576,7 @@ class LoadedFilesWidget(QtWidgets.QWidget): filepaths = [] for url in mime_data.urls(): filepath = url.toLocalFile() - ext = os.path.splitext(filepath)[-1] - if os.path.exists(filepath) and ext == ".json": + if os.path.exists(filepath): filepaths.append(filepath) self._add_filepaths(filepaths) event.accept() From 85a7d2e24e3f712eea225cb5baac4ea236cdedbe Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 8 Aug 2024 15:27:45 +0200 Subject: [PATCH 060/203] added more information about plugin to report --- client/ayon_core/tools/publisher/models/publish.py | 8 +++++++- .../tools/publisher/publish_report_viewer/report_items.py | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index ef207bfb79..42dcca7bb3 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -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,17 @@ class PublishReportMaker: if hasattr(plugin, "label"): label = plugin.label + plugin_type = "instance" if plugin.__instanceEnabled__ else "context" + return { "id": plugin.id, "name": plugin.__name__, "label": label, "order": plugin.order, + "filepath": inspect.getfile(plugin), + "docstring": inspect.getdoc(plugin), + "plugin_type": plugin_type, + "families": list(plugin.families), "targets": list(plugin.targets), "instances_data": [], "actions_data": [], diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py b/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py index 206f999bac..cfc2fbfd67 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py @@ -13,6 +13,12 @@ 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 for instance_data in plugin_data["instances_data"]: for log_item in instance_data["logs"]: From d0ea1a31b1562a0803f62fc8c040eb7bb47bf941 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 8 Aug 2024 15:30:25 +0200 Subject: [PATCH 061/203] mark tab widget as active to avoid long loading time --- .../publish_report_viewer/widgets.py | 103 +++++++++++++++--- 1 file changed, 87 insertions(+), 16 deletions(-) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index 61a52533ba..3e2c6adc8a 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -23,32 +23,73 @@ 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 __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): @@ -95,10 +136,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) @@ -108,6 +151,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 @@ -136,10 +184,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 @@ -229,6 +273,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() @@ -237,21 +283,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 @@ -422,6 +480,8 @@ class PublishReportViewerWidget(QtWidgets.QFrame): logs_text_widget = DetailsWidget(details_tab_widget) plugin_load_report_widget = PluginLoadReportWidget(details_tab_widget) + plugin_load_report_widget.set_active(False) + details_tab_widget.addTab(logs_text_widget, "Logs") details_tab_widget.addTab(plugin_load_report_widget, "Crashed plugins") @@ -440,6 +500,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 ) @@ -458,6 +519,7 @@ 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 @@ -517,6 +579,15 @@ class PublishReportViewerWidget(QtWidgets.QFrame): 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 From d073f15a7a8dbd8e9271459e210384c52963898b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 8 Aug 2024 15:30:43 +0200 Subject: [PATCH 062/203] added helper 'ElideLabel' widget to elide long text --- client/ayon_core/tools/utils/__init__.py | 2 + client/ayon_core/tools/utils/widgets.py | 64 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py index 4b5fbeaf67..1eada0c67a 100644 --- a/client/ayon_core/tools/utils/__init__.py +++ b/client/ayon_core/tools/utils/__init__.py @@ -5,6 +5,7 @@ from .widgets import ( ComboBox, CustomTextComboBox, PlaceholderLineEdit, + ElideLabel, ExpandingTextEdit, BaseClickableFrame, ClickableFrame, @@ -88,6 +89,7 @@ __all__ = ( "ComboBox", "CustomTextComboBox", "PlaceholderLineEdit", + "ElideLabel", "ExpandingTextEdit", "BaseClickableFrame", "ClickableFrame", diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 28331fbc35..af85fc915e 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -104,6 +104,70 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit): self.setPalette(filter_palette) +class ElideLabel(QtWidgets.QLabel): + """Label which elide text. + + By default, elide happens in middle. Can be changed with + 'set_elide_mode' method. + """ + 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.ElideMiddle + # 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 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 + + 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) + + class ExpandingTextEdit(QtWidgets.QTextEdit): """QTextEdit which does not have sroll area but expands height.""" From 2a0208a49406537aad756f7305b1cdc9a235e0eb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 8 Aug 2024 15:48:37 +0200 Subject: [PATCH 063/203] added plugin details widget --- .../publish_report_viewer/widgets.py | 161 +++++++++++++++++- 1 file changed, 159 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index 3e2c6adc8a..1dc4eccce8 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -1,7 +1,7 @@ from math import ceil from qtpy import QtWidgets, QtCore, QtGui -from ayon_core.tools.utils import NiceCheckbox +from ayon_core.tools.utils import NiceCheckbox, ElideLabel # from ayon_core.tools.utils import DeselectableTreeView from .constants import ( @@ -352,6 +352,155 @@ 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.QWidget(self) + + plugin_label_widget = QtWidgets.QLabel(content_widget) + + plugin_type_label = QtWidgets.QLabel("Plugin type:") + plugin_type_widget = QtWidgets.QLabel(content_widget) + + plugin_path_label = QtWidgets.QLabel("File Path:") + plugin_path_widget = ElideLabel(content_widget) + + plugin_doc_widget = QtWidgets.QLabel(content_widget) + plugin_doc_widget.setWordWrap(True) + + plugin_families_label = QtWidgets.QLabel("Families:") + plugin_families_widget = QtWidgets.QLabel(content_widget) + plugin_families_widget.setWordWrap(True) + + plugin_label_widget.setText(plugin_item.label or plugin_item.name) + plugin_doc_widget.setText(plugin_item.docstring or "N/A") + plugin_type_widget.setText(plugin_item.plugin_type or "N/A") + plugin_path_widget.setText(plugin_item.filepath or "N/A") + plugin_path_widget.setToolTip(plugin_item.filepath or None) + plugin_families_widget.setText(str(plugin_item.families or "N/A")) + + row = 0 + + content_layout = QtWidgets.QGridLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.setColumnStretch(0, 0) + content_layout.setColumnStretch(1, 1) + + content_layout.addWidget(plugin_label_widget, row, 0, 1, 2) + row += 1 + + content_layout.addWidget(plugin_doc_widget, row, 0, 1, 2) + row += 1 + + content_layout.addWidget(plugin_type_label, row, 0) + content_layout.addWidget(plugin_type_widget, row, 1) + row += 1 + + content_layout.addWidget(plugin_path_label, row, 0) + content_layout.addWidget(plugin_path_widget, row, 1) + row += 1 + + content_layout.addWidget(plugin_families_label, row, 0) + content_layout.addWidget(plugin_families_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) + + content_widget = QtWidgets.QWidget(scroll_area) + + scroll_area.setWidget(content_widget) + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.addStretch(1) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(scroll_area, 1) + + self._scroll_area = scroll_area + self._content_layout = content_layout + self._content_widget = content_widget + + self._widgets_by_plugin_id = {} + + 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._plugin_filter = plugin_filter + self._need_refresh = True + self._update_widgets() + + def set_report(self, report): + self._report_item = report + self._plugin_ids = None + self._plugin_filter = set() + self._need_refresh = True + 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(): + self._content_layout.takeAt(0) + + 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 + + is_new = len(self._widgets_by_plugin_id) == 0 + for plugin_id in self._get_plugin_ids(): + widget = self._widgets_by_plugin_id.get(plugin_id) + if is_new: + 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 + + widget.setVisible( + not self._plugin_filter + or plugin_id in self._plugin_filter + ) + + if is_new: + self._content_layout.addWidget(widget, 0) + + if is_new: + self._content_layout.addStretch(1) + + class DeselectableTreeView(QtWidgets.QTreeView): """A tree view that deselects on clicking on an empty area in the view""" @@ -479,11 +628,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) @@ -524,6 +678,7 @@ class PublishReportViewerWidget(QtWidgets.QFrame): 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 @@ -573,6 +728,7 @@ 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 @@ -609,6 +765,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( From f65cf0325bc62f53147074c8206a6a71a5fdf5d9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 8 Aug 2024 17:04:54 +0200 Subject: [PATCH 064/203] overall fixes of details widget --- client/ayon_core/style/style.css | 12 +++ .../publish_report_viewer/widgets.py | 88 ++++++++++++------- 2 files changed, 69 insertions(+), 31 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 607fd1fa31..c8ba2ba8f2 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -1231,6 +1231,18 @@ ValidationArtistMessage QLabel { background: transparent; } +#PluginDetailsContent { + background: {color:bg-inputs}; + border-radius: 0.2em; +} +#PluginDetailsContent #PluginLabel { + font-size: 14pt; + font-weight: bold; +} +#PluginDetailsContent #PluginFormLabel { + font-weight: bold; +} + CreateNextPageOverlay { font-size: 32pt; } diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index 1dc4eccce8..263ca0464a 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -356,46 +356,66 @@ class PluginDetailsWidget(QtWidgets.QWidget): def __init__(self, plugin_item, parent): super().__init__(parent) - content_widget = QtWidgets.QWidget(self) + content_widget = QtWidgets.QFrame(self) + content_widget.setObjectName("PluginDetailsContent") plugin_label_widget = QtWidgets.QLabel(content_widget) - - plugin_type_label = QtWidgets.QLabel("Plugin type:") - plugin_type_widget = QtWidgets.QLabel(content_widget) + plugin_label_widget.setObjectName("PluginLabel") + plugin_label_widget.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) plugin_path_label = QtWidgets.QLabel("File Path:") plugin_path_widget = ElideLabel(content_widget) - - plugin_doc_widget = QtWidgets.QLabel(content_widget) - plugin_doc_widget.setWordWrap(True) + plugin_path_widget.set_elide_mode(QtCore.Qt.ElideLeft) plugin_families_label = QtWidgets.QLabel("Families:") plugin_families_widget = QtWidgets.QLabel(content_widget) + plugin_families_widget.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) plugin_families_widget.setWordWrap(True) - plugin_label_widget.setText(plugin_item.label or plugin_item.name) - plugin_doc_widget.setText(plugin_item.docstring or "N/A") - plugin_type_widget.setText(plugin_item.plugin_type or "N/A") + for label_widget in ( + plugin_path_label, + plugin_families_label, + ): + label_widget.setObjectName("PluginFormLabel") + + plugin_doc_widget = QtWidgets.QLabel(content_widget) + plugin_doc_widget.setWordWrap(True) + plugin_doc_widget.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + + plugin_label = plugin_item.label or plugin_item.name + if plugin_item.plugin_type: + plugin_label += " ({})".format( + plugin_item.plugin_type.capitalize() + ) + plugin_label_widget.setText(plugin_label) + # plugin_type_widget.setText(plugin_item.plugin_type or "N/A") plugin_path_widget.setText(plugin_item.filepath or "N/A") plugin_path_widget.setToolTip(plugin_item.filepath or None) plugin_families_widget.setText(str(plugin_item.families or "N/A")) + plugin_doc_widget.setText(plugin_item.docstring or "N/A") row = 0 content_layout = QtWidgets.QGridLayout(content_widget) - content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.setContentsMargins(8, 8, 8, 8) content_layout.setColumnStretch(0, 0) content_layout.setColumnStretch(1, 1) content_layout.addWidget(plugin_label_widget, row, 0, 1, 2) row += 1 - content_layout.addWidget(plugin_doc_widget, row, 0, 1, 2) - row += 1 - - content_layout.addWidget(plugin_type_label, row, 0) - content_layout.addWidget(plugin_type_widget, row, 1) - 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(plugin_path_label, row, 0) content_layout.addWidget(plugin_path_widget, row, 1) @@ -417,12 +437,19 @@ class PluginsDetailsWidget(QtWidgets.QWidget): scroll_area = QtWidgets.QScrollArea(self) scroll_area.setWidgetResizable(True) - content_widget = QtWidgets.QWidget(scroll_area) + scroll_content_widget = QtWidgets.QWidget(scroll_area) - scroll_area.setWidget(content_widget) + scroll_area.setWidget(scroll_content_widget) + + content_widget = QtWidgets.QWidget(scroll_content_widget) content_layout = QtWidgets.QVBoxLayout(content_widget) - content_layout.addStretch(1) + content_layout.setContentsMargins(0, 0, 0, 0) + + scroll_content_layout = QtWidgets.QVBoxLayout(scroll_content_widget) + scroll_content_layout.setContentsMargins(0, 0, 0, 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) @@ -433,6 +460,7 @@ class PluginsDetailsWidget(QtWidgets.QWidget): self._content_widget = content_widget self._widgets_by_plugin_id = {} + self._stretch_item_index = 0 self._is_active = True self._need_refresh = False @@ -448,15 +476,15 @@ class PluginsDetailsWidget(QtWidgets.QWidget): self._update_widgets() def set_plugin_filter(self, plugin_filter): - self._plugin_filter = plugin_filter self._need_refresh = True + self._plugin_filter = plugin_filter self._update_widgets() def set_report(self, report): - self._report_item = 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): @@ -465,7 +493,11 @@ class PluginsDetailsWidget(QtWidgets.QWidget): # Clear layout and clear widgets while self._content_layout.count(): - self._content_layout.takeAt(0) + item = self._content_layout.takeAt(0) + widget = item.widget() + if widget: + widget.setVisible(False) + widget.deleteLater() self._widgets_by_plugin_id.clear() @@ -481,25 +513,19 @@ class PluginsDetailsWidget(QtWidgets.QWidget): self._need_refresh = False - is_new = len(self._widgets_by_plugin_id) == 0 for plugin_id in self._get_plugin_ids(): widget = self._widgets_by_plugin_id.get(plugin_id) - if is_new: + 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) widget.setVisible( not self._plugin_filter or plugin_id in self._plugin_filter ) - if is_new: - self._content_layout.addWidget(widget, 0) - - if is_new: - self._content_layout.addStretch(1) - class DeselectableTreeView(QtWidgets.QTreeView): """A tree view that deselects on clicking on an empty area in the view""" From f16cb1f4e137fa0b20fd18e06472bbf74b2735b7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 8 Aug 2024 17:05:09 +0200 Subject: [PATCH 065/203] added option to copy text from ElideLabel --- client/ayon_core/tools/utils/widgets.py | 36 +++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index af85fc915e..e58333a07d 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -107,8 +107,11 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit): class ElideLabel(QtWidgets.QLabel): """Label which elide text. - By default, elide happens in middle. Can be changed with + 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) @@ -119,7 +122,7 @@ class ElideLabel(QtWidgets.QLabel): # Store text set during init self._text = self.text() # Define initial elide mode - self._elide_mode = QtCore.Qt.ElideMiddle + self._elide_mode = QtCore.Qt.ElideRight # Make sure that text of QLabel is empty super().setText("") @@ -133,6 +136,30 @@ class ElideLabel(QtWidgets.QLabel): 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) @@ -156,6 +183,7 @@ class ElideLabel(QtWidgets.QLabel): ): raise ValueError(f"Unknown value '{elide_mode}'") self._elide_mode = elide_mode + self.update() def paintEvent(self, event): super().paintEvent(event) @@ -167,6 +195,10 @@ class ElideLabel(QtWidgets.QLabel): ) painter.drawText(QtCore.QPoint(0, fm.ascent()), elided_line) + def _on_copy_text(self): + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(self._text) + class ExpandingTextEdit(QtWidgets.QTextEdit): """QTextEdit which does not have sroll area but expands height.""" From 3977a21820d0d6e5d8f547e95a0567b0f8e1450e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 8 Aug 2024 17:33:59 +0200 Subject: [PATCH 066/203] use only plugin's docstring --- client/ayon_core/tools/publisher/models/publish.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index 42dcca7bb3..a973d47a11 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -195,14 +195,20 @@ class PublishReportMaker: 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": inspect.getdoc(plugin), + "docstring": docstring, "plugin_type": plugin_type, "families": list(plugin.families), "targets": list(plugin.targets), From cf68742129e89f929793c22255a72ee2f84d2105 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 8 Aug 2024 17:34:20 +0200 Subject: [PATCH 067/203] added class name and order --- .../publish_report_viewer/widgets.py | 78 ++++++++++++------- 1 file changed, 49 insertions(+), 29 deletions(-) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index 263ca0464a..a403bd06fc 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -1,7 +1,7 @@ from math import ceil from qtpy import QtWidgets, QtCore, QtGui -from ayon_core.tools.utils import NiceCheckbox, ElideLabel +from ayon_core.tools.utils import NiceCheckbox, ElideLabel, SeparatorWidget # from ayon_core.tools.utils import DeselectableTreeView from .constants import ( @@ -361,51 +361,66 @@ class PluginDetailsWidget(QtWidgets.QWidget): plugin_label_widget = QtWidgets.QLabel(content_widget) plugin_label_widget.setObjectName("PluginLabel") - plugin_label_widget.setTextInteractionFlags( - QtCore.Qt.TextBrowserInteraction - ) + + plugin_doc_widget = QtWidgets.QLabel(content_widget) + plugin_doc_widget.setWordWrap(True) + + form_separator = SeparatorWidget(parent=content_widget) + + plugin_families_label = QtWidgets.QLabel("Families:") + plugin_families_widget = QtWidgets.QLabel(content_widget) + plugin_families_widget.setWordWrap(True) + + plugin_order_label = QtWidgets.QLabel("Order:") + plugin_order_widget = QtWidgets.QLabel(content_widget) + + plugin_class_label = QtWidgets.QLabel("Class:") + plugin_class_widget = QtWidgets.QLabel(content_widget) plugin_path_label = QtWidgets.QLabel("File Path:") plugin_path_widget = ElideLabel(content_widget) plugin_path_widget.set_elide_mode(QtCore.Qt.ElideLeft) - plugin_families_label = QtWidgets.QLabel("Families:") - plugin_families_widget = QtWidgets.QLabel(content_widget) - plugin_families_widget.setTextInteractionFlags( - QtCore.Qt.TextBrowserInteraction - ) - plugin_families_widget.setWordWrap(True) - + # Set interaction flags + for label_widget in ( + plugin_label_widget, + plugin_families_widget, + plugin_order_widget, + plugin_class_widget, + plugin_doc_widget, + ): + label_widget.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + + # Change style of form labels for label_widget in ( - plugin_path_label, plugin_families_label, + plugin_order_label, + plugin_class_label, + plugin_path_label, ): label_widget.setObjectName("PluginFormLabel") - plugin_doc_widget = QtWidgets.QLabel(content_widget) - plugin_doc_widget.setWordWrap(True) - plugin_doc_widget.setTextInteractionFlags( - QtCore.Qt.TextBrowserInteraction - ) - plugin_label = plugin_item.label or plugin_item.name if plugin_item.plugin_type: plugin_label += " ({})".format( plugin_item.plugin_type.capitalize() ) plugin_label_widget.setText(plugin_label) - # plugin_type_widget.setText(plugin_item.plugin_type or "N/A") - plugin_path_widget.setText(plugin_item.filepath or "N/A") - plugin_path_widget.setToolTip(plugin_item.filepath or None) - plugin_families_widget.setText(str(plugin_item.families or "N/A")) plugin_doc_widget.setText(plugin_item.docstring or "N/A") - - row = 0 + plugin_families_widget.setText(str(plugin_item.families or "N/A")) + plugin_order_widget.setText(str(plugin_item.order or "N/A")) + plugin_class_widget.setText(plugin_item.name or "N/A") + plugin_path_widget.setText(plugin_item.filepath or "N/A") + # Show full path in tooltip + plugin_path_widget.setToolTip(plugin_item.filepath or None) 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 @@ -417,13 +432,18 @@ class PluginDetailsWidget(QtWidgets.QWidget): else: plugin_doc_widget.setVisible(False) - content_layout.addWidget(plugin_path_label, row, 0) - content_layout.addWidget(plugin_path_widget, row, 1) + content_layout.addWidget(form_separator, row, 0, 1, 2) row += 1 - content_layout.addWidget(plugin_families_label, row, 0) - content_layout.addWidget(plugin_families_widget, row, 1) - row += 1 + for label_widget, value_widget in ( + (plugin_families_label, plugin_families_widget), + (plugin_class_label, plugin_class_widget), + (plugin_order_label, plugin_order_widget), + (plugin_path_label, plugin_path_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) From 3bda6370e3049a163b8f642d45d4e71e32b0b51f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 8 Aug 2024 17:34:29 +0200 Subject: [PATCH 068/203] added spacing to widgets --- .../ayon_core/tools/publisher/publish_report_viewer/widgets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index a403bd06fc..de24e1f653 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -465,6 +465,7 @@ class PluginsDetailsWidget(QtWidgets.QWidget): 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) From 08e0eb03b631dbea795ccc8745a6564ea63ab803 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 8 Aug 2024 17:34:44 +0200 Subject: [PATCH 069/203] remove bold style from labels --- client/ayon_core/style/style.css | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index c8ba2ba8f2..2e3ea53fb3 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -1239,9 +1239,6 @@ ValidationArtistMessage QLabel { font-size: 14pt; font-weight: bold; } -#PluginDetailsContent #PluginFormLabel { - font-weight: bold; -} CreateNextPageOverlay { font-size: 32pt; From 3eb960a8568e5366ad685572c9107c653b8fb9ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 8 Aug 2024 18:13:13 +0200 Subject: [PATCH 070/203] :art: use product name for render templates this is fixing issue where product name templates were hardcoded. Old behavior is enabled by default but deprecation warning is issued --- .../ayon_core/pipeline/create/product_name.py | 8 +- .../pipeline/farm/pyblish_functions.py | 274 +++++++++++++----- .../publish/collect_anatomy_instance_data.py | 17 +- client/ayon_core/plugins/publish/integrate.py | 5 + server/settings/tools.py | 8 + 5 files changed, 237 insertions(+), 75 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 8a08bdc36c..597c9f4862 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -1,7 +1,6 @@ import ayon_api - +from ayon_core.lib import StringTemplate, filter_profiles, prepare_template_data from ayon_core.settings import get_project_settings -from ayon_core.lib import filter_profiles, prepare_template_data from .constants import DEFAULT_PRODUCT_TEMPLATE @@ -183,7 +182,10 @@ def get_product_name( fill_pairs[key] = value try: - return template.format(**prepare_template_data(fill_pairs)) + return StringTemplate.format_template( + template=template, + data=prepare_template_data(fill_pairs) + ) except KeyError as exp: raise TemplateFillError( "Value for {} key is missing in template '{}'." diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 72deee185e..fc769a06bb 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -1,5 +1,5 @@ -import os import copy +import os import re import warnings from copy import deepcopy @@ -7,14 +7,11 @@ from copy import deepcopy import attr import ayon_api import clique - -from ayon_core.pipeline import ( - get_current_project_name, - get_representation_path, -) from ayon_core.lib import Logger -from ayon_core.pipeline.publish import KnownPublishError +from ayon_core.pipeline import get_current_project_name, get_representation_path +from ayon_core.pipeline.create import get_product_name from ayon_core.pipeline.farm.patterning import match_aov_pattern +from ayon_core.pipeline.publish import KnownPublishError @attr.s @@ -250,6 +247,9 @@ def create_skeleton_instance( "colorspace": data.get("colorspace") } + if data.get("renderlayer"): + instance_skeleton_data["renderlayer"] = data["renderlayer"] + # skip locking version if we are creating v01 instance_version = data.get("version") # take this if exists if instance_version != 1: @@ -464,7 +464,9 @@ def create_instances_for_aov(instance, skeleton, aov_filter, Args: instance (pyblish.api.Instance): Original instance. skeleton (dict): Skeleton instance data. + aov_filter (dict): AOV filter. skip_integration_repre_list (list): skip + do_not_add_review (bool): Explicitly disable reviews Returns: list of pyblish.api.Instance: Instances created from @@ -515,6 +517,113 @@ def create_instances_for_aov(instance, skeleton, aov_filter, ) +def _get_legacy_product_name_and_group( + product_type, + source_product_name, + task_name, + dynamic_data): + """Get product name with legacy logic. + + This function holds legacy behaviour of creating product name + that is deprecated. This wasn't using product name templates + at all, only hardcoded values. It shouldn't be used anymore, + but transition to templates need careful checking of the project + and studio settings. + + Deprecated: + since 0.4.4 + + Args: + product_type (str): Product type. + source_product_name (str): Source product name. + task_name (str): Task name. + dynamic_data (dict): Dynamic data (camera, aov, ...) + + Returns: + tuple: product name and group name + + """ + warnings.warn("Using legacy product name for renders", + DeprecationWarning) + + if not source_product_name.startswith(product_type): + resulting_group_name = '{}{}{}{}{}'.format( + product_type, + task_name[0].upper(), task_name[1:], + source_product_name[0].upper(), source_product_name[1:]) + else: + resulting_group_name = source_product_name + + # create product name `` + if not source_product_name.startswith(product_type): + resulting_group_name = '{}{}{}{}{}'.format( + product_type, + task_name[0].upper(), task_name[1:], + source_product_name[0].upper(), source_product_name[1:]) + else: + resulting_group_name = source_product_name + + resulting_product_name = '{}'.format(resulting_group_name) + camera = dynamic_data.get("camera") + aov = dynamic_data.get("aov") + if camera: + if not aov: + resulting_product_name = '{}_{}'.format( + resulting_group_name, camera) + elif not aov.startswith(camera): + resulting_product_name = '{}_{}_{}'.format( + resulting_group_name, camera, aov) + else: + resulting_product_name = "{}_{}".format( + resulting_group_name, aov) + else: + if aov: + resulting_product_name = '{}_{}'.format( + resulting_group_name, aov) + + return resulting_product_name, resulting_group_name + + +def _get_product_name_and_group_from_template( + task_entity, + project_name, + host_name, + product_type, + variant, + project_settings, + dynamic_data=None): + """Get product name and group name from template. + + This will get product name and group name from template based on + data provided. It is doing similar work as + `func::_get_legacy_product_name_and_group` but using templates. + + Args: + task_entity (dict): Task entity. + project_name (str): Project name. + host_name (str): Host name. + product_type (str): Product type. + variant (str): Variant. + project_settings (dict): Project settings. + dynamic_data (dict): Dynamic data (aov, renderlayer, camera, ...). + + Returns: + tuple: product name and group name. + + """ + resulting_product_name = get_product_name( + project_name=project_name, + task_name=task_entity["taskName"], + task_type=task_entity["taskType"], + host_name=host_name, + product_type=product_type, + dynamic_data=dynamic_data, + variant=variant, + project_settings=project_settings, + ) + return resulting_product_name, "" + + def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, skip_integration_repre_list, do_not_add_review): """Create instance for each AOV found. @@ -526,10 +635,10 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, instance (pyblish.api.Instance): Original instance. skeleton (dict): Skeleton data for instance (those needed) later by collector. - additional_data (dict): .. + additional_data (dict): ... skip_integration_repre_list (list): list of extensions that shouldn't be published - do_not_addbe _review (bool): explicitly disable review + do_not_add_review (bool): explicitly disable review Returns: @@ -539,68 +648,69 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, ValueError: """ - # TODO: this needs to be taking the task from context or instance - task = os.environ["AYON_TASK_NAME"] anatomy = instance.context.data["anatomy"] - s_product_name = skeleton["productName"] + source_product_name = skeleton["productName"] cameras = instance.data.get("cameras", []) - exp_files = instance.data["expectedFiles"] + expected_files = instance.data["expectedFiles"] log = Logger.get_logger("farm_publishing") instances = [] # go through AOVs in expected files - for aov, files in exp_files[0].items(): - cols, rem = clique.assemble(files) - # we shouldn't have any reminders. And if we do, it should - # be just one item for single frame renders. - if not cols and rem: - if len(rem) != 1: - raise ValueError("Found multiple non related files " - "to render, don't know what to do " - "with them.") - col = rem[0] - ext = os.path.splitext(col)[1].lstrip(".") - else: - # but we really expect only one collection. - # Nothing else make sense. - if len(cols) != 1: - raise ValueError("Only one image sequence type is expected.") # noqa: E501 - ext = cols[0].tail.lstrip(".") - col = list(cols[0]) + for aov, files in expected_files[0].items(): + collected_files = _collect_expected_files_for_aov(files) - # create product name `` - # TODO refactor/remove me - product_type = skeleton["productType"] - if not s_product_name.startswith(product_type): - group_name = '{}{}{}{}{}'.format( - product_type, - task[0].upper(), task[1:], - s_product_name[0].upper(), s_product_name[1:]) - else: - group_name = s_product_name + expected_filepath = collected_files[0] \ + if isinstance(collected_files, (list, tuple)) else collected_files - # if there are multiple cameras, we need to add camera name - expected_filepath = col[0] if isinstance(col, (list, tuple)) else col - cams = [cam for cam in cameras if cam in expected_filepath] - if cams: - for cam in cams: - if not aov: - product_name = '{}_{}'.format(group_name, cam) - elif not aov.startswith(cam): - product_name = '{}_{}_{}'.format(group_name, cam, aov) - else: - product_name = "{}_{}".format(group_name, aov) - else: - if aov: - product_name = '{}_{}'.format(group_name, aov) - else: - product_name = '{}'.format(group_name) + dynamic_data = { + "aov": aov, + "renderlayer": instance.data.get("renderlayer"), + } + + # find if camera is used in the file path + # TODO: this must be changed to be more robust. Any coincidence + # of camera name in the file path will be considered as + # camera name. This is not correct. + camera = [cam for cam in cameras if cam in expected_filepath] + + # Is there just one camera matching? + # TODO: this is not true, we can have multiple cameras in the scene + # and we should be able to detect them all. Currently, we are + # keeping the old behavior, taking the first one found. + if camera: + dynamic_data["camera"] = camera[0] + + project_settings = instance.context.data.get("project_settings") + + use_legacy_product_name = True + try: + use_legacy_product_name = project_settings["core"]["tools"]["creator"]["product_name_profiles"]["use_legacy_for_renders"] # noqa: E501 + except KeyError: + warnings.warn( + ("use_legacy_for_renders not found in project settings. " + "Using legacy product name for renders. Please update " + "your ayon-core version."), DeprecationWarning) + use_legacy_product_name = True + + if use_legacy_product_name: + product_name, group_name = _get_legacy_product_name_and_group( + product_type=skeleton["productType"], + source_product_name=source_product_name, + task_name=instance.data["task"], + dynamic_data=dynamic_data) - if isinstance(col, (list, tuple)): - staging = os.path.dirname(col[0]) else: - staging = os.path.dirname(col) + product_name, group_name = _get_product_name_and_group_from_template( + task_entity=instance.data["taskEntity"], + project_name=instance.context.data["projectName"], + host_name=instance.context.data["hostName"], + product_type=skeleton["productType"], + variant=instance.data.get('variant', ''), + project_settings=project_settings + ) + + staging = os.path.dirname(expected_filepath) try: staging = remap_source(staging, anatomy) @@ -611,10 +721,8 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, app = os.environ.get("AYON_HOST_NAME", "") - if isinstance(col, list): - render_file_name = os.path.basename(col[0]) - else: - render_file_name = os.path.basename(col) + render_file_name = os.path.basename(expected_filepath) + aov_patterns = aov_filter preview = match_aov_pattern(app, aov_patterns, render_file_name) @@ -622,9 +730,10 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, new_instance = deepcopy(skeleton) new_instance["productName"] = product_name new_instance["productGroup"] = group_name + new_instance["aov"] = aov # toggle preview on if multipart is on - # Because we cant query the multipartExr data member of each AOV we'll + # Because we can't query the multipartExr data member of each AOV we'll # need to have hardcoded rule of excluding any renders with # "cryptomatte" in the file name from being a multipart EXR. This issue # happens with Redshift that forces Cryptomatte renders to be separate @@ -650,10 +759,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, new_instance["review"] = True # create representation - if isinstance(col, (list, tuple)): - files = [os.path.basename(f) for f in col] - else: - files = os.path.basename(col) + ext = os.path.splitext(render_file_name)[-1].lstrip(".") # Copy render product "colorspace" data to representation. colorspace = "" @@ -708,6 +814,36 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, return instances +def _collect_expected_files_for_aov(files): + """Collect expected files. + + Args: + files (list): List of files. + + Returns: + list or str: Collection of files or single file. + + Raises: + ValueError: If there are multiple collections. + + """ + cols, rem = clique.assemble(files) + # we shouldn't have any reminders. And if we do, it should + # be just one item for single frame renders. + if not cols and rem: + if len(rem) != 1: + raise ValueError("Found multiple non related files " + "to render, don't know what to do " + "with them.") + return rem[0] + else: + # but we really expect only one collection. + # Nothing else make sense. + if len(cols) != 1: + raise ValueError("Only one image sequence type is expected.") # noqa: E501 + return list(cols[0]) + + def get_resources(project_name, version_entity, extension=None): """Get the files from the specific version. diff --git a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py index b6636696c1..c83906d7f1 100644 --- a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py +++ b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py @@ -138,7 +138,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): folder_path_by_id = {} for instance in context: folder_entity = instance.data.get("folderEntity") - # Skip if instnace does not have filled folder entity + # Skip if instance does not have filled folder entity if not folder_entity: continue folder_id = folder_entity["id"] @@ -385,8 +385,19 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): json.dumps(anatomy_data, indent=4) )) + # make render layer available in anatomy data + render_layer = instance.data.get("renderLayer") + if render_layer: + anatomy_data["renderLayer"] = render_layer + + # make aov name available in anatomy data + aov = instance.data.get("aov") + if aov: + anatomy_data["aov"] = aov + + def _fill_folder_data(self, instance, project_entity, anatomy_data): - # QUESTION should we make sure that all folder data are poped if + # QUESTION: should we make sure that all folder data are popped if # folder data cannot be found? # - 'folder', 'hierarchy', 'parent', 'folder' folder_entity = instance.data.get("folderEntity") @@ -426,7 +437,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): }) def _fill_task_data(self, instance, task_types_by_name, anatomy_data): - # QUESTION should we make sure that all task data are poped if task + # QUESTION: should we make sure that all task data are popped if task # data cannot be resolved? # - 'task' diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 69c14465eb..d3f6c04333 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -744,6 +744,11 @@ class IntegrateAsset(pyblish.api.InstancePlugin): if not is_udim: repre_context["frame"] = first_index_padded + # store renderlayer in context if it exists + # to be later used for example by delivery templates + if instance.data.get("renderlayer"): + repre_context["renderlayer"] = instance.data["renderlayer"] + # Update the destination indexes and padding dst_collection = clique.assemble(dst_filepaths)[0][0] dst_collection.padding = destination_padding diff --git a/server/settings/tools.py b/server/settings/tools.py index 85a66f6a70..3b2c140b3a 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -22,6 +22,14 @@ class ProductTypeSmartSelectModel(BaseSettingsModel): class ProductNameProfile(BaseSettingsModel): _layout = "expanded" + # TODO: change to False in next releases + use_legacy_for_renders: bool = SettingsField( + True, title="Use legacy for renders", + description="Use product naming logic for renders. " + "This is for backwards compatibility enabled by default." + "When enabled, it will ignore any templates for renders " + "that are set in the product name profiles.") + product_types: list[str] = SettingsField( default_factory=list, title="Product types" ) From 551d4f2b7065c3c13467aff3ab749e7a9632dc8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 8 Aug 2024 18:13:47 +0200 Subject: [PATCH 071/203] :fire: remove old python interface file --- .../pipeline/farm/pyblish_functions.pyi | 24 ------------------- 1 file changed, 24 deletions(-) delete mode 100644 client/ayon_core/pipeline/farm/pyblish_functions.pyi diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.pyi b/client/ayon_core/pipeline/farm/pyblish_functions.pyi deleted file mode 100644 index fe0ae57da0..0000000000 --- a/client/ayon_core/pipeline/farm/pyblish_functions.pyi +++ /dev/null @@ -1,24 +0,0 @@ -import pyblish.api -from ayon_core.pipeline import Anatomy -from typing import Tuple, List - - -class TimeData: - start: int - end: int - fps: float | int - step: int - handle_start: int - handle_end: int - - def __init__(self, start: int, end: int, fps: float | int, step: int, handle_start: int, handle_end: int): - ... - ... - -def remap_source(source: str, anatomy: Anatomy): ... -def extend_frames(folder_path: str, product_name: str, start: int, end: int) -> Tuple[int, int]: ... -def get_time_data_from_instance_or_context(instance: pyblish.api.Instance) -> TimeData: ... -def get_transferable_representations(instance: pyblish.api.Instance) -> list: ... -def create_skeleton_instance(instance: pyblish.api.Instance, families_transfer: list = ..., instance_transfer: dict = ...) -> dict: ... -def create_instances_for_aov(instance: pyblish.api.Instance, skeleton: dict, aov_filter: dict) -> List[pyblish.api.Instance]: ... -def attach_instances_to_product(attach_to: list, instances: list) -> list: ... From c50badd51df6dac2987c46111900f7b07d620b70 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 8 Aug 2024 18:34:18 +0200 Subject: [PATCH 072/203] added process time --- .../publish_report_viewer/report_items.py | 3 ++ .../publish_report_viewer/widgets.py | 51 ++++++++++++++----- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py b/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py index cfc2fbfd67..a3c5a7a2fd 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py @@ -20,7 +20,9 @@ class PluginItem: 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: @@ -28,6 +30,7 @@ class PluginItem: if errored: break + self.process_time = process_time self.errored = errored @property diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index de24e1f653..c58fae5c87 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -22,6 +22,21 @@ TRACEBACK_ROLE = QtCore.Qt.UserRole + 2 IS_DETAIL_ITEM_ROLE = QtCore.Qt.UserRole + 3 +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__() @@ -367,27 +382,31 @@ class PluginDetailsWidget(QtWidgets.QWidget): form_separator = SeparatorWidget(parent=content_widget) - plugin_families_label = QtWidgets.QLabel("Families:") - plugin_families_widget = QtWidgets.QLabel(content_widget) - plugin_families_widget.setWordWrap(True) + 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_class_label = QtWidgets.QLabel("Class:") - plugin_class_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_families_widget, - plugin_order_widget, - plugin_class_widget, plugin_doc_widget, + plugin_class_widget, + plugin_order_widget, + plugin_families_widget, + plugin_time_widget, ): label_widget.setTextInteractionFlags( QtCore.Qt.TextBrowserInteraction @@ -395,10 +414,11 @@ class PluginDetailsWidget(QtWidgets.QWidget): # Change style of form labels for label_widget in ( - plugin_families_label, - plugin_order_label, plugin_class_label, + plugin_order_label, + plugin_families_label, plugin_path_label, + plugin_time_label, ): label_widget.setObjectName("PluginFormLabel") @@ -409,12 +429,14 @@ class PluginDetailsWidget(QtWidgets.QWidget): ) plugin_label_widget.setText(plugin_label) plugin_doc_widget.setText(plugin_item.docstring or "N/A") - plugin_families_widget.setText(str(plugin_item.families or "N/A")) - plugin_order_widget.setText(str(plugin_item.order or "N/A")) plugin_class_widget.setText(plugin_item.name or "N/A") + plugin_order_widget.setText(str(plugin_item.order or "N/A")) + plugin_families_widget.setText(str(plugin_item.families or "N/A")) plugin_path_widget.setText(plugin_item.filepath or "N/A") - # Show full path in tooltip plugin_path_widget.setToolTip(plugin_item.filepath or None) + plugin_time_widget.setText( + get_pretty_milliseconds(plugin_item.process_time) + ) content_layout = QtWidgets.QGridLayout(content_widget) content_layout.setContentsMargins(8, 8, 8, 8) @@ -436,10 +458,11 @@ class PluginDetailsWidget(QtWidgets.QWidget): row += 1 for label_widget, value_widget in ( - (plugin_families_label, plugin_families_widget), (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) From 6608a18bb0c8e83acd43eb577d31357a694f8a98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 8 Aug 2024 18:38:56 +0200 Subject: [PATCH 073/203] :recycle: make function public --- client/ayon_core/pipeline/farm/pyblish_functions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index fc769a06bb..4825818980 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -584,7 +584,7 @@ def _get_legacy_product_name_and_group( return resulting_product_name, resulting_group_name -def _get_product_name_and_group_from_template( +def get_product_name_and_group_from_template( task_entity, project_name, host_name, @@ -701,7 +701,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, dynamic_data=dynamic_data) else: - product_name, group_name = _get_product_name_and_group_from_template( + product_name, group_name = get_product_name_and_group_from_template( task_entity=instance.data["taskEntity"], project_name=instance.context.data["projectName"], host_name=instance.context.data["hostName"], From 6acff9e04653930eeb51d83d20af28b8c5746460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 8 Aug 2024 19:00:33 +0200 Subject: [PATCH 074/203] :recycle: first shot at group name also removing need for project settings --- client/ayon_core/pipeline/farm/pyblish_functions.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 4825818980..5a00f8e973 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -590,7 +590,6 @@ def get_product_name_and_group_from_template( host_name, product_type, variant, - project_settings, dynamic_data=None): """Get product name and group name from template. @@ -604,7 +603,6 @@ def get_product_name_and_group_from_template( host_name (str): Host name. product_type (str): Product type. variant (str): Variant. - project_settings (dict): Project settings. dynamic_data (dict): Dynamic data (aov, renderlayer, camera, ...). Returns: @@ -619,9 +617,8 @@ def get_product_name_and_group_from_template( product_type=product_type, dynamic_data=dynamic_data, variant=variant, - project_settings=project_settings, ) - return resulting_product_name, "" + return resulting_product_name, f"{product_type}{variant}" def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, @@ -706,8 +703,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, project_name=instance.context.data["projectName"], host_name=instance.context.data["hostName"], product_type=skeleton["productType"], - variant=instance.data.get('variant', ''), - project_settings=project_settings + variant=instance.data.get('variant', source_product_name), ) staging = os.path.dirname(expected_filepath) From 07f697d5a4dea4618fc480cdbe76f37a96ed5396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 9 Aug 2024 10:43:05 +0200 Subject: [PATCH 075/203] :recycle: derive group name from templates too --- .../pipeline/farm/pyblish_functions.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 5a00f8e973..0cc3cef879 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -597,6 +597,13 @@ def get_product_name_and_group_from_template( data provided. It is doing similar work as `func::_get_legacy_product_name_and_group` but using templates. + To get group name, template is called without any dynamic data, so + (depending on the template itself) it should be product name without + aov. + + Todo: + Maybe we should introduce templates for the groups themselves. + Args: task_entity (dict): Task entity. project_name (str): Project name. @@ -609,6 +616,16 @@ def get_product_name_and_group_from_template( tuple: product name and group name. """ + + resulting_group_name = get_product_name( + project_name=project_name, + task_name=task_entity["taskName"], + task_type=task_entity["taskType"], + host_name=host_name, + product_type=product_type, + variant=variant, + ) + resulting_product_name = get_product_name( project_name=project_name, task_name=task_entity["taskName"], @@ -618,7 +635,7 @@ def get_product_name_and_group_from_template( dynamic_data=dynamic_data, variant=variant, ) - return resulting_product_name, f"{product_type}{variant}" + return resulting_product_name, resulting_group_name def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, From a394556103dfd6f300e0f1998286fb1d0dfc4f0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 9 Aug 2024 13:17:20 +0200 Subject: [PATCH 076/203] :bug: fix task name reference --- client/ayon_core/pipeline/farm/pyblish_functions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 0cc3cef879..54ee081c9f 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -619,7 +619,7 @@ def get_product_name_and_group_from_template( resulting_group_name = get_product_name( project_name=project_name, - task_name=task_entity["taskName"], + task_name=task_entity["name"], task_type=task_entity["taskType"], host_name=host_name, product_type=product_type, @@ -628,7 +628,7 @@ def get_product_name_and_group_from_template( resulting_product_name = get_product_name( project_name=project_name, - task_name=task_entity["taskName"], + task_name=task_entity["name"], task_type=task_entity["taskType"], host_name=host_name, product_type=product_type, From c63d2005cf5cad0941b0097c2c5c00e816ba3f1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 9 Aug 2024 13:18:16 +0200 Subject: [PATCH 077/203] :recycle: move the setting one level up --- .../ayon_core/pipeline/farm/pyblish_functions.py | 2 +- server/settings/tools.py | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 54ee081c9f..aa30aefbd0 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -699,7 +699,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, use_legacy_product_name = True try: - use_legacy_product_name = project_settings["core"]["tools"]["creator"]["product_name_profiles"]["use_legacy_for_renders"] # noqa: E501 + use_legacy_product_name = project_settings["core"]["tools"]["creator"]["use_legacy_product_names_for_renders"] # noqa: E501 except KeyError: warnings.warn( ("use_legacy_for_renders not found in project settings. " diff --git a/server/settings/tools.py b/server/settings/tools.py index 3b2c140b3a..5e9c8e14a0 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -22,13 +22,6 @@ class ProductTypeSmartSelectModel(BaseSettingsModel): class ProductNameProfile(BaseSettingsModel): _layout = "expanded" - # TODO: change to False in next releases - use_legacy_for_renders: bool = SettingsField( - True, title="Use legacy for renders", - description="Use product naming logic for renders. " - "This is for backwards compatibility enabled by default." - "When enabled, it will ignore any templates for renders " - "that are set in the product name profiles.") product_types: list[str] = SettingsField( default_factory=list, title="Product types" @@ -73,6 +66,14 @@ class CreatorToolModel(BaseSettingsModel): title="Create Smart Select" ) ) + # TODO: change to False in next releases + use_legacy_product_names_for_renders: bool = SettingsField( + True, title="Use legacy product names for renders", + description="Use product naming templates for renders. " + "This is for backwards compatibility enabled by default." + "When enabled, it will ignore any templates for renders " + "that are set in the product name profiles.") + product_name_profiles: list[ProductNameProfile] = SettingsField( default_factory=list, title="Product name profiles" From 3350b91cf934aceb0558423e5b7707fd50934315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 9 Aug 2024 13:27:34 +0200 Subject: [PATCH 078/203] :bug: fix letter-case typo --- .../plugins/publish/collect_anatomy_instance_data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py index c83906d7f1..5b750a5232 100644 --- a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py +++ b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py @@ -386,9 +386,9 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): )) # make render layer available in anatomy data - render_layer = instance.data.get("renderLayer") + render_layer = instance.data.get("renderlayer") if render_layer: - anatomy_data["renderLayer"] = render_layer + anatomy_data["renderlayer"] = render_layer # make aov name available in anatomy data aov = instance.data.get("aov") From eddba580ef29cbe1525a4c1579571afb925bb400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 9 Aug 2024 14:17:20 +0200 Subject: [PATCH 079/203] :recycle: explicitly remove `aov` key for group name this solution is a little hack. proper one would be probably introducing product group name templates as mentioned in the code comment --- client/ayon_core/pipeline/farm/pyblish_functions.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index aa30aefbd0..91e155469d 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -616,13 +616,18 @@ def get_product_name_and_group_from_template( tuple: product name and group name. """ - + # remove 'aov' from data used to format group. See todo comment above + # for possible solution. + _dynamic_data = deepcopy(dynamic_data) or {} + if _dynamic_data["aov"]: + del _dynamic_data["aov"] resulting_group_name = get_product_name( project_name=project_name, task_name=task_entity["name"], task_type=task_entity["taskType"], host_name=host_name, product_type=product_type, + dynamic_data=_dynamic_data, variant=variant, ) @@ -721,6 +726,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, host_name=instance.context.data["hostName"], product_type=skeleton["productType"], variant=instance.data.get('variant', source_product_name), + dynamic_data=dynamic_data ) staging = os.path.dirname(expected_filepath) From 9ff4ec856b0ce9f78fe15bedabbca731b36fd837 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 9 Aug 2024 16:00:10 +0200 Subject: [PATCH 080/203] introduced new function to get launcher storage and local dir --- client/ayon_core/lib/__init__.py | 4 +++ client/ayon_core/lib/local_settings.py | 49 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 12c391d867..d4b161031e 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -9,6 +9,8 @@ from .local_settings import ( AYONSettingsRegistry, OpenPypeSecureRegistry, OpenPypeSettingsRegistry, + get_launcher_local_dir, + get_launcher_storage_dir, get_local_site_id, get_ayon_username, get_openpype_username, @@ -144,6 +146,8 @@ __all__ = [ "AYONSettingsRegistry", "OpenPypeSecureRegistry", "OpenPypeSettingsRegistry", + "get_launcher_local_dir", + "get_launcher_storage_dir", "get_local_site_id", "get_ayon_username", "get_openpype_username", diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 54432265d9..68ec48695f 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -30,6 +30,55 @@ import ayon_api _PLACEHOLDER = object() +def get_launcher_storage_dir(*subdirs: str) -> str: + """Get storage directory for launcher. + + Storage directory is used for storing shims, addons, dependencies, etc. + + It is not recommended, but the location can be shared across + multiple machines. + + Note: + This function should be called at least once on bootstrap. + + Args: + *subdirs (str): Subdirectories relative to storage dir. + + Returns: + str: Path to storage directory. + + """ + storage_dir = os.getenv("AYON_LAUNCHER_STORAGE_DIR") + if not storage_dir: + storage_dir = get_ayon_appdirs() + + return os.path.join(storage_dir, *subdirs) + + +def get_launcher_local_dir(*subdirs: str) -> str: + """Get local directory for launcher. + + Local directory is used for storing machine or user specific data. + + The location is user specific. + + Note: + This function should be called at least once on bootstrap. + + Args: + *subdirs (str): Subdirectories relative to local dir. + + Returns: + str: Path to local directory. + + """ + storage_dir = os.getenv("AYON_LAUNCHER_LOCAL_DIR") + if not storage_dir: + storage_dir = get_ayon_appdirs() + + return os.path.join(storage_dir, *subdirs) + + class AYONSecureRegistry: """Store information using keyring. From 0e95019995d6d5b077a9487692934a2ac5afe34d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 9 Aug 2024 16:03:17 +0200 Subject: [PATCH 081/203] use new functions in codebase --- client/ayon_core/addon/base.py | 11 ++++++----- client/ayon_core/lib/local_settings.py | 10 ++-------- client/ayon_core/pipeline/thumbnails.py | 4 ++-- .../tools/publisher/publish_report_viewer/window.py | 10 +++------- client/ayon_core/tools/tray/lib.py | 4 ++-- 5 files changed, 15 insertions(+), 24 deletions(-) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 001ec5d534..0dbbe19942 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -17,7 +17,11 @@ import ayon_api from semver import VersionInfo from ayon_core import AYON_CORE_ROOT -from ayon_core.lib import Logger, is_dev_mode_enabled +from ayon_core.lib import ( + Logger, + is_dev_mode_enabled, + get_launcher_storage_dir, +) from ayon_core.settings import get_studio_settings from .interfaces import ( @@ -327,10 +331,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log): addons_dir = os.environ.get("AYON_ADDONS_DIR") if not addons_dir: - addons_dir = os.path.join( - appdirs.user_data_dir("AYON", "Ynput"), - "addons" - ) + addons_dir = get_launcher_storage_dir("addons") dev_mode_enabled = is_dev_mode_enabled() dev_addons_info = {} diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 68ec48695f..a4ffdb8e39 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -519,20 +519,14 @@ class JSONSettingRegistry(ASettingRegistry): class AYONSettingsRegistry(JSONSettingRegistry): """Class handling AYON general settings registry. - Attributes: - vendor (str): Name used for path construction. - product (str): Additional name used for path construction. - Args: name (Optional[str]): Name of the registry. """ def __init__(self, name=None): - self.vendor = "Ynput" - self.product = "AYON" if not name: name = "AYON_settings" - path = appdirs.user_data_dir(self.product, self.vendor) + path = get_launcher_storage_dir() super(AYONSettingsRegistry, self).__init__(name, path) @@ -578,7 +572,7 @@ def get_local_site_id(): if site_id: return site_id - site_id_path = get_ayon_appdirs("site_id") + site_id_path = get_launcher_local_dir("site_id") if os.path.exists(site_id_path): with open(site_id_path, "r") as stream: site_id = stream.read() diff --git a/client/ayon_core/pipeline/thumbnails.py b/client/ayon_core/pipeline/thumbnails.py index dbb38615d8..401d95f273 100644 --- a/client/ayon_core/pipeline/thumbnails.py +++ b/client/ayon_core/pipeline/thumbnails.py @@ -4,7 +4,7 @@ import collections import ayon_api -from ayon_core.lib.local_settings import get_ayon_appdirs +from ayon_core.lib.local_settings import get_launcher_local_dir FileInfo = collections.namedtuple( @@ -54,7 +54,7 @@ class ThumbnailsCache: """ if self._thumbnails_dir is None: - self._thumbnails_dir = get_ayon_appdirs("thumbnails") + self._thumbnails_dir = get_launcher_local_dir("thumbnails") return self._thumbnails_dir thumbnails_dir = property(get_thumbnails_dir) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/window.py b/client/ayon_core/tools/publisher/publish_report_viewer/window.py index aedc3b9e31..9d1ed86a2b 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/window.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/window.py @@ -2,11 +2,11 @@ import os import json import uuid -import appdirs import arrow from qtpy import QtWidgets, QtCore, QtGui from ayon_core import style +from ayon_core.lib import get_launcher_local_dir from ayon_core.resources import get_ayon_icon_filepath from ayon_core.tools import resources from ayon_core.tools.utils import ( @@ -35,12 +35,8 @@ def get_reports_dir(): str: Path to directory where reports are stored. """ - report_dir = os.path.join( - appdirs.user_data_dir("AYON", "Ynput"), - "publish_report_viewer" - ) - if not os.path.exists(report_dir): - os.makedirs(report_dir) + report_dir = get_launcher_local_dir("publish_report_viewer") + os.makedirs(report_dir, exist_ok=True) return report_dir diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index fd84a9bd10..5f92e8a04f 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -19,7 +19,7 @@ from ayon_core.lib import ( run_detached_process, get_ayon_username, ) -from ayon_core.lib.local_settings import get_ayon_appdirs +from ayon_core.lib.local_settings import get_launcher_local_dir class TrayState: @@ -146,7 +146,7 @@ def get_tray_storage_dir() -> str: str: Tray storage directory where metadata files are stored. """ - return get_ayon_appdirs("tray") + return get_launcher_local_dir("tray") def _get_tray_info_filepath( From 9d2ff9637fb67ba2e1af479caee9b41d80712d73 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 9 Aug 2024 16:05:20 +0200 Subject: [PATCH 082/203] marked 'get_ayon_appdirs' as deprecated --- client/ayon_core/lib/local_settings.py | 53 +++++++++++++++++--------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index a4ffdb8e39..a6814f8b2f 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -3,6 +3,7 @@ import os import json import platform +import warnings from datetime import datetime from abc import ABC, abstractmethod @@ -30,6 +31,38 @@ import ayon_api _PLACEHOLDER = object() +def _get_ayon_appdirs(*args): + return os.path.join( + appdirs.user_data_dir("AYON", "Ynput"), + *args + ) + + +def get_ayon_appdirs(*args): + """Local app data directory of AYON client. + + Deprecated: + Use 'get_launcher_local_dir' or 'get_launcher_storage_dir' based on + use-case. Deprecation added 24/08/09 (0.4.4-dev.1). + + Args: + *args (Iterable[str]): Subdirectories/files in local app data dir. + + Returns: + str: Path to directory/file in local app data dir. + + """ + warnings.warn( + ( + "Function 'get_ayon_appdirs' is deprecated. Should be replaced" + " with 'get_launcher_local_dir' or 'get_launcher_storage_dir'" + " based on use-case." + ), + DeprecationWarning + ) + return _get_ayon_appdirs(*args) + + def get_launcher_storage_dir(*subdirs: str) -> str: """Get storage directory for launcher. @@ -50,7 +83,7 @@ def get_launcher_storage_dir(*subdirs: str) -> str: """ storage_dir = os.getenv("AYON_LAUNCHER_STORAGE_DIR") if not storage_dir: - storage_dir = get_ayon_appdirs() + storage_dir = _get_ayon_appdirs() return os.path.join(storage_dir, *subdirs) @@ -74,7 +107,7 @@ def get_launcher_local_dir(*subdirs: str) -> str: """ storage_dir = os.getenv("AYON_LAUNCHER_LOCAL_DIR") if not storage_dir: - storage_dir = get_ayon_appdirs() + storage_dir = _get_ayon_appdirs() return os.path.join(storage_dir, *subdirs) @@ -546,22 +579,6 @@ def _create_local_site_id(registry=None): return new_id -def get_ayon_appdirs(*args): - """Local app data directory of AYON client. - - Args: - *args (Iterable[str]): Subdirectories/files in local app data dir. - - Returns: - str: Path to directory/file in local app data dir. - """ - - return os.path.join( - appdirs.user_data_dir("AYON", "Ynput"), - *args - ) - - def get_local_site_id(): """Get local site identifier. From f3c9ffac1a5fde32cabe419d12025bb0c1e7165d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 9 Aug 2024 16:05:32 +0200 Subject: [PATCH 083/203] removed unused function '_create_local_site_id' --- client/ayon_core/lib/local_settings.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index a6814f8b2f..256e7bcd28 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -563,22 +563,6 @@ class AYONSettingsRegistry(JSONSettingRegistry): super(AYONSettingsRegistry, self).__init__(name, path) -def _create_local_site_id(registry=None): - """Create a local site identifier.""" - from coolname import generate_slug - - if registry is None: - registry = AYONSettingsRegistry() - - new_id = generate_slug(3) - - print("Created local site id \"{}\"".format(new_id)) - - registry.set_item("localId", new_id) - - return new_id - - def get_local_site_id(): """Get local site identifier. From c9a2022d0c87227be96123a3074fedbfd8614baa Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 9 Aug 2024 16:19:27 +0200 Subject: [PATCH 084/203] remove unused import --- client/ayon_core/addon/base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 0dbbe19942..383703e2bc 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -12,7 +12,6 @@ from uuid import uuid4 from abc import ABC, abstractmethod from typing import Optional -import appdirs import ayon_api from semver import VersionInfo From 6e21bc4779c1ff1124ede43e55088a409bdafddc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 9 Aug 2024 17:12:07 +0200 Subject: [PATCH 085/203] change popup text to popup icon --- client/ayon_core/resources/images/popout.png | Bin 0 -> 4224 bytes .../publish_report_viewer/widgets.py | 27 ++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 client/ayon_core/resources/images/popout.png diff --git a/client/ayon_core/resources/images/popout.png b/client/ayon_core/resources/images/popout.png new file mode 100644 index 0000000000000000000000000000000000000000..838c29483ec9309603e8bbe6b300f5a780cea681 GIT binary patch literal 4224 zcmeHKdr*_t65lVN1W^G+NK}YQ02M(Vt&kE4qM$*<_Y+YRAwE!|D1riCs?t;mQi2F5 zk5($8f}m6Z1wo_HXe2%>R;ys5XjLEx8e&N9W;%E7zjtmsb7$_{%w*2_owK`Vch5e~ zw|`#P%prqD4g!E7+*vag08nsB0qQ^;dJ-RM0f=umOrJh4e!XzI)4x3(i>A{NdThx)-|@Z=c6s84+@FL-i@k`}Et?Fw;Y6 zUB_NF&!6r#Cvih&S*gFbkHfL@6!yHfYktKGPwVQ)3V&Ol#bssI4)b?Nh85Wqt*Y5B z+?f{sXdTlz+5K76%>ikh_ET4=3MBi-k*wH#drDe2+AbN^=&MbC^mCT&;oJxLVHGDi zpSP~MZ>5kcxbLmvB+Q$pgiR?e>d-VT{cw^MBXw7FP`>VRpnlS6@#$OT+1De}Ou3VG z&4|eUW9$ASZ?@7WoC`A;K0SIkP0Aw*f~*$*}D{LX;Xrx!SxoBc>ij)I>v;Z>!> zmAjYTJawx0XvIFt-x_iM*lvE^fafk#*UlKU;CSuq2d%w>M@sH)v)D2FdQt!TcrEp( zA$_MtwQvMmSe?uD1LsfYJxkMe+{HUF=g(Rm58&=-{1b`CISBwmk8@{CTa;Mv;rF>^ zuRcde)jYY!#r^5O%yK#H893tlfgrma+ri9QtI+Vx8%Cy>hOR&Gp}ze+jUeQMk~0T1v@y`W z&0BVx>+h=>Iu5|sQpuqc&~%b+Fh`5RWSY4l0Q`wkaxF3~^w4Jy2Z+6+nH&I6<)mz+ zBJC29AJIy+~8+QG_3U!)D98|S5(U_2jccNOYGmn0>N) zLYUO7v0Ki9qR{R-CZ?s8z}`b2y<~`h5z>8}$@%8bg|sEQpa6w)-wF|@$yP+3d_-xj z$xdbo6}=|>uceJGA#p_gA>$Q6i@9?3mYk(fG^bnmCsYh0x6y5y+~l^ zt189yaXn+fFH+aXsWxkBe|e&xxDcsJ%FQ=vth_;v1`9gxv{h;eev-6!<`GXwb=ChoxoI(tbl4;X&9yfsU>l=j-HIjn zt}RNRZP5ZDDXz&KBn+X91;0!@2F9J?tCft{m@G71-|+ZV(P60~QtJ+)Lp z8jW<{TndRXJp@>I>Q~A_iLM@_>@~VNF9ND;l?rE5X!=IPIf@~&$wMEzu@p2-NZ}l< zd-5!l;71|na4&tqwNMO;OLQ^}<3TRd#4n&gq`$P-7<-Quzs7jFm8M> z=S0M-tzQZ&qegTpG$d~)V_}yqG$r*l7f+yK?s)1$rvnrS)x3!mXzdYk9-!VQqILOV z%n^H~;uln41C1=j(7wRaBF=3z%q~jzWB?Mem5MFMs-%$#K>+L`HE$;#Q$i;bxCjf>yc&di z5zYaq&sFn2BScqCL>>cAcXBKiSP^HjDG1ajFav+X9dr0GUwz^*zzRBf3PG0o1giQQ zjr<EaB6Cp6O6gaG+9jU*o>*h(4A zvjDvD@E8iv?r0?V44}z0k})`3*GK|zxT29nQoz?oX=xP8qLX=OOfzoIFa`clDZ?4- z<3ew}$5;Xi#A+ee99kENIH93<$EeXZwCRXiH~Pj1U~$m6k!K@B@u3!zQYFH-#2TW_dLXK&%%S^w>ZLzI^AEb7)cH6KTSQ-H~>t&^4x7)G=vi zQwI{P3C`?%L;Alzf_g_kq>dteJqchp*WGB*Na(mvYabb{wB+F}M-}cW$lw%Yp^siJ za@?$IlcUi2;hE>vLc-{<@Qe#;;WV5rUuqwxv@}5vOmz2qDP;^Cj^2&fF3;eEVhDx% zCOpJhAkLDW)s7>n+p&?}{j*nNJ85|y z8oo#p{%>W=dTjrju!$EGw(FGIG1G<8z!prRbyCk+*IFX5ENRGvb%p994Kp=K8Ony26Xl?o2EMtt$OkDOvV} zGKQ;9G-~>+SmgiX@3C74#>$$uh4oC?hvYm%{Uq?vwXs0^h6nfSR!d z7sQeX@+dy8EQ!d4WUlH&Hac+DCHiV&)=aj#X>}w0I zepiq6Xt~z5DPpd3-%%!Kiq;CwsEWn9P_e-wg=$u979eJ4t2S|;`{0iIt2?8gfBu~Q z+}Z16IP7dVqFNfSDY1s~SC_b{PEzulxg!78S!fi4o0{5X4=d%J&V3*6&Forr+B9IB zW?5^aOJ7~N`qqGe>Y8j_m{=k5-)9!y#)FQ}v0}vyg(=AYxHEdyCo-wao_+NI@x$m; zkFMG3x7cZQ@#57t+uCdLT24(q-grdi$Vx7?5wMcS3fp6pje^A{V_3T?Oys_36u4(4h?x7l(9jVkNJ@Csd-FES<$R`!a+~U*5|Dhu6kew{DlX zn3inOw5mTv%(>I%qU&GW81%snI(E3qEIQf#hgI6Uo8Z^p1aYNv-&%2FXm`cg_Nuh0 zW=#!6x7v6EQaxl3K2P29y6YB!)`{!0RJ~suDxLbaiQ_W;hWynpJ>>T-Gz`#k+utXg S%EFcbzzqtUaV9Ww&p!bC9@&Hd literal 0 HcmV?d00001 diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index 61a52533ba..96663a4c4a 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -1,7 +1,13 @@ from math import ceil from qtpy import QtWidgets, QtCore, QtGui -from ayon_core.tools.utils import NiceCheckbox +from ayon_core.tools.utils import ( + NiceCheckbox, + IconButton, + paint_image_with_color, +) +from ayon_core.resources import get_image_path +from ayon_core.style import get_objected_colors # from ayon_core.tools.utils import DeselectableTreeView from .constants import ( @@ -410,12 +416,27 @@ class PublishReportViewerWidget(QtWidgets.QFrame): details_widget = QtWidgets.QWidget(self) details_tab_widget = QtWidgets.QTabWidget(details_widget) - details_popup_btn = QtWidgets.QPushButton("PopUp", details_widget) + + btns_widget = QtWidgets.QWidget(details_widget) + + popout_image = QtGui.QImage(get_image_path("popout.png")) + popout_color = get_objected_colors("font") + popout_icon = QtGui.QIcon( + paint_image_with_color(popout_image, popout_color.get_qcolor()) + ) + details_popup_btn = IconButton(btns_widget) + details_popup_btn.setIcon(popout_icon) + details_popup_btn.setToolTip("Pop Out") + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(details_popup_btn, 0) 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_layout.addWidget(btns_widget, 0) details_popup = DetailsPopup(self, details_tab_widget) From c02f90d5d3a3b99eb602bbff5aefff2ccf78355c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 9 Aug 2024 17:12:54 +0200 Subject: [PATCH 086/203] change default size of window based on center widget --- .../tools/publisher/publish_report_viewer/widgets.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index 96663a4c4a..24c26baa70 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -343,11 +343,15 @@ class DetailsPopup(QtWidgets.QDialog): def showEvent(self, event): layout = self.layout() + cw_size = self._center_widget.size() layout.insertWidget(0, self._center_widget) - super().showEvent(event) if self._first_show: self._first_show = False - self.resize(700, 400) + self.resize( + max(cw_size.width(), 700), + max(cw_size.height(), 400) + ) + super().showEvent(event) def closeEvent(self, event): super().closeEvent(event) From fe8b57f1d3c98508a55e38e1d4bdfd5423889326 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 12 Aug 2024 12:08:09 +0200 Subject: [PATCH 087/203] moved exceptions to single file --- client/ayon_core/pipeline/create/__init__.py | 16 ++- client/ayon_core/pipeline/create/context.py | 128 ++---------------- .../pipeline/create/creator_plugins.py | 10 -- .../ayon_core/pipeline/create/exceptions.py | 114 ++++++++++++++++ 4 files changed, 140 insertions(+), 128 deletions(-) create mode 100644 client/ayon_core/pipeline/create/exceptions.py diff --git a/client/ayon_core/pipeline/create/__init__.py b/client/ayon_core/pipeline/create/__init__.py index da9cafad5a..68e173d6b9 100644 --- a/client/ayon_core/pipeline/create/__init__.py +++ b/client/ayon_core/pipeline/create/__init__.py @@ -4,6 +4,20 @@ from .constants import ( PRE_CREATE_THUMBNAIL_KEY, DEFAULT_VARIANT_VALUE, ) +from .exceptions import ( + UnavailableSharedData, + ImmutableKeyError, + HostMissRequiredMethod, + ConvertorsOperationFailed, + ConvertorsFindFailed, + ConvertorsConversionFailed, + CreatorError, + CreatorsCreateFailed, + CreatorsCollectionFailed, + CreatorsSaveFailed, + CreatorsRemoveFailed, + CreatorsOperationFailed, +) from .utils import ( get_last_versions_for_instances, @@ -17,8 +31,6 @@ from .product_name import ( ) from .creator_plugins import ( - CreatorError, - BaseCreator, Creator, AutoCreator, diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 1c64d22733..0dd8ed1bd1 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -29,12 +29,23 @@ from ayon_core.pipeline import ( ) from ayon_core.pipeline.plugin_discover import DiscoverResult +from .exceptions import ( + CreatorError, + ImmutableKeyError, + CreatorsCreateFailed, + CreatorsCollectionFailed, + CreatorsSaveFailed, + CreatorsRemoveFailed, + ConvertorsFindFailed, + ConvertorsConversionFailed, + UnavailableSharedData, + HostMissRequiredMethod, +) from .creator_plugins import ( Creator, AutoCreator, discover_creator_plugins, discover_convertor_plugins, - CreatorError, ) # Changes of instances and context are send as tuple of 2 information @@ -42,68 +53,6 @@ UpdateData = collections.namedtuple("UpdateData", ["instance", "changes"]) _NOT_SET = object() -class UnavailableSharedData(Exception): - """Shared data are not available at the moment when are accessed.""" - pass - - -class ImmutableKeyError(TypeError): - """Accessed key is immutable so does not allow changes or removals.""" - - def __init__(self, key, msg=None): - self.immutable_key = key - if not msg: - msg = "Key \"{}\" is immutable and does not allow changes.".format( - key - ) - super(ImmutableKeyError, self).__init__(msg) - - -class HostMissRequiredMethod(Exception): - """Host does not have implemented required functions for creation.""" - - def __init__(self, host, missing_methods): - self.missing_methods = missing_methods - self.host = host - joined_methods = ", ".join( - ['"{}"'.format(name) for name in missing_methods] - ) - dirpath = os.path.dirname( - os.path.normpath(inspect.getsourcefile(host)) - ) - dirpath_parts = dirpath.split(os.path.sep) - host_name = dirpath_parts.pop(-1) - if host_name == "api": - host_name = dirpath_parts.pop(-1) - - msg = "Host \"{}\" does not have implemented method/s {}".format( - host_name, joined_methods - ) - super(HostMissRequiredMethod, self).__init__(msg) - - -class ConvertorsOperationFailed(Exception): - def __init__(self, msg, failed_info): - super(ConvertorsOperationFailed, self).__init__(msg) - self.failed_info = failed_info - - -class ConvertorsFindFailed(ConvertorsOperationFailed): - def __init__(self, failed_info): - msg = "Failed to find incompatible products" - super(ConvertorsFindFailed, self).__init__( - msg, failed_info - ) - - -class ConvertorsConversionFailed(ConvertorsOperationFailed): - def __init__(self, failed_info): - msg = "Failed to convert incompatible products" - super(ConvertorsConversionFailed, self).__init__( - msg, failed_info - ) - - def prepare_failed_convertor_operation_info(identifier, exc_info): exc_type, exc_value, exc_traceback = exc_info formatted_traceback = "".join(traceback.format_exception( @@ -117,59 +66,6 @@ def prepare_failed_convertor_operation_info(identifier, exc_info): } -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 = "Failed to create instances" - super(CreatorsCreateFailed, self).__init__( - msg, failed_info - ) - - def prepare_failed_creator_operation_info( identifier, label, exc_info, add_traceback=True ): diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 624f1c9588..1e09eb62a1 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -26,16 +26,6 @@ if TYPE_CHECKING: from .context import CreateContext, CreatedInstance, UpdateData # noqa: F401 -class CreatorError(Exception): - """Should be raised when creator failed because of known issue. - - Message of error should be user readable. - """ - - def __init__(self, message): - super(CreatorError, self).__init__(message) - - class ProductConvertorPlugin(ABC): """Helper for conversion of instances created using legacy creators. diff --git a/client/ayon_core/pipeline/create/exceptions.py b/client/ayon_core/pipeline/create/exceptions.py new file mode 100644 index 0000000000..24264840cb --- /dev/null +++ b/client/ayon_core/pipeline/create/exceptions.py @@ -0,0 +1,114 @@ +import os +import inspect + + +class UnavailableSharedData(Exception): + """Shared data are not available at the moment when are accessed.""" + pass + + +class ImmutableKeyError(TypeError): + """Accessed key is immutable so does not allow changes or removals.""" + + def __init__(self, key, msg=None): + self.immutable_key = key + if not msg: + msg = "Key \"{}\" is immutable and does not allow changes.".format( + key + ) + super().__init__(msg) + + +class HostMissRequiredMethod(Exception): + """Host does not have implemented required functions for creation.""" + + def __init__(self, host, missing_methods): + self.missing_methods = missing_methods + self.host = host + joined_methods = ", ".join( + ['"{}"'.format(name) for name in missing_methods] + ) + dirpath = os.path.dirname( + os.path.normpath(inspect.getsourcefile(host)) + ) + dirpath_parts = dirpath.split(os.path.sep) + host_name = dirpath_parts.pop(-1) + if host_name == "api": + host_name = dirpath_parts.pop(-1) + + msg = "Host \"{}\" does not have implemented method/s {}".format( + host_name, joined_methods + ) + super().__init__(msg) + + +class ConvertorsOperationFailed(Exception): + def __init__(self, msg, failed_info): + super().__init__(msg) + self.failed_info = failed_info + + +class ConvertorsFindFailed(ConvertorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed to find incompatible products" + super().__init__(msg, failed_info) + + +class ConvertorsConversionFailed(ConvertorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed to convert incompatible products" + super().__init__(msg, failed_info) + + +class CreatorError(Exception): + """Should be raised when creator failed because of known issue. + + Message of error should be artist friendly. + """ + pass + + +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().__init__(msg) + self.failed_info = failed_info + + +class CreatorsCollectionFailed(CreatorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed to collect instances" + super().__init__(msg, failed_info) + + +class CreatorsSaveFailed(CreatorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed update instance changes" + super().__init__(msg, failed_info) + + +class CreatorsRemoveFailed(CreatorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed to remove instances" + super().__init__(msg, failed_info) + + +class CreatorsCreateFailed(CreatorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed to create instances" + super().__init__(msg, failed_info) + From 558cc13cdc10942334d5815829792eb078d67c27 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 12 Aug 2024 12:10:15 +0200 Subject: [PATCH 088/203] move 'TrackChangesItem' to separate file --- client/ayon_core/pipeline/create/changes.py | 313 +++++++++++++++++++ client/ayon_core/pipeline/create/context.py | 314 +------------------- 2 files changed, 314 insertions(+), 313 deletions(-) create mode 100644 client/ayon_core/pipeline/create/changes.py diff --git a/client/ayon_core/pipeline/create/changes.py b/client/ayon_core/pipeline/create/changes.py new file mode 100644 index 0000000000..217478ee30 --- /dev/null +++ b/client/ayon_core/pipeline/create/changes.py @@ -0,0 +1,313 @@ +import copy + +_EMPTY_VALUE = object() + + +class TrackChangesItem(object): + """Helper object to track changes in data. + + Has access to full old and new data and will create deep copy of them, + so it is not needed to create copy before passed in. + + Can work as a dictionary if old or new value is a dictionary. In + that case received object is another object of 'TrackChangesItem'. + + Goal is to be able to get old or new value as was or only changed values + or get information about removed/changed keys, and all of that on + any "dictionary level". + + ``` + # Example of possible usages + >>> old_value = { + ... "key_1": "value_1", + ... "key_2": { + ... "key_sub_1": 1, + ... "key_sub_2": { + ... "enabled": True + ... } + ... }, + ... "key_3": "value_2" + ... } + >>> new_value = { + ... "key_1": "value_1", + ... "key_2": { + ... "key_sub_2": { + ... "enabled": False + ... }, + ... "key_sub_3": 3 + ... }, + ... "key_3": "value_3" + ... } + + >>> changes = TrackChangesItem(old_value, new_value) + >>> changes.changed + True + + >>> changes["key_2"]["key_sub_1"].new_value is None + True + + >>> list(sorted(changes.changed_keys)) + ['key_2', 'key_3'] + + >>> changes["key_2"]["key_sub_2"]["enabled"].changed + True + + >>> changes["key_2"].removed_keys + {'key_sub_1'} + + >>> list(sorted(changes["key_2"].available_keys)) + ['key_sub_1', 'key_sub_2', 'key_sub_3'] + + >>> changes.new_value == new_value + True + + # Get only changed values + only_changed_new_values = { + key: changes[key].new_value + for key in changes.changed_keys + } + ``` + + Args: + old_value (Any): Old value. + new_value (Any): New value. + """ + + def __init__(self, old_value, new_value): + self._changed = old_value != new_value + # Resolve if value is '_EMPTY_VALUE' after comparison of the values + if old_value is _EMPTY_VALUE: + old_value = None + if new_value is _EMPTY_VALUE: + new_value = None + self._old_value = copy.deepcopy(old_value) + self._new_value = copy.deepcopy(new_value) + + self._old_is_dict = isinstance(old_value, dict) + self._new_is_dict = isinstance(new_value, dict) + + self._old_keys = None + self._new_keys = None + self._available_keys = None + self._removed_keys = None + + self._changed_keys = None + + self._sub_items = None + + def __getitem__(self, key): + """Getter looks into subitems if object is dictionary.""" + + if self._sub_items is None: + self._prepare_sub_items() + return self._sub_items[key] + + def __bool__(self): + """Boolean of object is if old and new value are the same.""" + + return self._changed + + def get(self, key, default=None): + """Try to get sub item.""" + + if self._sub_items is None: + self._prepare_sub_items() + return self._sub_items.get(key, default) + + @property + def old_value(self): + """Get copy of old value. + + Returns: + Any: Whatever old value was. + """ + + return copy.deepcopy(self._old_value) + + @property + def new_value(self): + """Get copy of new value. + + Returns: + Any: Whatever new value was. + """ + + return copy.deepcopy(self._new_value) + + @property + def changed(self): + """Value changed. + + Returns: + bool: If data changed. + """ + + return self._changed + + @property + def is_dict(self): + """Object can be used as dictionary. + + Returns: + bool: When can be used that way. + """ + + return self._old_is_dict or self._new_is_dict + + @property + def changes(self): + """Get changes in raw data. + + This method should be used only if 'is_dict' value is 'True'. + + Returns: + Dict[str, Tuple[Any, Any]]: Changes are by key in tuple + (, ). If 'is_dict' is 'False' then + output is always empty dictionary. + """ + + output = {} + if not self.is_dict: + return output + + old_value = self.old_value + new_value = self.new_value + for key in self.changed_keys: + _old = None + _new = None + if self._old_is_dict: + _old = old_value.get(key) + if self._new_is_dict: + _new = new_value.get(key) + output[key] = (_old, _new) + return output + + # Methods/properties that can be used when 'is_dict' is 'True' + @property + def old_keys(self): + """Keys from old value. + + Empty set is returned if old value is not a dict. + + Returns: + Set[str]: Keys from old value. + """ + + if self._old_keys is None: + self._prepare_keys() + return set(self._old_keys) + + @property + def new_keys(self): + """Keys from new value. + + Empty set is returned if old value is not a dict. + + Returns: + Set[str]: Keys from new value. + """ + + if self._new_keys is None: + self._prepare_keys() + return set(self._new_keys) + + @property + def changed_keys(self): + """Keys that has changed from old to new value. + + Empty set is returned if both old and new value are not a dict. + + Returns: + Set[str]: Keys of changed keys. + """ + + if self._changed_keys is None: + self._prepare_sub_items() + return set(self._changed_keys) + + @property + def available_keys(self): + """All keys that are available in old and new value. + + Empty set is returned if both old and new value are not a dict. + Output is Union of 'old_keys' and 'new_keys'. + + Returns: + Set[str]: All keys from old and new value. + """ + + if self._available_keys is None: + self._prepare_keys() + return set(self._available_keys) + + @property + def removed_keys(self): + """Key that are not available in new value but were in old value. + + Returns: + Set[str]: All removed keys. + """ + + if self._removed_keys is None: + self._prepare_sub_items() + return set(self._removed_keys) + + def _prepare_keys(self): + old_keys = set() + new_keys = set() + if self._old_is_dict and self._new_is_dict: + old_keys = set(self._old_value.keys()) + new_keys = set(self._new_value.keys()) + + elif self._old_is_dict: + old_keys = set(self._old_value.keys()) + + elif self._new_is_dict: + new_keys = set(self._new_value.keys()) + + self._old_keys = old_keys + self._new_keys = new_keys + self._available_keys = old_keys | new_keys + self._removed_keys = old_keys - new_keys + + def _prepare_sub_items(self): + sub_items = {} + changed_keys = set() + + old_keys = self.old_keys + new_keys = self.new_keys + new_value = self.new_value + old_value = self.old_value + if self._old_is_dict and self._new_is_dict: + for key in self.available_keys: + item = TrackChangesItem( + old_value.get(key), new_value.get(key) + ) + sub_items[key] = item + if item.changed or key not in old_keys or key not in new_keys: + changed_keys.add(key) + + elif self._old_is_dict: + old_keys = set(old_value.keys()) + available_keys = set(old_keys) + changed_keys = set(available_keys) + for key in available_keys: + # NOTE Use '_EMPTY_VALUE' because old value could be 'None' + # which would result in "unchanged" item + sub_items[key] = TrackChangesItem( + old_value.get(key), _EMPTY_VALUE + ) + + elif self._new_is_dict: + new_keys = set(new_value.keys()) + available_keys = set(new_keys) + changed_keys = set(available_keys) + for key in available_keys: + # NOTE Use '_EMPTY_VALUE' because new value could be 'None' + # which would result in "unchanged" item + sub_items[key] = TrackChangesItem( + _EMPTY_VALUE, new_value.get(key) + ) + + self._sub_items = sub_items + self._changed_keys = changed_keys diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 0dd8ed1bd1..6f802a5a6e 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -41,6 +41,7 @@ from .exceptions import ( UnavailableSharedData, HostMissRequiredMethod, ) +from .changes import TrackChangesItem from .creator_plugins import ( Creator, AutoCreator, @@ -84,319 +85,6 @@ def prepare_failed_creator_operation_info( } -_EMPTY_VALUE = object() - - -class TrackChangesItem(object): - """Helper object to track changes in data. - - Has access to full old and new data and will create deep copy of them, - so it is not needed to create copy before passed in. - - Can work as a dictionary if old or new value is a dictionary. In - that case received object is another object of 'TrackChangesItem'. - - Goal is to be able to get old or new value as was or only changed values - or get information about removed/changed keys, and all of that on - any "dictionary level". - - ``` - # Example of possible usages - >>> old_value = { - ... "key_1": "value_1", - ... "key_2": { - ... "key_sub_1": 1, - ... "key_sub_2": { - ... "enabled": True - ... } - ... }, - ... "key_3": "value_2" - ... } - >>> new_value = { - ... "key_1": "value_1", - ... "key_2": { - ... "key_sub_2": { - ... "enabled": False - ... }, - ... "key_sub_3": 3 - ... }, - ... "key_3": "value_3" - ... } - - >>> changes = TrackChangesItem(old_value, new_value) - >>> changes.changed - True - - >>> changes["key_2"]["key_sub_1"].new_value is None - True - - >>> list(sorted(changes.changed_keys)) - ['key_2', 'key_3'] - - >>> changes["key_2"]["key_sub_2"]["enabled"].changed - True - - >>> changes["key_2"].removed_keys - {'key_sub_1'} - - >>> list(sorted(changes["key_2"].available_keys)) - ['key_sub_1', 'key_sub_2', 'key_sub_3'] - - >>> changes.new_value == new_value - True - - # Get only changed values - only_changed_new_values = { - key: changes[key].new_value - for key in changes.changed_keys - } - ``` - - Args: - old_value (Any): Old value. - new_value (Any): New value. - """ - - def __init__(self, old_value, new_value): - self._changed = old_value != new_value - # Resolve if value is '_EMPTY_VALUE' after comparison of the values - if old_value is _EMPTY_VALUE: - old_value = None - if new_value is _EMPTY_VALUE: - new_value = None - self._old_value = copy.deepcopy(old_value) - self._new_value = copy.deepcopy(new_value) - - self._old_is_dict = isinstance(old_value, dict) - self._new_is_dict = isinstance(new_value, dict) - - self._old_keys = None - self._new_keys = None - self._available_keys = None - self._removed_keys = None - - self._changed_keys = None - - self._sub_items = None - - def __getitem__(self, key): - """Getter looks into subitems if object is dictionary.""" - - if self._sub_items is None: - self._prepare_sub_items() - return self._sub_items[key] - - def __bool__(self): - """Boolean of object is if old and new value are the same.""" - - return self._changed - - def get(self, key, default=None): - """Try to get sub item.""" - - if self._sub_items is None: - self._prepare_sub_items() - return self._sub_items.get(key, default) - - @property - def old_value(self): - """Get copy of old value. - - Returns: - Any: Whatever old value was. - """ - - return copy.deepcopy(self._old_value) - - @property - def new_value(self): - """Get copy of new value. - - Returns: - Any: Whatever new value was. - """ - - return copy.deepcopy(self._new_value) - - @property - def changed(self): - """Value changed. - - Returns: - bool: If data changed. - """ - - return self._changed - - @property - def is_dict(self): - """Object can be used as dictionary. - - Returns: - bool: When can be used that way. - """ - - return self._old_is_dict or self._new_is_dict - - @property - def changes(self): - """Get changes in raw data. - - This method should be used only if 'is_dict' value is 'True'. - - Returns: - Dict[str, Tuple[Any, Any]]: Changes are by key in tuple - (, ). If 'is_dict' is 'False' then - output is always empty dictionary. - """ - - output = {} - if not self.is_dict: - return output - - old_value = self.old_value - new_value = self.new_value - for key in self.changed_keys: - _old = None - _new = None - if self._old_is_dict: - _old = old_value.get(key) - if self._new_is_dict: - _new = new_value.get(key) - output[key] = (_old, _new) - return output - - # Methods/properties that can be used when 'is_dict' is 'True' - @property - def old_keys(self): - """Keys from old value. - - Empty set is returned if old value is not a dict. - - Returns: - Set[str]: Keys from old value. - """ - - if self._old_keys is None: - self._prepare_keys() - return set(self._old_keys) - - @property - def new_keys(self): - """Keys from new value. - - Empty set is returned if old value is not a dict. - - Returns: - Set[str]: Keys from new value. - """ - - if self._new_keys is None: - self._prepare_keys() - return set(self._new_keys) - - @property - def changed_keys(self): - """Keys that has changed from old to new value. - - Empty set is returned if both old and new value are not a dict. - - Returns: - Set[str]: Keys of changed keys. - """ - - if self._changed_keys is None: - self._prepare_sub_items() - return set(self._changed_keys) - - @property - def available_keys(self): - """All keys that are available in old and new value. - - Empty set is returned if both old and new value are not a dict. - Output is Union of 'old_keys' and 'new_keys'. - - Returns: - Set[str]: All keys from old and new value. - """ - - if self._available_keys is None: - self._prepare_keys() - return set(self._available_keys) - - @property - def removed_keys(self): - """Key that are not available in new value but were in old value. - - Returns: - Set[str]: All removed keys. - """ - - if self._removed_keys is None: - self._prepare_sub_items() - return set(self._removed_keys) - - def _prepare_keys(self): - old_keys = set() - new_keys = set() - if self._old_is_dict and self._new_is_dict: - old_keys = set(self._old_value.keys()) - new_keys = set(self._new_value.keys()) - - elif self._old_is_dict: - old_keys = set(self._old_value.keys()) - - elif self._new_is_dict: - new_keys = set(self._new_value.keys()) - - self._old_keys = old_keys - self._new_keys = new_keys - self._available_keys = old_keys | new_keys - self._removed_keys = old_keys - new_keys - - def _prepare_sub_items(self): - sub_items = {} - changed_keys = set() - - old_keys = self.old_keys - new_keys = self.new_keys - new_value = self.new_value - old_value = self.old_value - if self._old_is_dict and self._new_is_dict: - for key in self.available_keys: - item = TrackChangesItem( - old_value.get(key), new_value.get(key) - ) - sub_items[key] = item - if item.changed or key not in old_keys or key not in new_keys: - changed_keys.add(key) - - elif self._old_is_dict: - old_keys = set(old_value.keys()) - available_keys = set(old_keys) - changed_keys = set(available_keys) - for key in available_keys: - # NOTE Use '_EMPTY_VALUE' because old value could be 'None' - # which would result in "unchanged" item - sub_items[key] = TrackChangesItem( - old_value.get(key), _EMPTY_VALUE - ) - - elif self._new_is_dict: - new_keys = set(new_value.keys()) - available_keys = set(new_keys) - changed_keys = set(available_keys) - for key in available_keys: - # NOTE Use '_EMPTY_VALUE' because new value could be 'None' - # which would result in "unchanged" item - sub_items[key] = TrackChangesItem( - _EMPTY_VALUE, new_value.get(key) - ) - - self._sub_items = sub_items - self._changed_keys = changed_keys - - class InstanceMember: """Representation of instance member. From f41a830f437d000c0f83f44765c6a5e00b25a137 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 12 Aug 2024 12:17:27 +0200 Subject: [PATCH 089/203] move structure classes to separate file --- client/ayon_core/pipeline/create/__init__.py | 23 +- client/ayon_core/pipeline/create/context.py | 866 +---------------- .../ayon_core/pipeline/create/structures.py | 870 ++++++++++++++++++ 3 files changed, 889 insertions(+), 870 deletions(-) create mode 100644 client/ayon_core/pipeline/create/structures.py diff --git a/client/ayon_core/pipeline/create/__init__.py b/client/ayon_core/pipeline/create/__init__.py index 68e173d6b9..bb05bc6a09 100644 --- a/client/ayon_core/pipeline/create/__init__.py +++ b/client/ayon_core/pipeline/create/__init__.py @@ -18,7 +18,7 @@ from .exceptions import ( CreatorsRemoveFailed, CreatorsOperationFailed, ) - +from .structures import CreatedInstance from .utils import ( get_last_versions_for_instances, get_next_versions_for_instances, @@ -48,10 +48,7 @@ from .creator_plugins import ( cache_and_get_instances, ) -from .context import ( - CreatedInstance, - CreateContext -) +from .context import CreateContext from .legacy_create import ( LegacyCreator, @@ -65,6 +62,21 @@ __all__ = ( "PRE_CREATE_THUMBNAIL_KEY", "DEFAULT_VARIANT_VALUE", + "UnavailableSharedData", + "ImmutableKeyError", + "HostMissRequiredMethod", + "ConvertorsOperationFailed", + "ConvertorsFindFailed", + "ConvertorsConversionFailed", + "CreatorError", + "CreatorsCreateFailed", + "CreatorsCollectionFailed", + "CreatorsSaveFailed", + "CreatorsRemoveFailed", + "CreatorsOperationFailed", + + "CreatedInstance", + "get_last_versions_for_instances", "get_next_versions_for_instances", @@ -90,7 +102,6 @@ __all__ = ( "cache_and_get_instances", - "CreatedInstance", "CreateContext", "LegacyCreator", diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 6f802a5a6e..a11bc311dc 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -5,7 +5,6 @@ import logging import traceback import collections import inspect -from uuid import uuid4 from contextlib import contextmanager from typing import Optional @@ -16,22 +15,14 @@ import ayon_api from ayon_core.settings import get_project_settings from ayon_core.lib import is_func_signature_supported from ayon_core.lib.attribute_definitions import ( - UnknownDef, - serialize_attr_defs, - deserialize_attr_defs, get_default_values, ) from ayon_core.host import IPublishHost, IWorkfileHost -from ayon_core.pipeline import ( - Anatomy, - AYON_INSTANCE_ID, - AVALON_INSTANCE_ID, -) +from ayon_core.pipeline import Anatomy from ayon_core.pipeline.plugin_discover import DiscoverResult from .exceptions import ( CreatorError, - ImmutableKeyError, CreatorsCreateFailed, CreatorsCollectionFailed, CreatorsSaveFailed, @@ -42,6 +33,7 @@ from .exceptions import ( HostMissRequiredMethod, ) from .changes import TrackChangesItem +from .structures import PublishAttributes, ConvertorItem from .creator_plugins import ( Creator, AutoCreator, @@ -85,860 +77,6 @@ def prepare_failed_creator_operation_info( } -class InstanceMember: - """Representation of instance member. - - TODO: - Implement and use! - """ - - def __init__(self, instance, name): - self.instance = instance - - instance.add_members(self) - - self.name = name - self._actions = [] - - def add_action(self, label, callback): - self._actions.append({ - "label": label, - "callback": callback - }) - - -class AttributeValues(object): - """Container which keep values of Attribute definitions. - - Goal is to have one object which hold values of attribute definitions for - single instance. - - Has dictionary like methods. Not all of them are allowed all the time. - - Args: - attr_defs(AbstractAttrDef): Definitions of value type and properties. - values(dict): Values after possible conversion. - origin_data(dict): Values loaded from host before conversion. - """ - - def __init__(self, attr_defs, values, origin_data=None): - if origin_data is None: - origin_data = copy.deepcopy(values) - self._origin_data = origin_data - - attr_defs_by_key = { - attr_def.key: attr_def - for attr_def in attr_defs - if attr_def.is_value_def - } - for key, value in values.items(): - if key not in attr_defs_by_key: - new_def = UnknownDef(key, label=key, default=value) - attr_defs.append(new_def) - attr_defs_by_key[key] = new_def - - self._attr_defs = attr_defs - self._attr_defs_by_key = attr_defs_by_key - - self._data = {} - for attr_def in attr_defs: - value = values.get(attr_def.key) - if value is not None: - self._data[attr_def.key] = value - - def __setitem__(self, key, value): - if key not in self._attr_defs_by_key: - raise KeyError("Key \"{}\" was not found.".format(key)) - - old_value = self._data.get(key) - if old_value == value: - return - self._data[key] = value - - def __getitem__(self, key): - if key not in self._attr_defs_by_key: - return self._data[key] - return self._data.get(key, self._attr_defs_by_key[key].default) - - def __contains__(self, key): - return key in self._attr_defs_by_key - - def get(self, key, default=None): - if key in self._attr_defs_by_key: - return self[key] - return default - - def keys(self): - return self._attr_defs_by_key.keys() - - def values(self): - for key in self._attr_defs_by_key.keys(): - yield self._data.get(key) - - def items(self): - for key in self._attr_defs_by_key.keys(): - yield key, self._data.get(key) - - def update(self, value): - for _key, _value in dict(value): - self[_key] = _value - - def pop(self, key, default=None): - value = self._data.pop(key, default) - # Remove attribute definition if is 'UnknownDef' - # - gives option to get rid of unknown values - attr_def = self._attr_defs_by_key.get(key) - if isinstance(attr_def, UnknownDef): - self._attr_defs_by_key.pop(key) - self._attr_defs.remove(attr_def) - return value - - def reset_values(self): - self._data = {} - - def mark_as_stored(self): - self._origin_data = copy.deepcopy(self._data) - - @property - def attr_defs(self): - """Pointer to attribute definitions. - - Returns: - List[AbstractAttrDef]: Attribute definitions. - """ - - return list(self._attr_defs) - - @property - def origin_data(self): - return copy.deepcopy(self._origin_data) - - def data_to_store(self): - """Create new dictionary with data to store. - - Returns: - Dict[str, Any]: Attribute values that should be stored. - """ - - output = {} - for key in self._data: - output[key] = self[key] - - for key, attr_def in self._attr_defs_by_key.items(): - if key not in output: - output[key] = attr_def.default - return output - - def get_serialized_attr_defs(self): - """Serialize attribute definitions to json serializable types. - - Returns: - List[Dict[str, Any]]: Serialized attribute definitions. - """ - - return serialize_attr_defs(self._attr_defs) - - -class CreatorAttributeValues(AttributeValues): - """Creator specific attribute values of an instance. - - Args: - instance (CreatedInstance): Instance for which are values hold. - """ - - def __init__(self, instance, *args, **kwargs): - self.instance = instance - super(CreatorAttributeValues, self).__init__(*args, **kwargs) - - -class PublishAttributeValues(AttributeValues): - """Publish plugin specific attribute values. - - Values are for single plugin which can be on `CreatedInstance` - or context values stored on `CreateContext`. - - Args: - publish_attributes(PublishAttributes): Wrapper for multiple publish - attributes is used as parent object. - """ - - def __init__(self, publish_attributes, *args, **kwargs): - self.publish_attributes = publish_attributes - super(PublishAttributeValues, self).__init__(*args, **kwargs) - - @property - def parent(self): - return self.publish_attributes.parent - - -class PublishAttributes: - """Wrapper for publish plugin attribute definitions. - - Cares about handling attribute definitions of multiple publish plugins. - Keep information about attribute definitions and their values. - - Args: - parent(CreatedInstance, CreateContext): Parent for which will be - data stored and from which are data loaded. - origin_data(dict): Loaded data by plugin class name. - attr_plugins(Union[List[pyblish.api.Plugin], None]): List of publish - plugins that may have defined attribute definitions. - """ - - def __init__(self, parent, origin_data, attr_plugins=None): - self.parent = parent - self._origin_data = copy.deepcopy(origin_data) - - attr_plugins = attr_plugins or [] - self.attr_plugins = attr_plugins - - self._data = copy.deepcopy(origin_data) - self._plugin_names_order = [] - self._missing_plugins = [] - - self.set_publish_plugins(attr_plugins) - - def __getitem__(self, key): - return self._data[key] - - def __contains__(self, key): - return key in self._data - - def keys(self): - return self._data.keys() - - def values(self): - return self._data.values() - - def items(self): - return self._data.items() - - def pop(self, key, default=None): - """Remove or reset value for plugin. - - Plugin values are reset to defaults if plugin is available but - data of plugin which was not found are removed. - - Args: - key(str): Plugin name. - default: Default value if plugin was not found. - """ - - if key not in self._data: - return default - - if key in self._missing_plugins: - self._missing_plugins.remove(key) - removed_item = self._data.pop(key) - return removed_item.data_to_store() - - value_item = self._data[key] - # Prepare value to return - output = value_item.data_to_store() - # Reset values - value_item.reset_values() - return output - - def plugin_names_order(self): - """Plugin names order by their 'order' attribute.""" - - for name in self._plugin_names_order: - yield name - - def mark_as_stored(self): - self._origin_data = copy.deepcopy(self.data_to_store()) - - def data_to_store(self): - """Convert attribute values to "data to store".""" - - output = {} - for key, attr_value in self._data.items(): - output[key] = attr_value.data_to_store() - return output - - @property - def origin_data(self): - return copy.deepcopy(self._origin_data) - - def set_publish_plugins(self, attr_plugins): - """Set publish plugins attribute definitions.""" - - self._plugin_names_order = [] - self._missing_plugins = [] - self.attr_plugins = attr_plugins or [] - - origin_data = self._origin_data - data = self._data - self._data = {} - added_keys = set() - for plugin in attr_plugins: - output = plugin.convert_attribute_values(data) - if output is not None: - data = output - attr_defs = plugin.get_attribute_defs() - if not attr_defs: - continue - - key = plugin.__name__ - added_keys.add(key) - self._plugin_names_order.append(key) - - value = data.get(key) or {} - orig_value = copy.deepcopy(origin_data.get(key) or {}) - self._data[key] = PublishAttributeValues( - self, attr_defs, value, orig_value - ) - - for key, value in data.items(): - if key not in added_keys: - self._missing_plugins.append(key) - self._data[key] = PublishAttributeValues( - self, [], value, value - ) - - def serialize_attributes(self): - return { - "attr_defs": { - plugin_name: attrs_value.get_serialized_attr_defs() - for plugin_name, attrs_value in self._data.items() - }, - "plugin_names_order": self._plugin_names_order, - "missing_plugins": self._missing_plugins - } - - def deserialize_attributes(self, data): - self._plugin_names_order = data["plugin_names_order"] - self._missing_plugins = data["missing_plugins"] - - attr_defs = deserialize_attr_defs(data["attr_defs"]) - - origin_data = self._origin_data - data = self._data - self._data = {} - - added_keys = set() - for plugin_name, attr_defs_data in attr_defs.items(): - attr_defs = deserialize_attr_defs(attr_defs_data) - value = data.get(plugin_name) or {} - orig_value = copy.deepcopy(origin_data.get(plugin_name) or {}) - self._data[plugin_name] = PublishAttributeValues( - self, attr_defs, value, orig_value - ) - - for key, value in data.items(): - if key not in added_keys: - self._missing_plugins.append(key) - self._data[key] = PublishAttributeValues( - self, [], value, value - ) - - -class CreatedInstance: - """Instance entity with data that will be stored to workfile. - - I think `data` must be required argument containing all minimum information - about instance like "folderPath" and "task" and all data used for filling - product name as creators may have custom data for product name filling. - - Notes: - Object have 2 possible initialization. One using 'creator' object which - is recommended for api usage. Second by passing information about - creator. - - Args: - product_type (str): Product type that will be created. - product_name (str): Name of product that will be created. - data (Dict[str, Any]): Data used for filling product name or override - data from already existing instance. - creator (Union[BaseCreator, None]): Creator responsible for instance. - creator_identifier (str): Identifier of creator plugin. - creator_label (str): Creator plugin label. - group_label (str): Default group label from creator plugin. - creator_attr_defs (List[AbstractAttrDef]): Attribute definitions from - creator. - """ - - # Keys that can't be changed or removed from data after loading using - # creator. - # - 'creator_attributes' and 'publish_attributes' can change values of - # their individual children but not on their own - __immutable_keys = ( - "id", - "instance_id", - "product_type", - "creator_identifier", - "creator_attributes", - "publish_attributes" - ) - - def __init__( - self, - product_type, - product_name, - data, - creator=None, - creator_identifier=None, - creator_label=None, - group_label=None, - creator_attr_defs=None, - ): - if creator is not None: - creator_identifier = creator.identifier - group_label = creator.get_group_label() - creator_label = creator.label - creator_attr_defs = creator.get_instance_attr_defs() - - self._creator_label = creator_label - self._group_label = group_label or creator_identifier - - # Instance members may have actions on them - # TODO implement members logic - self._members = [] - - # Data that can be used for lifetime of object - self._transient_data = {} - - # Create a copy of passed data to avoid changing them on the fly - data = copy.deepcopy(data or {}) - - # Pop dictionary values that will be converted to objects to be able - # catch changes - orig_creator_attributes = data.pop("creator_attributes", None) or {} - orig_publish_attributes = data.pop("publish_attributes", None) or {} - - # Store original value of passed data - self._orig_data = copy.deepcopy(data) - - # Pop 'productType' and 'productName' to prevent unexpected changes - data.pop("productType", None) - data.pop("productName", None) - # Backwards compatibility with OpenPype instances - data.pop("family", None) - data.pop("subset", None) - - asset_name = data.pop("asset", None) - if "folderPath" not in data: - data["folderPath"] = asset_name - - # QUESTION Does it make sense to have data stored as ordered dict? - self._data = collections.OrderedDict() - # QUESTION Do we need this "id" information on instance? - item_id = data.get("id") - # TODO use only 'AYON_INSTANCE_ID' when all hosts support it - if item_id not in {AYON_INSTANCE_ID, AVALON_INSTANCE_ID}: - item_id = AVALON_INSTANCE_ID - self._data["id"] = item_id - self._data["productType"] = product_type - self._data["productName"] = product_name - self._data["active"] = data.get("active", True) - self._data["creator_identifier"] = creator_identifier - - # Pop from source data all keys that are defined in `_data` before - # this moment and through their values away - # - they should be the same and if are not then should not change - # already set values - for key in self._data.keys(): - if key in data: - data.pop(key) - - self._data["variant"] = self._data.get("variant") or "" - # Stored creator specific attribute values - # {key: value} - creator_values = copy.deepcopy(orig_creator_attributes) - - self._data["creator_attributes"] = CreatorAttributeValues( - self, - list(creator_attr_defs), - creator_values, - orig_creator_attributes - ) - - # Stored publish specific attribute values - # {: {key: value}} - # - must be set using 'set_publish_plugins' - self._data["publish_attributes"] = PublishAttributes( - self, orig_publish_attributes, None - ) - if data: - self._data.update(data) - - if not self._data.get("instance_id"): - self._data["instance_id"] = str(uuid4()) - - self._folder_is_valid = self.has_set_folder - self._task_is_valid = self.has_set_task - - def __str__(self): - return ( - " {data}" - ).format( - creator_identifier=self.creator_identifier, - product={"name": self.product_name, "type": self.product_type}, - data=str(self._data) - ) - - # --- Dictionary like methods --- - def __getitem__(self, key): - return self._data[key] - - def __contains__(self, key): - return key in self._data - - def __setitem__(self, key, value): - # Validate immutable keys - if key not in self.__immutable_keys: - self._data[key] = value - - elif value != self._data.get(key): - # Raise exception if key is immutable and value has changed - raise ImmutableKeyError(key) - - def get(self, key, default=None): - return self._data.get(key, default) - - def pop(self, key, *args, **kwargs): - # Raise exception if is trying to pop key which is immutable - if key in self.__immutable_keys: - raise ImmutableKeyError(key) - - self._data.pop(key, *args, **kwargs) - - def keys(self): - return self._data.keys() - - def values(self): - return self._data.values() - - def items(self): - return self._data.items() - # ------ - - @property - def product_type(self): - return self._data["productType"] - - @property - def product_name(self): - return self._data["productName"] - - @property - def label(self): - label = self._data.get("label") - if not label: - label = self.product_name - return label - - @property - def group_label(self): - label = self._data.get("group") - if label: - return label - return self._group_label - - @property - def origin_data(self): - output = copy.deepcopy(self._orig_data) - output["creator_attributes"] = self.creator_attributes.origin_data - output["publish_attributes"] = self.publish_attributes.origin_data - return output - - @property - def creator_identifier(self): - return self._data["creator_identifier"] - - @property - def creator_label(self): - return self._creator_label or self.creator_identifier - - @property - def id(self): - """Instance identifier. - - Returns: - str: UUID of instance. - """ - - return self._data["instance_id"] - - @property - def data(self): - """Legacy access to data. - - Access to data is needed to modify values. - - Returns: - CreatedInstance: Object can be used as dictionary but with - validations of immutable keys. - """ - - return self - - @property - def transient_data(self): - """Data stored for lifetime of instance object. - - These data are not stored to scene and will be lost on object - deletion. - - Can be used to store objects. In some host implementations is not - possible to reference to object in scene with some unique identifier - (e.g. node in Fusion.). In that case it is handy to store the object - here. Should be used that way only if instance data are stored on the - node itself. - - Returns: - Dict[str, Any]: Dictionary object where you can store data related - to instance for lifetime of instance object. - """ - - return self._transient_data - - def changes(self): - """Calculate and return changes.""" - - return TrackChangesItem(self.origin_data, self.data_to_store()) - - def mark_as_stored(self): - """Should be called when instance data are stored. - - Origin data are replaced by current data so changes are cleared. - """ - - orig_keys = set(self._orig_data.keys()) - for key, value in self._data.items(): - orig_keys.discard(key) - if key in ("creator_attributes", "publish_attributes"): - continue - self._orig_data[key] = copy.deepcopy(value) - - for key in orig_keys: - self._orig_data.pop(key) - - self.creator_attributes.mark_as_stored() - self.publish_attributes.mark_as_stored() - - @property - def creator_attributes(self): - return self._data["creator_attributes"] - - @property - def creator_attribute_defs(self): - """Attribute definitions defined by creator plugin. - - Returns: - List[AbstractAttrDef]: Attribute definitions. - """ - - return self.creator_attributes.attr_defs - - @property - def publish_attributes(self): - return self._data["publish_attributes"] - - def data_to_store(self): - """Collect data that contain json parsable types. - - It is possible to recreate the instance using these data. - - Todos: - We probably don't need OrderedDict. When data are loaded they - are not ordered anymore. - - Returns: - OrderedDict: Ordered dictionary with instance data. - """ - - output = collections.OrderedDict() - for key, value in self._data.items(): - if key in ("creator_attributes", "publish_attributes"): - continue - output[key] = value - - output["creator_attributes"] = self.creator_attributes.data_to_store() - output["publish_attributes"] = self.publish_attributes.data_to_store() - - return output - - @classmethod - def from_existing(cls, instance_data, creator): - """Convert instance data from workfile to CreatedInstance. - - Args: - instance_data (Dict[str, Any]): Data in a structure ready for - 'CreatedInstance' object. - creator (BaseCreator): Creator plugin which is creating the - instance of for which the instance belong. - """ - - instance_data = copy.deepcopy(instance_data) - - product_type = instance_data.get("productType") - if product_type is None: - product_type = instance_data.get("family") - if product_type is None: - product_type = creator.product_type - product_name = instance_data.get("productName") - if product_name is None: - product_name = instance_data.get("subset") - - return cls( - product_type, product_name, instance_data, creator - ) - - def set_publish_plugins(self, attr_plugins): - """Set publish plugins with attribute definitions. - - This method should be called only from 'CreateContext'. - - Args: - attr_plugins (List[pyblish.api.Plugin]): Pyblish plugins which - inherit from 'AYONPyblishPluginMixin' and may contain - attribute definitions. - """ - - self.publish_attributes.set_publish_plugins(attr_plugins) - - def add_members(self, members): - """Currently unused method.""" - - for member in members: - if member not in self._members: - self._members.append(member) - - def serialize_for_remote(self): - """Serialize object into data to be possible recreated object. - - Returns: - Dict[str, Any]: Serialized data. - """ - - creator_attr_defs = self.creator_attributes.get_serialized_attr_defs() - publish_attributes = self.publish_attributes.serialize_attributes() - return { - "data": self.data_to_store(), - "orig_data": self.origin_data, - "creator_attr_defs": creator_attr_defs, - "publish_attributes": publish_attributes, - "creator_label": self._creator_label, - "group_label": self._group_label, - } - - @classmethod - def deserialize_on_remote(cls, serialized_data): - """Convert instance data to CreatedInstance. - - This is fake instance in remote process e.g. in UI process. The creator - is not a full creator and should not be used for calling methods when - instance is created from this method (matters on implementation). - - Args: - serialized_data (Dict[str, Any]): Serialized data for remote - recreating. Should contain 'data' and 'orig_data'. - """ - - instance_data = copy.deepcopy(serialized_data["data"]) - creator_identifier = instance_data["creator_identifier"] - - product_type = instance_data["productType"] - product_name = instance_data.get("productName", None) - - creator_label = serialized_data["creator_label"] - group_label = serialized_data["group_label"] - creator_attr_defs = deserialize_attr_defs( - serialized_data["creator_attr_defs"] - ) - publish_attributes = serialized_data["publish_attributes"] - - obj = cls( - product_type, - product_name, - instance_data, - creator_identifier=creator_identifier, - creator_label=creator_label, - group_label=group_label, - creator_attr_defs=creator_attr_defs - ) - obj._orig_data = serialized_data["orig_data"] - obj.publish_attributes.deserialize_attributes(publish_attributes) - - return obj - - # Context validation related methods/properties - @property - def has_set_folder(self): - """Folder path is set in data.""" - - return "folderPath" in self._data - - @property - def has_set_task(self): - """Task name is set in data.""" - - return "task" in self._data - - @property - def has_valid_context(self): - """Context data are valid for publishing.""" - - return self.has_valid_folder and self.has_valid_task - - @property - def has_valid_folder(self): - """Folder set in context exists in project.""" - - if not self.has_set_folder: - return False - return self._folder_is_valid - - @property - def has_valid_task(self): - """Task set in context exists in project.""" - - if not self.has_set_task: - return False - return self._task_is_valid - - def set_folder_invalid(self, invalid): - # TODO replace with `set_folder_path` - self._folder_is_valid = not invalid - - def set_task_invalid(self, invalid): - # TODO replace with `set_task_name` - self._task_is_valid = not invalid - - -class ConvertorItem(object): - """Item representing convertor plugin. - - Args: - identifier (str): Identifier of convertor. - label (str): Label which will be shown in UI. - """ - - def __init__(self, identifier, label): - self._id = str(uuid4()) - self.identifier = identifier - self.label = label - - @property - def id(self): - return self._id - - def to_data(self): - return { - "id": self.id, - "identifier": self.identifier, - "label": self.label - } - - @classmethod - def from_data(cls, data): - obj = cls(data["identifier"], data["label"]) - obj._id = data["id"] - return obj - - class CreateContext: """Context of instance creation. diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py new file mode 100644 index 0000000000..7fe854c4fc --- /dev/null +++ b/client/ayon_core/pipeline/create/structures.py @@ -0,0 +1,870 @@ +import copy +import collections +from uuid import uuid4 + +from ayon_core.lib.attribute_definitions import ( + UnknownDef, + serialize_attr_defs, + deserialize_attr_defs, +) +from ayon_core.pipeline import ( + AYON_INSTANCE_ID, + AVALON_INSTANCE_ID, +) + +from .exceptions import ImmutableKeyError +from .changes import TrackChangesItem + + +class ConvertorItem(object): + """Item representing convertor plugin. + + Args: + identifier (str): Identifier of convertor. + label (str): Label which will be shown in UI. + """ + + def __init__(self, identifier, label): + self._id = str(uuid4()) + self.identifier = identifier + self.label = label + + @property + def id(self): + return self._id + + def to_data(self): + return { + "id": self.id, + "identifier": self.identifier, + "label": self.label + } + + @classmethod + def from_data(cls, data): + obj = cls(data["identifier"], data["label"]) + obj._id = data["id"] + return obj + + +class InstanceMember: + """Representation of instance member. + + TODO: + Implement and use! + """ + + def __init__(self, instance, name): + self.instance = instance + + instance.add_members(self) + + self.name = name + self._actions = [] + + def add_action(self, label, callback): + self._actions.append({ + "label": label, + "callback": callback + }) + + +class AttributeValues(object): + """Container which keep values of Attribute definitions. + + Goal is to have one object which hold values of attribute definitions for + single instance. + + Has dictionary like methods. Not all of them are allowed all the time. + + Args: + attr_defs(AbstractAttrDef): Definitions of value type and properties. + values(dict): Values after possible conversion. + origin_data(dict): Values loaded from host before conversion. + """ + + def __init__(self, attr_defs, values, origin_data=None): + if origin_data is None: + origin_data = copy.deepcopy(values) + self._origin_data = origin_data + + attr_defs_by_key = { + attr_def.key: attr_def + for attr_def in attr_defs + if attr_def.is_value_def + } + for key, value in values.items(): + if key not in attr_defs_by_key: + new_def = UnknownDef(key, label=key, default=value) + attr_defs.append(new_def) + attr_defs_by_key[key] = new_def + + self._attr_defs = attr_defs + self._attr_defs_by_key = attr_defs_by_key + + self._data = {} + for attr_def in attr_defs: + value = values.get(attr_def.key) + if value is not None: + self._data[attr_def.key] = value + + def __setitem__(self, key, value): + if key not in self._attr_defs_by_key: + raise KeyError("Key \"{}\" was not found.".format(key)) + + old_value = self._data.get(key) + if old_value == value: + return + self._data[key] = value + + def __getitem__(self, key): + if key not in self._attr_defs_by_key: + return self._data[key] + return self._data.get(key, self._attr_defs_by_key[key].default) + + def __contains__(self, key): + return key in self._attr_defs_by_key + + def get(self, key, default=None): + if key in self._attr_defs_by_key: + return self[key] + return default + + def keys(self): + return self._attr_defs_by_key.keys() + + def values(self): + for key in self._attr_defs_by_key.keys(): + yield self._data.get(key) + + def items(self): + for key in self._attr_defs_by_key.keys(): + yield key, self._data.get(key) + + def update(self, value): + for _key, _value in dict(value): + self[_key] = _value + + def pop(self, key, default=None): + value = self._data.pop(key, default) + # Remove attribute definition if is 'UnknownDef' + # - gives option to get rid of unknown values + attr_def = self._attr_defs_by_key.get(key) + if isinstance(attr_def, UnknownDef): + self._attr_defs_by_key.pop(key) + self._attr_defs.remove(attr_def) + return value + + def reset_values(self): + self._data = {} + + def mark_as_stored(self): + self._origin_data = copy.deepcopy(self._data) + + @property + def attr_defs(self): + """Pointer to attribute definitions. + + Returns: + List[AbstractAttrDef]: Attribute definitions. + """ + + return list(self._attr_defs) + + @property + def origin_data(self): + return copy.deepcopy(self._origin_data) + + def data_to_store(self): + """Create new dictionary with data to store. + + Returns: + Dict[str, Any]: Attribute values that should be stored. + """ + + output = {} + for key in self._data: + output[key] = self[key] + + for key, attr_def in self._attr_defs_by_key.items(): + if key not in output: + output[key] = attr_def.default + return output + + def get_serialized_attr_defs(self): + """Serialize attribute definitions to json serializable types. + + Returns: + List[Dict[str, Any]]: Serialized attribute definitions. + """ + + return serialize_attr_defs(self._attr_defs) + + +class CreatorAttributeValues(AttributeValues): + """Creator specific attribute values of an instance. + + Args: + instance (CreatedInstance): Instance for which are values hold. + """ + + def __init__(self, instance, *args, **kwargs): + self.instance = instance + super(CreatorAttributeValues, self).__init__(*args, **kwargs) + + +class PublishAttributeValues(AttributeValues): + """Publish plugin specific attribute values. + + Values are for single plugin which can be on `CreatedInstance` + or context values stored on `CreateContext`. + + Args: + publish_attributes(PublishAttributes): Wrapper for multiple publish + attributes is used as parent object. + """ + + def __init__(self, publish_attributes, *args, **kwargs): + self.publish_attributes = publish_attributes + super(PublishAttributeValues, self).__init__(*args, **kwargs) + + @property + def parent(self): + return self.publish_attributes.parent + + +class PublishAttributes: + """Wrapper for publish plugin attribute definitions. + + Cares about handling attribute definitions of multiple publish plugins. + Keep information about attribute definitions and their values. + + Args: + parent(CreatedInstance, CreateContext): Parent for which will be + data stored and from which are data loaded. + origin_data(dict): Loaded data by plugin class name. + attr_plugins(Union[List[pyblish.api.Plugin], None]): List of publish + plugins that may have defined attribute definitions. + """ + + def __init__(self, parent, origin_data, attr_plugins=None): + self.parent = parent + self._origin_data = copy.deepcopy(origin_data) + + attr_plugins = attr_plugins or [] + self.attr_plugins = attr_plugins + + self._data = copy.deepcopy(origin_data) + self._plugin_names_order = [] + self._missing_plugins = [] + + self.set_publish_plugins(attr_plugins) + + def __getitem__(self, key): + return self._data[key] + + def __contains__(self, key): + return key in self._data + + def keys(self): + return self._data.keys() + + def values(self): + return self._data.values() + + def items(self): + return self._data.items() + + def pop(self, key, default=None): + """Remove or reset value for plugin. + + Plugin values are reset to defaults if plugin is available but + data of plugin which was not found are removed. + + Args: + key(str): Plugin name. + default: Default value if plugin was not found. + """ + + if key not in self._data: + return default + + if key in self._missing_plugins: + self._missing_plugins.remove(key) + removed_item = self._data.pop(key) + return removed_item.data_to_store() + + value_item = self._data[key] + # Prepare value to return + output = value_item.data_to_store() + # Reset values + value_item.reset_values() + return output + + def plugin_names_order(self): + """Plugin names order by their 'order' attribute.""" + + for name in self._plugin_names_order: + yield name + + def mark_as_stored(self): + self._origin_data = copy.deepcopy(self.data_to_store()) + + def data_to_store(self): + """Convert attribute values to "data to store".""" + + output = {} + for key, attr_value in self._data.items(): + output[key] = attr_value.data_to_store() + return output + + @property + def origin_data(self): + return copy.deepcopy(self._origin_data) + + def set_publish_plugins(self, attr_plugins): + """Set publish plugins attribute definitions.""" + + self._plugin_names_order = [] + self._missing_plugins = [] + self.attr_plugins = attr_plugins or [] + + origin_data = self._origin_data + data = self._data + self._data = {} + added_keys = set() + for plugin in attr_plugins: + output = plugin.convert_attribute_values(data) + if output is not None: + data = output + attr_defs = plugin.get_attribute_defs() + if not attr_defs: + continue + + key = plugin.__name__ + added_keys.add(key) + self._plugin_names_order.append(key) + + value = data.get(key) or {} + orig_value = copy.deepcopy(origin_data.get(key) or {}) + self._data[key] = PublishAttributeValues( + self, attr_defs, value, orig_value + ) + + for key, value in data.items(): + if key not in added_keys: + self._missing_plugins.append(key) + self._data[key] = PublishAttributeValues( + self, [], value, value + ) + + def serialize_attributes(self): + return { + "attr_defs": { + plugin_name: attrs_value.get_serialized_attr_defs() + for plugin_name, attrs_value in self._data.items() + }, + "plugin_names_order": self._plugin_names_order, + "missing_plugins": self._missing_plugins + } + + def deserialize_attributes(self, data): + self._plugin_names_order = data["plugin_names_order"] + self._missing_plugins = data["missing_plugins"] + + attr_defs = deserialize_attr_defs(data["attr_defs"]) + + origin_data = self._origin_data + data = self._data + self._data = {} + + added_keys = set() + for plugin_name, attr_defs_data in attr_defs.items(): + attr_defs = deserialize_attr_defs(attr_defs_data) + value = data.get(plugin_name) or {} + orig_value = copy.deepcopy(origin_data.get(plugin_name) or {}) + self._data[plugin_name] = PublishAttributeValues( + self, attr_defs, value, orig_value + ) + + for key, value in data.items(): + if key not in added_keys: + self._missing_plugins.append(key) + self._data[key] = PublishAttributeValues( + self, [], value, value + ) + + +class CreatedInstance: + """Instance entity with data that will be stored to workfile. + + I think `data` must be required argument containing all minimum information + about instance like "folderPath" and "task" and all data used for filling + product name as creators may have custom data for product name filling. + + Notes: + Object have 2 possible initialization. One using 'creator' object which + is recommended for api usage. Second by passing information about + creator. + + Args: + product_type (str): Product type that will be created. + product_name (str): Name of product that will be created. + data (Dict[str, Any]): Data used for filling product name or override + data from already existing instance. + creator (Union[BaseCreator, None]): Creator responsible for instance. + creator_identifier (str): Identifier of creator plugin. + creator_label (str): Creator plugin label. + group_label (str): Default group label from creator plugin. + creator_attr_defs (List[AbstractAttrDef]): Attribute definitions from + creator. + """ + + # Keys that can't be changed or removed from data after loading using + # creator. + # - 'creator_attributes' and 'publish_attributes' can change values of + # their individual children but not on their own + __immutable_keys = ( + "id", + "instance_id", + "product_type", + "creator_identifier", + "creator_attributes", + "publish_attributes" + ) + + def __init__( + self, + product_type, + product_name, + data, + creator=None, + creator_identifier=None, + creator_label=None, + group_label=None, + creator_attr_defs=None, + ): + if creator is not None: + creator_identifier = creator.identifier + group_label = creator.get_group_label() + creator_label = creator.label + creator_attr_defs = creator.get_instance_attr_defs() + + self._creator_label = creator_label + self._group_label = group_label or creator_identifier + + # Instance members may have actions on them + # TODO implement members logic + self._members = [] + + # Data that can be used for lifetime of object + self._transient_data = {} + + # Create a copy of passed data to avoid changing them on the fly + data = copy.deepcopy(data or {}) + + # Pop dictionary values that will be converted to objects to be able + # catch changes + orig_creator_attributes = data.pop("creator_attributes", None) or {} + orig_publish_attributes = data.pop("publish_attributes", None) or {} + + # Store original value of passed data + self._orig_data = copy.deepcopy(data) + + # Pop 'productType' and 'productName' to prevent unexpected changes + data.pop("productType", None) + data.pop("productName", None) + # Backwards compatibility with OpenPype instances + data.pop("family", None) + data.pop("subset", None) + + asset_name = data.pop("asset", None) + if "folderPath" not in data: + data["folderPath"] = asset_name + + # QUESTION Does it make sense to have data stored as ordered dict? + self._data = collections.OrderedDict() + # QUESTION Do we need this "id" information on instance? + item_id = data.get("id") + # TODO use only 'AYON_INSTANCE_ID' when all hosts support it + if item_id not in {AYON_INSTANCE_ID, AVALON_INSTANCE_ID}: + item_id = AVALON_INSTANCE_ID + self._data["id"] = item_id + self._data["productType"] = product_type + self._data["productName"] = product_name + self._data["active"] = data.get("active", True) + self._data["creator_identifier"] = creator_identifier + + # Pop from source data all keys that are defined in `_data` before + # this moment and through their values away + # - they should be the same and if are not then should not change + # already set values + for key in self._data.keys(): + if key in data: + data.pop(key) + + self._data["variant"] = self._data.get("variant") or "" + # Stored creator specific attribute values + # {key: value} + creator_values = copy.deepcopy(orig_creator_attributes) + + self._data["creator_attributes"] = CreatorAttributeValues( + self, + list(creator_attr_defs), + creator_values, + orig_creator_attributes + ) + + # Stored publish specific attribute values + # {: {key: value}} + # - must be set using 'set_publish_plugins' + self._data["publish_attributes"] = PublishAttributes( + self, orig_publish_attributes, None + ) + if data: + self._data.update(data) + + if not self._data.get("instance_id"): + self._data["instance_id"] = str(uuid4()) + + self._folder_is_valid = self.has_set_folder + self._task_is_valid = self.has_set_task + + def __str__(self): + return ( + " {data}" + ).format( + creator_identifier=self.creator_identifier, + product={"name": self.product_name, "type": self.product_type}, + data=str(self._data) + ) + + # --- Dictionary like methods --- + def __getitem__(self, key): + return self._data[key] + + def __contains__(self, key): + return key in self._data + + def __setitem__(self, key, value): + # Validate immutable keys + if key not in self.__immutable_keys: + self._data[key] = value + + elif value != self._data.get(key): + # Raise exception if key is immutable and value has changed + raise ImmutableKeyError(key) + + def get(self, key, default=None): + return self._data.get(key, default) + + def pop(self, key, *args, **kwargs): + # Raise exception if is trying to pop key which is immutable + if key in self.__immutable_keys: + raise ImmutableKeyError(key) + + self._data.pop(key, *args, **kwargs) + + def keys(self): + return self._data.keys() + + def values(self): + return self._data.values() + + def items(self): + return self._data.items() + # ------ + + @property + def product_type(self): + return self._data["productType"] + + @property + def product_name(self): + return self._data["productName"] + + @property + def label(self): + label = self._data.get("label") + if not label: + label = self.product_name + return label + + @property + def group_label(self): + label = self._data.get("group") + if label: + return label + return self._group_label + + @property + def origin_data(self): + output = copy.deepcopy(self._orig_data) + output["creator_attributes"] = self.creator_attributes.origin_data + output["publish_attributes"] = self.publish_attributes.origin_data + return output + + @property + def creator_identifier(self): + return self._data["creator_identifier"] + + @property + def creator_label(self): + return self._creator_label or self.creator_identifier + + @property + def id(self): + """Instance identifier. + + Returns: + str: UUID of instance. + """ + + return self._data["instance_id"] + + @property + def data(self): + """Legacy access to data. + + Access to data is needed to modify values. + + Returns: + CreatedInstance: Object can be used as dictionary but with + validations of immutable keys. + """ + + return self + + @property + def transient_data(self): + """Data stored for lifetime of instance object. + + These data are not stored to scene and will be lost on object + deletion. + + Can be used to store objects. In some host implementations is not + possible to reference to object in scene with some unique identifier + (e.g. node in Fusion.). In that case it is handy to store the object + here. Should be used that way only if instance data are stored on the + node itself. + + Returns: + Dict[str, Any]: Dictionary object where you can store data related + to instance for lifetime of instance object. + """ + + return self._transient_data + + def changes(self): + """Calculate and return changes.""" + + return TrackChangesItem(self.origin_data, self.data_to_store()) + + def mark_as_stored(self): + """Should be called when instance data are stored. + + Origin data are replaced by current data so changes are cleared. + """ + + orig_keys = set(self._orig_data.keys()) + for key, value in self._data.items(): + orig_keys.discard(key) + if key in ("creator_attributes", "publish_attributes"): + continue + self._orig_data[key] = copy.deepcopy(value) + + for key in orig_keys: + self._orig_data.pop(key) + + self.creator_attributes.mark_as_stored() + self.publish_attributes.mark_as_stored() + + @property + def creator_attributes(self): + return self._data["creator_attributes"] + + @property + def creator_attribute_defs(self): + """Attribute definitions defined by creator plugin. + + Returns: + List[AbstractAttrDef]: Attribute definitions. + """ + + return self.creator_attributes.attr_defs + + @property + def publish_attributes(self): + return self._data["publish_attributes"] + + def data_to_store(self): + """Collect data that contain json parsable types. + + It is possible to recreate the instance using these data. + + Todos: + We probably don't need OrderedDict. When data are loaded they + are not ordered anymore. + + Returns: + OrderedDict: Ordered dictionary with instance data. + """ + + output = collections.OrderedDict() + for key, value in self._data.items(): + if key in ("creator_attributes", "publish_attributes"): + continue + output[key] = value + + output["creator_attributes"] = self.creator_attributes.data_to_store() + output["publish_attributes"] = self.publish_attributes.data_to_store() + + return output + + @classmethod + def from_existing(cls, instance_data, creator): + """Convert instance data from workfile to CreatedInstance. + + Args: + instance_data (Dict[str, Any]): Data in a structure ready for + 'CreatedInstance' object. + creator (BaseCreator): Creator plugin which is creating the + instance of for which the instance belong. + """ + + instance_data = copy.deepcopy(instance_data) + + product_type = instance_data.get("productType") + if product_type is None: + product_type = instance_data.get("family") + if product_type is None: + product_type = creator.product_type + product_name = instance_data.get("productName") + if product_name is None: + product_name = instance_data.get("subset") + + return cls( + product_type, product_name, instance_data, creator + ) + + def set_publish_plugins(self, attr_plugins): + """Set publish plugins with attribute definitions. + + This method should be called only from 'CreateContext'. + + Args: + attr_plugins (List[pyblish.api.Plugin]): Pyblish plugins which + inherit from 'AYONPyblishPluginMixin' and may contain + attribute definitions. + """ + + self.publish_attributes.set_publish_plugins(attr_plugins) + + def add_members(self, members): + """Currently unused method.""" + + for member in members: + if member not in self._members: + self._members.append(member) + + def serialize_for_remote(self): + """Serialize object into data to be possible recreated object. + + Returns: + Dict[str, Any]: Serialized data. + """ + + creator_attr_defs = self.creator_attributes.get_serialized_attr_defs() + publish_attributes = self.publish_attributes.serialize_attributes() + return { + "data": self.data_to_store(), + "orig_data": self.origin_data, + "creator_attr_defs": creator_attr_defs, + "publish_attributes": publish_attributes, + "creator_label": self._creator_label, + "group_label": self._group_label, + } + + @classmethod + def deserialize_on_remote(cls, serialized_data): + """Convert instance data to CreatedInstance. + + This is fake instance in remote process e.g. in UI process. The creator + is not a full creator and should not be used for calling methods when + instance is created from this method (matters on implementation). + + Args: + serialized_data (Dict[str, Any]): Serialized data for remote + recreating. Should contain 'data' and 'orig_data'. + """ + + instance_data = copy.deepcopy(serialized_data["data"]) + creator_identifier = instance_data["creator_identifier"] + + product_type = instance_data["productType"] + product_name = instance_data.get("productName", None) + + creator_label = serialized_data["creator_label"] + group_label = serialized_data["group_label"] + creator_attr_defs = deserialize_attr_defs( + serialized_data["creator_attr_defs"] + ) + publish_attributes = serialized_data["publish_attributes"] + + obj = cls( + product_type, + product_name, + instance_data, + creator_identifier=creator_identifier, + creator_label=creator_label, + group_label=group_label, + creator_attr_defs=creator_attr_defs + ) + obj._orig_data = serialized_data["orig_data"] + obj.publish_attributes.deserialize_attributes(publish_attributes) + + return obj + + # Context validation related methods/properties + @property + def has_set_folder(self): + """Folder path is set in data.""" + + return "folderPath" in self._data + + @property + def has_set_task(self): + """Task name is set in data.""" + + return "task" in self._data + + @property + def has_valid_context(self): + """Context data are valid for publishing.""" + + return self.has_valid_folder and self.has_valid_task + + @property + def has_valid_folder(self): + """Folder set in context exists in project.""" + + if not self.has_set_folder: + return False + return self._folder_is_valid + + @property + def has_valid_task(self): + """Task set in context exists in project.""" + + if not self.has_set_task: + return False + return self._task_is_valid + + def set_folder_invalid(self, invalid): + # TODO replace with `set_folder_path` + self._folder_is_valid = not invalid + + def set_task_invalid(self, invalid): + # TODO replace with `set_task_name` + self._task_is_valid = not invalid From b0abbf36fb57d6238a1ee8a29669301c8697ede8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 12 Aug 2024 12:19:57 +0200 Subject: [PATCH 090/203] moved exceptions from product name too --- client/ayon_core/pipeline/create/__init__.py | 6 ++++-- client/ayon_core/pipeline/create/exceptions.py | 13 +++++++++++++ client/ayon_core/pipeline/create/product_name.py | 15 +-------------- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/client/ayon_core/pipeline/create/__init__.py b/client/ayon_core/pipeline/create/__init__.py index bb05bc6a09..fa8d639c6f 100644 --- a/client/ayon_core/pipeline/create/__init__.py +++ b/client/ayon_core/pipeline/create/__init__.py @@ -17,6 +17,8 @@ from .exceptions import ( CreatorsSaveFailed, CreatorsRemoveFailed, CreatorsOperationFailed, + TaskNotSetError, + TemplateFillError, ) from .structures import CreatedInstance from .utils import ( @@ -25,7 +27,6 @@ from .utils import ( ) from .product_name import ( - TaskNotSetError, get_product_name, get_product_name_template, ) @@ -74,13 +75,14 @@ __all__ = ( "CreatorsSaveFailed", "CreatorsRemoveFailed", "CreatorsOperationFailed", + "TaskNotSetError", + "TemplateFillError", "CreatedInstance", "get_last_versions_for_instances", "get_next_versions_for_instances", - "TaskNotSetError", "get_product_name", "get_product_name_template", diff --git a/client/ayon_core/pipeline/create/exceptions.py b/client/ayon_core/pipeline/create/exceptions.py index 24264840cb..8910d3fa09 100644 --- a/client/ayon_core/pipeline/create/exceptions.py +++ b/client/ayon_core/pipeline/create/exceptions.py @@ -112,3 +112,16 @@ class CreatorsCreateFailed(CreatorsOperationFailed): msg = "Failed to create instances" super().__init__(msg, failed_info) + +class TaskNotSetError(KeyError): + def __init__(self, msg=None): + if not msg: + msg = "Creator's product name template requires task name." + super().__init__(msg) + + +class TemplateFillError(Exception): + def __init__(self, msg=None): + if not msg: + msg = "Creator's product name template is missing key value." + super().__init__(msg) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 8a08bdc36c..0c6fb70169 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -4,20 +4,7 @@ from ayon_core.settings import get_project_settings from ayon_core.lib import filter_profiles, prepare_template_data from .constants import DEFAULT_PRODUCT_TEMPLATE - - -class TaskNotSetError(KeyError): - def __init__(self, msg=None): - if not msg: - msg = "Creator's product name template requires task name." - super(TaskNotSetError, self).__init__(msg) - - -class TemplateFillError(Exception): - def __init__(self, msg=None): - if not msg: - msg = "Creator's product name template is missing key value." - super(TemplateFillError, self).__init__(msg) +from .exceptions import TaskNotSetError, TemplateFillError def get_product_name_template( From 5d4e086978e7411b0705fcb340c3e8bf9ba2f9ff Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 12 Aug 2024 12:21:50 +0200 Subject: [PATCH 091/203] remove python 2 compatibility --- client/ayon_core/pipeline/create/changes.py | 2 +- client/ayon_core/pipeline/create/creator_plugins.py | 2 +- client/ayon_core/pipeline/create/legacy_create.py | 2 +- client/ayon_core/pipeline/create/structures.py | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/create/changes.py b/client/ayon_core/pipeline/create/changes.py index 217478ee30..c8b81cac48 100644 --- a/client/ayon_core/pipeline/create/changes.py +++ b/client/ayon_core/pipeline/create/changes.py @@ -3,7 +3,7 @@ import copy _EMPTY_VALUE = object() -class TrackChangesItem(object): +class TrackChangesItem: """Helper object to track changes in data. Has access to full old and new data and will create deep copy of them, diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 1e09eb62a1..61c10ee736 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -644,7 +644,7 @@ class Creator(BaseCreator): cls._get_default_variant_wrap, cls._set_default_variant_wrap ) - super(Creator, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) @property def show_order(self): diff --git a/client/ayon_core/pipeline/create/legacy_create.py b/client/ayon_core/pipeline/create/legacy_create.py index fc24bcf934..ec9b23ac62 100644 --- a/client/ayon_core/pipeline/create/legacy_create.py +++ b/client/ayon_core/pipeline/create/legacy_create.py @@ -14,7 +14,7 @@ from ayon_core.pipeline.constants import AVALON_INSTANCE_ID from .product_name import get_product_name -class LegacyCreator(object): +class LegacyCreator: """Determine how assets are created""" label = None product_type = None diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 7fe854c4fc..41c130214d 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -16,7 +16,7 @@ from .exceptions import ImmutableKeyError from .changes import TrackChangesItem -class ConvertorItem(object): +class ConvertorItem: """Item representing convertor plugin. Args: @@ -69,7 +69,7 @@ class InstanceMember: }) -class AttributeValues(object): +class AttributeValues: """Container which keep values of Attribute definitions. Goal is to have one object which hold values of attribute definitions for @@ -210,7 +210,7 @@ class CreatorAttributeValues(AttributeValues): def __init__(self, instance, *args, **kwargs): self.instance = instance - super(CreatorAttributeValues, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) class PublishAttributeValues(AttributeValues): @@ -226,7 +226,7 @@ class PublishAttributeValues(AttributeValues): def __init__(self, publish_attributes, *args, **kwargs): self.publish_attributes = publish_attributes - super(PublishAttributeValues, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) @property def parent(self): From 7ef60ad4c91af830376d3ffeccd825dd5133e3f1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 12 Aug 2024 12:22:03 +0200 Subject: [PATCH 092/203] fix 'update' method --- client/ayon_core/pipeline/create/structures.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 41c130214d..4f7caa6e11 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -112,10 +112,7 @@ class AttributeValues: if key not in self._attr_defs_by_key: raise KeyError("Key \"{}\" was not found.".format(key)) - old_value = self._data.get(key) - if old_value == value: - return - self._data[key] = value + self.update({key: value}) def __getitem__(self, key): if key not in self._attr_defs_by_key: @@ -142,8 +139,12 @@ class AttributeValues: yield key, self._data.get(key) def update(self, value): - for _key, _value in dict(value): - self[_key] = _value + changes = {} + for _key, _value in dict(value).items(): + if _key in self._data and self._data.get(_key) == _value: + continue + self._data[_key] = _value + changes[_key] = _value def pop(self, key, default=None): value = self._data.pop(key, default) From d5602cb89a3123dcc5d7b457b40e652db9133c78 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 12 Aug 2024 12:27:30 +0200 Subject: [PATCH 093/203] simpler import --- client/ayon_core/pipeline/create/context.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index a11bc311dc..76eb620b4d 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -14,9 +14,7 @@ import ayon_api from ayon_core.settings import get_project_settings from ayon_core.lib import is_func_signature_supported -from ayon_core.lib.attribute_definitions import ( - get_default_values, -) +from ayon_core.lib.attribute_definitions import get_default_values from ayon_core.host import IPublishHost, IWorkfileHost from ayon_core.pipeline import Anatomy from ayon_core.pipeline.plugin_discover import DiscoverResult From 4b8b57e39a47cf446b9368f6a8de146d0de04b6c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 12 Aug 2024 12:27:41 +0200 Subject: [PATCH 094/203] remove unecessary line --- client/ayon_core/pipeline/create/context.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 76eb620b4d..f5ba7b4774 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -373,7 +373,6 @@ class CreateContext: self._current_task_entity = task_entity return copy.deepcopy(self._current_task_entity) - def get_current_workfile_path(self): """Workfile path which was opened on context reset. From 3296079df689acff6d2d950043428b3ebaaa1b91 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 12 Aug 2024 12:27:50 +0200 Subject: [PATCH 095/203] make sure exc_info is defined --- client/ayon_core/pipeline/create/context.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index f5ba7b4774..2326a829e3 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -871,6 +871,7 @@ class CreateContext: add_traceback = False result = None fail_info = None + exc_info = None success = False try: From 04b37b83c64e294da9255698c01c227fe443cb29 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 12 Aug 2024 12:27:59 +0200 Subject: [PATCH 096/203] fix bulk processing --- client/ayon_core/pipeline/create/context.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 2326a829e3..b3a46bb778 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -965,9 +965,11 @@ class CreateContext: finally: self._bulk_counter -= 1 - # Trigger validation if there is no more context manager for bulk - # instance validation - if self._bulk_counter == 0: + # Trigger validation if there is no more context manager for bulk + # instance validation + if self._bulk_counter != 0: + return + ( self._bulk_instances_to_process, instances_to_validate From cc74f25e7a182363a82f319a97f4a24a61b80c02 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 14 Aug 2024 14:12:24 +0200 Subject: [PATCH 097/203] Provided unique file prefix Limits overwrites from multiple instances --- .../plugins/publish/extract_otio_review.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index be365520c7..8c7719ceb6 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -49,7 +49,6 @@ class ExtractOTIOReview(publish.Extractor): hosts = ["resolve", "hiero", "flame"] # plugin default attributes - temp_file_head = "tempFile." to_width = 1280 to_height = 720 output_ext = ".jpg" @@ -62,6 +61,8 @@ class ExtractOTIOReview(publish.Extractor): make_sequence_collection ) + self.temp_file_head = self._get_unique_file_prefix(instance) + # TODO: convert resulting image sequence to mp4 # get otio clip and other time info from instance clip @@ -491,3 +492,19 @@ class ExtractOTIOReview(publish.Extractor): out_frame_start = self.used_frames[-1] return output_path, out_frame_start + + def _get_unique_file_prefix(self, instance): + """Creates unique human readable file prefix to differentiate. + + Multiple instances might share same temp folder, this will provide + unique prefix for intermediate file for burnins. + """ + folder_path = instance.data["folderPath"] + folder_name = folder_path.split("/")[-1] + folder_path = folder_path.replace("/", "_").lstrip("_") + + file_prefix = f"{folder_path}_{folder_name}." + self.log.debug(f"file_prefix::{file_prefix}") + + return file_prefix + From eaa1b503b7e3e956ed753d83644dad8023765d24 Mon Sep 17 00:00:00 2001 From: robin Date: Wed, 14 Aug 2024 17:52:29 -0400 Subject: [PATCH 098/203] Validate opening and closing brackets on Anatomy keys. --- client/ayon_core/lib/path_templates.py | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index 01a6985a25..0a39d05e1d 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -460,6 +460,30 @@ class FormattingPart: return True return False + @staticmethod + def validate_key_is_matched(key): + """ + Finds out how balanced an expression is. + With a string containing only brackets. + + >>> is_matched('[]()()(((([])))') + False + >>> is_matched('[](){{{[]}}}') + True + """ + opening = tuple('({[') + closing = tuple(')}]') + mapping = dict(zip(opening, closing)) + queue = [] + + for letter in key: + if letter in opening: + queue.append(mapping[letter]) + elif letter in closing: + if not queue or letter != queue.pop(): + return False + return not queue + def format(self, data, result): """Format the formattings string. @@ -472,6 +496,12 @@ class FormattingPart: result.add_output(result.realy_used_values[key]) return result + # ensure key is properly formed [({})] properly closed. + if not self.validate_key_is_matched(key): + result.add_missing_key(key) + result.add_output(self.template) + return result + # check if key expects subdictionary keys (e.g. project[name]) existence_check = key key_padding = list(KEY_PADDING_PATTERN.findall(existence_check)) From 44c417fdb0e960c6f8af2bae56309efd70724dc6 Mon Sep 17 00:00:00 2001 From: __robin__ Date: Mon, 19 Aug 2024 08:08:33 -0400 Subject: [PATCH 099/203] Update client/ayon_core/lib/path_templates.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/lib/path_templates.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index 0a39d05e1d..46dd997da2 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -462,14 +462,17 @@ class FormattingPart: @staticmethod def validate_key_is_matched(key): - """ - Finds out how balanced an expression is. - With a string containing only brackets. + """Validate that opening has closing at correct place. + + Example: + >>> is_matched("[]()()(((([])))") + False + >>> is_matched("[](){{{[]}}}") + True + + Returns: + bool: Openings and closinga are valid. - >>> is_matched('[]()()(((([])))') - False - >>> is_matched('[](){{{[]}}}') - True """ opening = tuple('({[') closing = tuple(')}]') From 4d884db269de1f8de4b5824bfa9fb5a7b728220f Mon Sep 17 00:00:00 2001 From: __robin__ Date: Mon, 19 Aug 2024 08:08:50 -0400 Subject: [PATCH 100/203] Update client/ayon_core/lib/path_templates.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/lib/path_templates.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index 46dd997da2..edc7478cef 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -474,8 +474,8 @@ class FormattingPart: bool: Openings and closinga are valid. """ - opening = tuple('({[') - closing = tuple(')}]') + opening = tuple("({[") + closing = tuple(")}]") mapping = dict(zip(opening, closing)) queue = [] From b0490fd15dbd24534c15e1f80ccf116b5ef2003f Mon Sep 17 00:00:00 2001 From: robin Date: Mon, 19 Aug 2024 08:13:55 -0400 Subject: [PATCH 101/203] Adjust docstring from PR feedback. --- client/ayon_core/lib/path_templates.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index edc7478cef..3e3bdd8f78 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -463,6 +463,7 @@ class FormattingPart: @staticmethod def validate_key_is_matched(key): """Validate that opening has closing at correct place. + Future-proof, only square brackets are currently used in keys. Example: >>> is_matched("[]()()(((([])))") @@ -471,7 +472,7 @@ class FormattingPart: True Returns: - bool: Openings and closinga are valid. + bool: Openings and closing are valid. """ opening = tuple("({[") From b2c9ea31258cb763069c89c77726c7471e1dfa74 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 19 Aug 2024 14:39:22 +0200 Subject: [PATCH 102/203] added default iterator on initialization --- .../ayon_core/tools/publisher/models/publish.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index ef207bfb79..ab8a041414 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -829,7 +829,7 @@ class PublishModel: ) # Plugin iterator - self._main_thread_iter: Iterable[partial] = [] + self._main_thread_iter: Iterable[partial] = self._default_iterator() def reset(self): create_context = self._controller.get_create_context() @@ -900,10 +900,9 @@ class PublishModel: # only in specific cases (e.g. when it happens for a first time) if ( - self._main_thread_iter is None # There are validation errors and validation is passed # - can't do any progree - or ( + ( self._publish_has_validated and self._publish_has_validation_errors ) @@ -1070,6 +1069,18 @@ class PublishModel: {"value": value} ) + def _default_iterator(self): + """Iterator used on initialization. + + Should be replaced by real iterator when 'reset' is called. + + Yields: + partial: Function that will be called in main thread. + + """ + while True: + yield partial(self.stop_publish) + def _start_publish(self): """Start or continue in publishing.""" if self._publish_is_running: From f0cfc968d83294fe8d0a0c93dfca17c61a71c41c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Mon, 19 Aug 2024 15:01:22 +0200 Subject: [PATCH 103/203] Update client/ayon_core/pipeline/create/product_name.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/create/product_name.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 597c9f4862..3ca6611644 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -182,7 +182,7 @@ def get_product_name( fill_pairs[key] = value try: - return StringTemplate.format_template( + return StringTemplate.format_strict_template( template=template, data=prepare_template_data(fill_pairs) ) From 2e79075fe35430d754e01d98496fc708960bb11c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Mon, 19 Aug 2024 15:17:12 +0200 Subject: [PATCH 104/203] Update server/settings/tools.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- server/settings/tools.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/settings/tools.py b/server/settings/tools.py index 5e9c8e14a0..a2785c1edf 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -68,7 +68,8 @@ class CreatorToolModel(BaseSettingsModel): ) # TODO: change to False in next releases use_legacy_product_names_for_renders: bool = SettingsField( - True, title="Use legacy product names for renders", + True, + title="Use legacy product names for renders", description="Use product naming templates for renders. " "This is for backwards compatibility enabled by default." "When enabled, it will ignore any templates for renders " From 1f45f8e0a07f91081defcf2e74693d8a005c5fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Mon, 19 Aug 2024 15:17:29 +0200 Subject: [PATCH 105/203] Update client/ayon_core/pipeline/farm/pyblish_functions.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/farm/pyblish_functions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 91e155469d..fc9371f719 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -563,7 +563,7 @@ def _get_legacy_product_name_and_group( else: resulting_group_name = source_product_name - resulting_product_name = '{}'.format(resulting_group_name) + resulting_product_name = resulting_group_name camera = dynamic_data.get("camera") aov = dynamic_data.get("aov") if camera: From 51c51eeef0415fc65b52d1bc35949926cd996526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Mon, 19 Aug 2024 15:18:29 +0200 Subject: [PATCH 106/203] Update client/ayon_core/pipeline/farm/pyblish_functions.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/farm/pyblish_functions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index fc9371f719..b9a927f02d 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -679,8 +679,9 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, for aov, files in expected_files[0].items(): collected_files = _collect_expected_files_for_aov(files) - expected_filepath = collected_files[0] \ - if isinstance(collected_files, (list, tuple)) else collected_files + expected_filepath = collected_files + if isinstance(collected_files, (list, tuple)): + expected_filepath = collected_files[0] dynamic_data = { "aov": aov, From 009ab46be04be91c3f94e2e93af5c36476175e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Mon, 19 Aug 2024 15:37:18 +0200 Subject: [PATCH 107/203] Update client/ayon_core/pipeline/farm/pyblish_functions.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/farm/pyblish_functions.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index b9a927f02d..b61007d0cb 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -619,8 +619,7 @@ def get_product_name_and_group_from_template( # remove 'aov' from data used to format group. See todo comment above # for possible solution. _dynamic_data = deepcopy(dynamic_data) or {} - if _dynamic_data["aov"]: - del _dynamic_data["aov"] + _dynamic_data.pop("aov", None) resulting_group_name = get_product_name( project_name=project_name, task_name=task_entity["name"], From cb4af77a429077bc92ead11bda6b50521e31f2d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Mon, 19 Aug 2024 15:37:29 +0200 Subject: [PATCH 108/203] Update client/ayon_core/pipeline/farm/pyblish_functions.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/farm/pyblish_functions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index b61007d0cb..f9ce2d3cf5 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -585,11 +585,11 @@ def _get_legacy_product_name_and_group( def get_product_name_and_group_from_template( - task_entity, project_name, - host_name, + task_entity, product_type, variant, + host_name, dynamic_data=None): """Get product name and group name from template. From e784c0073532414dfb5020af659954ef24c57db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Mon, 19 Aug 2024 15:38:12 +0200 Subject: [PATCH 109/203] Update client/ayon_core/pipeline/farm/pyblish_functions.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/farm/pyblish_functions.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index f9ce2d3cf5..e071d46321 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -855,12 +855,11 @@ def _collect_expected_files_for_aov(files): "to render, don't know what to do " "with them.") return rem[0] - else: - # but we really expect only one collection. - # Nothing else make sense. - if len(cols) != 1: - raise ValueError("Only one image sequence type is expected.") # noqa: E501 - return list(cols[0]) + # but we really expect only one collection. + # Nothing else make sense. + if len(cols) != 1: + raise ValueError("Only one image sequence type is expected.") # noqa: E501 + return list(cols[0]) def get_resources(project_name, version_entity, extension=None): From caf674c00827a0338b035a5d9c8b033124a2b25d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Mon, 19 Aug 2024 15:38:28 +0200 Subject: [PATCH 110/203] Update client/ayon_core/pipeline/farm/pyblish_functions.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/farm/pyblish_functions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index e071d46321..b218dc78e5 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -725,7 +725,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, project_name=instance.context.data["projectName"], host_name=instance.context.data["hostName"], product_type=skeleton["productType"], - variant=instance.data.get('variant', source_product_name), + variant=instance.data.get("variant", source_product_name), dynamic_data=dynamic_data ) From f752940c1175cfd2bd9665264e218ed0732622c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 19 Aug 2024 16:10:33 +0200 Subject: [PATCH 111/203] :memo: added help for product templates --- .../plugins/publish/help/validate_unique_subsets.xml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/help/validate_unique_subsets.xml b/client/ayon_core/plugins/publish/help/validate_unique_subsets.xml index e163fc39fe..96b07979b7 100644 --- a/client/ayon_core/plugins/publish/help/validate_unique_subsets.xml +++ b/client/ayon_core/plugins/publish/help/validate_unique_subsets.xml @@ -11,7 +11,11 @@ Multiples instances from your scene are set to publish into the same folder > pr ### How to repair? -Remove the offending instances or rename to have a unique name. +Remove the offending instances or rename to have a unique name. Also, please + check your product name templates to ensure that resolved names are + sufficiently unique. You can find that settings: + + ayon+settings://core/tools/creator/product_name_profiles - \ No newline at end of file + From a39c96ea068463360ffa475f8b7c260262dbedac Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 19 Aug 2024 16:59:09 +0200 Subject: [PATCH 112/203] move some generic checks from iterator --- .../tools/publisher/models/publish.py | 39 ++++++++----------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index ab8a041414..c909fd1ef2 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -829,7 +829,9 @@ class PublishModel: ) # Plugin iterator - self._main_thread_iter: Iterable[partial] = self._default_iterator() + self._main_thread_iter: collections.abc.Generator[partial] = ( + self._default_iterator() + ) def reset(self): create_context = self._controller.get_create_context() @@ -898,17 +900,19 @@ class PublishModel: # Validations of progress before using iterator # - same conditions may be inside iterator but they may be used # only in specific cases (e.g. when it happens for a first time) - if ( - # There are validation errors and validation is passed - # - can't do any progree - ( - self._publish_has_validated - and self._publish_has_validation_errors - ) # Any unexpected error happened # - everything should stop - or self._publish_has_crashed + self._publish_has_crashed + # Stop if validation is over and validation errors happened + # or publishing should stop at validation + or ( + self._publish_has_validated + and ( + self._publish_has_validation_errors + or self._publish_up_validation + ) + ) ): item = partial(self.stop_publish) @@ -1074,8 +1078,9 @@ class PublishModel: Should be replaced by real iterator when 'reset' is called. - Yields: - partial: Function that will be called in main thread. + Returns: + collections.abc.Generator[partial]: Generator with partial + functions that should be called in main thread. """ while True: @@ -1117,18 +1122,6 @@ class PublishModel: plugin.order >= self._validation_order ) - # Stop if plugin is over validation order and process - # should process up to validation. - if self._publish_up_validation and self._publish_has_validated: - yield partial(self.stop_publish) - - # Stop if validation is over and validation errors happened - if ( - self._publish_has_validated - and self.has_validation_errors() - ): - yield partial(self.stop_publish) - # Add plugin to publish report self._publish_report.add_plugin_iter( plugin.id, self._publish_context) From e271b06c9fe50b90275740a38cd162763f6d5b91 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 19 Aug 2024 17:00:52 +0200 Subject: [PATCH 113/203] simplify clear --- client/ayon_core/tools/publisher/control_qt.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/control_qt.py b/client/ayon_core/tools/publisher/control_qt.py index b42b9afea3..f769fab91e 100644 --- a/client/ayon_core/tools/publisher/control_qt.py +++ b/client/ayon_core/tools/publisher/control_qt.py @@ -60,9 +60,8 @@ class MainThreadProcess(QtCore.QObject): self._timer.stop() def clear(self): - if self._timer.isActive(): - self._timer.stop() self._items_to_process = collections.deque() + self.stop() class QtPublisherController(PublisherController): From 5442cacdc346b7ef1c34c5493f116ea82300d531 Mon Sep 17 00:00:00 2001 From: Robin De Lillo Date: Mon, 19 Aug 2024 11:08:44 -0400 Subject: [PATCH 114/203] Update client/ayon_core/lib/path_templates.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/lib/path_templates.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index 3e3bdd8f78..33af503dd5 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -475,9 +475,9 @@ class FormattingPart: bool: Openings and closing are valid. """ - opening = tuple("({[") - closing = tuple(")}]") - mapping = dict(zip(opening, closing)) + mapping = dict(zip("({[", ")}]")) + opening = set(mapping.keys()) + closing = set(mapping.values()) queue = [] for letter in key: From 29825b6d0ebaafe900cd55ee05a1ee4fadd19283 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 19 Aug 2024 17:11:01 +0200 Subject: [PATCH 115/203] more readable code --- .../tools/publisher/models/publish.py | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index c909fd1ef2..c80e49d91f 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -898,29 +898,24 @@ class PublishModel: def get_next_process_func(self) -> partial: # Validations of progress before using iterator - # - same conditions may be inside iterator but they may be used - # only in specific cases (e.g. when it happens for a first time) + # Any unexpected error happened + # - everything should stop + if self._publish_has_crashed: + return partial(self.stop_publish) + + # Stop if validation is over and validation errors happened + # or publishing should stop at validation if ( - # Any unexpected error happened - # - everything should stop - self._publish_has_crashed - # Stop if validation is over and validation errors happened - # or publishing should stop at validation - or ( - self._publish_has_validated - and ( - self._publish_has_validation_errors - or self._publish_up_validation - ) + self._publish_has_validated + and ( + self._publish_has_validation_errors + or self._publish_up_validation ) ): - item = partial(self.stop_publish) + return partial(self.stop_publish) # Everything is ok so try to get new processing item - else: - item = next(self._main_thread_iter) - - return item + return next(self._main_thread_iter) def stop_publish(self): if self._publish_is_running: From 4041d7b8705879df0a8d9667c5d6143af1c617de Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 19 Aug 2024 17:11:27 +0200 Subject: [PATCH 116/203] raise error when 'get_next_process_func' is called and publishing did not start --- client/ayon_core/tools/publisher/models/publish.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index c80e49d91f..3bd6b33f49 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -897,6 +897,11 @@ class PublishModel: func() def get_next_process_func(self) -> partial: + # Raise error if this function is called when publishing + # is not running + if not self._publish_is_running: + raise ValueError("Publish is not running") + # Validations of progress before using iterator # Any unexpected error happened # - everything should stop From 9331187000e18c70d4a1d38f159f0ebc4c483f6b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 20 Aug 2024 14:09:29 +0200 Subject: [PATCH 117/203] ProcessContext also has information about preparation state --- client/ayon_core/addon/base.py | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 383703e2bc..41ba7d4a0e 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -116,6 +116,47 @@ class ProcessContext: if kwargs: unknown_keys = ", ".join([f'"{key}"' for key in kwargs.keys()]) print(f"Unknown keys in ProcessContext: {unknown_keys}") + self._prepared: bool = False + self._exception: Optional[Exception] = None + + def is_prepared(self) -> bool: + """Preparation of process finished. + + Returns: + bool: Preparation is done. + """ + return self._prepared + + def set_prepared(self): + """Mark process as prepared.""" + self._prepared = True + + def preparation_failed(self) -> bool: + """Preparation failed. + + Returns: + bool: Preparation failed. + + """ + return self._exception is not None + + def get_exception(self) -> Optional[Exception]: + """Get exception that occurred during preparation. + + Returns: + Optional[Exception]: Exception that caused preparation fail. + + """ + return self._exception + + def set_exception(self, exception: Exception): + """Set exception that occurred during preparation. + + Args: + exception (Exception): Exception that caused preparation fail. + + """ + self._exception = exception # Inherit from `object` for Python 2 hosts From 501cacc4b8e5e081d7211ddae7a11ad492348e3c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 20 Aug 2024 14:09:46 +0200 Subject: [PATCH 118/203] change state of context when is done --- client/ayon_core/addon/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index ac5ff25984..5f6922b924 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -134,6 +134,8 @@ def ensure_addons_are_process_context_ready( failed = True break + process_context.set_prepared() + process_context.set_exception(exception) output_str = output.getvalue() # Print stdout/stderr to console as it was redirected print(output_str) From 8e9c28a84a416d51169ccc160d269481b4a026b0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 20 Aug 2024 14:10:02 +0200 Subject: [PATCH 119/203] 'ensure_addons_are_process_ready' returns ProcessContext --- client/ayon_core/addon/utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index 5f6922b924..b301c70564 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -161,7 +161,7 @@ def ensure_addons_are_process_ready( addons_manager: Optional[AddonsManager] = None, exit_on_failure: bool = True, **kwargs, -) -> Optional[Exception]: +) -> ProcessContext: """Ensure all enabled addons are ready to be used in the given context. Call this method only in AYON launcher process and as first thing @@ -181,6 +181,7 @@ def ensure_addons_are_process_ready( """ context: ProcessContext = ProcessContext(**kwargs) - return ensure_addons_are_process_context_ready( + ensure_addons_are_process_context_ready( context, addons_manager, exit_on_failure ) + return context From f3cccccab73bc58e0b8c9daa53ebd9b37b80e74c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 20 Aug 2024 14:10:16 +0200 Subject: [PATCH 120/203] do not return exception on fail --- client/ayon_core/addon/utils.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index b301c70564..0481ed4cad 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -72,7 +72,7 @@ def ensure_addons_are_process_context_ready( process_context: ProcessContext, addons_manager: Optional[AddonsManager] = None, exit_on_failure: bool = True, -) -> Optional[Exception]: +): """Ensure all enabled addons are ready to be used in the given context. Call this method only in AYON launcher process and as first thing @@ -152,9 +152,8 @@ def ensure_addons_are_process_context_ready( detail = output_str _handle_error(process_context, message, detail) - if not exit_on_failure: - return exception - sys.exit(1) + if exit_on_failure: + sys.exit(1) def ensure_addons_are_process_ready( From 2b46eee1dd2d074e979923ba313086cbbd90a271 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 20 Aug 2024 14:19:52 +0200 Subject: [PATCH 121/203] added ProcessContext arguments to 'ensure_addons_are_process_ready' --- client/ayon_core/addon/base.py | 3 ++- client/ayon_core/addon/utils.py | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 41ba7d4a0e..e5b5087423 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -94,7 +94,8 @@ class ProcessContext: project_name (Optional[str]): Project name. Can be filled in case process is triggered for specific project. Some addons can have different behavior based on project. - headless (Optional[bool]): Is process running in headless mode. + headless (Optional[bool]): Is process running in headless mode. Value + is filled with value based on state set in AYON launcher. """ def __init__( diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index 0481ed4cad..ad04586019 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -157,6 +157,10 @@ def ensure_addons_are_process_context_ready( def ensure_addons_are_process_ready( + addon_name: Optional[str] = None, + addon_version: Optional[str] = None, + project_name: Optional[str] = None, + headless: Optional[bool] = None, addons_manager: Optional[AddonsManager] = None, exit_on_failure: bool = True, **kwargs, @@ -168,6 +172,13 @@ def ensure_addons_are_process_ready( should not be created. Args: + addon_name (Optional[str]): Addon name which triggered process. + addon_version (Optional[str]): Addon version which triggered process. + project_name (Optional[str]): Project name. Can be filled in case + process is triggered for specific project. Some addons can have + different behavior based on project. + headless (Optional[bool]): Is process running in headless mode. Value + is filled with value based on state set in AYON launcher. addons_manager (Optional[AddonsManager]): The addons manager to use. If not provided, a new one will be created. exit_on_failure (bool, optional): If True, the process will exit @@ -179,7 +190,13 @@ def ensure_addons_are_process_ready( preparation, if any. """ - context: ProcessContext = ProcessContext(**kwargs) + context: ProcessContext = ProcessContext( + addon_name, + addon_version, + project_name, + headless, + **kwargs + ) ensure_addons_are_process_context_ready( context, addons_manager, exit_on_failure ) From d983879ccd7f4215c63d9baa52b4e0b16607c62e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 20 Aug 2024 14:39:56 +0200 Subject: [PATCH 122/203] added comment to project name --- client/ayon_core/addon/base.py | 2 +- client/ayon_core/addon/utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index e5b5087423..5662375c0a 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -93,7 +93,7 @@ class ProcessContext: addon_version (Optional[str]): Addon version which triggered process. project_name (Optional[str]): Project name. Can be filled in case process is triggered for specific project. Some addons can have - different behavior based on project. + different behavior based on project. Value is NOT autofilled. headless (Optional[bool]): Is process running in headless mode. Value is filled with value based on state set in AYON launcher. diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index ad04586019..43118bff7e 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -176,7 +176,7 @@ def ensure_addons_are_process_ready( addon_version (Optional[str]): Addon version which triggered process. project_name (Optional[str]): Project name. Can be filled in case process is triggered for specific project. Some addons can have - different behavior based on project. + different behavior based on project. Value is NOT autofilled. headless (Optional[bool]): Is process running in headless mode. Value is filled with value based on state set in AYON launcher. addons_manager (Optional[AddonsManager]): The addons From eb4407b9efb55bf76bdb5b8040422f943bc2410f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 20 Aug 2024 14:57:56 +0200 Subject: [PATCH 123/203] fix returns in docstrings --- client/ayon_core/addon/utils.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index 43118bff7e..f2441e37be 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -87,10 +87,6 @@ def ensure_addons_are_process_context_ready( exit_on_failure (bool, optional): If True, the process will exit if an error occurs. Defaults to True. - Returns: - Optional[Exception]: The exception that occurred during the - preparation, if any. - """ if addons_manager is None: addons_manager = AddonsManager() @@ -186,7 +182,7 @@ def ensure_addons_are_process_ready( kwargs: The keyword arguments to pass to the ProcessContext. Returns: - Optional[Exception]: The exception that occurred during the + ProcessContext: The exception that occurred during the preparation, if any. """ From 92ce331a302c26a6a9a9fd47361269225d07b098 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 20 Aug 2024 18:45:54 +0200 Subject: [PATCH 124/203] added 'is_headless_mode_enabled' function --- client/ayon_core/addon/base.py | 4 ++-- client/ayon_core/lib/__init__.py | 2 ++ client/ayon_core/lib/ayon_info.py | 4 ++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 5662375c0a..6343166ac8 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -20,6 +20,7 @@ from ayon_core.lib import ( Logger, is_dev_mode_enabled, get_launcher_storage_dir, + is_headless_mode_enabled, ) from ayon_core.settings import get_studio_settings @@ -107,8 +108,7 @@ class ProcessContext: **kwargs, ): if headless is None: - # TODO use lib function to get headless mode - headless = os.getenv("AYON_HEADLESS_MODE") == "1" + headless = is_headless_mode_enabled() self.addon_name: Optional[str] = addon_name self.addon_version: Optional[str] = addon_version self.project_name: Optional[str] = project_name diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index d4b161031e..0074c4d2bd 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -132,6 +132,7 @@ from .ayon_info import ( is_in_ayon_launcher_process, is_running_from_build, is_using_ayon_console, + is_headless_mode_enabled, is_staging_enabled, is_dev_mode_enabled, is_in_tests, @@ -245,6 +246,7 @@ __all__ = [ "is_in_ayon_launcher_process", "is_running_from_build", "is_using_ayon_console", + "is_headless_mode_enabled", "is_staging_enabled", "is_dev_mode_enabled", "is_in_tests", diff --git a/client/ayon_core/lib/ayon_info.py b/client/ayon_core/lib/ayon_info.py index c4333fab95..7e194a824e 100644 --- a/client/ayon_core/lib/ayon_info.py +++ b/client/ayon_core/lib/ayon_info.py @@ -78,6 +78,10 @@ def is_using_ayon_console(): return "ayon_console" in executable_filename +def is_headless_mode_enabled(): + return os.getenv("AYON_HEADLESS_MODE") == "1" + + def is_staging_enabled(): return os.getenv("AYON_USE_STAGING") == "1" From 3bc993399117f5690d6597a26947603fafa8d206 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 20 Aug 2024 19:10:57 +0200 Subject: [PATCH 125/203] return bool all the time --- client/ayon_core/addon/utils.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index f2441e37be..31cb128e4b 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -72,7 +72,7 @@ def ensure_addons_are_process_context_ready( process_context: ProcessContext, addons_manager: Optional[AddonsManager] = None, exit_on_failure: bool = True, -): +) -> bool: """Ensure all enabled addons are ready to be used in the given context. Call this method only in AYON launcher process and as first thing @@ -87,6 +87,9 @@ def ensure_addons_are_process_context_ready( exit_on_failure (bool, optional): If True, the process will exit if an error occurs. Defaults to True. + Returns: + bool: True if all addons are ready, False otherwise. + """ if addons_manager is None: addons_manager = AddonsManager() @@ -138,7 +141,7 @@ def ensure_addons_are_process_context_ready( if not failed: if not process_context.headless: _start_tray() - return None + return True detail = None if use_detail: @@ -150,6 +153,7 @@ def ensure_addons_are_process_context_ready( _handle_error(process_context, message, detail) if exit_on_failure: sys.exit(1) + return False def ensure_addons_are_process_ready( @@ -160,7 +164,7 @@ def ensure_addons_are_process_ready( addons_manager: Optional[AddonsManager] = None, exit_on_failure: bool = True, **kwargs, -) -> ProcessContext: +) -> bool: """Ensure all enabled addons are ready to be used in the given context. Call this method only in AYON launcher process and as first thing @@ -182,8 +186,7 @@ def ensure_addons_are_process_ready( kwargs: The keyword arguments to pass to the ProcessContext. Returns: - ProcessContext: The exception that occurred during the - preparation, if any. + bool: True if all addons are ready, False otherwise. """ context: ProcessContext = ProcessContext( @@ -193,7 +196,6 @@ def ensure_addons_are_process_ready( headless, **kwargs ) - ensure_addons_are_process_context_ready( + return ensure_addons_are_process_context_ready( context, addons_manager, exit_on_failure ) - return context From 173b2f27272cb408e4ae496eb5fe40bf3eb4c266 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 20 Aug 2024 19:14:22 +0200 Subject: [PATCH 126/203] remove ProcessContext methods --- client/ayon_core/addon/base.py | 41 --------------------------------- client/ayon_core/addon/utils.py | 7 +----- 2 files changed, 1 insertion(+), 47 deletions(-) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 6343166ac8..e627fb2b38 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -117,47 +117,6 @@ class ProcessContext: if kwargs: unknown_keys = ", ".join([f'"{key}"' for key in kwargs.keys()]) print(f"Unknown keys in ProcessContext: {unknown_keys}") - self._prepared: bool = False - self._exception: Optional[Exception] = None - - def is_prepared(self) -> bool: - """Preparation of process finished. - - Returns: - bool: Preparation is done. - """ - return self._prepared - - def set_prepared(self): - """Mark process as prepared.""" - self._prepared = True - - def preparation_failed(self) -> bool: - """Preparation failed. - - Returns: - bool: Preparation failed. - - """ - return self._exception is not None - - def get_exception(self) -> Optional[Exception]: - """Get exception that occurred during preparation. - - Returns: - Optional[Exception]: Exception that caused preparation fail. - - """ - return self._exception - - def set_exception(self, exception: Exception): - """Set exception that occurred during preparation. - - Args: - exception (Exception): Exception that caused preparation fail. - - """ - self._exception = exception # Inherit from `object` for Python 2 hosts diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index 31cb128e4b..32b42bf1e2 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -94,7 +94,6 @@ def ensure_addons_are_process_context_ready( if addons_manager is None: addons_manager = AddonsManager() - exception = None message = None failed = False use_detail = False @@ -111,13 +110,11 @@ def ensure_addons_are_process_context_ready( addon.ensure_is_process_ready(process_context) addon_failed = False except ProcessPreparationError as exc: - exception = exc message = str(exc) print(f"Addon preparation failed: '{addon.name}'") print(message) - except BaseException as exc: - exception = exc + except BaseException: use_detail = True message = "An unexpected error occurred." formatted_traceback = "".join(traceback.format_exception( @@ -133,8 +130,6 @@ def ensure_addons_are_process_context_ready( failed = True break - process_context.set_prepared() - process_context.set_exception(exception) output_str = output.getvalue() # Print stdout/stderr to console as it was redirected print(output_str) From 9260a80cb0becbf322a78ad431a7de45b8f29836 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 20 Aug 2024 19:14:53 +0200 Subject: [PATCH 127/203] require addon name and version --- client/ayon_core/addon/base.py | 29 +++++++++++++++++------------ client/ayon_core/addon/utils.py | 8 ++++---- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index e627fb2b38..494c0f3da7 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -81,17 +81,22 @@ class ProcessPreparationError(Exception): class ProcessContext: - """Context of child process. + """Hold context of process that is going to be started. - Notes: - This class is used to pass context to child process. It can be used - to use different behavior of addon based on information in - the context. - The context can be enhanced in future versions. + Right now the context is simple, having information about addon that wants + to trigger preparation and possibly project name for which it should + happen. + + Preparation for process can be required for ayon-core or any other addon. + It can be, change of environment variables, or request login to + a project management. + + At the moment of creation is 'ProcessContext' only data holder, but that + might change in future if there will be need. Args: - addon_name (Optional[str]): Addon name which triggered process. - addon_version (Optional[str]): Addon version which triggered process. + addon_name (str): Addon name which triggered process. + addon_version (str): Addon version which triggered process. project_name (Optional[str]): Project name. Can be filled in case process is triggered for specific project. Some addons can have different behavior based on project. Value is NOT autofilled. @@ -101,16 +106,16 @@ class ProcessContext: """ def __init__( self, - addon_name: Optional[str] = None, - addon_version: Optional[str] = None, + addon_name: str = None, + addon_version: str = None, project_name: Optional[str] = None, headless: Optional[bool] = None, **kwargs, ): if headless is None: headless = is_headless_mode_enabled() - self.addon_name: Optional[str] = addon_name - self.addon_version: Optional[str] = addon_version + self.addon_name: str = addon_name + self.addon_version: str = addon_version self.project_name: Optional[str] = project_name self.headless: bool = headless diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index 32b42bf1e2..1dea4cc4fe 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -152,8 +152,8 @@ def ensure_addons_are_process_context_ready( def ensure_addons_are_process_ready( - addon_name: Optional[str] = None, - addon_version: Optional[str] = None, + addon_name: str, + addon_version: str, project_name: Optional[str] = None, headless: Optional[bool] = None, addons_manager: Optional[AddonsManager] = None, @@ -167,8 +167,8 @@ def ensure_addons_are_process_ready( should not be created. Args: - addon_name (Optional[str]): Addon name which triggered process. - addon_version (Optional[str]): Addon version which triggered process. + addon_name (str): Addon name which triggered process. + addon_version (str): Addon version which triggered process. project_name (Optional[str]): Project name. Can be filled in case process is triggered for specific project. Some addons can have different behavior based on project. Value is NOT autofilled. From e5eefc81fc10db24c232157eb0ab0b08b5ef5017 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 20 Aug 2024 19:15:07 +0200 Subject: [PATCH 128/203] added todo --- client/ayon_core/addon/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index 1dea4cc4fe..8e8c2bd8d7 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -79,6 +79,10 @@ def ensure_addons_are_process_context_ready( to avoid possible clashes with preparation. For example 'QApplication' should not be created. + Todos: + Run all preparations and allow to "ignore" failed preparations. + Right now single addon can block using certain actions. + Args: process_context (ProcessContext): The context in which the addons should be prepared. From e0339aeb104c13b8aada5a728d67104d3041dc3e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 20 Aug 2024 19:25:45 +0200 Subject: [PATCH 129/203] require addons_manager and exit_on_failure as kwargs --- client/ayon_core/addon/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index 8e8c2bd8d7..f983e37d3c 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -160,6 +160,7 @@ def ensure_addons_are_process_ready( addon_version: str, project_name: Optional[str] = None, headless: Optional[bool] = None, + *, addons_manager: Optional[AddonsManager] = None, exit_on_failure: bool = True, **kwargs, From 723198c2a03be93e4408c5c4179bc48b8724ca5d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 10:19:12 +0200 Subject: [PATCH 130/203] use 'title' instead of 'topic' --- server/settings/publish_plugins.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index a5ea7bd762..8ca96432f4 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -562,12 +562,12 @@ class ExtractBurninDef(BaseSettingsModel): _isGroup = True _layout = "expanded" name: str = SettingsField("") - TOP_LEFT: str = SettingsField("", topic="Top Left") - TOP_CENTERED: str = SettingsField("", topic="Top Centered") - TOP_RIGHT: str = SettingsField("", topic="Top Right") - BOTTOM_LEFT: str = SettingsField("", topic="Bottom Left") - BOTTOM_CENTERED: str = SettingsField("", topic="Bottom Centered") - BOTTOM_RIGHT: str = SettingsField("", topic="Bottom Right") + TOP_LEFT: str = SettingsField("", title="Top Left") + TOP_CENTERED: str = SettingsField("", title="Top Centered") + TOP_RIGHT: str = SettingsField("", title="Top Right") + BOTTOM_LEFT: str = SettingsField("", title="Bottom Left") + BOTTOM_CENTERED: str = SettingsField("", title="Bottom Centered") + BOTTOM_RIGHT: str = SettingsField("", title="Bottom Right") filter: ExtractBurninDefFilter = SettingsField( default_factory=ExtractBurninDefFilter, title="Additional filtering" From 725584a8dbb9b29e7dc1f675cf2c174efc268d9d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 10:29:56 +0200 Subject: [PATCH 131/203] require addon name/version Co-authored-by: Roy Nieterau --- client/ayon_core/addon/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 494c0f3da7..7f0636ccca 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -106,8 +106,8 @@ class ProcessContext: """ def __init__( self, - addon_name: str = None, - addon_version: str = None, + addon_name: str, + addon_version: str, project_name: Optional[str] = None, headless: Optional[bool] = None, **kwargs, From 8dd1843be107919f01e9fdbd5baf1df297191854 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 10:52:40 +0200 Subject: [PATCH 132/203] implemented helper line edit with options --- client/ayon_core/style/style.css | 25 ++++++ client/ayon_core/tools/utils/widgets.py | 115 ++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 607fd1fa31..2c826f16a4 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -739,6 +739,31 @@ OverlayMessageWidget QWidget { background: transparent; } +/* Hinted Line Edit */ +#HintedLineEditInput { + border-radius: 0.2em; + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + border: 1px solid {color:border}; +} +#HintedLineEditInput:hover { + border-color: {color:border-hover}; +} +#HintedLineEditInput:focus{ + border-color: {color:border-focus}; +} +#HintedLineEditInput:disabled { + background: {color:bg-inputs-disabled}; +} +#HintedLineEditButton { + border: none; + border-radius: 0.2em; + border-bottom-left-radius: 0px; + border-top-left-radius: 0px; + padding: 0px; + qproperty-iconSize: 11px 11px; +} + /* Password dialog*/ #PasswordBtn { border: none; diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 28331fbc35..2065246190 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -1,4 +1,5 @@ import logging +from typing import Optional, List from qtpy import QtWidgets, QtCore, QtGui import qargparse @@ -104,6 +105,120 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit): self.setPalette(filter_palette) +def get_down_arrow_icon() -> QtGui.QIcon: + normal_pixmap = QtGui.QPixmap( + get_style_image_path("down_arrow") + ) + on_pixmap = QtGui.QPixmap( + get_style_image_path("down_arrow_on") + ) + disabled_pixmap = QtGui.QPixmap( + get_style_image_path("down_arrow_disabled") + ) + icon = QtGui.QIcon(normal_pixmap) + icon.addPixmap(on_pixmap, QtGui.QIcon.Active) + icon.addPixmap(disabled_pixmap, QtGui.QIcon.Disabled) + return icon + + +class HintedLineEdit(QtWidgets.QWidget): + returnPressed = QtCore.Signal() + textChanged = QtCore.Signal(str) + textEdited = QtCore.Signal(str) + + def __init__( + self, + options: Optional[List[str]] = None, + parent: Optional[QtWidgets.QWidget] = None + ): + super().__init__(parent) + + text_input = PlaceholderLineEdit(self) + options_button = QtWidgets.QPushButton(self) + + text_input.setObjectName("HintedLineEditInput") + options_button.setObjectName("HintedLineEditButton") + options_button.setIcon(get_down_arrow_icon()) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addWidget(text_input, 1) + main_layout.addWidget(options_button, 0) + + # Expand line edit and button vertically so they have same height + for widget in (text_input, options_button): + w_size_policy = widget.sizePolicy() + w_size_policy.setVerticalPolicy( + QtWidgets.QSizePolicy.MinimumExpanding) + widget.setSizePolicy(w_size_policy) + + # Set size hint of this frame to fixed so size hint height is + # used as fixed height + size_policy = self.sizePolicy() + size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Fixed) + self.setSizePolicy(size_policy) + + text_input.returnPressed.connect(self.returnPressed) + text_input.textChanged.connect(self.textChanged) + text_input.textEdited.connect(self.textEdited) + options_button.clicked.connect(self._on_options_button_clicked) + + self._text_input = text_input + self._options_button = options_button + self._options = None + + # Set default state + self.set_options(options) + + def text(self) -> str: + return self._text_input.text() + + def setText(self, text: str): + self._text_input.setText(text) + + def setPlaceholderText(self, text: str): + self._text_input.setPlaceholderText(text) + + def placeholderText(self) -> str: + return self._text_input.placeholderText() + + def setReadOnly(self, state: bool): + self._text_input.setReadOnly(state) + + def setIcon(self, icon: QtGui.QIcon): + self._options_button.setIcon(icon) + + def set_options(self, options: Optional[List[str]] = None): + self._options = options + self._options_button.setEnabled(bool(options)) + + def sizeHint(self) -> QtCore.QSize: + hint = super().sizeHint() + tsz = self._text_input.sizeHint() + bsz = self._options_button.sizeHint() + hint.setHeight(max(tsz.height(), bsz.height())) + return hint + + def _on_options_button_clicked(self): + if not self._options: + return + + menu = QtWidgets.QMenu(self) + for option in self._options: + action = menu.addAction(option) + action.triggered.connect(self._on_option_action) + + rect = self._options_button.rect() + pos = self._options_button.mapToGlobal(rect.bottomLeft()) + menu.exec_(pos) + + def _on_option_action(self): + action = self.sender() + if action: + self.setText(action.text()) + + class ExpandingTextEdit(QtWidgets.QTextEdit): """QTextEdit which does not have sroll area but expands height.""" From 7e12163c7a78dfd0dd7157d1760232ab0808a7c2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 11:25:54 +0200 Subject: [PATCH 133/203] added options to pass separator --- client/ayon_core/tools/utils/widgets.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 2065246190..e3ff42a102 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, List +from typing import Optional, List, Set from qtpy import QtWidgets, QtCore, QtGui import qargparse @@ -122,6 +122,7 @@ def get_down_arrow_icon() -> QtGui.QIcon: class HintedLineEdit(QtWidgets.QWidget): + SEPARATORS: Set[str] = {"---", "---separator---"} returnPressed = QtCore.Signal() textChanged = QtCore.Signal(str) textEdited = QtCore.Signal(str) @@ -206,6 +207,9 @@ class HintedLineEdit(QtWidgets.QWidget): menu = QtWidgets.QMenu(self) for option in self._options: + if option in self.SEPARATORS: + menu.addSeparator() + continue action = menu.addAction(option) action.triggered.connect(self._on_option_action) From 54665ba72b5732da893712586359f95ee3298ac1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 11:26:01 +0200 Subject: [PATCH 134/203] handle tooltips --- client/ayon_core/tools/utils/widgets.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index e3ff42a102..8109130c27 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -190,6 +190,12 @@ class HintedLineEdit(QtWidgets.QWidget): def setIcon(self, icon: QtGui.QIcon): self._options_button.setIcon(icon) + def setToolTip(self, text: str): + self._text_input.setToolTip(text) + + def set_button_tool_tip(self, text: str): + self._options_button.setToolTip(text) + def set_options(self, options: Optional[List[str]] = None): self._options = options self._options_button.setEnabled(bool(options)) From bf48d9e804f0b66b29ddaa417f28cf0bbb253c50 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 11:26:16 +0200 Subject: [PATCH 135/203] added 'HintedLineEdit' to utils init file --- client/ayon_core/tools/utils/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py index 4b5fbeaf67..059ac28b7b 100644 --- a/client/ayon_core/tools/utils/__init__.py +++ b/client/ayon_core/tools/utils/__init__.py @@ -5,6 +5,7 @@ from .widgets import ( ComboBox, CustomTextComboBox, PlaceholderLineEdit, + HintedLineEdit, ExpandingTextEdit, BaseClickableFrame, ClickableFrame, @@ -88,6 +89,7 @@ __all__ = ( "ComboBox", "CustomTextComboBox", "PlaceholderLineEdit", + "HintedLineEdit", "ExpandingTextEdit", "BaseClickableFrame", "ClickableFrame", From 8a9d815278746f5b49cf09b4cb50e8b5647b4a7e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 11:29:44 +0200 Subject: [PATCH 136/203] use class name over object name for formatting --- client/ayon_core/style/style.css | 10 +++++----- client/ayon_core/tools/utils/widgets.py | 15 ++++++++++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 2c826f16a4..1af355ac7b 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -740,22 +740,22 @@ OverlayMessageWidget QWidget { } /* Hinted Line Edit */ -#HintedLineEditInput { +HintedLineEditInput { border-radius: 0.2em; border-top-right-radius: 0px; border-bottom-right-radius: 0px; border: 1px solid {color:border}; } -#HintedLineEditInput:hover { +HintedLineEditInput:hover { border-color: {color:border-hover}; } -#HintedLineEditInput:focus{ +HintedLineEditInput:focus{ border-color: {color:border-focus}; } -#HintedLineEditInput:disabled { +HintedLineEditInput:disabled { background: {color:bg-inputs-disabled}; } -#HintedLineEditButton { +HintedLineEditButton { border: none; border-radius: 0.2em; border-bottom-left-radius: 0px; diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 8109130c27..008f48c502 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -121,6 +121,14 @@ def get_down_arrow_icon() -> QtGui.QIcon: return icon +class HintedLineEditInput(PlaceholderLineEdit): + pass + + +class HintedLineEditButton(QtWidgets.QPushButton): + pass + + class HintedLineEdit(QtWidgets.QWidget): SEPARATORS: Set[str] = {"---", "---separator---"} returnPressed = QtCore.Signal() @@ -134,11 +142,8 @@ class HintedLineEdit(QtWidgets.QWidget): ): super().__init__(parent) - text_input = PlaceholderLineEdit(self) - options_button = QtWidgets.QPushButton(self) - - text_input.setObjectName("HintedLineEditInput") - options_button.setObjectName("HintedLineEditButton") + text_input = HintedLineEditInput(self) + options_button = HintedLineEditButton(self) options_button.setIcon(get_down_arrow_icon()) main_layout = QtWidgets.QHBoxLayout(self) From c8880a87d63fcdd5af69ed1f95ba9910d2970527 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 12:11:58 +0200 Subject: [PATCH 137/203] added option to change style of the widget --- client/ayon_core/tools/utils/widgets.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 008f48c502..184465ee1b 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, List, Set +from typing import Optional, List, Set, Any from qtpy import QtWidgets, QtCore, QtGui import qargparse @@ -12,7 +12,7 @@ from ayon_core.style import ( ) from ayon_core.lib.attribute_definitions import AbstractAttrDef -from .lib import get_qta_icon_by_name_and_color +from .lib import get_qta_icon_by_name_and_color, set_style_property log = logging.getLogger(__name__) @@ -121,6 +121,7 @@ def get_down_arrow_icon() -> QtGui.QIcon: return icon +# These are placeholders for adding style class HintedLineEditInput(PlaceholderLineEdit): pass @@ -212,6 +213,21 @@ class HintedLineEdit(QtWidgets.QWidget): hint.setHeight(max(tsz.height(), bsz.height())) return hint + # Adds ability to change style of the widgets + # - because style change of the 'HintedLineEdit' may not propagate + # correctly 'HintedLineEditInput' and 'HintedLineEditButton' + def set_text_widget_object_name(self, name: str): + self._text_input.setObjectName(name) + + def set_text_widget_property(self, name: str, value: Any): + set_style_property(self._text_input, name, value) + + def set_button_widget_object_name(self, name: str): + self._text_input.setObjectName(name) + + def set_button_widget_property(self, name: str, value: Any): + set_style_property(self._options_button, name, value) + def _on_options_button_clicked(self): if not self._options: return From 9524aadeb2db0aa9c8cb37cea68f87521e2cb507 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 12:13:49 +0200 Subject: [PATCH 138/203] use 'HintedLineEdit' in publisher --- client/ayon_core/style/style.css | 11 --- .../tools/publisher/widgets/create_widget.py | 98 +++++-------------- 2 files changed, 22 insertions(+), 87 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 1af355ac7b..0857bc80c6 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -994,17 +994,6 @@ PixmapButton:disabled { #PublishLogConsole { font-family: "Noto Sans Mono"; } -#VariantInputsWidget QLineEdit { - border-bottom-right-radius: 0px; - border-top-right-radius: 0px; -} -#VariantInputsWidget QToolButton { - border-bottom-left-radius: 0px; - border-top-left-radius: 0px; - padding-top: 0.5em; - padding-bottom: 0.5em; - width: 0.5em; -} #VariantInput[state="new"], #VariantInput[state="new"]:focus, #VariantInput[state="new"]:hover { border-color: {color:publisher:success}; } diff --git a/client/ayon_core/tools/publisher/widgets/create_widget.py b/client/ayon_core/tools/publisher/widgets/create_widget.py index 479a63ebc9..4c94c5c9b9 100644 --- a/client/ayon_core/tools/publisher/widgets/create_widget.py +++ b/client/ayon_core/tools/publisher/widgets/create_widget.py @@ -19,6 +19,7 @@ from ayon_core.tools.publisher.constants import ( INPUTS_LAYOUT_HSPACING, INPUTS_LAYOUT_VSPACING, ) +from ayon_core.tools.utils import HintedLineEdit from .thumbnail_widget import ThumbnailWidget from .widgets import ( @@ -28,8 +29,6 @@ from .widgets import ( from .create_context_widgets import CreateContextWidget from .precreate_widget import PreCreateWidget -SEPARATORS = ("---separator---", "---") - class ResizeControlWidget(QtWidgets.QWidget): resized = QtCore.Signal() @@ -168,25 +167,9 @@ class CreateWidget(QtWidgets.QWidget): product_variant_widget = QtWidgets.QWidget(creator_basics_widget) # Variant and product input - variant_widget = ResizeControlWidget(product_variant_widget) - variant_widget.setObjectName("VariantInputsWidget") - - variant_input = QtWidgets.QLineEdit(variant_widget) - variant_input.setObjectName("VariantInput") - variant_input.setToolTip(VARIANT_TOOLTIP) - - variant_hints_btn = QtWidgets.QToolButton(variant_widget) - variant_hints_btn.setArrowType(QtCore.Qt.DownArrow) - variant_hints_btn.setIconSize(QtCore.QSize(12, 12)) - - variant_hints_menu = QtWidgets.QMenu(variant_widget) - variant_hints_group = QtWidgets.QActionGroup(variant_hints_menu) - - variant_layout = QtWidgets.QHBoxLayout(variant_widget) - variant_layout.setContentsMargins(0, 0, 0, 0) - variant_layout.setSpacing(0) - variant_layout.addWidget(variant_input, 1) - variant_layout.addWidget(variant_hints_btn, 0, QtCore.Qt.AlignVCenter) + variant_widget = HintedLineEdit(parent=product_variant_widget) + variant_widget.set_text_widget_object_name("VariantInput") + variant_widget.setToolTip(VARIANT_TOOLTIP) product_name_input = QtWidgets.QLineEdit(product_variant_widget) product_name_input.setEnabled(False) @@ -262,15 +245,12 @@ class CreateWidget(QtWidgets.QWidget): prereq_timer.timeout.connect(self._invalidate_prereq) create_btn.clicked.connect(self._on_create) - variant_widget.resized.connect(self._on_variant_widget_resize) creator_basics_widget.resized.connect(self._on_creator_basics_resize) - variant_input.returnPressed.connect(self._on_create) - variant_input.textChanged.connect(self._on_variant_change) + variant_widget.returnPressed.connect(self._on_create) + variant_widget.textChanged.connect(self._on_variant_change) creators_view.selectionModel().currentChanged.connect( self._on_creator_item_change ) - variant_hints_btn.clicked.connect(self._on_variant_btn_click) - variant_hints_menu.triggered.connect(self._on_variant_action) context_widget.folder_changed.connect(self._on_folder_change) context_widget.task_changed.connect(self._on_task_change) thumbnail_widget.thumbnail_created.connect(self._on_thumbnail_create) @@ -291,10 +271,7 @@ class CreateWidget(QtWidgets.QWidget): self.product_name_input = product_name_input - self.variant_input = variant_input - self.variant_hints_btn = variant_hints_btn - self.variant_hints_menu = variant_hints_menu - self.variant_hints_group = variant_hints_group + self._variant_widget = variant_widget self._creators_model = creators_model self._creators_sort_model = creators_sort_model @@ -314,6 +291,7 @@ class CreateWidget(QtWidgets.QWidget): self._last_current_context_folder_path = None self._last_current_context_task = None self._use_current_context = True + self._current_creator_variant_hints = [] def get_current_folder_path(self): return self._controller.get_current_folder_path() @@ -438,8 +416,7 @@ class CreateWidget(QtWidgets.QWidget): self._create_btn.setEnabled(prereq_available) - self.variant_input.setEnabled(prereq_available) - self.variant_hints_btn.setEnabled(prereq_available) + self._variant_widget.setEnabled(prereq_available) tooltip = "" if creator_btn_tooltips: @@ -611,35 +588,15 @@ class CreateWidget(QtWidgets.QWidget): if not default_variant: default_variant = default_variants[0] - for action in tuple(self.variant_hints_menu.actions()): - self.variant_hints_menu.removeAction(action) - action.deleteLater() - - for variant in default_variants: - if variant in SEPARATORS: - self.variant_hints_menu.addSeparator() - elif variant: - self.variant_hints_menu.addAction(variant) + self._current_creator_variant_hints = list(default_variants) + self._variant_widget.set_options(default_variants) variant_text = default_variant or DEFAULT_VARIANT_VALUE # Make sure product name is updated to new plugin - if variant_text == self.variant_input.text(): + if variant_text == self._variant_widget.text(): self._on_variant_change() else: - self.variant_input.setText(variant_text) - - def _on_variant_widget_resize(self): - self.variant_hints_btn.setFixedHeight(self.variant_input.height()) - - def _on_variant_btn_click(self): - pos = self.variant_hints_btn.rect().bottomLeft() - point = self.variant_hints_btn.mapToGlobal(pos) - self.variant_hints_menu.popup(point) - - def _on_variant_action(self, action): - value = action.text() - if self.variant_input.text() != value: - self.variant_input.setText(value) + self._variant_widget.setText(variant_text) def _on_variant_change(self, variant_value=None): if not self._prereq_available: @@ -652,7 +609,7 @@ class CreateWidget(QtWidgets.QWidget): return if variant_value is None: - variant_value = self.variant_input.text() + variant_value = self._variant_widget.text() if not self._compiled_name_pattern.match(variant_value): self._create_btn.setEnabled(False) @@ -707,20 +664,12 @@ class CreateWidget(QtWidgets.QWidget): if _result: variant_hints |= set(_result.groups()) - # Remove previous hints from menu - for action in tuple(self.variant_hints_group.actions()): - self.variant_hints_group.removeAction(action) - self.variant_hints_menu.removeAction(action) - action.deleteLater() - - # Add separator if there are hints and menu already has actions - if variant_hints and self.variant_hints_menu.actions(): - self.variant_hints_menu.addSeparator() - + options = list(self._current_creator_variant_hints) + if options: + options.append("---") + options.extend(variant_hints) # Add hints to actions - for variant_hint in variant_hints: - action = self.variant_hints_menu.addAction(variant_hint) - self.variant_hints_group.addAction(action) + self._variant_widget.set_options(options) # Indicate product existence if not variant_value: @@ -741,10 +690,7 @@ class CreateWidget(QtWidgets.QWidget): self._create_btn.setEnabled(variant_is_valid) def _set_variant_state_property(self, state): - current_value = self.variant_input.property("state") - if current_value != state: - self.variant_input.setProperty("state", state) - self.variant_input.style().polish(self.variant_input) + self._variant_widget.set_text_widget_property("state", state) def _on_first_show(self): width = self.width() @@ -776,7 +722,7 @@ class CreateWidget(QtWidgets.QWidget): index = indexes[0] creator_identifier = index.data(CREATOR_IDENTIFIER_ROLE) product_type = index.data(PRODUCT_TYPE_ROLE) - variant = self.variant_input.text() + variant = self._variant_widget.text() # Care about product name only if context change is enabled product_name = None folder_path = None @@ -810,7 +756,7 @@ class CreateWidget(QtWidgets.QWidget): if success: self._set_creator(self._selected_creator) - self.variant_input.setText(variant) + self._variant_widget.setText(variant) self._controller.emit_card_message("Creation finished...") self._last_thumbnail_path = None self._thumbnail_widget.set_current_thumbnails() From e54e639b9dd7473d590488fc9506ce3de2f21e45 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 14:39:23 +0200 Subject: [PATCH 139/203] don't show spent time if did not run yet --- .../tools/publisher/publish_report_viewer/widgets.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index 029ce300a8..8227008ff3 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -442,9 +442,14 @@ class PluginDetailsWidget(QtWidgets.QWidget): plugin_families_widget.setText(str(plugin_item.families or "N/A")) plugin_path_widget.setText(plugin_item.filepath or "N/A") plugin_path_widget.setToolTip(plugin_item.filepath or None) - plugin_time_widget.setText( - get_pretty_milliseconds(plugin_item.process_time) - ) + if plugin_item.passed: + time_label = get_pretty_milliseconds(plugin_item.process_time) + elif plugin_item.skipped: + time_label = "Skipped plugin" + else: + time_label = "Not started" + + plugin_time_widget.setText(time_label) content_layout = QtWidgets.QGridLayout(content_widget) content_layout.setContentsMargins(8, 8, 8, 8) From fd6c6eade49636dbb4604d25c02f235b3711512c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 14:42:01 +0200 Subject: [PATCH 140/203] details widget is acknowledged about being active --- .../tools/publisher/publish_report_viewer/widgets.py | 8 ++++++++ client/ayon_core/tools/publisher/window.py | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index 8227008ff3..af1a7e5281 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -817,6 +817,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) diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index 1218221420..0c6087b41d 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -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( From cf6cf0bf14eba836e575428e476baf99dda38dd1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 14:54:55 +0200 Subject: [PATCH 141/203] move time label calculation above value changes --- .../publisher/publish_report_viewer/widgets.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index af1a7e5281..b8ddb0ef54 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -435,6 +435,12 @@ class PluginDetailsWidget(QtWidgets.QWidget): 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" 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") @@ -442,13 +448,6 @@ class PluginDetailsWidget(QtWidgets.QWidget): plugin_families_widget.setText(str(plugin_item.families or "N/A")) plugin_path_widget.setText(plugin_item.filepath or "N/A") plugin_path_widget.setToolTip(plugin_item.filepath or None) - if plugin_item.passed: - time_label = get_pretty_milliseconds(plugin_item.process_time) - elif plugin_item.skipped: - time_label = "Skipped plugin" - else: - time_label = "Not started" - plugin_time_widget.setText(time_label) content_layout = QtWidgets.QGridLayout(content_widget) From f2232f0af15dd6b87323a9d1f5c45fa78f5c7c0c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 14:55:46 +0200 Subject: [PATCH 142/203] '0' order is not resolved as N/A --- .../tools/publisher/publish_report_viewer/widgets.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index b8ddb0ef54..5e854ae945 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -441,10 +441,14 @@ class PluginDetailsWidget(QtWidgets.QWidget): time_label = get_pretty_milliseconds(plugin_item.process_time) elif plugin_item.skipped: time_label = "Skipped plugin" + order = plugin_item.order + if order is None: + order = "N/A" + 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(str(plugin_item.order or "N/A")) + plugin_order_widget.setText(order) plugin_families_widget.setText(str(plugin_item.families or "N/A")) plugin_path_widget.setText(plugin_item.filepath or "N/A") plugin_path_widget.setToolTip(plugin_item.filepath or None) From 9fce774a7e545641ab0282e2f71e3e7512d3765d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 14:56:08 +0200 Subject: [PATCH 143/203] families are joined with comma --- .../tools/publisher/publish_report_viewer/widgets.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index 5e854ae945..925547bc1a 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -441,6 +441,11 @@ class PluginDetailsWidget(QtWidgets.QWidget): 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 = plugin_item.order if order is None: order = "N/A" @@ -449,7 +454,7 @@ class PluginDetailsWidget(QtWidgets.QWidget): 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(str(plugin_item.families or "N/A")) + 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) From 4eef7ecadd22185585e4cdb1e7107a69329c455a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 14:57:18 +0200 Subject: [PATCH 144/203] hide content widget before adding widgets to it's layout --- .../tools/publisher/publish_report_viewer/widgets.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index 925547bc1a..6441667264 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -578,6 +578,11 @@ class PluginsDetailsWidget(QtWidgets.QWidget): 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) + for plugin_id in self._get_plugin_ids(): widget = self._widgets_by_plugin_id.get(plugin_id) if widget is None: @@ -591,6 +596,7 @@ class PluginsDetailsWidget(QtWidgets.QWidget): or plugin_id in self._plugin_filter ) + self._content_widget.setVisible(True) class DeselectableTreeView(QtWidgets.QTreeView): """A tree view that deselects on clicking on an empty area in the view""" From a819797f21a6a1f287fd7dde789eab457a7b7f05 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 14:58:25 +0200 Subject: [PATCH 145/203] don't show all details if nothing is selected --- .../tools/publisher/publish_report_viewer/widgets.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index 6441667264..df6cd4b168 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -591,10 +591,7 @@ class PluginsDetailsWidget(QtWidgets.QWidget): self._widgets_by_plugin_id[plugin_id] = widget self._content_layout.addWidget(widget, 0) - widget.setVisible( - not self._plugin_filter - or plugin_id in self._plugin_filter - ) + widget.setVisible(plugin_id in self._plugin_filter) self._content_widget.setVisible(True) From 1e7be786eb69f2c0b265b4e7833aa2e5c097a72e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 14:58:47 +0200 Subject: [PATCH 146/203] added label shown if nothing is selected --- .../publish_report_viewer/widgets.py | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index df6cd4b168..fa4127fe8b 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -505,6 +505,12 @@ class PluginsDetailsWidget(QtWidgets.QWidget): scroll_area.setWidget(scroll_content_widget) + empty_label = QtWidgets.QLabel( + "

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) @@ -513,6 +519,7 @@ class PluginsDetailsWidget(QtWidgets.QWidget): 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) @@ -520,7 +527,10 @@ class PluginsDetailsWidget(QtWidgets.QWidget): 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 @@ -583,6 +593,7 @@ class PluginsDetailsWidget(QtWidgets.QWidget): # 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: @@ -591,9 +602,14 @@ class PluginsDetailsWidget(QtWidgets.QWidget): self._widgets_by_plugin_id[plugin_id] = widget self._content_layout.addWidget(widget, 0) - widget.setVisible(plugin_id in self._plugin_filter) + 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) - self._content_widget.setVisible(True) class DeselectableTreeView(QtWidgets.QTreeView): """A tree view that deselects on clicking on an empty area in the view""" From 7e4da0761d558bd42c9f0dcadbdb679d605f1699 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 15:00:07 +0200 Subject: [PATCH 147/203] order is converted to string --- .../tools/publisher/publish_report_viewer/widgets.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index fa4127fe8b..5fa1c04dc0 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -446,9 +446,9 @@ class PluginDetailsWidget(QtWidgets.QWidget): if plugin_item.families: families = ", ".join(plugin_item.families) - order = plugin_item.order - if order is None: - order = "N/A" + 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") From d8ca5e797cdfaa658c8251e89efbc8270c0d8e98 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 15:59:25 +0200 Subject: [PATCH 148/203] simplify menu handling --- client/ayon_core/tools/utils/widgets.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 184465ee1b..982b2fd6a6 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -233,21 +233,19 @@ class HintedLineEdit(QtWidgets.QWidget): return menu = QtWidgets.QMenu(self) + menu.triggered.connect(self._on_option_action) for option in self._options: if option in self.SEPARATORS: menu.addSeparator() - continue - action = menu.addAction(option) - action.triggered.connect(self._on_option_action) + else: + menu.addAction(option) rect = self._options_button.rect() pos = self._options_button.mapToGlobal(rect.bottomLeft()) menu.exec_(pos) - def _on_option_action(self): - action = self.sender() - if action: - self.setText(action.text()) + def _on_option_action(self, action): + self.setText(action.text()) class ExpandingTextEdit(QtWidgets.QTextEdit): From 767ce61208f549a9fb38d1acad9191f092638c52 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 Aug 2024 16:19:09 +0200 Subject: [PATCH 149/203] cache paths --- client/ayon_core/tools/utils/widgets.py | 29 +++++++++++++++---------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 982b2fd6a6..5e4cd75cfe 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -105,7 +105,14 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit): self.setPalette(filter_palette) +class _LocalCache: + down_arrow_icon = None + + def get_down_arrow_icon() -> QtGui.QIcon: + if _LocalCache.down_arrow_icon is not None: + return _LocalCache.down_arrow_icon + normal_pixmap = QtGui.QPixmap( get_style_image_path("down_arrow") ) @@ -118,6 +125,7 @@ def get_down_arrow_icon() -> QtGui.QIcon: icon = QtGui.QIcon(normal_pixmap) icon.addPixmap(on_pixmap, QtGui.QIcon.Active) icon.addPixmap(disabled_pixmap, QtGui.QIcon.Disabled) + _LocalCache.down_arrow_icon = icon return icon @@ -350,6 +358,8 @@ class ExpandBtnLabel(QtWidgets.QLabel): """Label showing expand icon meant for ExpandBtn.""" state_changed = QtCore.Signal() + branch_closed_path = get_style_image_path("branch_closed") + branch_open_path = get_style_image_path("branch_open") def __init__(self, parent): super(ExpandBtnLabel, self).__init__(parent) @@ -360,14 +370,10 @@ class ExpandBtnLabel(QtWidgets.QLabel): self._collapsed = True def _create_collapsed_pixmap(self): - return QtGui.QPixmap( - get_style_image_path("branch_closed") - ) + return QtGui.QPixmap(self.branch_closed_path) def _create_expanded_pixmap(self): - return QtGui.QPixmap( - get_style_image_path("branch_open") - ) + return QtGui.QPixmap(self.branch_open_path) @property def collapsed(self): @@ -435,15 +441,14 @@ class ExpandBtn(ClickableFrame): class ClassicExpandBtnLabel(ExpandBtnLabel): + right_arrow_path = get_style_image_path("right_arrow") + down_arrow_path = get_style_image_path("down_arrow") + def _create_collapsed_pixmap(self): - return QtGui.QPixmap( - get_style_image_path("right_arrow") - ) + return QtGui.QPixmap(self.right_arrow_path) def _create_expanded_pixmap(self): - return QtGui.QPixmap( - get_style_image_path("down_arrow") - ) + return QtGui.QPixmap(self.down_arrow_path) class ClassicExpandBtn(ExpandBtn): From 41694772c12739a8ac1e230997816d2596820e08 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 23 Aug 2024 17:58:51 +0200 Subject: [PATCH 150/203] Use proper aac argument for ExtractReview acc doesnt work for audio --- server/settings/publish_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 8ca96432f4..9745a07fcb 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -1066,7 +1066,7 @@ DEFAULT_PUBLISH_VALUES = { "output": [ "-pix_fmt yuv420p", "-crf 18", - "-c:a acc", + "-c:a aac", "-b:a 192k", "-g 1", "-movflags faststart" From 4344b032306d412d7e777574e1aaffb328e71d8e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Sun, 25 Aug 2024 11:25:19 +0200 Subject: [PATCH 151/203] fix validation stop check --- client/ayon_core/tools/publisher/models/publish.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index a73997c302..a60ef69fac 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -1129,10 +1129,16 @@ class PublishModel: self._publish_progress = idx # Check if plugin is over validation order - if not self._publish_has_validated: - self._set_has_validated( - plugin.order >= self._validation_order - ) + if ( + not self._publish_has_validated + and plugin.order >= self._validation_order + ): + self._set_has_validated(True) + if ( + self._publish_up_validation + or self._publish_has_validation_errors + ): + yield partial(self.stop_publish) # Add plugin to publish report self._publish_report.add_plugin_iter( From 4632d22275099045d2c1da0d757b92e7aacc37ec Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Sun, 25 Aug 2024 11:30:51 +0200 Subject: [PATCH 152/203] make sure only one loop is running Make sure loop of '_next_publish_item_process' is in processor only once --- client/ayon_core/tools/publisher/control_qt.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/publisher/control_qt.py b/client/ayon_core/tools/publisher/control_qt.py index f769fab91e..e87d546333 100644 --- a/client/ayon_core/tools/publisher/control_qt.py +++ b/client/ayon_core/tools/publisher/control_qt.py @@ -76,21 +76,27 @@ class QtPublisherController(PublisherController): self.register_event_callback( "publish.process.stopped", self._qt_on_publish_stop ) + self._item_process_in_loop = False def reset(self): self._main_thread_processor.clear() + self._item_process_in_loop = False super().reset() def _start_publish(self, up_validation): self._publish_model.set_publish_up_validation(up_validation) self._publish_model.start_publish(wait=False) - self._process_main_thread_item( - MainThreadItem(self._next_publish_item_process) - ) + if not self._item_process_in_loop: + self._process_main_thread_item( + MainThreadItem(self._next_publish_item_process) + ) def _next_publish_item_process(self): if not self._publish_model.is_running(): + self._item_process_in_loop = False return + + self._item_process_in_loop = True func = self._publish_model.get_next_process_func() self._process_main_thread_item(MainThreadItem(func)) self._process_main_thread_item( @@ -104,4 +110,6 @@ class QtPublisherController(PublisherController): self._main_thread_processor.start() def _qt_on_publish_stop(self): - self._main_thread_processor.stop() + self._process_main_thread_item( + MainThreadItem(self._main_thread_processor.stop) + ) From 714f58fbd692dd31f5446c8f4b1270dcd0cb0b32 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 26 Aug 2024 11:10:54 +0200 Subject: [PATCH 153/203] excape parenthesis for shell --- client/ayon_core/plugins/publish/extract_review.py | 7 +++++++ client/ayon_core/plugins/publish/extract_review_slate.py | 7 +++++++ client/ayon_core/plugins/publish/extract_thumbnail.py | 8 ++++++++ 3 files changed, 22 insertions(+) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index c2793f98a2..b2531ebae9 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -454,6 +454,13 @@ class ExtractReview(pyblish.api.InstancePlugin): raise NotImplementedError subprcs_cmd = " ".join(ffmpeg_args) + if os.getenv("SHELL") in ("/bin/bash", "/bin/sh"): + # Escape parentheses for bash + subprcs_cmd = ( + subprcs_cmd + .replace("(", "\\(") + .replace(")", "\\)") + ) # run subprocess self.log.debug("Executing: {}".format(subprcs_cmd)) diff --git a/client/ayon_core/plugins/publish/extract_review_slate.py b/client/ayon_core/plugins/publish/extract_review_slate.py index 35f55e275c..01a65e89ae 100644 --- a/client/ayon_core/plugins/publish/extract_review_slate.py +++ b/client/ayon_core/plugins/publish/extract_review_slate.py @@ -269,6 +269,13 @@ class ExtractReviewSlate(publish.Extractor): " ".join(output_args) ] slate_subprocess_cmd = " ".join(slate_args) + if os.getenv("SHELL") in ("/bin/bash", "/bin/sh"): + # Escape parentheses for bash + slate_subprocess_cmd = ( + slate_subprocess_cmd + .replace("(", "\\(") + .replace(")", "\\)") + ) # run slate generation subprocess self.log.debug( diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index d1b6e4e0cc..328cb308b9 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -455,6 +455,14 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # output file jpeg_items.append(path_to_subprocess_arg(dst_path)) subprocess_command = " ".join(jpeg_items) + if os.getenv("SHELL") in ("/bin/bash", "/bin/sh"): + # Escape parentheses for bash + subprocess_command = ( + subprocess_command + .replace("(", "\\(") + .replace(")", "\\)") + ) + try: run_subprocess( subprocess_command, shell=True, logger=self.log From 570b0c8b7038db3dac39e8dd0f61b85f273ae898 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 26 Aug 2024 11:45:01 +0200 Subject: [PATCH 154/203] added some comments --- client/ayon_core/tools/publisher/control_qt.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/ayon_core/tools/publisher/control_qt.py b/client/ayon_core/tools/publisher/control_qt.py index e87d546333..7d1c661603 100644 --- a/client/ayon_core/tools/publisher/control_qt.py +++ b/client/ayon_core/tools/publisher/control_qt.py @@ -76,6 +76,8 @@ class QtPublisherController(PublisherController): self.register_event_callback( "publish.process.stopped", self._qt_on_publish_stop ) + # Capture if '_next_publish_item_process' is in + # '_main_thread_processor' loop self._item_process_in_loop = False def reset(self): @@ -86,6 +88,8 @@ class QtPublisherController(PublisherController): def _start_publish(self, up_validation): self._publish_model.set_publish_up_validation(up_validation) self._publish_model.start_publish(wait=False) + # Make sure '_next_publish_item_process' is only once in + # the '_main_thread_processor' loop if not self._item_process_in_loop: self._process_main_thread_item( MainThreadItem(self._next_publish_item_process) @@ -93,6 +97,7 @@ class QtPublisherController(PublisherController): def _next_publish_item_process(self): if not self._publish_model.is_running(): + # This removes '_next_publish_item_process' from loop self._item_process_in_loop = False return From b84904ec43379763bb6ee9528a762a645a635f87 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 26 Aug 2024 17:12:45 +0200 Subject: [PATCH 155/203] screen capture happens only on one screen --- .../publisher/widgets/screenshot_widget.py | 478 ++++++++++++------ 1 file changed, 316 insertions(+), 162 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/screenshot_widget.py b/client/ayon_core/tools/publisher/widgets/screenshot_widget.py index 08a0a790b7..9037883c04 100644 --- a/client/ayon_core/tools/publisher/widgets/screenshot_widget.py +++ b/client/ayon_core/tools/publisher/widgets/screenshot_widget.py @@ -1,10 +1,169 @@ import os import tempfile +import uuid from qtpy import QtCore, QtGui, QtWidgets -class ScreenMarquee(QtWidgets.QDialog): +class ScreenMarqueeDialog(QtWidgets.QDialog): + mouse_moved = QtCore.Signal() + mouse_pressed = QtCore.Signal(QtCore.QPoint, str) + mouse_released = QtCore.Signal(QtCore.QPoint) + close_requested = QtCore.Signal() + + def __init__(self, screen: QtCore.QObject, screen_id: str): + super().__init__() + self.setWindowFlags( + QtCore.Qt.Window + | QtCore.Qt.FramelessWindowHint + | QtCore.Qt.WindowStaysOnTopHint + | QtCore.Qt.CustomizeWindowHint + ) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + self.setCursor(QtCore.Qt.CrossCursor) + self.setMouseTracking(True) + + screen.geometryChanged.connect(self._fit_screen_geometry) + + self._screen = screen + self._opacity = 100 + self._click_pos = None + self._screen_id = screen_id + + def set_click_pos(self, pos): + self._click_pos = pos + self.repaint() + + def convert_end_pos(self, pos): + glob_pos = self.mapFromGlobal(pos) + new_pos = self._convert_pos(glob_pos) + return self.mapToGlobal(new_pos) + + def paintEvent(self, event): + """Paint event""" + # Convert click and current mouse positions to local space. + mouse_pos = self._convert_pos(self.mapFromGlobal(QtGui.QCursor.pos())) + + rect = event.rect() + fill_path = QtGui.QPainterPath() + fill_path.addRect(rect) + + capture_rect = None + if self._click_pos is not None: + click_pos = self.mapFromGlobal(self._click_pos) + capture_rect = QtCore.QRect(click_pos, mouse_pos) + + # Clear the capture area + sub_path = QtGui.QPainterPath() + sub_path.addRect(capture_rect) + fill_path = fill_path.subtracted(sub_path) + + painter = QtGui.QPainter(self) + painter.setRenderHints( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + + # Draw background. Aside from aesthetics, this makes the full + # tool region accept mouse events. + painter.setBrush(QtGui.QColor(0, 0, 0, self._opacity)) + painter.setPen(QtCore.Qt.NoPen) + painter.drawPath(fill_path) + + # Draw cropping markers at current mouse position + pen_color = QtGui.QColor(255, 255, 255, self._opacity) + pen = QtGui.QPen(pen_color, 1, QtCore.Qt.DotLine) + painter.setPen(pen) + painter.drawLine( + rect.left(), mouse_pos.y(), + rect.right(), mouse_pos.y() + ) + painter.drawLine( + mouse_pos.x(), rect.top(), + mouse_pos.x(), rect.bottom() + ) + + # Draw rectangle around selection area + if capture_rect is not None: + pen_color = QtGui.QColor(92, 173, 214) + pen = QtGui.QPen(pen_color, 2) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.NoBrush) + l_x = capture_rect.left() + r_x = capture_rect.right() + if l_x > r_x: + l_x, r_x = r_x, l_x + t_y = capture_rect.top() + b_y = capture_rect.bottom() + if t_y > b_y: + t_y, b_y = b_y, t_y + + # -1 to draw 1px over the border + r_x -= 1 + b_y -= 1 + sel_rect = QtCore.QRect( + QtCore.QPoint(l_x, t_y), + QtCore.QPoint(r_x, b_y) + ) + painter.drawRect(sel_rect) + + painter.end() + + def mousePressEvent(self, event): + """Mouse click event""" + + if event.button() == QtCore.Qt.LeftButton: + # Begin click drag operation + self._click_pos = event.globalPos() + self.mouse_pressed.emit(self._click_pos, self._screen_id) + + def mouseReleaseEvent(self, event): + """Mouse release event""" + if event.button() == QtCore.Qt.LeftButton: + # End click drag operation and commit the current capture rect + self._click_pos = None + self.mouse_released.emit(event.globalPos()) + + def mouseMoveEvent(self, event): + """Mouse move event""" + self.mouse_moved.emit() + + def keyPressEvent(self, event): + """Mouse press event""" + if event.key() == QtCore.Qt.Key_Escape: + self._click_pos = None + event.accept() + self.close_requested.emit() + return + return super().keyPressEvent(event) + + def showEvent(self, event): + super().showEvent(event) + self._fit_screen_geometry() + + def closeEvent(self, event): + self._click_pos = None + super().closeEvent(event) + + def _convert_pos(self, pos): + geo = self.geometry() + if pos.x() > geo.width(): + pos.setX(geo.width() - 1) + elif pos.x() < 0: + pos.setX(0) + + if pos.y() > geo.height(): + pos.setY(geo.height() - 1) + elif pos.y() < 0: + pos.setY(0) + return pos + + def _fit_screen_geometry(self): + # Compute the union of all screen geometries, and resize to fit. + self.setGeometry(self._screen.geometry()) + + +class ScreenMarquee(QtCore.QObject): """Dialog to interactively define screen area. This allows to select a screen area through a marquee selection. @@ -17,187 +176,186 @@ class ScreenMarquee(QtWidgets.QDialog): def __init__(self, parent=None): super().__init__(parent=parent) - self.setWindowFlags( - QtCore.Qt.Window - | QtCore.Qt.FramelessWindowHint - | QtCore.Qt.WindowStaysOnTopHint - | QtCore.Qt.CustomizeWindowHint - ) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) - self.setCursor(QtCore.Qt.CrossCursor) - self.setMouseTracking(True) - - app = QtWidgets.QApplication.instance() - if hasattr(app, "screenAdded"): - app.screenAdded.connect(self._on_screen_added) - app.screenRemoved.connect(self._fit_screen_geometry) - elif hasattr(app, "desktop"): - desktop = app.desktop() - desktop.screenCountChanged.connect(self._fit_screen_geometry) - + screens_by_id = {} for screen in QtWidgets.QApplication.screens(): - screen.geometryChanged.connect(self._fit_screen_geometry) + screen_id = uuid.uuid4().hex + screen_dialog = ScreenMarqueeDialog(screen, screen_id) + screens_by_id[screen_id] = screen_dialog + screen_dialog.mouse_moved.connect(self._on_mouse_move) + screen_dialog.mouse_pressed.connect(self._on_mouse_press) + screen_dialog.mouse_released.connect(self._on_mouse_release) + screen_dialog.close_requested.connect(self._on_close_request) - self._opacity = 50 - self._click_pos = None - self._capture_rect = None + self._screens_by_id = screens_by_id + self._finished = False + self._captured = False + self._start_pos = None + self._end_pos = None + self._start_screen_id = None + self._pix = None def get_captured_pixmap(self): - if self._capture_rect is None: + if self._pix is None: return QtGui.QPixmap() + return self._pix - return self.get_desktop_pixmap(self._capture_rect) + def _close_dialogs(self): + for dialog in self._screens_by_id.values(): + dialog.close() - def paintEvent(self, event): - """Paint event""" + def _on_close_request(self): + self._close_dialogs() + self._finished = True - # Convert click and current mouse positions to local space. - mouse_pos = self.mapFromGlobal(QtGui.QCursor.pos()) - click_pos = None - if self._click_pos is not None: - click_pos = self.mapFromGlobal(self._click_pos) - - painter = QtGui.QPainter(self) - painter.setRenderHints( - QtGui.QPainter.Antialiasing - | QtGui.QPainter.SmoothPixmapTransform - ) - - # Draw background. Aside from aesthetics, this makes the full - # tool region accept mouse events. - painter.setBrush(QtGui.QColor(0, 0, 0, self._opacity)) - painter.setPen(QtCore.Qt.NoPen) - rect = event.rect() - fill_path = QtGui.QPainterPath() - fill_path.addRect(rect) - - # Clear the capture area - if click_pos is not None: - sub_path = QtGui.QPainterPath() - capture_rect = QtCore.QRect(click_pos, mouse_pos) - sub_path.addRect(capture_rect) - fill_path = fill_path.subtracted(sub_path) - - painter.drawPath(fill_path) - - pen_color = QtGui.QColor(255, 255, 255, self._opacity) - pen = QtGui.QPen(pen_color, 1, QtCore.Qt.DotLine) - painter.setPen(pen) - - # Draw cropping markers at click position - if click_pos is not None: - painter.drawLine( - rect.left(), click_pos.y(), - rect.right(), click_pos.y() - ) - painter.drawLine( - click_pos.x(), rect.top(), - click_pos.x(), rect.bottom() - ) - - # Draw cropping markers at current mouse position - painter.drawLine( - rect.left(), mouse_pos.y(), - rect.right(), mouse_pos.y() - ) - painter.drawLine( - mouse_pos.x(), rect.top(), - mouse_pos.x(), rect.bottom() - ) - painter.end() - - def mousePressEvent(self, event): - """Mouse click event""" - - if event.button() == QtCore.Qt.LeftButton: - # Begin click drag operation - self._click_pos = event.globalPos() - - def mouseReleaseEvent(self, event): - """Mouse release event""" - if ( - self._click_pos is not None - and event.button() == QtCore.Qt.LeftButton - ): - # End click drag operation and commit the current capture rect - self._capture_rect = QtCore.QRect( - self._click_pos, event.globalPos() - ).normalized() - self._click_pos = None - self.close() - - def mouseMoveEvent(self, event): - """Mouse move event""" - self.repaint() - - def keyPressEvent(self, event): - """Mouse press event""" - if event.key() == QtCore.Qt.Key_Escape: - self._click_pos = None - self._capture_rect = None - event.accept() - self.close() + def _on_mouse_release(self, pos): + start_screen_dialog = self._screens_by_id.get(self._start_screen_id) + if start_screen_dialog is None: + self._finished = True + self._captured = False return - return super().keyPressEvent(event) - def showEvent(self, event): - self._fit_screen_geometry() + end_pos = start_screen_dialog.convert_end_pos(pos) - def _fit_screen_geometry(self): - # Compute the union of all screen geometries, and resize to fit. - workspace_rect = QtCore.QRect() - for screen in QtWidgets.QApplication.screens(): - workspace_rect = workspace_rect.united(screen.geometry()) - self.setGeometry(workspace_rect) + self._close_dialogs() + self._end_pos = end_pos + self._finished = True + self._captured = True - def _on_screen_added(self): - for screen in QtGui.QGuiApplication.screens(): - screen.geometryChanged.connect(self._fit_screen_geometry) + def _on_mouse_press(self, pos, screen_id): + self._start_pos = pos + self._start_screen_id = screen_id + + def _on_mouse_move(self): + for dialog in self._screens_by_id.values(): + dialog.repaint() + + def start_capture(self): + for dialog in self._screens_by_id.values(): + dialog.show() + # Activate so Escape event is not ignored. + dialog.setWindowState(QtCore.Qt.WindowActive) + + app = QtWidgets.QApplication.instance() + while not self._finished: + app.processEvents() + + # Give time to cloe dialogs + for _ in range(2): + app.processEvents() + + if self._captured: + self._pix = self.get_desktop_pixmap( + self._start_pos, self._end_pos + ) @classmethod - def get_desktop_pixmap(cls, rect): + def get_desktop_pixmap(cls, pos_start, pos_end): """Performs a screen capture on the specified rectangle. Args: - rect (QtCore.QRect): The rectangle to capture. + pos_start (QtCore.QPoint): Start of screen capture. + pos_end (QtCore.QPoint): End of screen capture. Returns: QtGui.QPixmap: Captured pixmap image - """ + """ + # Unify start and end points + # - start is top left + # - end is bottom right + if pos_start.y() > pos_end.y(): + pos_start, pos_end = pos_end, pos_start + + if pos_start.x() > pos_end.x(): + new_start = QtCore.QPoint(pos_end.x(), pos_start.y()) + new_end = QtCore.QPoint(pos_start.x(), pos_end.y()) + pos_start = new_start + pos_end = new_end + + # Validate if the rectangle is valid + rect = QtCore.QRect(pos_start, pos_end) if rect.width() < 1 or rect.height() < 1: return QtGui.QPixmap() - screen_pixes = [] - for screen in QtWidgets.QApplication.screens(): - screen_geo = screen.geometry() - if not screen_geo.intersects(rect): - continue + screen = QtWidgets.QApplication.screenAt(pos_start) + return screen.grabWindow( + 0, + pos_start.x() - screen.geometry().x(), + pos_start.y() - screen.geometry().y(), + pos_end.x() - pos_start.x(), + pos_end.y() - pos_start.y() + ) + # Multiscreen capture that does not work + # - does not handle pixel aspect ratio and positioning of screens - screen_pix_rect = screen_geo.intersected(rect) - screen_pix = screen.grabWindow( - 0, - screen_pix_rect.x() - screen_geo.x(), - screen_pix_rect.y() - screen_geo.y(), - screen_pix_rect.width(), screen_pix_rect.height() - ) - paste_point = QtCore.QPoint( - screen_pix_rect.x() - rect.x(), - screen_pix_rect.y() - rect.y() - ) - screen_pixes.append((screen_pix, paste_point)) - - output_pix = QtGui.QPixmap(rect.width(), rect.height()) - output_pix.fill(QtCore.Qt.transparent) - pix_painter = QtGui.QPainter() - pix_painter.begin(output_pix) - for item in screen_pixes: - (screen_pix, offset) = item - pix_painter.drawPixmap(offset, screen_pix) - - pix_painter.end() - - return output_pix + # most_left = None + # most_top = None + # for screen in QtWidgets.QApplication.screens(): + # screen_geo = screen.geometry() + # if most_left is None or most_left > screen_geo.x(): + # most_left = screen_geo.x() + # + # if most_top is None or most_top > screen_geo.y(): + # most_top = screen_geo.y() + # + # most_left = most_left or 0 + # most_top = most_top or 0 + # + # screen_pixes = [] + # for screen in QtWidgets.QApplication.screens(): + # screen_geo = screen.geometry() + # if not screen_geo.intersects(rect): + # continue + # + # pos_l_x = screen_geo.x() + # pos_l_y = screen_geo.y() + # pos_r_x = screen_geo.x() + screen_geo.width() + # pos_r_y = screen_geo.y() + screen_geo.height() + # if pos_start.x() > pos_l_x: + # pos_l_x = pos_start.x() + # + # if pos_start.y() > pos_l_y: + # pos_l_y = pos_start.y() + # + # if pos_end.x() < pos_r_x: + # pos_r_x = pos_end.x() + # + # if pos_end.y() < pos_r_y: + # pos_r_y = pos_end.y() + # + # capture_pos_x = pos_l_x - screen_geo.x() + # capture_pos_y = pos_l_y - screen_geo.y() + # capture_screen_width = pos_r_x - pos_l_x + # capture_screen_height = pos_r_y - pos_l_y + # screen_pix = screen.grabWindow( + # 0, + # capture_pos_x, capture_pos_y, + # capture_screen_width, capture_screen_height + # ) + # paste_point = QtCore.QPoint( + # (pos_l_x - screen_geo.x()) - rect.x(), + # (pos_l_y - screen_geo.y()) - rect.y() + # ) + # screen_pixes.append((screen_pix, paste_point)) + # + # output_pix = QtGui.QPixmap(rect.width(), rect.height()) + # output_pix.fill(QtCore.Qt.transparent) + # pix_painter = QtGui.QPainter() + # pix_painter.begin(output_pix) + # render_hints = ( + # QtGui.QPainter.Antialiasing + # | QtGui.QPainter.SmoothPixmapTransform + # ) + # if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): + # render_hints |= QtGui.QPainter.HighQualityAntialiasing + # pix_painter.setRenderHints(render_hints) + # for item in screen_pixes: + # (screen_pix, offset) = item + # pix_painter.drawPixmap(offset, screen_pix) + # + # pix_painter.end() + # + # return output_pix @classmethod def capture_to_pixmap(cls): @@ -209,12 +367,8 @@ class ScreenMarquee(QtWidgets.QDialog): Returns: QtGui.QPixmap: Captured pixmap image. """ - tool = cls() - # Activate so Escape event is not ignored. - tool.setWindowState(QtCore.Qt.WindowActive) - # Exec dialog and return captured pixmap. - tool.exec_() + tool.start_capture() return tool.get_captured_pixmap() @classmethod From b309ec6967cb33070413117d98af95eb188f6508 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 27 Aug 2024 14:55:28 +0200 Subject: [PATCH 156/203] Match the defaults from settings - so that if for whatever reason settings do not exist (e.g. dev mode using older addon on server in bundle) that it still runs backwards compatible and has this disabled by default --- .../plugins/publish/extract_usd_layer_contributions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index 7ed129a127..8b58f447d6 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -562,7 +562,7 @@ class ExtractUSDLayerContribution(publish.Extractor): label = "Extract USD Layer Contributions (Asset/Shot)" order = pyblish.api.ExtractorOrder + 0.45 - use_ayon_entity_uri = True + use_ayon_entity_uri = False def process(self, instance): @@ -724,7 +724,7 @@ class ExtractUSDAssetContribution(publish.Extractor): label = "Extract USD Asset/Shot Contributions" order = ExtractUSDLayerContribution.order + 0.01 - use_ayon_entity_uri = True + use_ayon_entity_uri = False def process(self, instance): From 911ef45a458a51a90d5e5caee2058748cfc68b67 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 28 Aug 2024 10:33:48 +0200 Subject: [PATCH 157/203] handle escape in 'run_subprocess' --- client/ayon_core/lib/execute.py | 14 ++++++++++++++ client/ayon_core/plugins/publish/extract_review.py | 7 ------- .../plugins/publish/extract_review_slate.py | 7 ------- .../ayon_core/plugins/publish/extract_thumbnail.py | 7 ------- 4 files changed, 14 insertions(+), 21 deletions(-) diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index bc55c27bd8..4e6cb415e7 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -108,6 +108,20 @@ def run_subprocess(*args, **kwargs): | getattr(subprocess, "CREATE_NO_WINDOW", 0) ) + # Escape parentheses for bash + if ( + kwargs.get("shell") is True + and len(args) == 1 + and isinstance(args[0], str) + and os.getenv("SHELL") in ("/bin/bash", "/bin/sh") + ): + new_arg = ( + args[0] + .replace("(", "\\(") + .replace(")", "\\)") + ) + args = (new_arg, ) + # Get environents from kwarg or use current process environments if were # not passed. env = kwargs.get("env") or os.environ diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index b2531ebae9..c2793f98a2 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -454,13 +454,6 @@ class ExtractReview(pyblish.api.InstancePlugin): raise NotImplementedError subprcs_cmd = " ".join(ffmpeg_args) - if os.getenv("SHELL") in ("/bin/bash", "/bin/sh"): - # Escape parentheses for bash - subprcs_cmd = ( - subprcs_cmd - .replace("(", "\\(") - .replace(")", "\\)") - ) # run subprocess self.log.debug("Executing: {}".format(subprcs_cmd)) diff --git a/client/ayon_core/plugins/publish/extract_review_slate.py b/client/ayon_core/plugins/publish/extract_review_slate.py index 01a65e89ae..35f55e275c 100644 --- a/client/ayon_core/plugins/publish/extract_review_slate.py +++ b/client/ayon_core/plugins/publish/extract_review_slate.py @@ -269,13 +269,6 @@ class ExtractReviewSlate(publish.Extractor): " ".join(output_args) ] slate_subprocess_cmd = " ".join(slate_args) - if os.getenv("SHELL") in ("/bin/bash", "/bin/sh"): - # Escape parentheses for bash - slate_subprocess_cmd = ( - slate_subprocess_cmd - .replace("(", "\\(") - .replace(")", "\\)") - ) # run slate generation subprocess self.log.debug( diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 328cb308b9..4ffabf6028 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -455,13 +455,6 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # output file jpeg_items.append(path_to_subprocess_arg(dst_path)) subprocess_command = " ".join(jpeg_items) - if os.getenv("SHELL") in ("/bin/bash", "/bin/sh"): - # Escape parentheses for bash - subprocess_command = ( - subprocess_command - .replace("(", "\\(") - .replace(")", "\\)") - ) try: run_subprocess( From 102ec52dd0c88d466e33adc947f3df1ffff33ee0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 28 Aug 2024 17:30:18 +0200 Subject: [PATCH 158/203] add exceptions moved to different file --- client/ayon_core/pipeline/create/context.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index b3a46bb778..69103159c6 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -39,6 +39,13 @@ from .creator_plugins import ( discover_convertor_plugins, ) +# Import of exceptions that were moved to different file +from .exceptions import ( + ImmutableKeyError, + CreatorsOperationFailed, + ConvertorsOperationFailed, +) # noqa: F401 + # Changes of instances and context are send as tuple of 2 information UpdateData = collections.namedtuple("UpdateData", ["instance", "changes"]) _NOT_SET = object() From f414d73f7dcd5d4359795154028c3ab355cd5dd3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 28 Aug 2024 17:58:34 +0200 Subject: [PATCH 159/203] use shorter import --- client/ayon_core/tools/publisher/abstract.py | 2 +- client/ayon_core/tools/publisher/models/create.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 768f4b052f..ce9c6ac1ed 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -14,7 +14,7 @@ from typing import ( from ayon_core.lib import AbstractAttrDef from ayon_core.host import HostBase from ayon_core.pipeline.create import CreateContext, CreatedInstance -from ayon_core.pipeline.create.context import ConvertorItem +from ayon_core.pipeline.create import ConvertorItem from ayon_core.tools.common_models import ( FolderItem, TaskItem, diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index ab2bf07614..9fe114f4bd 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -18,7 +18,7 @@ from ayon_core.pipeline.create import ( CreateContext, CreatedInstance, ) -from ayon_core.pipeline.create.context import ( +from ayon_core.pipeline.create import ( CreatorsOperationFailed, ConvertorsOperationFailed, ConvertorItem, From a4ed32e3e114480e0fe54624b664b04c90d21e92 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 28 Aug 2024 18:12:56 +0200 Subject: [PATCH 160/203] added strucctures to init file --- client/ayon_core/pipeline/create/__init__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/__init__.py b/client/ayon_core/pipeline/create/__init__.py index fa8d639c6f..ced43528eb 100644 --- a/client/ayon_core/pipeline/create/__init__.py +++ b/client/ayon_core/pipeline/create/__init__.py @@ -20,7 +20,14 @@ from .exceptions import ( TaskNotSetError, TemplateFillError, ) -from .structures import CreatedInstance +from .structures import ( + CreatedInstance, + ConvertorItem, + AttributeValues, + CreatorAttributeValues, + PublishAttributeValues, + PublishAttributes, +) from .utils import ( get_last_versions_for_instances, get_next_versions_for_instances, @@ -79,6 +86,11 @@ __all__ = ( "TemplateFillError", "CreatedInstance", + "ConvertorItem", + "AttributeValues", + "CreatorAttributeValues", + "PublishAttributeValues", + "PublishAttributes", "get_last_versions_for_instances", "get_next_versions_for_instances", From e33f8670fd9cc3f1c3c99cfad8d989b5c0cd6cf6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 28 Aug 2024 18:13:06 +0200 Subject: [PATCH 161/203] fake import classes from structures too --- client/ayon_core/pipeline/create/context.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 69103159c6..71ba3b7799 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -39,12 +39,18 @@ from .creator_plugins import ( discover_convertor_plugins, ) -# Import of exceptions that were moved to different file +# Import of functions and classes that were moved to different file +# TODO Should be removed in future release - Added 24/08/28, 0.4.3-dev.1 from .exceptions import ( ImmutableKeyError, CreatorsOperationFailed, ConvertorsOperationFailed, ) # noqa: F401 +from .structures import ( + AttributeValues, + CreatorAttributeValues, + PublishAttributeValues, +) # noqa: F401 # Changes of instances and context are send as tuple of 2 information UpdateData = collections.namedtuple("UpdateData", ["instance", "changes"]) From 0ce2bf6633bdfc1ac7ef6b6ec9fbcf2c8cd64066 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 28 Aug 2024 18:25:40 +0200 Subject: [PATCH 162/203] changed noqa location --- client/ayon_core/pipeline/create/context.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 71ba3b7799..3f067427fa 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -42,15 +42,15 @@ from .creator_plugins import ( # Import of functions and classes that were moved to different file # TODO Should be removed in future release - Added 24/08/28, 0.4.3-dev.1 from .exceptions import ( - ImmutableKeyError, - CreatorsOperationFailed, - ConvertorsOperationFailed, -) # noqa: F401 + ImmutableKeyError, # noqa: F401 + CreatorsOperationFailed, # noqa: F401 + ConvertorsOperationFailed, # noqa: F401 +) from .structures import ( - AttributeValues, - CreatorAttributeValues, - PublishAttributeValues, -) # noqa: F401 + AttributeValues, # noqa: F401 + CreatorAttributeValues, # noqa: F401 + PublishAttributeValues, # noqa: F401 +) # Changes of instances and context are send as tuple of 2 information UpdateData = collections.namedtuple("UpdateData", ["instance", "changes"]) From 237e17b658d2cf016614e8c8ac30a69a2e0208e0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 28 Aug 2024 18:34:20 +0200 Subject: [PATCH 163/203] merge import --- client/ayon_core/tools/publisher/abstract.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index ce9c6ac1ed..362fa38882 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -13,8 +13,11 @@ from typing import ( from ayon_core.lib import AbstractAttrDef from ayon_core.host import HostBase -from ayon_core.pipeline.create import CreateContext, CreatedInstance -from ayon_core.pipeline.create import ConvertorItem +from ayon_core.pipeline.create import ( + CreateContext, + CreatedInstance, + ConvertorItem, +) from ayon_core.tools.common_models import ( FolderItem, TaskItem, From 3245a74534a38bd795721810dbc2b20eda05134f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 28 Aug 2024 20:53:12 +0200 Subject: [PATCH 164/203] Add `max` and `substancepainter` + sort the host names --- client/ayon_core/plugins/publish/validate_file_saved.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/validate_file_saved.py b/client/ayon_core/plugins/publish/validate_file_saved.py index e4f009615a..8f956f586b 100644 --- a/client/ayon_core/plugins/publish/validate_file_saved.py +++ b/client/ayon_core/plugins/publish/validate_file_saved.py @@ -36,7 +36,7 @@ class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): label = "Validate File Saved" order = pyblish.api.ValidatorOrder - 0.1 - hosts = ["maya", "houdini", "nuke", "fusion"] + hosts = ["fusion", "houdini", "max", "maya", "nuke", "substancepainter"] actions = [SaveByVersionUpAction, ShowWorkfilesAction] def process(self, context): From 41497e76e9c631af6a42026d8ac56914ed30ddc3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 2 Sep 2024 17:12:33 +0200 Subject: [PATCH 165/203] set screen of dialog before fitting it --- client/ayon_core/tools/publisher/widgets/screenshot_widget.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/screenshot_widget.py b/client/ayon_core/tools/publisher/widgets/screenshot_widget.py index 9037883c04..0706299f32 100644 --- a/client/ayon_core/tools/publisher/widgets/screenshot_widget.py +++ b/client/ayon_core/tools/publisher/widgets/screenshot_widget.py @@ -159,7 +159,9 @@ class ScreenMarqueeDialog(QtWidgets.QDialog): return pos def _fit_screen_geometry(self): - # Compute the union of all screen geometries, and resize to fit. + # On macOs it is required to set screen explicitly + if hasattr(self, "setScreen"): + self.setScreen(self._screen) self.setGeometry(self._screen.geometry()) From 8ea4dfd9f29fd2c477762226370fd866330cbd91 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:11:25 +0200 Subject: [PATCH 166/203] bump version to '0.4.4' --- client/ayon_core/version.py | 2 +- package.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 55a14ba567..44517627b9 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON core addon version.""" -__version__ = "0.4.4-dev.1" +__version__ = "0.4.4" diff --git a/package.py b/package.py index ca4006425d..c704422aa1 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "0.4.4-dev.1" +version = "0.4.4" client_dir = "ayon_core" From f549a6e4377e046330781568b9223854590caade Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:11:51 +0200 Subject: [PATCH 167/203] bump version to '0.4.5-dev.1' --- client/ayon_core/version.py | 2 +- package.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 44517627b9..3ee3c976b9 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON core addon version.""" -__version__ = "0.4.4" +__version__ = "0.4.5-dev.1" diff --git a/package.py b/package.py index c704422aa1..26c004ae84 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "0.4.4" +version = "0.4.5-dev.1" client_dir = "ayon_core" From f6477b98f1b25907d399a0966f90a4a7ce28ab09 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:24:08 +0200 Subject: [PATCH 168/203] added pycodestyle warnings to ruff validation --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f8f840d2c9..cb2991fe44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ target-version = "py39" [tool.ruff.lint] # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. -select = ["E4", "E7", "E9", "F"] +select = ["E4", "E7", "E9", "F", "W"] ignore = [] # Allow fix for all enabled rules (when `--fix`) is provided. From a1b9d48c129321510ed29107a0dad4474879246b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:24:25 +0200 Subject: [PATCH 169/203] remove not existing path from ignore paths --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cb2991fe44..35d0df0964 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,6 @@ exclude = [ [tool.ruff.lint.per-file-ignores] "client/ayon_core/lib/__init__.py" = ["E402"] -"client/ayon_core/hosts/max/startup/startup.py" = ["E402"] [tool.ruff.format] # Like Black, use double quotes for strings. From 9fa4144feb1f7abfab068dd87619e2539406e04b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:28:51 +0200 Subject: [PATCH 170/203] remove trailing spaces --- client/ayon_core/lib/path_templates.py | 2 +- .../plugins/publish/extract_usd_layer_contributions.py | 2 +- client/ayon_core/tools/tray/lib.py | 2 +- server/settings/publish_plugins.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index ba0d26d5c1..dc88ec956b 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -503,7 +503,7 @@ class FormattingPart: # ensure key is properly formed [({})] properly closed. if not self.validate_key_is_matched(key): result.add_missing_key(key) - result.add_output(self.template) + result.add_output(self.template) return result # check if key expects subdictionary keys (e.g. project[name]) diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index 68f2a8f00d..03ea66f418 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -83,7 +83,7 @@ def get_representation_path_in_publish_context( Allow resolving 'latest' paths from a publishing context's instances as if they will exist after publishing without them being integrated yet. - + Use first instance that has same folder path and product name, and contains representation with passed name. diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 5f92e8a04f..39fcc2cdd3 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -578,7 +578,7 @@ def make_sure_tray_is_running( args = get_ayon_launcher_args("tray", "--force") if env is None: env = os.environ.copy() - + # Make sure 'QT_API' is not set env.pop("QT_API", None) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 8e6b60f0d7..61972e64c4 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -57,7 +57,7 @@ class CollectFramesFixDefModel(BaseSettingsModel): True, title="Show 'Rewrite latest version' toggle" ) - + class ContributionLayersModel(BaseSettingsModel): _layout = "compact" From 809cb34171ff0e0c5c1f4c7c7d88706fa4be2366 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:29:03 +0200 Subject: [PATCH 171/203] add new line characters --- client/ayon_core/hooks/pre_global_host_data.py | 2 +- client/ayon_core/plugins/publish/collect_context_entities.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/hooks/pre_global_host_data.py b/client/ayon_core/hooks/pre_global_host_data.py index e93b512742..12da6f12f8 100644 --- a/client/ayon_core/hooks/pre_global_host_data.py +++ b/client/ayon_core/hooks/pre_global_host_data.py @@ -94,4 +94,4 @@ class GlobalHostDataHook(PreLaunchHook): task_entity = get_task_by_name( project_name, folder_entity["id"], task_name ) - self.data["task_entity"] = task_entity \ No newline at end of file + self.data["task_entity"] = task_entity diff --git a/client/ayon_core/plugins/publish/collect_context_entities.py b/client/ayon_core/plugins/publish/collect_context_entities.py index f340178e4f..c8d25bc3e6 100644 --- a/client/ayon_core/plugins/publish/collect_context_entities.py +++ b/client/ayon_core/plugins/publish/collect_context_entities.py @@ -113,4 +113,4 @@ class CollectContextEntities(pyblish.api.ContextPlugin): "Task '{}' was not found in project '{}'.".format( task_path, project_name) ) - return task_entity \ No newline at end of file + return task_entity From 7a1950f98e01b28c0ae05360714c7101fff285e0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:33:29 +0200 Subject: [PATCH 172/203] use correct escape sequence --- client/ayon_core/plugins/publish/extract_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index c2793f98a2..4390b00754 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -1900,7 +1900,7 @@ class OverscanCrop: string_value = re.sub(r"([ ]+)?px", " ", string_value) string_value = re.sub(r"([ ]+)%", "%", string_value) # Make sure +/- sign at the beginning of string is next to number - string_value = re.sub(r"^([\+\-])[ ]+", "\g<1>", string_value) + string_value = re.sub(r"^([\+\-])[ ]+", r"\g<1>", string_value) # Make sure +/- sign in the middle has zero spaces before number under # which belongs string_value = re.sub( From 4b0d13c477308afcc665ec89464d00fcb4468e11 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:37:57 +0200 Subject: [PATCH 173/203] removed 'module' command from cli --- client/ayon_core/cli.py | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index db6674d88f..b80b243db2 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -21,21 +21,7 @@ from ayon_core.lib import ( -class AliasedGroup(click.Group): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._aliases = {} - - def set_alias(self, src_name, dst_name): - self._aliases[dst_name] = src_name - - def get_command(self, ctx, cmd_name): - if cmd_name in self._aliases: - cmd_name = self._aliases[cmd_name] - return super().get_command(ctx, cmd_name) - - -@click.group(cls=AliasedGroup, invoke_without_command=True) +@click.group(invoke_without_command=True) @click.pass_context @click.option("--use-staging", is_flag=True, expose_value=False, help="use staging variants") @@ -86,10 +72,6 @@ def addon(ctx): pass -# Add 'addon' as alias for module -main_cli.set_alias("addon", "module") - - @main_cli.command() @click.pass_context @click.argument("output_json_path") From b887a91e207923850c31612ee2cad193ed47ca3b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:45:09 +0200 Subject: [PATCH 174/203] remove addon code from ayon_core/modules --- client/ayon_core/modules/__init__.py | 40 -------------------------- client/ayon_core/modules/base.py | 25 ---------------- client/ayon_core/modules/click_wrap.py | 1 - client/ayon_core/modules/interfaces.py | 21 -------------- 4 files changed, 87 deletions(-) delete mode 100644 client/ayon_core/modules/base.py delete mode 100644 client/ayon_core/modules/click_wrap.py delete mode 100644 client/ayon_core/modules/interfaces.py diff --git a/client/ayon_core/modules/__init__.py b/client/ayon_core/modules/__init__.py index f4e381f4a0..e69de29bb2 100644 --- a/client/ayon_core/modules/__init__.py +++ b/client/ayon_core/modules/__init__.py @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- -from . import click_wrap -from .interfaces import ( - IPluginPaths, - ITrayAddon, - ITrayModule, - ITrayAction, - ITrayService, - IHostAddon, -) - -from .base import ( - AYONAddon, - OpenPypeModule, - OpenPypeAddOn, - - load_modules, - - ModulesManager, -) - - -__all__ = ( - "click_wrap", - - "IPluginPaths", - "ITrayAddon", - "ITrayModule", - "ITrayAction", - "ITrayService", - "IHostAddon", - - "AYONAddon", - "OpenPypeModule", - "OpenPypeAddOn", - - "load_modules", - - "ModulesManager", -) diff --git a/client/ayon_core/modules/base.py b/client/ayon_core/modules/base.py deleted file mode 100644 index df412d141e..0000000000 --- a/client/ayon_core/modules/base.py +++ /dev/null @@ -1,25 +0,0 @@ -# Backwards compatibility support -# - TODO should be removed before release 1.0.0 -from ayon_core.addon import ( - AYONAddon, - AddonsManager, - load_addons, -) -from ayon_core.addon.base import ( - OpenPypeModule, - OpenPypeAddOn, -) - -ModulesManager = AddonsManager -load_modules = load_addons - - -__all__ = ( - "AYONAddon", - "AddonsManager", - "load_addons", - "OpenPypeModule", - "OpenPypeAddOn", - "ModulesManager", - "load_modules", -) diff --git a/client/ayon_core/modules/click_wrap.py b/client/ayon_core/modules/click_wrap.py deleted file mode 100644 index 8f68de187a..0000000000 --- a/client/ayon_core/modules/click_wrap.py +++ /dev/null @@ -1 +0,0 @@ -from ayon_core.addon.click_wrap import * diff --git a/client/ayon_core/modules/interfaces.py b/client/ayon_core/modules/interfaces.py deleted file mode 100644 index 4b114b7a0e..0000000000 --- a/client/ayon_core/modules/interfaces.py +++ /dev/null @@ -1,21 +0,0 @@ -from ayon_core.addon.interfaces import ( - IPluginPaths, - ITrayAddon, - ITrayAction, - ITrayService, - IHostAddon, -) - -ITrayModule = ITrayAddon -ILaunchHookPaths = object - - -__all__ = ( - "IPluginPaths", - "ITrayAddon", - "ITrayAction", - "ITrayService", - "IHostAddon", - "ITrayModule", - "ILaunchHookPaths", -) From f68ae4833d6821acc68c7d90bbb841cf5c695fb6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 15:56:25 +0200 Subject: [PATCH 175/203] remove lib functions and classes with openpype --- client/ayon_core/lib/__init__.py | 10 ------- client/ayon_core/lib/execute.py | 38 -------------------------- client/ayon_core/lib/local_settings.py | 8 ------ 3 files changed, 56 deletions(-) diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 0074c4d2bd..03ed574081 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -7,13 +7,10 @@ from .local_settings import ( JSONSettingRegistry, AYONSecureRegistry, AYONSettingsRegistry, - OpenPypeSecureRegistry, - OpenPypeSettingsRegistry, get_launcher_local_dir, get_launcher_storage_dir, get_local_site_id, get_ayon_username, - get_openpype_username, ) from .ayon_connection import initialize_ayon_connection from .cache import ( @@ -59,13 +56,11 @@ from .env_tools import ( from .terminal import Terminal from .execute import ( get_ayon_launcher_args, - get_openpype_execute_args, get_linux_launcher_args, execute, run_subprocess, run_detached_process, run_ayon_launcher_process, - run_openpype_process, path_to_subprocess_arg, CREATE_NO_WINDOW ) @@ -145,13 +140,10 @@ __all__ = [ "JSONSettingRegistry", "AYONSecureRegistry", "AYONSettingsRegistry", - "OpenPypeSecureRegistry", - "OpenPypeSettingsRegistry", "get_launcher_local_dir", "get_launcher_storage_dir", "get_local_site_id", "get_ayon_username", - "get_openpype_username", "initialize_ayon_connection", @@ -162,13 +154,11 @@ __all__ = [ "register_event_callback", "get_ayon_launcher_args", - "get_openpype_execute_args", "get_linux_launcher_args", "execute", "run_subprocess", "run_detached_process", "run_ayon_launcher_process", - "run_openpype_process", "path_to_subprocess_arg", "CREATE_NO_WINDOW", diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index 4e6cb415e7..768412b4dd 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -235,26 +235,6 @@ def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs): return run_subprocess(args, env=env, **kwargs) -def run_openpype_process(*args, **kwargs): - """Execute AYON process with passed arguments and wait. - - Wrapper for 'run_process' which prepends AYON executable arguments - before passed arguments and define environments if are not passed. - - Values from 'os.environ' are used for environments if are not passed. - They are cleaned using 'clean_envs_for_ayon_process' function. - - Example: - >>> run_openpype_process("version") - - Args: - *args (tuple): AYON cli arguments. - **kwargs (dict): Keyword arguments for subprocess.Popen. - - """ - return run_ayon_launcher_process(*args, **kwargs) - - def run_detached_process(args, **kwargs): """Execute process with passed arguments as separated process. @@ -414,21 +394,3 @@ def get_linux_launcher_args(*args): launch_args.extend(args) return launch_args - - -def get_openpype_execute_args(*args): - """Arguments to run pype command. - - Arguments for subprocess when need to spawn new pype process. Which may be - needed when new python process for pype scripts must be executed in build - pype. - - ## Why is this needed? - Pype executed from code has different executable set to virtual env python - and must have path to script as first argument which is not needed for - build pype. - - It is possible to pass any arguments that will be added after pype - executables. - """ - return get_ayon_launcher_args(*args) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 5f49dd512c..690781151c 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -584,11 +584,3 @@ def get_ayon_username(): """ return ayon_api.get_user()["name"] - - -def get_openpype_username(): - return get_ayon_username() - - -OpenPypeSecureRegistry = AYONSecureRegistry -OpenPypeSettingsRegistry = AYONSettingsRegistry From 0c830d7dc846fa4485c08a8e5844213892702383 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:10:31 +0200 Subject: [PATCH 176/203] remove openpype modules logic from addons --- client/ayon_core/addon/base.py | 321 +++++---------------------------- 1 file changed, 41 insertions(+), 280 deletions(-) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 7f0636ccca..982626ad9d 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -36,9 +36,6 @@ IGNORED_FILENAMES = { # Files ignored on addons import from "./ayon_core/modules" IGNORED_DEFAULT_FILENAMES = { "__init__.py", - "base.py", - "interfaces.py", - "click_wrap.py", } # When addon was moved from ayon-core codebase @@ -124,77 +121,10 @@ class ProcessContext: print(f"Unknown keys in ProcessContext: {unknown_keys}") -# Inherit from `object` for Python 2 hosts -class _ModuleClass(object): - """Fake module class for storing AYON addons. - - Object of this class can be stored to `sys.modules` and used for storing - dynamically imported modules. - """ - - def __init__(self, name): - # Call setattr on super class - super(_ModuleClass, self).__setattr__("name", name) - super(_ModuleClass, self).__setattr__("__name__", name) - - # Where modules and interfaces are stored - super(_ModuleClass, self).__setattr__("__attributes__", dict()) - super(_ModuleClass, self).__setattr__("__defaults__", set()) - - super(_ModuleClass, self).__setattr__("_log", None) - - def __getattr__(self, attr_name): - if attr_name not in self.__attributes__: - if attr_name in ("__path__", "__file__"): - return None - raise AttributeError("'{}' has not attribute '{}'".format( - self.name, attr_name - )) - return self.__attributes__[attr_name] - - def __iter__(self): - for module in self.values(): - yield module - - def __setattr__(self, attr_name, value): - if attr_name in self.__attributes__: - self.log.warning( - "Duplicated name \"{}\" in {}. Overriding.".format( - attr_name, self.name - ) - ) - self.__attributes__[attr_name] = value - - def __setitem__(self, key, value): - self.__setattr__(key, value) - - def __getitem__(self, key): - return getattr(self, key) - - @property - def log(self): - if self._log is None: - super(_ModuleClass, self).__setattr__( - "_log", Logger.get_logger(self.name) - ) - return self._log - - def get(self, key, default=None): - return self.__attributes__.get(key, default) - - def keys(self): - return self.__attributes__.keys() - - def values(self): - return self.__attributes__.values() - - def items(self): - return self.__attributes__.items() - - class _LoadCache: addons_lock = threading.Lock() addons_loaded = False + addon_modules = [] def load_addons(force=False): @@ -308,7 +238,7 @@ def _handle_moved_addons(addon_name, milestone_version, log): return addon_dir -def _load_ayon_addons(openpype_modules, modules_key, log): +def _load_ayon_addons(log): """Load AYON addons based on information from server. This function should not trigger downloading of any addons but only use @@ -316,23 +246,14 @@ def _load_ayon_addons(openpype_modules, modules_key, log): development). Args: - openpype_modules (_ModuleClass): Module object where modules are - stored. - modules_key (str): Key under which will be modules imported in - `sys.modules`. log (logging.Logger): Logger object. - Returns: - List[str]: List of v3 addons to skip to load because v4 alternative is - imported. """ - - addons_to_skip_in_core = [] - + all_addon_modules = [] bundle_info = _get_ayon_bundle_data() addons_info = _get_ayon_addons_information(bundle_info) if not addons_info: - return addons_to_skip_in_core + return all_addon_modules addons_dir = os.environ.get("AYON_ADDONS_DIR") if not addons_dir: @@ -355,7 +276,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log): addon_version = addon_info["version"] # core addon does not have any addon object - if addon_name in ("openpype", "core"): + if addon_name == "core": continue dev_addon_info = dev_addons_info.get(addon_name, {}) @@ -394,7 +315,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log): continue sys.path.insert(0, addon_dir) - imported_modules = [] + addon_modules = [] for name in os.listdir(addon_dir): # Ignore of files is implemented to be able to run code from code # where usually is more files than just the addon @@ -421,7 +342,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log): inspect.isclass(attr) and issubclass(attr, AYONAddon) ): - imported_modules.append(mod) + addon_modules.append(mod) break except BaseException: @@ -430,50 +351,37 @@ def _load_ayon_addons(openpype_modules, modules_key, log): exc_info=True ) - if not imported_modules: + if not addon_modules: log.warning("Addon {} {} has no content to import".format( addon_name, addon_version )) continue - if len(imported_modules) > 1: + if len(addon_modules) > 1: log.warning(( - "Skipping addon '{}'." - " Multiple modules were found ({}) in dir {}." + "Multiple modules ({}) were found in addon '{}' in dir {}." ).format( + ", ".join([m.__name__ for m in addon_modules]), addon_name, - ", ".join([m.__name__ for m in imported_modules]), addon_dir, )) - continue + all_addon_modules.extend(addon_modules) - mod = imported_modules[0] - addon_alias = getattr(mod, "V3_ALIAS", None) - if not addon_alias: - addon_alias = addon_name - addons_to_skip_in_core.append(addon_alias) - new_import_str = "{}.{}".format(modules_key, addon_alias) - - sys.modules[new_import_str] = mod - setattr(openpype_modules, addon_alias, mod) - - return addons_to_skip_in_core + return all_addon_modules -def _load_addons_in_core( - ignore_addon_names, openpype_modules, modules_key, log -): +def _load_addons_in_core(log): # Add current directory at first place # - has small differences in import logic + addon_modules = [] modules_dir = os.path.join(AYON_CORE_ROOT, "modules") if not os.path.exists(modules_dir): log.warning( f"Could not find path when loading AYON addons \"{modules_dir}\"" ) - return + return addon_modules ignored_filenames = IGNORED_FILENAMES | IGNORED_DEFAULT_FILENAMES - for filename in os.listdir(modules_dir): # Ignore filenames if filename in ignored_filenames: @@ -482,9 +390,6 @@ def _load_addons_in_core( fullpath = os.path.join(modules_dir, filename) basename, ext = os.path.splitext(filename) - if basename in ignore_addon_names: - continue - # Validations if os.path.isdir(fullpath): # Check existence of init file @@ -503,69 +408,43 @@ def _load_addons_in_core( # - check manifest and content of manifest try: # Don't import dynamically current directory modules - new_import_str = f"{modules_key}.{basename}" - import_str = f"ayon_core.modules.{basename}" default_module = __import__(import_str, fromlist=("", )) - sys.modules[new_import_str] = default_module - setattr(openpype_modules, basename, default_module) + addon_modules.append(default_module) except Exception: log.error( f"Failed to import in-core addon '{basename}'.", exc_info=True ) + return addon_modules def _load_addons(): - # Key under which will be modules imported in `sys.modules` - modules_key = "openpype_modules" - - # Change `sys.modules` - sys.modules[modules_key] = openpype_modules = _ModuleClass(modules_key) - log = Logger.get_logger("AddonsLoader") - ignore_addon_names = _load_ayon_addons( - openpype_modules, modules_key, log - ) - _load_addons_in_core( - ignore_addon_names, openpype_modules, modules_key, log - ) + addon_modules = _load_ayon_addons(log) + # All addon in 'modules' folder are tray actions and should be moved + # to tray tool. + # TODO remove + addon_modules.extend(_load_addons_in_core(log)) - -_MARKING_ATTR = "_marking" -def mark_func(func): - """Mark function to be used in report. - - Args: - func (Callable): Function to mark. - - Returns: - Callable: Marked function. - """ - - setattr(func, _MARKING_ATTR, True) - return func - - -def is_func_marked(func): - return getattr(func, _MARKING_ATTR, False) + # Store modules to local cache + _LoadCache.addon_modules = addon_modules class AYONAddon(ABC): """Base class of AYON addon. Attributes: - id (UUID): Addon object id. enabled (bool): Is addon enabled. name (str): Addon name. Args: manager (AddonsManager): Manager object who discovered addon. settings (dict[str, Any]): AYON settings. - """ + """ enabled = True _id = None @@ -585,8 +464,8 @@ class AYONAddon(ABC): Returns: str: Object id. - """ + """ if self._id is None: self._id = uuid4() return self._id @@ -598,8 +477,8 @@ class AYONAddon(ABC): Returns: str: Addon name. - """ + """ pass @property @@ -630,16 +509,16 @@ class AYONAddon(ABC): Args: settings (dict[str, Any]): Settings. - """ + """ pass - @mark_func def connect_with_addons(self, enabled_addons): """Connect with other enabled addons. Args: enabled_addons (list[AYONAddon]): Addons that are enabled. + """ pass @@ -673,8 +552,8 @@ class AYONAddon(ABC): Returns: dict[str, str]: Environment variables. - """ + """ return {} def modify_application_launch_arguments(self, application, env): @@ -686,8 +565,8 @@ class AYONAddon(ABC): Args: application (Application): Application that is launched. env (dict[str, str]): Current environment variables. - """ + """ pass def on_host_install(self, host, host_name, project_name): @@ -706,8 +585,8 @@ class AYONAddon(ABC): host_name (str): Name of host. project_name (str): Project name which is main part of host context. - """ + """ pass def cli(self, addon_click_group): @@ -734,31 +613,11 @@ class AYONAddon(ABC): Args: addon_click_group (click.Group): Group to which can be added commands. + """ - pass -class OpenPypeModule(AYONAddon): - """Base class of OpenPype module. - - Deprecated: - Use `AYONAddon` instead. - - Args: - manager (AddonsManager): Manager object who discovered addon. - settings (dict[str, Any]): Module settings (OpenPype settings). - """ - - # Disable by default - enabled = False - - -class OpenPypeAddOn(OpenPypeModule): - # Enable Addon by default - enabled = True - - class _AddonReportInfo: def __init__( self, class_name, name, version, report_value_by_label @@ -790,8 +649,8 @@ class AddonsManager: settings (Optional[dict[str, Any]]): AYON studio settings. initialize (Optional[bool]): Initialize addons on init. True by default. - """ + """ # Helper attributes for report _report_total_key = "Total" _log = None @@ -827,8 +686,8 @@ class AddonsManager: Returns: Union[AYONAddon, Any]: Addon found by name or `default`. - """ + """ return self._addons_by_name.get(addon_name, default) @property @@ -855,8 +714,8 @@ class AddonsManager: Returns: Union[AYONAddon, None]: Enabled addon found by name or None. - """ + """ addon = self.get(addon_name) if addon is not None and addon.enabled: return addon @@ -867,8 +726,8 @@ class AddonsManager: Returns: list[AYONAddon]: Initialized and enabled addons. - """ + """ return [ addon for addon in self._addons @@ -880,8 +739,6 @@ class AddonsManager: # Make sure modules are loaded load_addons() - import openpype_modules - self.log.debug("*** AYON addons initialization.") # Prepare settings for addons @@ -889,14 +746,12 @@ class AddonsManager: if settings is None: settings = get_studio_settings() - modules_settings = {} - report = {} time_start = time.time() prev_start_time = time_start addon_classes = [] - for module in openpype_modules: + for module in _LoadCache.addon_modules: # Go through globals in `ayon_core.modules` for name in dir(module): modules_item = getattr(module, name, None) @@ -905,8 +760,6 @@ class AddonsManager: if ( not inspect.isclass(modules_item) or modules_item is AYONAddon - or modules_item is OpenPypeModule - or modules_item is OpenPypeAddOn or not issubclass(modules_item, AYONAddon) ): continue @@ -932,33 +785,14 @@ class AddonsManager: addon_classes.append(modules_item) - aliased_names = [] for addon_cls in addon_classes: name = addon_cls.__name__ - if issubclass(addon_cls, OpenPypeModule): - # TODO change to warning - self.log.debug(( - "Addon '{}' is inherited from 'OpenPypeModule'." - " Please use 'AYONAddon'." - ).format(name)) - try: - # Try initialize module - if issubclass(addon_cls, OpenPypeModule): - addon = addon_cls(self, modules_settings) - else: - addon = addon_cls(self, settings) + addon = addon_cls(self, settings) # Store initialized object self._addons.append(addon) self._addons_by_id[addon.id] = addon self._addons_by_name[addon.name] = addon - # NOTE This will be removed with release 1.0.0 of ayon-core - # please use carefully. - # Gives option to use alias name for addon for cases when - # name in OpenPype was not the same as in AYON. - name_alias = getattr(addon, "openpype_alias", None) - if name_alias: - aliased_names.append((name_alias, addon)) now = time.time() report[addon.__class__.__name__] = now - prev_start_time @@ -977,17 +811,6 @@ class AddonsManager: f"[{enabled_str}] {addon.name} ({addon.version})" ) - for item in aliased_names: - name_alias, addon = item - if name_alias not in self._addons_by_name: - self._addons_by_name[name_alias] = addon - continue - self.log.warning( - "Alias name '{}' of addon '{}' is already assigned.".format( - name_alias, addon.name - ) - ) - if self._report is not None: report[self._report_total_key] = time.time() - time_start self._report["Initialization"] = report @@ -1004,16 +827,7 @@ class AddonsManager: self.log.debug("Has {} enabled addons.".format(len(enabled_addons))) for addon in enabled_addons: try: - if not is_func_marked(addon.connect_with_addons): - addon.connect_with_addons(enabled_addons) - - elif hasattr(addon, "connect_with_modules"): - self.log.warning(( - "DEPRECATION WARNING: Addon '{}' still uses" - " 'connect_with_modules' method. Please switch to use" - " 'connect_with_addons' method." - ).format(addon.name)) - addon.connect_with_modules(enabled_addons) + addon.connect_with_addons(enabled_addons) except Exception: self.log.error( @@ -1362,56 +1176,3 @@ class AddonsManager: # Join rows with newline char and add new line at the end output = "\n".join(formatted_rows) + "\n" print(output) - - # DEPRECATED - Module compatibility - @property - def modules(self): - self.log.warning( - "DEPRECATION WARNING: Used deprecated property" - " 'modules' please use 'addons' instead." - ) - return self.addons - - @property - def modules_by_id(self): - self.log.warning( - "DEPRECATION WARNING: Used deprecated property" - " 'modules_by_id' please use 'addons_by_id' instead." - ) - return self.addons_by_id - - @property - def modules_by_name(self): - self.log.warning( - "DEPRECATION WARNING: Used deprecated property" - " 'modules_by_name' please use 'addons_by_name' instead." - ) - return self.addons_by_name - - def get_enabled_module(self, *args, **kwargs): - self.log.warning( - "DEPRECATION WARNING: Used deprecated method" - " 'get_enabled_module' please use 'get_enabled_addon' instead." - ) - return self.get_enabled_addon(*args, **kwargs) - - def initialize_modules(self): - self.log.warning( - "DEPRECATION WARNING: Used deprecated method" - " 'initialize_modules' please use 'initialize_addons' instead." - ) - self.initialize_addons() - - def get_enabled_modules(self): - self.log.warning( - "DEPRECATION WARNING: Used deprecated method" - " 'get_enabled_modules' please use 'get_enabled_addons' instead." - ) - return self.get_enabled_addons() - - def get_host_module(self, host_name): - self.log.warning( - "DEPRECATION WARNING: Used deprecated method" - " 'get_host_module' please use 'get_host_addon' instead." - ) - return self.get_host_addon(host_name) From b2849a648af72bb689197fe32ddd2aed224a3345 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:11:29 +0200 Subject: [PATCH 177/203] remove openpype compatibility from pipeline --- client/ayon_core/pipeline/__init__.py | 4 ---- client/ayon_core/pipeline/context_tools.py | 10 ---------- client/ayon_core/pipeline/publish/README.md | 2 +- client/ayon_core/pipeline/publish/__init__.py | 2 -- client/ayon_core/pipeline/publish/lib.py | 2 +- client/ayon_core/pipeline/publish/publish_plugins.py | 3 --- client/ayon_core/pipeline/tempdir.py | 8 +------- 7 files changed, 3 insertions(+), 28 deletions(-) diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 8fd00ee6b6..e0b7d4acae 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -55,7 +55,6 @@ from .publish import ( PublishXmlValidationError, KnownPublishError, AYONPyblishPluginMixin, - OpenPypePyblishPluginMixin, OptionalPyblishPluginMixin, ) @@ -77,7 +76,6 @@ from .actions import ( from .context_tools import ( install_ayon_plugins, - install_openpype_plugins, install_host, uninstall_host, is_installed, @@ -168,7 +166,6 @@ __all__ = ( "PublishXmlValidationError", "KnownPublishError", "AYONPyblishPluginMixin", - "OpenPypePyblishPluginMixin", "OptionalPyblishPluginMixin", # --- Actions --- @@ -187,7 +184,6 @@ __all__ = ( # --- Process context --- "install_ayon_plugins", - "install_openpype_plugins", "install_host", "uninstall_host", "is_installed", diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index 8b72405048..5c14cf20e6 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -234,16 +234,6 @@ def install_ayon_plugins(project_name=None, host_name=None): register_inventory_action_path(path) -def install_openpype_plugins(project_name=None, host_name=None): - """Install AYON core plugins and make sure the core is initialized. - - Deprecated: - Use `install_ayon_plugins` instead. - - """ - install_ayon_plugins(project_name, host_name) - - def uninstall_host(): """Undo all of what `install()` did""" host = registered_host() diff --git a/client/ayon_core/pipeline/publish/README.md b/client/ayon_core/pipeline/publish/README.md index ee2124dfd3..954c10494f 100644 --- a/client/ayon_core/pipeline/publish/README.md +++ b/client/ayon_core/pipeline/publish/README.md @@ -1,5 +1,5 @@ # Publish -AYON is using `pyblish` for publishing process which is a little bit extented and modified mainly for UI purposes. OpenPype's (new) publish UI does not allow to enable/disable instances or plugins that can be done during creation part. Also does support actions only for validators after validation exception. +AYON is using `pyblish` for publishing process which is a little bit extented and modified mainly for UI purposes. AYON's (new) publish UI does not allow to enable/disable instances or plugins that can be done during creation part. Also does support actions only for validators after validation exception. ## Exceptions AYON define few specific exceptions that should be used in publish plugins. diff --git a/client/ayon_core/pipeline/publish/__init__.py b/client/ayon_core/pipeline/publish/__init__.py index ab19b6e360..cb181c2f2b 100644 --- a/client/ayon_core/pipeline/publish/__init__.py +++ b/client/ayon_core/pipeline/publish/__init__.py @@ -13,7 +13,6 @@ from .publish_plugins import ( PublishXmlValidationError, KnownPublishError, AYONPyblishPluginMixin, - OpenPypePyblishPluginMixin, OptionalPyblishPluginMixin, RepairAction, @@ -66,7 +65,6 @@ __all__ = ( "PublishXmlValidationError", "KnownPublishError", "AYONPyblishPluginMixin", - "OpenPypePyblishPluginMixin", "OptionalPyblishPluginMixin", "RepairAction", diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 8b82622e4c..dc2eef3bb9 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -379,7 +379,7 @@ def get_plugin_settings(plugin, project_settings, log, category=None): plugin_kind = split_path[-2] # TODO: change after all plugins are moved one level up - if category_from_file in ("ayon_core", "openpype"): + if category_from_file == "ayon_core": category_from_file = "core" try: diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index 6b1984d92b..1eca8df7cb 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -165,9 +165,6 @@ class AYONPyblishPluginMixin: return self.get_attr_values_from_data_for_plugin(self.__class__, data) -OpenPypePyblishPluginMixin = AYONPyblishPluginMixin - - class OptionalPyblishPluginMixin(AYONPyblishPluginMixin): """Prepare mixin for optional plugins. diff --git a/client/ayon_core/pipeline/tempdir.py b/client/ayon_core/pipeline/tempdir.py index 29d4659393..d8f42ea60a 100644 --- a/client/ayon_core/pipeline/tempdir.py +++ b/client/ayon_core/pipeline/tempdir.py @@ -25,13 +25,7 @@ def create_custom_tempdir(project_name, anatomy=None): """ env_tmpdir = os.getenv("AYON_TMPDIR") if not env_tmpdir: - env_tmpdir = os.getenv("OPENPYPE_TMPDIR") - if not env_tmpdir: - return - print( - "DEPRECATION WARNING: Used 'OPENPYPE_TMPDIR' environment" - " variable. Please use 'AYON_TMPDIR' instead." - ) + return custom_tempdir = None if "{" in env_tmpdir: From 7a2a2c67171edf381fafb4c9e81eb5f5b9eda266 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:12:12 +0200 Subject: [PATCH 178/203] removed ayon/openpype compatible constants --- client/ayon_core/__init__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/client/ayon_core/__init__.py b/client/ayon_core/__init__.py index ce5a28601c..6cde11c822 100644 --- a/client/ayon_core/__init__.py +++ b/client/ayon_core/__init__.py @@ -9,10 +9,6 @@ AYON_CORE_ROOT = os.path.dirname(os.path.abspath(__file__)) # ------------------------- PACKAGE_DIR = AYON_CORE_ROOT PLUGINS_DIR = os.path.join(AYON_CORE_ROOT, "plugins") -AYON_SERVER_ENABLED = True - -# Indicate if AYON entities should be used instead of OpenPype entities -USE_AYON_ENTITIES = True # ------------------------- @@ -23,6 +19,4 @@ __all__ = ( "AYON_CORE_ROOT", "PACKAGE_DIR", "PLUGINS_DIR", - "AYON_SERVER_ENABLED", - "USE_AYON_ENTITIES", ) From f194ddcc49cc743f2d7e18cfb4075237809c1d46 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:12:37 +0200 Subject: [PATCH 179/203] removed openpype functions from resources and tools --- client/ayon_core/resources/__init__.py | 16 ---------------- client/ayon_core/tools/utils/__init__.py | 2 -- client/ayon_core/tools/utils/lib.py | 4 ---- 3 files changed, 22 deletions(-) diff --git a/client/ayon_core/resources/__init__.py b/client/ayon_core/resources/__init__.py index 2a98cc1968..ea8bf7ca6c 100644 --- a/client/ayon_core/resources/__init__.py +++ b/client/ayon_core/resources/__init__.py @@ -70,19 +70,3 @@ def get_ayon_splash_filepath(staging=None): else: splash_file_name = "AYON_splash.png" return get_resource("icons", splash_file_name) - - -def get_openpype_production_icon_filepath(): - return get_ayon_production_icon_filepath() - - -def get_openpype_staging_icon_filepath(): - return get_ayon_staging_icon_filepath() - - -def get_openpype_icon_filepath(staging=None): - return get_ayon_icon_filepath(staging) - - -def get_openpype_splash_filepath(staging=None): - return get_ayon_splash_filepath(staging) diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py index 3e265c7692..4714e76ea3 100644 --- a/client/ayon_core/tools/utils/__init__.py +++ b/client/ayon_core/tools/utils/__init__.py @@ -38,7 +38,6 @@ from .lib import ( qt_app_context, get_qt_app, get_ayon_qt_app, - get_openpype_qt_app, get_qt_icon, ) @@ -122,7 +121,6 @@ __all__ = ( "qt_app_context", "get_qt_app", "get_ayon_qt_app", - "get_openpype_qt_app", "get_qt_icon", "RecursiveSortFilterProxyModel", diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index 8689a97451..200e281664 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -196,10 +196,6 @@ def get_ayon_qt_app(): return app -def get_openpype_qt_app(): - return get_ayon_qt_app() - - def iter_model_rows(model, column=0, include_root=False): """Iterate over all row indices in a model""" indexes_queue = collections.deque() From f54ff5622e8e5e00de3531a71006c224cd30c001 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:13:38 +0200 Subject: [PATCH 180/203] use correct naming --- .../publish/collect_input_representations_to_versions.py | 2 +- .../plugins/publish/extract_usd_layer_contributions.py | 2 +- client/ayon_core/tools/publisher/widgets/overview_widget.py | 2 +- client/ayon_core/tools/publisher/widgets/widgets.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_input_representations_to_versions.py b/client/ayon_core/plugins/publish/collect_input_representations_to_versions.py index b9fe97b80b..f8311f7dfb 100644 --- a/client/ayon_core/plugins/publish/collect_input_representations_to_versions.py +++ b/client/ayon_core/plugins/publish/collect_input_representations_to_versions.py @@ -7,7 +7,7 @@ class CollectInputRepresentationsToVersions(pyblish.api.ContextPlugin): """Converts collected input representations to input versions. Any data in `instance.data["inputRepresentations"]` gets converted into - `instance.data["inputVersions"]` as supported in OpenPype v3. + `instance.data["inputVersions"]` as supported in OpenPype. """ # This is a ContextPlugin because then we can query the database only once diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index 68f2a8f00d..89fc7acb6d 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -238,7 +238,7 @@ def add_representation(instance, name, class CollectUSDLayerContributions(pyblish.api.InstancePlugin, - publish.OpenPypePyblishPluginMixin): + publish.AYONPyblishPluginMixin): """Collect the USD Layer Contributions and create dependent instances. Our contributions go to the layer diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index 52a45d0881..d00edb9883 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -387,7 +387,7 @@ class OverviewWidget(QtWidgets.QFrame): Returns: list[str]: Selected legacy convertor identifiers. - Example: ['io.openpype.creators.houdini.legacy'] + Example: ['io.ayon.creators.houdini.legacy'] """ _, _, convertor_identifiers = self.get_selected_items() diff --git a/client/ayon_core/tools/publisher/widgets/widgets.py b/client/ayon_core/tools/publisher/widgets/widgets.py index 1f782ddc67..affa0f1423 100644 --- a/client/ayon_core/tools/publisher/widgets/widgets.py +++ b/client/ayon_core/tools/publisher/widgets/widgets.py @@ -1339,7 +1339,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget): Attributes are defined on creator so are dynamic. Their look and type is based on attribute definitions that are defined in `~/ayon_core/lib/attribute_definitions.py` and their widget - representation in `~/openpype/tools/attribute_defs/*`. + representation in `~/ayon_core/tools/attribute_defs/*`. Widgets are disabled if context of instance is not valid. From 84f1f6f17a417c8b184f3402c0d6d16ed3b30a4f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:13:54 +0200 Subject: [PATCH 181/203] removed openpype env variables for webserver --- client/ayon_core/tools/tray/ui/addons_manager.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/ayon_core/tools/tray/ui/addons_manager.py b/client/ayon_core/tools/tray/ui/addons_manager.py index 3fe4bb8dd8..2e6f0c0aae 100644 --- a/client/ayon_core/tools/tray/ui/addons_manager.py +++ b/client/ayon_core/tools/tray/ui/addons_manager.py @@ -237,11 +237,8 @@ class TrayAddonsManager(AddonsManager): webserver_url = self.webserver_url statics_url = f"{webserver_url}/res" + # Deprecated # TODO stop using these env variables # - function 'get_tray_server_url' should be used instead os.environ[self.webserver_url_env] = webserver_url os.environ["AYON_STATICS_SERVER"] = statics_url - - # Deprecated - os.environ["OPENPYPE_WEBSERVER_URL"] = webserver_url - os.environ["OPENPYPE_STATICS_SERVER"] = statics_url From a9c100c91ee56a92432db84e413f3289975a13ef Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:14:04 +0200 Subject: [PATCH 182/203] removed unused 'OpenPypeVersionLabel' style --- client/ayon_core/style/style.css | 8 -------- 1 file changed, 8 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 8578522c79..10aa918d08 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -1472,14 +1472,6 @@ CreateNextPageOverlay { border-radius: 5px; } -#OpenPypeVersionLabel[state="success"] { - color: {color:settings:version-exists}; -} - -#OpenPypeVersionLabel[state="warning"] { - color: {color:settings:version-not-found}; -} - #ShadowWidget { font-size: 36pt; } From a08a1af331163492644fa8f5713328c95c51cc44 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:14:35 +0200 Subject: [PATCH 183/203] don't use openpype naming keys --- client/ayon_core/plugins/publish/collect_addons.py | 2 -- client/ayon_core/plugins/publish/collect_rendered_files.py | 5 +---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_addons.py b/client/ayon_core/plugins/publish/collect_addons.py index 9bba9978ab..661cf9cb31 100644 --- a/client/ayon_core/plugins/publish/collect_addons.py +++ b/client/ayon_core/plugins/publish/collect_addons.py @@ -15,5 +15,3 @@ class CollectAddons(pyblish.api.ContextPlugin): manager = AddonsManager() context.data["ayonAddonsManager"] = manager context.data["ayonAddons"] = manager.addons_by_name - # Backwards compatibility - remove - context.data["openPypeModules"] = manager.addons_by_name diff --git a/client/ayon_core/plugins/publish/collect_rendered_files.py b/client/ayon_core/plugins/publish/collect_rendered_files.py index 8a60e7619d..42ba096d14 100644 --- a/client/ayon_core/plugins/publish/collect_rendered_files.py +++ b/client/ayon_core/plugins/publish/collect_rendered_files.py @@ -138,10 +138,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): def process(self, context): self._context = context - publish_data_paths = ( - os.environ.get("AYON_PUBLISH_DATA") - or os.environ.get("OPENPYPE_PUBLISH_DATA") - ) + publish_data_paths = os.environ.get("AYON_PUBLISH_DATA") if not publish_data_paths: raise KnownPublishError("Missing `AYON_PUBLISH_DATA`") From 55061e6f65ae29d933fb87940284a1e37c0a3137 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:22:37 +0200 Subject: [PATCH 184/203] added ayon prefix option to OTIO extraction --- .../plugins/publish/extract_otio_review.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index be365520c7..f879ad2d36 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -104,10 +104,19 @@ class ExtractOTIOReview(publish.Extractor): media_metadata = otio_media.metadata # get from media reference metadata source - if media_metadata.get("openpype.source.width"): - width = int(media_metadata.get("openpype.source.width")) - if media_metadata.get("openpype.source.height"): - height = int(media_metadata.get("openpype.source.height")) + # TODO 'openpype' prefix should be removed (added 24/09/03) + # NOTE it looks like it is set only in hiero integration + for key in {"ayon.source.width", "openpype.source.width"}: + value = media_metadata.get(key) + if value is not None: + width = int(value) + break + + for key in {"ayon.source.height", "openpype.source.height"}: + value = media_metadata.get(key) + if value is not None: + height = int(value) + break # compare and reset if width != self.to_width: From d24187a1544aeb7a14111adf6a3c46f7ce2bf642 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:43:41 +0200 Subject: [PATCH 185/203] removed local_settings.md --- client/ayon_core/settings/local_settings.md | 79 --------------------- 1 file changed, 79 deletions(-) delete mode 100644 client/ayon_core/settings/local_settings.md diff --git a/client/ayon_core/settings/local_settings.md b/client/ayon_core/settings/local_settings.md deleted file mode 100644 index fbb5cf3df1..0000000000 --- a/client/ayon_core/settings/local_settings.md +++ /dev/null @@ -1,79 +0,0 @@ -# Structure of local settings -- local settings do not have any validation schemas right now this should help to see what is stored to local settings and how it works -- they are stored by identifier site_id which should be unified identifier of workstation -- all keys may and may not available on load -- contain main categories: `general`, `applications`, `projects` - -## Categories -### General -- ATM contain only label of site -```json -{ - "general": { - "site_label": "MySite" - } -} -``` - -### Applications -- modifications of application executables -- output should match application groups and variants -```json -{ - "applications": { - "": { - "": { - "executable": "/my/path/to/nuke_12_2" - } - } - } -} -``` - -### Projects -- project specific modifications -- default project is stored under constant key defined in `pype.settings.contants` -```json -{ - "projects": { - "": { - "active_site": "", - "remote_site": "", - "roots": { - "": { - "": "" - } - } - } - } -} -``` - -## Final document -```json -{ - "_id": "", - "site_id": "", - "general": { - "site_label": "MySite" - }, - "applications": { - "": { - "": { - "executable": "" - } - } - }, - "projects": { - "": { - "active_site": "", - "remote_site": "", - "roots": { - "": { - "": "" - } - } - } - } -} -``` From f0f64a1f76f4952795e1926ef5d2b94cb6360f60 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:43:59 +0200 Subject: [PATCH 186/203] modified 'get_ayon_launcher_args' docstring --- client/ayon_core/lib/execute.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index 768412b4dd..95696fd272 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -321,14 +321,12 @@ def path_to_subprocess_arg(path): def get_ayon_launcher_args(*args): - """Arguments to run ayon-launcher process. + """Arguments to run AYON launcher process. - Arguments for subprocess when need to spawn new pype process. Which may be - needed when new python process for pype scripts must be executed in build - pype. + Arguments for subprocess when need to spawn new AYON launcher process. Reasons: - Ayon-launcher started from code has different executable set to + AYON launcher started from code has different executable set to virtual env python and must have path to script as first argument which is not needed for built application. @@ -336,7 +334,8 @@ def get_ayon_launcher_args(*args): *args (str): Any arguments that will be added after executables. Returns: - list[str]: List of arguments to run ayon-launcher process. + list[str]: List of arguments to run AYON launcher process. + """ executable = os.environ["AYON_EXECUTABLE"] launch_args = [executable] From 6c189f6c3682c569d1f0307542cd9ed6ab1d176e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:44:12 +0200 Subject: [PATCH 187/203] remove pype todo --- client/ayon_core/tools/pyblish_pype/util.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/pyblish_pype/util.py b/client/ayon_core/tools/pyblish_pype/util.py index 09a370c6e4..d24b07a409 100644 --- a/client/ayon_core/tools/pyblish_pype/util.py +++ b/client/ayon_core/tools/pyblish_pype/util.py @@ -135,7 +135,6 @@ class OrderGroups: def env_variable_to_bool(env_key, default=False): """Boolean based on environment variable value.""" - # TODO: move to pype lib value = os.environ.get(env_key) if value is not None: value = value.lower() From 614f2d4f6334186ee7d9673e61d9f4617534743f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 3 Sep 2024 22:18:16 +0200 Subject: [PATCH 188/203] Avoid double quotes around paths --- .../plugins/publish/collect_anatomy_instance_data.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py index 5b750a5232..925e14166e 100644 --- a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py +++ b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py @@ -217,9 +217,8 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): joined_paths = ", ".join( ["\"{}\"".format(path) for path in not_found_task_paths] ) - self.log.warning(( - "Not found task entities with paths \"{}\"." - ).format(joined_paths)) + self.log.warning( + f"Not found task entities with paths {joined_paths}.") def fill_latest_versions(self, context, project_name): """Try to find latest version for each instance's product name. From 73e125639bbeab03649022322d1165b54084937a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 4 Sep 2024 10:29:56 +0200 Subject: [PATCH 189/203] remove trailing spaces --- client/ayon_core/plugins/publish/validate_file_saved.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/validate_file_saved.py b/client/ayon_core/plugins/publish/validate_file_saved.py index 8f956f586b..d132ba8d3a 100644 --- a/client/ayon_core/plugins/publish/validate_file_saved.py +++ b/client/ayon_core/plugins/publish/validate_file_saved.py @@ -51,9 +51,9 @@ class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): def get_description(self): return inspect.cleandoc(""" ### File not saved - + Your workfile must be saved to continue publishing. - - The **Save Workfile** action will save it for you with the first + + The **Save Workfile** action will save it for you with the first available workfile version number in your current context. """) From 916c9a625783522366778ce170bf882ec832a137 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 5 Sep 2024 12:48:32 +0200 Subject: [PATCH 190/203] Added todo note to get rid of instance variable --- .../plugins/publish/extract_otio_review.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index 8c7719ceb6..c1cf50333c 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -61,7 +61,8 @@ class ExtractOTIOReview(publish.Extractor): make_sequence_collection ) - self.temp_file_head = self._get_unique_file_prefix(instance) + # TODO refactore from using instance variable + self.temp_file_head = self._get_folder_name_based_prefix(instance) # TODO: convert resulting image sequence to mp4 @@ -493,11 +494,13 @@ class ExtractOTIOReview(publish.Extractor): return output_path, out_frame_start - def _get_unique_file_prefix(self, instance): - """Creates unique human readable file prefix to differentiate. + def _get_folder_name_based_prefix(self, instance): + """Creates 'unique' human readable file prefix to differentiate. - Multiple instances might share same temp folder, this will provide - unique prefix for intermediate file for burnins. + Multiple instances might share same temp folder, but each instance + would be differentiated by asset, eg. folder name. + + It ix expected that there won't be multiple instances for same asset. """ folder_path = instance.data["folderPath"] folder_name = folder_path.split("/")[-1] From b636658f43d89cb093948363486f1ea5d70f75ea Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 5 Sep 2024 20:57:20 +0200 Subject: [PATCH 191/203] Allow placeholder representation name to be set as an empty value to load all representations --- .../pipeline/workfile/workfile_template_builder.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 7b15dff049..be5b7437de 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -1519,9 +1519,10 @@ class PlaceholderLoadMixin(object): if "asset" in placeholder.data: return [] - representation_name = placeholder.data["representation"] - if not representation_name: - return [] + representation_names = None + representation_name: str = placeholder.data["representation"] + if representation_name: + representation_names = [representation_name] project_name = self.builder.project_name current_folder_entity = self.builder.current_folder_entity @@ -1578,7 +1579,7 @@ class PlaceholderLoadMixin(object): ) return list(get_representations( project_name, - representation_names={representation_name}, + representation_names=representation_names, version_ids=version_ids )) From 2e039af0151c9ee8920c51a6d3dba1ff8b383122 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 6 Sep 2024 10:30:51 +0200 Subject: [PATCH 192/203] Fix if statement --- client/ayon_core/vendor/python/scriptsmenu/launchformaya.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py b/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py index c8b0c777de..496278ac6f 100644 --- a/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py +++ b/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py @@ -130,7 +130,7 @@ def main(title="Scripts", parent=None, objectName=None): # Register control + shift callback to add to shelf (maya behavior) modifiers = QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier - if int(cmds.about(version=True)) <= 2025: + if int(cmds.about(version=True)) < 2025: modifiers = int(modifiers) menu.register_callback(modifiers, to_shelf) From f0af0a5700c8f18113099381d37ccfe73e222689 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 6 Sep 2024 17:17:45 +0200 Subject: [PATCH 193/203] Allow custom `resolve_template_path` to be implemented by AYON addon integrations --- .../workfile/workfile_template_builder.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index be5b7437de..b65355fe8b 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -876,6 +876,8 @@ class AbstractTemplateBuilder(ABC): if result.solved: path = result.normalized() + path = self.resolve_template_path(path) + if path and os.path.exists(path): self.log.info("Found template at: '{}'".format(path)) return { @@ -914,6 +916,23 @@ class AbstractTemplateBuilder(ABC): "create_first_version": create_first_version } + def resolve_template_path(self, path: str) -> str: + """Resolve the template path. + + By default, this does nothing except returning the path directly. + But, this allows additional resolving over the template path inside + a custom AYON integration. Like, in Houdini using + `hou.text.expandString` + + Arguments: + path (str): The input path. + + Returns: + str: The resolved path. + + """ + return path + def emit_event(self, topic, data=None, source=None) -> Event: return self._event_system.emit(topic, data, source) From a8a69766ad00ea56b51f5e0c8dd3bdffebc1c67d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 9 Sep 2024 16:22:29 +0200 Subject: [PATCH 194/203] Move more logic into the `resolve_template_path` method --- .../workfile/workfile_template_builder.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index b65355fe8b..51dcb48420 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -872,11 +872,7 @@ class AbstractTemplateBuilder(ABC): "code": anatomy.project_code, } - result = StringTemplate.format_template(path, fill_data) - if result.solved: - path = result.normalized() - - path = self.resolve_template_path(path) + path = self.resolve_template_path(path, fill_data) if path and os.path.exists(path): self.log.info("Found template at: '{}'".format(path)) @@ -916,21 +912,25 @@ class AbstractTemplateBuilder(ABC): "create_first_version": create_first_version } - def resolve_template_path(self, path: str) -> str: + def resolve_template_path(self, path, fill_data) -> str: """Resolve the template path. By default, this does nothing except returning the path directly. - But, this allows additional resolving over the template path inside - a custom AYON integration. Like, in Houdini using - `hou.text.expandString` + + This can be overridden in host integrations to perform additional + resolving over the template. Like, `hou.text.expandString` in Houdini. Arguments: path (str): The input path. + fill_data (dict[str, str]): Data to use for template formatting. Returns: str: The resolved path. """ + result = StringTemplate.format_template(path, fill_data) + if result.solved: + path = result.normalized() return path def emit_event(self, topic, data=None, source=None) -> Event: From 0f0b7db2e1f01c57911baa323bedfb77b4922474 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 9 Sep 2024 16:22:37 +0200 Subject: [PATCH 195/203] Fix grammar in comment --- client/ayon_core/pipeline/workfile/workfile_template_builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 51dcb48420..c38725ffba 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -859,7 +859,7 @@ class AbstractTemplateBuilder(ABC): "Settings\\Profiles" ).format(host_name.title())) - # Try fill path with environments and anatomy roots + # Try to fill path with environments and anatomy roots anatomy = Anatomy(project_name) fill_data = { key: value From 66353ec8c4f0d81d29c7b1ef7fd997e07bf754ec Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 10 Sep 2024 18:29:48 +0200 Subject: [PATCH 196/203] change how context validation happens on instances --- client/ayon_core/pipeline/create/context.py | 127 +++++++++++++----- .../ayon_core/pipeline/create/structures.py | 76 +++++------ client/ayon_core/tools/publisher/abstract.py | 6 + client/ayon_core/tools/publisher/control.py | 3 + .../tools/publisher/models/create.py | 8 ++ .../publisher/widgets/card_view_widgets.py | 44 +++--- .../publisher/widgets/list_view_widgets.py | 29 ++-- .../tools/publisher/widgets/widgets.py | 26 +++- client/ayon_core/tools/publisher/window.py | 16 ++- 9 files changed, 213 insertions(+), 122 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 3f067427fa..7706860499 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -6,7 +6,8 @@ import traceback import collections import inspect from contextlib import contextmanager -from typing import Optional +import typing +from typing import Optional, Iterable, Dict import pyblish.logic import pyblish.api @@ -31,13 +32,15 @@ from .exceptions import ( HostMissRequiredMethod, ) from .changes import TrackChangesItem -from .structures import PublishAttributes, ConvertorItem +from .structures import PublishAttributes, ConvertorItem, InstanceContextInfo from .creator_plugins import ( Creator, AutoCreator, discover_creator_plugins, discover_convertor_plugins, ) +if typing.TYPE_CHECKING: + from .structures import CreatedInstance # Import of functions and classes that were moved to different file # TODO Should be removed in future release - Added 24/08/28, 0.4.3-dev.1 @@ -183,6 +186,10 @@ class CreateContext: # Shared data across creators during collection phase self._collection_shared_data = None + # Context validation cache + self._folder_id_by_folder_path = {} + self._task_names_by_folder_path = {} + self.thumbnail_paths_by_instance_id = {} # Trigger reset if was enabled @@ -202,17 +209,19 @@ class CreateContext: """Access to global publish attributes.""" return self._publish_attributes - def get_instance_by_id(self, instance_id): + def get_instance_by_id( + self, instance_id: str + ) -> Optional["CreatedInstance"]: """Receive instance by id. Args: instance_id (str): Instance id. Returns: - Union[CreatedInstance, None]: Instance or None if instance with + Optional[CreatedInstance]: Instance or None if instance with given id is not available. - """ + """ return self._instances_by_id.get(instance_id) def get_sorted_creators(self, identifiers=None): @@ -224,8 +233,8 @@ class CreateContext: Returns: List[BaseCreator]: Sorted creator plugins by 'order' value. - """ + """ if identifiers is not None: identifiers = set(identifiers) creators = [ @@ -491,6 +500,8 @@ class CreateContext: # Give ability to store shared data for collection phase self._collection_shared_data = {} + self._folder_id_by_folder_path = {} + self._task_names_by_folder_path = {} def reset_finalization(self): """Cleanup of attributes after reset.""" @@ -715,7 +726,7 @@ class CreateContext: self._original_context_data, self.context_data_to_store() ) - def creator_adds_instance(self, instance): + def creator_adds_instance(self, instance: "CreatedInstance"): """Creator adds new instance to context. Instances should be added only from creators. @@ -942,7 +953,7 @@ class CreateContext: def _remove_instance(self, instance): self._instances_by_id.pop(instance.id, None) - def creator_removed_instance(self, instance): + def creator_removed_instance(self, instance: "CreatedInstance"): """When creator removes instance context should be acknowledged. If creator removes instance conext should know about it to avoid @@ -990,7 +1001,7 @@ class CreateContext: [], self._bulk_instances_to_process ) - self.validate_instances_context(instances_to_validate) + self.get_instances_context_info(instances_to_validate) def reset_instances(self): """Reload instances""" @@ -1079,26 +1090,70 @@ class CreateContext: if failed_info: raise CreatorsCreateFailed(failed_info) - def validate_instances_context(self, instances=None): - """Validate 'folder' and 'task' instance context.""" + def get_instances_context_info( + self, instances: Optional[Iterable["CreatedInstance"]] = None + ) -> Dict[str, InstanceContextInfo]: + """Validate 'folder' and 'task' instance context. + + Args: + instances (Optional[Iterable[CreatedInstance]]): Instances to + validate. If not provided all instances are validated. + + Returns: + Dict[str, InstanceContextInfo]: Validation results by instance id. + + """ # Use all instances from context if 'instances' are not passed if instances is None: - instances = tuple(self._instances_by_id.values()) + instances = self._instances_by_id.values() + instances = tuple(instances) + info_by_instance_id = { + instance.id: InstanceContextInfo( + instance.get("folderPath"), + instance.get("task"), + False, + False, + ) + for instance in instances + } # Skip if instances are empty - if not instances: - return + if not info_by_instance_id: + return info_by_instance_id project_name = self.project_name - task_names_by_folder_path = {} + to_validate = [] + task_names_by_folder_path = collections.defaultdict(set) for instance in instances: - folder_path = instance.get("folderPath") - task_name = instance.get("task") - if folder_path: - task_names_by_folder_path[folder_path] = set() - if task_name: - task_names_by_folder_path[folder_path].add(task_name) + context_info = info_by_instance_id[instance.id] + if instance.has_promised_context: + context_info.folder_is_valid = True + context_info.task_is_valid = True + continue + # TODO allow context promise + folder_path = context_info.folder_path + if not folder_path: + continue + + if folder_path in self._folder_id_by_folder_path: + folder_id = self._folder_id_by_folder_path[folder_path] + if folder_id is None: + continue + context_info.folder_is_valid = True + + task_name = context_info.task_name + if task_name is not None: + tasks_cache = self._task_names_by_folder_path.get(folder_path) + if tasks_cache is not None: + context_info.task_is_valid = task_name in tasks_cache + continue + + to_validate.append(instance) + task_names_by_folder_path[folder_path].add(task_name) + + if not to_validate: + return info_by_instance_id # Backwards compatibility for cases where folder name is set instead # of folder path @@ -1120,7 +1175,9 @@ class CreateContext: fields={"id", "path"} ): folder_id = folder_entity["id"] - folder_paths_by_id[folder_id] = folder_entity["path"] + folder_path = folder_entity["path"] + folder_paths_by_id[folder_id] = folder_path + self._folder_id_by_folder_path[folder_path] = folder_id folder_entities_by_name = collections.defaultdict(list) if folder_names: @@ -1131,8 +1188,10 @@ class CreateContext: ): folder_id = folder_entity["id"] folder_name = folder_entity["name"] - folder_paths_by_id[folder_id] = folder_entity["path"] + folder_path = folder_entity["path"] + folder_paths_by_id[folder_id] = folder_path folder_entities_by_name[folder_name].append(folder_entity) + self._folder_id_by_folder_path[folder_path] = folder_id tasks_entities = ayon_api.get_tasks( project_name, @@ -1145,12 +1204,11 @@ class CreateContext: folder_id = task_entity["folderId"] folder_path = folder_paths_by_id[folder_id] task_names_by_folder_path[folder_path].add(task_entity["name"]) + self._task_names_by_folder_path.update(task_names_by_folder_path) - for instance in instances: - if not instance.has_valid_folder or not instance.has_valid_task: - continue - + for instance in to_validate: folder_path = instance["folderPath"] + task_name = instance.get("task") if folder_path and "/" not in folder_path: folder_entities = folder_entities_by_name.get(folder_path) if len(folder_entities) == 1: @@ -1158,15 +1216,16 @@ class CreateContext: instance["folderPath"] = folder_path if folder_path not in task_names_by_folder_path: - instance.set_folder_invalid(True) continue + context_info = info_by_instance_id[instance.id] + context_info.folder_is_valid = True - task_name = instance["task"] - if not task_name: - continue - - if task_name not in task_names_by_folder_path[folder_path]: - instance.set_task_invalid(True) + if ( + not task_name + or task_name in task_names_by_folder_path[folder_path] + ): + context_info.task_is_valid = True + return info_by_instance_id def save_changes(self): """Save changes. Update all changed values.""" diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 4f7caa6e11..311d382ac9 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -1,6 +1,7 @@ import copy import collections from uuid import uuid4 +from typing import Optional from ayon_core.lib.attribute_definitions import ( UnknownDef, @@ -396,6 +397,24 @@ class PublishAttributes: ) +class InstanceContextInfo: + def __init__( + self, + folder_path: Optional[str], + task_name: Optional[str], + folder_is_valid: bool, + task_is_valid: bool, + ): + self.folder_path: Optional[str] = folder_path + self.task_name: Optional[str] = task_name + self.folder_is_valid: bool = folder_is_valid + self.task_is_valid: bool = task_is_valid + + @property + def is_valid(self) -> bool: + return self.folder_is_valid and self.task_is_valid + + class CreatedInstance: """Instance entity with data that will be stored to workfile. @@ -528,9 +547,6 @@ class CreatedInstance: if not self._data.get("instance_id"): self._data["instance_id"] = str(uuid4()) - self._folder_is_valid = self.has_set_folder - self._task_is_valid = self.has_set_task - def __str__(self): return ( " bool: + """Get context data that are promised to be set by creator. + + Returns: + bool: Has context that won't bo validated. Artist can't change + value when set to True. + + """ + return self._data.get("has_promised_context", False) + def data_to_store(self): """Collect data that contain json parsable types. @@ -826,46 +853,3 @@ class CreatedInstance: obj.publish_attributes.deserialize_attributes(publish_attributes) return obj - - # Context validation related methods/properties - @property - def has_set_folder(self): - """Folder path is set in data.""" - - return "folderPath" in self._data - - @property - def has_set_task(self): - """Task name is set in data.""" - - return "task" in self._data - - @property - def has_valid_context(self): - """Context data are valid for publishing.""" - - return self.has_valid_folder and self.has_valid_task - - @property - def has_valid_folder(self): - """Folder set in context exists in project.""" - - if not self.has_set_folder: - return False - return self._folder_is_valid - - @property - def has_valid_task(self): - """Task set in context exists in project.""" - - if not self.has_set_task: - return False - return self._task_is_valid - - def set_folder_invalid(self, invalid): - # TODO replace with `set_folder_path` - self._folder_is_valid = not invalid - - def set_task_invalid(self, invalid): - # TODO replace with `set_task_name` - self._task_is_valid = not invalid diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 362fa38882..ad566eb354 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -322,6 +322,12 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): ) -> Dict[str, Union[CreatedInstance, None]]: pass + @abstractmethod + def get_instances_context_info( + self, instance_ids: Optional[Iterable[str]] = None + ): + pass + @abstractmethod def get_existing_product_names(self, folder_path: str) -> List[str]: pass diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index 257b45de08..fe1545f219 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -190,6 +190,9 @@ class PublisherController( def get_instances_by_id(self, instance_ids=None): return self._create_model.get_instances_by_id(instance_ids) + def get_instances_context_info(self, instance_ids=None): + return self._create_model.get_instances_context_info(instance_ids) + def get_convertor_items(self): return self._create_model.get_convertor_items() diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 9fe114f4bd..dcd2ce4acc 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -306,6 +306,14 @@ class CreateModel: for instance_id in instance_ids } + def get_instances_context_info( + self, instance_ids: Optional[Iterable[str]] = None + ): + instances = self.get_instances_by_id(instance_ids).values() + return self._create_context.get_instances_context_info( + instances + ) + def get_convertor_items(self) -> Dict[str, ConvertorItem]: return self._create_context.convertor_items_by_id diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index d67252e302..c0e27d9c60 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -217,20 +217,22 @@ class InstanceGroupWidget(BaseGroupWidget): def update_icons(self, group_icons): self._group_icons = group_icons - def update_instance_values(self): + def update_instance_values(self, context_info_by_id): """Trigger update on instance widgets.""" - for widget in self._widgets_by_id.values(): - widget.update_instance_values() + for instance_id, widget in self._widgets_by_id.items(): + widget.update_instance_values(context_info_by_id[instance_id]) - def update_instances(self, instances): + def update_instances(self, instances, context_info_by_id): """Update instances for the group. Args: - instances(list): List of instances in + instances (list[CreatedInstance]): List of instances in CreateContext. - """ + context_info_by_id (Dict[str, InstanceContextInfo]): Instance + context info by instance id. + """ # Store instances by id and by product name instances_by_id = {} instances_by_product_name = collections.defaultdict(list) @@ -249,13 +251,14 @@ class InstanceGroupWidget(BaseGroupWidget): widget_idx = 1 for product_names in sorted_product_names: for instance in instances_by_product_name[product_names]: + context_info = context_info_by_id[instance.id] if instance.id in self._widgets_by_id: widget = self._widgets_by_id[instance.id] - widget.update_instance(instance) + widget.update_instance(instance, context_info) else: group_icon = self._group_icons[instance.creator_identifier] widget = InstanceCardWidget( - instance, group_icon, self + instance, context_info, group_icon, self ) widget.selected.connect(self._on_widget_selection) widget.active_changed.connect(self._on_active_changed) @@ -388,7 +391,7 @@ class ConvertorItemCardWidget(CardWidget): self._icon_widget = icon_widget self._label_widget = label_widget - def update_instance_values(self): + def update_instance_values(self, context_info): pass @@ -397,7 +400,7 @@ class InstanceCardWidget(CardWidget): active_changed = QtCore.Signal(str, bool) - def __init__(self, instance, group_icon, parent): + def __init__(self, instance, context_info, group_icon, parent): super().__init__(parent) self._id = instance.id @@ -458,7 +461,7 @@ class InstanceCardWidget(CardWidget): self._active_checkbox = active_checkbox self._expand_btn = expand_btn - self.update_instance_values() + self.update_instance_values(context_info) def set_active_toggle_enabled(self, enabled): self._active_checkbox.setEnabled(enabled) @@ -480,13 +483,13 @@ class InstanceCardWidget(CardWidget): if checkbox_value != new_value: self._active_checkbox.setChecked(new_value) - def update_instance(self, instance): + def update_instance(self, instance, context_info): """Update instance object and update UI.""" self.instance = instance - self.update_instance_values() + self.update_instance_values(context_info) - def _validate_context(self): - valid = self.instance.has_valid_context + def _validate_context(self, context_info): + valid = context_info.is_valid self._icon_widget.setVisible(valid) self._context_warning.setVisible(not valid) @@ -519,11 +522,11 @@ class InstanceCardWidget(CardWidget): QtCore.Qt.NoTextInteraction ) - def update_instance_values(self): + def update_instance_values(self, context_info): """Update instance data""" self._update_product_name() self.set_active(self.instance["active"]) - self._validate_context() + self._validate_context(context_info) def _set_expanded(self, expanded=None): if expanded is None: @@ -694,6 +697,8 @@ class InstanceCardView(AbstractInstanceView): self._update_convertor_items_group() + context_info_by_id = self._controller.get_instances_context_info() + # Prepare instances by group and identifiers by group instances_by_group = collections.defaultdict(list) identifiers_by_group = collections.defaultdict(set) @@ -747,7 +752,7 @@ class InstanceCardView(AbstractInstanceView): widget_idx += 1 group_widget.update_instances( - instances_by_group[group_name] + instances_by_group[group_name], context_info_by_id ) group_widget.set_active_toggle_enabled( self._active_toggle_enabled @@ -814,8 +819,9 @@ class InstanceCardView(AbstractInstanceView): def refresh_instance_states(self): """Trigger update of instances on group widgets.""" + context_info_by_id = self._controller.get_instances_context_info() for widget in self._widgets_by_group.values(): - widget.update_instance_values() + widget.update_instance_values(context_info_by_id) def _on_active_changed(self, group_name, instance_id, value): group_widget = self._widgets_by_group[group_name] diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index 930d6bb88c..ab9f2db52c 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -115,7 +115,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): active_changed = QtCore.Signal(str, bool) double_clicked = QtCore.Signal() - def __init__(self, instance, parent): + def __init__(self, instance, context_info, parent): super().__init__(parent) self.instance = instance @@ -151,7 +151,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): self._has_valid_context = None - self._set_valid_property(instance.has_valid_context) + self._set_valid_property(context_info.is_valid) def mouseDoubleClickEvent(self, event): widget = self.childAt(event.pos()) @@ -188,12 +188,12 @@ class InstanceListItemWidget(QtWidgets.QWidget): if checkbox_value != new_value: self._active_checkbox.setChecked(new_value) - def update_instance(self, instance): + def update_instance(self, instance, context_info): """Update instance object.""" self.instance = instance - self.update_instance_values() + self.update_instance_values(context_info) - def update_instance_values(self): + def update_instance_values(self, context_info): """Update instance data propagated to widgets.""" # Check product name label = self.instance.label @@ -202,7 +202,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): # Check active state self.set_active(self.instance["active"]) # Check valid states - self._set_valid_property(self.instance.has_valid_context) + self._set_valid_property(context_info.is_valid) def _on_active_change(self): new_value = self._active_checkbox.isChecked() @@ -583,6 +583,8 @@ class InstanceListView(AbstractInstanceView): self._update_convertor_items_group() + context_info_by_id = self._controller.get_instances_context_info() + # Prepare instances by their groups instances_by_group_name = collections.defaultdict(list) group_names = set() @@ -643,13 +645,15 @@ class InstanceListView(AbstractInstanceView): elif activity != instance["active"]: activity = -1 + context_info = context_info_by_id[instance_id] + self._group_by_instance_id[instance_id] = group_name # Remove instance id from `to_remove` if already exists and # trigger update of widget if instance_id in to_remove: to_remove.remove(instance_id) widget = self._widgets_by_id[instance_id] - widget.update_instance(instance) + widget.update_instance(instance, context_info) continue # Create new item and store it as new @@ -695,7 +699,8 @@ class InstanceListView(AbstractInstanceView): group_item.appendRows(new_items) for item, instance in new_items_with_instance: - if not instance.has_valid_context: + context_info = context_info_by_id[instance.id] + if not context_info.is_valid: expand_groups.add(group_name) item_index = self._instance_model.index( item.row(), @@ -704,7 +709,7 @@ class InstanceListView(AbstractInstanceView): ) proxy_index = self._proxy_model.mapFromSource(item_index) widget = InstanceListItemWidget( - instance, self._instance_view + instance, context_info, self._instance_view ) widget.set_active_toggle_enabled( self._active_toggle_enabled @@ -870,8 +875,10 @@ class InstanceListView(AbstractInstanceView): def refresh_instance_states(self): """Trigger update of all instances.""" - for widget in self._widgets_by_id.values(): - widget.update_instance_values() + context_info_by_id = self._controller.get_instances_context_info() + for instance_id, widget in self._widgets_by_id.items(): + context_info = context_info_by_id[instance_id] + widget.update_instance_values(context_info) def _on_active_changed(self, changed_instance_id, new_value): selected_instance_ids, _, _ = self.get_selected_items() diff --git a/client/ayon_core/tools/publisher/widgets/widgets.py b/client/ayon_core/tools/publisher/widgets/widgets.py index 1f782ddc67..4ad527126b 100644 --- a/client/ayon_core/tools/publisher/widgets/widgets.py +++ b/client/ayon_core/tools/publisher/widgets/widgets.py @@ -1206,7 +1206,6 @@ class GlobalAttrsWidget(QtWidgets.QWidget): except TaskNotSetError: invalid_tasks = True - instance.set_task_invalid(True) product_names.add(instance["productName"]) continue @@ -1216,11 +1215,9 @@ class GlobalAttrsWidget(QtWidgets.QWidget): if folder_path is not None: instance["folderPath"] = folder_path - instance.set_folder_invalid(False) if task_name is not None: instance["task"] = task_name or None - instance.set_task_invalid(False) instance["productName"] = new_product_name @@ -1768,9 +1765,16 @@ class ProductAttributesWidget(QtWidgets.QWidget): self.bottom_separator = bottom_separator def _on_instance_context_changed(self): + instance_ids = { + instance.id + for instance in self._current_instances + } + context_info_by_id = self._controller.get_instances_context_info( + instance_ids + ) all_valid = True - for instance in self._current_instances: - if not instance.has_valid_context: + for instance_id, context_info in context_info_by_id.items(): + if not context_info.is_valid: all_valid = False break @@ -1795,9 +1799,17 @@ class ProductAttributesWidget(QtWidgets.QWidget): convertor_identifiers(List[str]): Identifiers of convert items. """ + instance_ids = { + instance.id + for instance in instances + } + context_info_by_id = self._controller.get_instances_context_info( + instance_ids + ) + all_valid = True - for instance in instances: - if not instance.has_valid_context: + for context_info in context_info_by_id.values(): + if not context_info.is_valid: all_valid = False break diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index 0c6087b41d..a8ca605ecb 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -913,12 +913,18 @@ class PublisherWindow(QtWidgets.QDialog): self._set_footer_enabled(True) return + active_instances_by_id = { + instance.id: instance + for instance in self._controller.get_instances() + if instance["active"] + } + context_info_by_id = self._controller.get_instances_context_info( + active_instances_by_id.keys() + ) all_valid = None - for instance in self._controller.get_instances(): - if not instance["active"]: - continue - - if not instance.has_valid_context: + for instance_id, instance in active_instances_by_id.items(): + context_info = context_info_by_id[instance_id] + if not context_info.is_valid: all_valid = False break From 930e10f63dca9ced383327e2a31c1fcbd641ca3b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 10 Sep 2024 18:30:09 +0200 Subject: [PATCH 197/203] insntances with context promise have disabled inputs --- .../tools/publisher/widgets/widgets.py | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/widgets.py b/client/ayon_core/tools/publisher/widgets/widgets.py index 4ad527126b..2427195812 100644 --- a/client/ayon_core/tools/publisher/widgets/widgets.py +++ b/client/ayon_core/tools/publisher/widgets/widgets.py @@ -1182,6 +1182,10 @@ class GlobalAttrsWidget(QtWidgets.QWidget): invalid_tasks = False folder_paths = [] for instance in self._current_instances: + # Ignore instances that have promised context + if instance.has_promised_context: + continue + new_variant_value = instance.get("variant") new_folder_path = instance.get("folderPath") new_task_name = instance.get("task") @@ -1303,7 +1307,13 @@ class GlobalAttrsWidget(QtWidgets.QWidget): editable = False folder_task_combinations = [] + context_editable = None for instance in instances: + if not instance.has_promised_context: + context_editable = True + elif context_editable is None: + context_editable = False + # NOTE I'm not sure how this can even happen? if instance.creator_identifier is None: editable = False @@ -1316,6 +1326,11 @@ class GlobalAttrsWidget(QtWidgets.QWidget): folder_task_combinations.append((folder_path, task_name)) product_names.add(instance.get("productName") or self.unknown_value) + if not editable: + context_editable = False + elif context_editable is None: + context_editable = True + self.variant_input.set_value(variants) # Set context of folder widget @@ -1326,8 +1341,21 @@ class GlobalAttrsWidget(QtWidgets.QWidget): self.product_value_widget.set_value(product_names) self.variant_input.setEnabled(editable) - self.folder_value_widget.setEnabled(editable) - self.task_value_widget.setEnabled(editable) + self.folder_value_widget.setEnabled(context_editable) + self.task_value_widget.setEnabled(context_editable) + + if not editable: + folder_tooltip = "Select instances to change folder path." + task_tooltip = "Select instances to change task name." + elif not context_editable: + folder_tooltip = "Folder path is defined by Create plugin." + task_tooltip = "Task is defined by Create plugin." + else: + folder_tooltip = "Change folder path of selected instances." + task_tooltip = "Change task of selected instances." + + self.folder_value_widget.setToolTip(folder_tooltip) + self.task_value_widget.setToolTip(task_tooltip) class CreatorAttrsWidget(QtWidgets.QWidget): From f38fad901d164475500f7e0a5f5ab65a439655fe Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 10 Sep 2024 22:02:05 +0200 Subject: [PATCH 198/203] Allow `.tif` and `.tiff` extensions for review --- client/ayon_core/plugins/publish/extract_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 4390b00754..06b451bfbe 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -95,7 +95,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ] # Supported extensions - image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga"] + image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"] video_exts = ["mov", "mp4"] supported_exts = image_exts + video_exts From e0ab613d965c79ba3e5944f3fe9f94541aca26ec Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 11 Sep 2024 19:55:36 +0200 Subject: [PATCH 199/203] Fix typos, ported from https://github.com/ynput/OpenPype/pull/6346 --- client/ayon_core/lib/attribute_definitions.py | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 7e022f6dba..fd0de2e41b 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -60,7 +60,7 @@ def get_default_values(attribute_definitions): for which default values should be collected. Returns: - Dict[str, Any]: Default values for passet attribute definitions. + Dict[str, Any]: Default values for passed attribute definitions. """ output = {} @@ -75,13 +75,13 @@ def get_default_values(attribute_definitions): class AbstractAttrDefMeta(ABCMeta): - """Metaclass to validate existence of 'key' attribute. + """Metaclass to validate the existence of 'key' attribute. - Each object of `AbstractAttrDef` mus have defined 'key' attribute. + Each object of `AbstractAttrDef` must have defined 'key' attribute. """ - def __call__(self, *args, **kwargs): - obj = super(AbstractAttrDefMeta, self).__call__(*args, **kwargs) + def __call__(cls, *args, **kwargs): + obj = super(AbstractAttrDefMeta, cls).__call__(*args, **kwargs) init_class = getattr(obj, "__init__class__", None) if init_class is not AbstractAttrDef: raise TypeError("{} super was not called in __init__.".format( @@ -162,7 +162,8 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): def __ne__(self, other): return not self.__eq__(other) - @abstractproperty + @property + @abstractmethod def type(self): """Attribute definition type also used as identifier of class. @@ -215,10 +216,11 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): # ----------------------------------------- -# UI attribute definitoins won't hold value +# UI attribute definitions won't hold value # ----------------------------------------- class UIDef(AbstractAttrDef): + type = "ui-def" is_value_def = False def __init__(self, key=None, default=None, *args, **kwargs): @@ -245,7 +247,7 @@ class UILabelDef(UIDef): # --------------------------------------- -# Attribute defintioins should hold value +# Attribute definitions should hold value # --------------------------------------- class UnknownDef(AbstractAttrDef): @@ -311,7 +313,7 @@ class NumberDef(AbstractAttrDef): ): minimum = 0 if minimum is None else minimum maximum = 999999 if maximum is None else maximum - # Swap min/max when are passed in opposited order + # Swap min/max when are passed in opposite order if minimum > maximum: maximum, minimum = minimum, maximum @@ -364,10 +366,10 @@ class NumberDef(AbstractAttrDef): class TextDef(AbstractAttrDef): """Text definition. - Text can have multiline option so endline characters are allowed regex + Text can have multiline option so end-line characters are allowed regex validation can be applied placeholder for UI purposes and default value. - Regex validation is not part of attribute implemntentation. + Regex validation is not part of attribute implementation. Args: multiline(bool): Text has single or multiline support. @@ -949,7 +951,8 @@ def deserialize_attr_def(attr_def_data): """Deserialize attribute definition from data. Args: - attr_def (Dict[str, Any]): Attribute definition data to deserialize. + attr_def_data (Dict[str, Any]): Attribute definition data to + deserialize. """ attr_type = attr_def_data.pop("type") From 440d2271d46d3eeb49a763f459266b3740e18069 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 11 Sep 2024 20:33:45 +0200 Subject: [PATCH 200/203] Fix variable redeclaration --- client/ayon_core/scripts/slates/slate_base/items.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/scripts/slates/slate_base/items.py b/client/ayon_core/scripts/slates/slate_base/items.py index 6d19fc6a0c..ec3358ed5e 100644 --- a/client/ayon_core/scripts/slates/slate_base/items.py +++ b/client/ayon_core/scripts/slates/slate_base/items.py @@ -486,11 +486,11 @@ class TableField(BaseItem): line = self.ellide_text break - for idx, char in enumerate(_word): + for char_index, char in enumerate(_word): _line = line + char + self.ellide_text _line_width = font.getsize(_line)[0] if _line_width > max_width: - if idx == 0: + if char_index == 0: line = _line break line = line + char From 23ac8b9ca7117262db82e001b2eff636f89903d2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 12 Sep 2024 15:49:42 +0200 Subject: [PATCH 201/203] Update client/ayon_core/lib/attribute_definitions.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/lib/attribute_definitions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index fd0de2e41b..bda5bc2c47 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -220,7 +220,6 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): # ----------------------------------------- class UIDef(AbstractAttrDef): - type = "ui-def" is_value_def = False def __init__(self, key=None, default=None, *args, **kwargs): From f4c63805853014f5c31ea53d942e632fc3a753a4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 12 Sep 2024 15:51:22 +0200 Subject: [PATCH 202/203] Remove unused import --- client/ayon_core/lib/attribute_definitions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index fd0de2e41b..0a00e0e3ac 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -4,7 +4,7 @@ import collections import uuid import json import copy -from abc import ABCMeta, abstractmethod, abstractproperty +from abc import ABCMeta, abstractmethod import clique From ed2a3102039d84be7d830a6ca20a910daac3c514 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 12 Sep 2024 15:51:57 +0200 Subject: [PATCH 203/203] Grammar --- client/ayon_core/lib/attribute_definitions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 0a00e0e3ac..c2563333be 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -16,7 +16,7 @@ _attr_defs_by_type = {} def register_attr_def_class(cls): """Register attribute definition. - Currently are registered definitions used to deserialize data to objects. + Currently registered definitions are used to deserialize data to objects. Attrs: cls (AbstractAttrDef): Non-abstract class to be registered with unique