From 1e1828bbdc8cc9fb8c5f81d7a2f34a4e745d3285 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:21:40 +0200 Subject: [PATCH 001/108] moved current actions to subdir --- client/ayon_core/pipeline/actions/__init__.py | 33 ++++++ .../ayon_core/pipeline/actions/inventory.py | 108 ++++++++++++++++++ .../{actions.py => actions/launcher.py} | 104 ----------------- 3 files changed, 141 insertions(+), 104 deletions(-) create mode 100644 client/ayon_core/pipeline/actions/__init__.py create mode 100644 client/ayon_core/pipeline/actions/inventory.py rename client/ayon_core/pipeline/{actions.py => actions/launcher.py} (76%) diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py new file mode 100644 index 0000000000..bda9b50ede --- /dev/null +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -0,0 +1,33 @@ +from .launcher import ( + LauncherAction, + LauncherActionSelection, + discover_launcher_actions, + register_launcher_action, + register_launcher_action_path, +) + +from .inventory import ( + InventoryAction, + discover_inventory_actions, + register_inventory_action, + register_inventory_action_path, + + deregister_inventory_action, + deregister_inventory_action_path, +) + + +__all__= ( + "LauncherAction", + "LauncherActionSelection", + "discover_launcher_actions", + "register_launcher_action", + "register_launcher_action_path", + + "InventoryAction", + "discover_inventory_actions", + "register_inventory_action", + "register_inventory_action_path", + "deregister_inventory_action", + "deregister_inventory_action_path", +) diff --git a/client/ayon_core/pipeline/actions/inventory.py b/client/ayon_core/pipeline/actions/inventory.py new file mode 100644 index 0000000000..2300119336 --- /dev/null +++ b/client/ayon_core/pipeline/actions/inventory.py @@ -0,0 +1,108 @@ +import logging + +from ayon_core.pipeline.plugin_discover import ( + discover, + register_plugin, + register_plugin_path, + deregister_plugin, + deregister_plugin_path +) +from ayon_core.pipeline.load.utils import get_representation_path_from_context + + +class InventoryAction: + """A custom action for the scene inventory tool + + If registered the action will be visible in the Right Mouse Button menu + under the submenu "Actions". + + """ + + label = None + icon = None + color = None + order = 0 + + log = logging.getLogger("InventoryAction") + log.propagate = True + + @staticmethod + def is_compatible(container): + """Override function in a custom class + + This method is specifically used to ensure the action can operate on + the container. + + Args: + container(dict): the data of a loaded asset, see host.ls() + + Returns: + bool + """ + return bool(container.get("objectName")) + + def process(self, containers): + """Override function in a custom class + + This method will receive all containers even those which are + incompatible. It is advised to create a small filter along the lines + of this example: + + valid_containers = filter(self.is_compatible(c) for c in containers) + + The return value will need to be a True-ish value to trigger + the data_changed signal in order to refresh the view. + + You can return a list of container names to trigger GUI to select + treeview items. + + You can return a dict to carry extra GUI options. For example: + { + "objectNames": [container names...], + "options": {"mode": "toggle", + "clear": False} + } + Currently workable GUI options are: + - clear (bool): Clear current selection before selecting by action. + Default `True`. + - mode (str): selection mode, use one of these: + "select", "deselect", "toggle". Default is "select". + + Args: + containers (list): list of dictionaries + + Return: + bool, list or dict + + """ + return True + + @classmethod + def filepath_from_context(cls, context): + return get_representation_path_from_context(context) + + +def discover_inventory_actions(): + actions = discover(InventoryAction) + filtered_actions = [] + for action in actions: + if action is not InventoryAction: + filtered_actions.append(action) + + return filtered_actions + + +def register_inventory_action(plugin): + return register_plugin(InventoryAction, plugin) + + +def deregister_inventory_action(plugin): + deregister_plugin(InventoryAction, plugin) + + +def register_inventory_action_path(path): + return register_plugin_path(InventoryAction, path) + + +def deregister_inventory_action_path(path): + return deregister_plugin_path(InventoryAction, path) diff --git a/client/ayon_core/pipeline/actions.py b/client/ayon_core/pipeline/actions/launcher.py similarity index 76% rename from client/ayon_core/pipeline/actions.py rename to client/ayon_core/pipeline/actions/launcher.py index 860fed5e8b..d47123cf20 100644 --- a/client/ayon_core/pipeline/actions.py +++ b/client/ayon_core/pipeline/actions/launcher.py @@ -8,12 +8,8 @@ from ayon_core.pipeline.plugin_discover import ( discover, register_plugin, register_plugin_path, - deregister_plugin, - deregister_plugin_path ) -from .load.utils import get_representation_path_from_context - class LauncherActionSelection: """Object helper to pass selection to actions. @@ -347,79 +343,6 @@ class LauncherAction(object): pass -class InventoryAction(object): - """A custom action for the scene inventory tool - - If registered the action will be visible in the Right Mouse Button menu - under the submenu "Actions". - - """ - - label = None - icon = None - color = None - order = 0 - - log = logging.getLogger("InventoryAction") - log.propagate = True - - @staticmethod - def is_compatible(container): - """Override function in a custom class - - This method is specifically used to ensure the action can operate on - the container. - - Args: - container(dict): the data of a loaded asset, see host.ls() - - Returns: - bool - """ - return bool(container.get("objectName")) - - def process(self, containers): - """Override function in a custom class - - This method will receive all containers even those which are - incompatible. It is advised to create a small filter along the lines - of this example: - - valid_containers = filter(self.is_compatible(c) for c in containers) - - The return value will need to be a True-ish value to trigger - the data_changed signal in order to refresh the view. - - You can return a list of container names to trigger GUI to select - treeview items. - - You can return a dict to carry extra GUI options. For example: - { - "objectNames": [container names...], - "options": {"mode": "toggle", - "clear": False} - } - Currently workable GUI options are: - - clear (bool): Clear current selection before selecting by action. - Default `True`. - - mode (str): selection mode, use one of these: - "select", "deselect", "toggle". Default is "select". - - Args: - containers (list): list of dictionaries - - Return: - bool, list or dict - - """ - return True - - @classmethod - def filepath_from_context(cls, context): - return get_representation_path_from_context(context) - - -# Launcher action def discover_launcher_actions(): return discover(LauncherAction) @@ -430,30 +353,3 @@ def register_launcher_action(plugin): def register_launcher_action_path(path): return register_plugin_path(LauncherAction, path) - - -# Inventory action -def discover_inventory_actions(): - actions = discover(InventoryAction) - filtered_actions = [] - for action in actions: - if action is not InventoryAction: - filtered_actions.append(action) - - return filtered_actions - - -def register_inventory_action(plugin): - return register_plugin(InventoryAction, plugin) - - -def deregister_inventory_action(plugin): - deregister_plugin(InventoryAction, plugin) - - -def register_inventory_action_path(path): - return register_plugin_path(InventoryAction, path) - - -def deregister_inventory_action_path(path): - return deregister_plugin_path(InventoryAction, path) From bd94d7ede6d2cf4806e817aa2b93d7d6d2160408 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:36:39 +0200 Subject: [PATCH 002/108] move 'StrEnum' to lib --- client/ayon_core/host/constants.py | 9 +-------- client/ayon_core/lib/__init__.py | 3 +++ client/ayon_core/lib/_compatibility.py | 8 ++++++++ 3 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 client/ayon_core/lib/_compatibility.py diff --git a/client/ayon_core/host/constants.py b/client/ayon_core/host/constants.py index 2564c5d54d..1ca33728d8 100644 --- a/client/ayon_core/host/constants.py +++ b/client/ayon_core/host/constants.py @@ -1,11 +1,4 @@ -from enum import Enum - - -class StrEnum(str, Enum): - """A string-based Enum class that allows for string comparison.""" - - def __str__(self) -> str: - return self.value +from ayon_core.lib import StrEnum class ContextChangeReason(StrEnum): diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 5ccc8d03e5..1097cf701a 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -2,6 +2,7 @@ # flake8: noqa E402 """AYON lib functions.""" +from ._compatibility import StrEnum from .local_settings import ( IniSettingRegistry, JSONSettingRegistry, @@ -140,6 +141,8 @@ from .ayon_info import ( terminal = Terminal __all__ = [ + "StrEnum", + "IniSettingRegistry", "JSONSettingRegistry", "AYONSecureRegistry", diff --git a/client/ayon_core/lib/_compatibility.py b/client/ayon_core/lib/_compatibility.py new file mode 100644 index 0000000000..299ed5e233 --- /dev/null +++ b/client/ayon_core/lib/_compatibility.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class StrEnum(str, Enum): + """A string-based Enum class that allows for string comparison.""" + + def __str__(self) -> str: + return self.value From 5e3b38376c6e5ed5f4bc0450a08632fb19e45f9b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:37:16 +0200 Subject: [PATCH 003/108] separated discover logic from 'PluginDiscoverContext' --- client/ayon_core/pipeline/plugin_discover.py | 124 +++++++++++-------- 1 file changed, 75 insertions(+), 49 deletions(-) diff --git a/client/ayon_core/pipeline/plugin_discover.py b/client/ayon_core/pipeline/plugin_discover.py index 03da7fce79..dddd6847ec 100644 --- a/client/ayon_core/pipeline/plugin_discover.py +++ b/client/ayon_core/pipeline/plugin_discover.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import os import inspect import traceback +from typing import Optional from ayon_core.lib import Logger from ayon_core.lib.python_module_tools import ( @@ -96,6 +99,70 @@ class DiscoverResult: log.info(report) +def discover_plugins( + base_class: type, + paths: Optional[list[str]] = None, + classes: Optional[list[type]] = None, + ignored_classes: Optional[list[type]] = None, + allow_duplicates: bool = True, +): + """Find and return subclasses of `superclass` + + Args: + base_class (type): Class which determines discovered subclasses. + paths (Optional[list[str]]): List of paths to look for plug-ins. + classes (Optional[list[str]]): List of classes to filter. + ignored_classes (list[type]): List of classes that won't be added to + the output plugins. + allow_duplicates (bool): Validate class name duplications. + + Returns: + DiscoverResult: Object holding successfully + discovered plugins, ignored plugins, plugins with missing + abstract implementation and duplicated plugin. + + """ + ignored_classes = ignored_classes or [] + paths = paths or [] + classes = classes or [] + + result = DiscoverResult(base_class) + + all_plugins = list(classes) + + for path in paths: + modules, crashed = modules_from_path(path) + for (filepath, exc_info) in crashed: + result.crashed_file_paths[filepath] = exc_info + + for item in modules: + filepath, module = item + result.add_module(module) + all_plugins.extend(classes_from_module(base_class, module)) + + if base_class not in ignored_classes: + ignored_classes.append(base_class) + + plugin_names = set() + for cls in all_plugins: + if cls in ignored_classes: + result.ignored_plugins.add(cls) + continue + + if inspect.isabstract(cls): + result.abstract_plugins.append(cls) + continue + + if not allow_duplicates: + class_name = cls.__name__ + if class_name in plugin_names: + result.duplicated_plugins.append(cls) + continue + plugin_names.add(class_name) + result.plugins.append(cls) + return result + + class PluginDiscoverContext(object): """Store and discover registered types nad registered paths to types. @@ -141,58 +208,17 @@ class PluginDiscoverContext(object): Union[DiscoverResult, list[Any]]: Object holding successfully discovered plugins, ignored plugins, plugins with missing abstract implementation and duplicated plugin. + """ - - if not ignore_classes: - ignore_classes = [] - - result = DiscoverResult(superclass) - plugin_names = set() registered_classes = self._registered_plugins.get(superclass) or [] registered_paths = self._registered_plugin_paths.get(superclass) or [] - for cls in registered_classes: - if cls is superclass or cls in ignore_classes: - result.ignored_plugins.add(cls) - continue - - if inspect.isabstract(cls): - result.abstract_plugins.append(cls) - continue - - class_name = cls.__name__ - if class_name in plugin_names: - result.duplicated_plugins.append(cls) - continue - plugin_names.add(class_name) - result.plugins.append(cls) - - # Include plug-ins from registered paths - for path in registered_paths: - modules, crashed = modules_from_path(path) - for item in crashed: - filepath, exc_info = item - result.crashed_file_paths[filepath] = exc_info - - for item in modules: - filepath, module = item - result.add_module(module) - for cls in classes_from_module(superclass, module): - if cls is superclass or cls in ignore_classes: - result.ignored_plugins.add(cls) - continue - - if inspect.isabstract(cls): - result.abstract_plugins.append(cls) - continue - - if not allow_duplicates: - class_name = cls.__name__ - if class_name in plugin_names: - result.duplicated_plugins.append(cls) - continue - plugin_names.add(class_name) - - result.plugins.append(cls) + result = discover_plugins( + superclass, + paths=registered_paths, + classes=registered_classes, + ignored_classes=ignore_classes, + allow_duplicates=allow_duplicates, + ) # Store in memory last result to keep in memory loaded modules self._last_discovered_results[superclass] = result From 723932cfac04cfb114b949898fe0a07a851f9f9b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:38:56 +0200 Subject: [PATCH 004/108] reduced information that is used in loader for action item --- client/ayon_core/tools/loader/abstract.py | 36 ++++---------- client/ayon_core/tools/loader/control.py | 10 ++-- .../ayon_core/tools/loader/models/actions.py | 47 ++++++------------- .../tools/loader/ui/products_widget.py | 6 +-- .../tools/loader/ui/repres_widget.py | 6 +-- 5 files changed, 33 insertions(+), 72 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 5ab7e78212..04cf0c6037 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -324,11 +324,6 @@ class ActionItem: options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): Action options. Note: 'qargparse' is considered as deprecated. order (int): Action order. - project_name (str): Project name. - folder_ids (list[str]): Folder ids. - product_ids (list[str]): Product ids. - version_ids (list[str]): Version ids. - representation_ids (list[str]): Representation ids. """ def __init__( @@ -339,11 +334,6 @@ class ActionItem: tooltip, options, order, - project_name, - folder_ids, - product_ids, - version_ids, - representation_ids, ): self.identifier = identifier self.label = label @@ -351,11 +341,6 @@ class ActionItem: self.tooltip = tooltip self.options = options self.order = order - self.project_name = project_name - self.folder_ids = folder_ids - self.product_ids = product_ids - self.version_ids = version_ids - self.representation_ids = representation_ids def _options_to_data(self): options = self.options @@ -382,11 +367,6 @@ class ActionItem: "tooltip": self.tooltip, "options": options, "order": self.order, - "project_name": self.project_name, - "folder_ids": self.folder_ids, - "product_ids": self.product_ids, - "version_ids": self.version_ids, - "representation_ids": self.representation_ids, } @classmethod @@ -1013,11 +993,11 @@ class FrontendLoaderController(_BaseLoaderController): @abstractmethod def trigger_action_item( self, - identifier, - options, - project_name, - version_ids, - representation_ids + identifier: str, + options: dict[str, Any], + project_name: str, + entity_ids: set[str], + entity_type: str, ): """Trigger action item. @@ -1038,10 +1018,10 @@ class FrontendLoaderController(_BaseLoaderController): identifier (str): Action identifier. options (dict[str, Any]): Action option values from UI. project_name (str): Project name. - version_ids (Iterable[str]): Version ids. - representation_ids (Iterable[str]): Representation ids. - """ + entity_ids (set[str]): Selected entity ids. + entity_type (str): Selected entity type. + """ pass @abstractmethod diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 7ba42a0981..a48fa7b853 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -309,14 +309,14 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): identifier, options, project_name, - version_ids, - representation_ids + entity_ids, + entity_type, ): if self._sitesync_model.is_sitesync_action(identifier): self._sitesync_model.trigger_action_item( identifier, project_name, - representation_ids + entity_ids, ) return @@ -324,8 +324,8 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): identifier, options, project_name, - version_ids, - representation_ids + entity_ids, + entity_type, ) # Selection model wrappers diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index b792f92dfd..ec0997685f 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -113,11 +113,11 @@ class LoaderActionsModel: def trigger_action_item( self, - identifier, - options, - project_name, - version_ids, - representation_ids + identifier: str, + options: dict[str, Any], + project_name: str, + entity_ids: set[str], + entity_type: str, ): """Trigger action by identifier. @@ -131,10 +131,10 @@ class LoaderActionsModel: identifier (str): Loader identifier. options (dict[str, Any]): Loader option values. project_name (str): Project name. - version_ids (Iterable[str]): Version ids. - representation_ids (Iterable[str]): Representation ids. - """ + entity_ids (set[str]): Entity ids. + entity_type (str): Entity type. + """ event_data = { "identifier": identifier, "id": uuid.uuid4().hex, @@ -145,23 +145,24 @@ class LoaderActionsModel: ACTIONS_MODEL_SENDER, ) loader = self._get_loader_by_identifier(project_name, identifier) - if representation_ids is not None: + if entity_type == "representation": error_info = self._trigger_representation_loader( loader, options, project_name, - representation_ids, + entity_ids, ) - elif version_ids is not None: + elif entity_type == "version": error_info = self._trigger_version_loader( loader, options, project_name, - version_ids, + entity_ids, ) else: raise NotImplementedError( - "Invalid arguments to trigger action item") + f"Invalid entity type '{entity_type}' to trigger action item" + ) event_data["error_info"] = error_info self._controller.emit_event( @@ -276,11 +277,6 @@ class LoaderActionsModel: self, loader, contexts, - project_name, - folder_ids=None, - product_ids=None, - version_ids=None, - representation_ids=None, repre_name=None, ): label = self._get_action_label(loader) @@ -293,11 +289,6 @@ class LoaderActionsModel: tooltip=self._get_action_tooltip(loader), options=loader.get_options(contexts), order=loader.order, - project_name=project_name, - folder_ids=folder_ids, - product_ids=product_ids, - version_ids=version_ids, - representation_ids=representation_ids, ) def _get_loaders(self, project_name): @@ -570,17 +561,11 @@ class LoaderActionsModel: item = self._create_loader_action_item( loader, repre_contexts, - project_name=project_name, - folder_ids=repre_folder_ids, - product_ids=repre_product_ids, - version_ids=repre_version_ids, - representation_ids=repre_ids, repre_name=repre_name, ) action_items.append(item) # Product Loaders. - version_ids = set(version_context_by_id.keys()) product_folder_ids = set() product_ids = set() for product_context in version_context_by_id.values(): @@ -592,10 +577,6 @@ class LoaderActionsModel: item = self._create_loader_action_item( loader, version_contexts, - project_name=project_name, - folder_ids=product_folder_ids, - product_ids=product_ids, - version_ids=version_ids, ) action_items.append(item) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index e5bb75a208..caa2ee82d0 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -439,9 +439,9 @@ class ProductsWidget(QtWidgets.QWidget): self._controller.trigger_action_item( action_item.identifier, options, - action_item.project_name, - version_ids=action_item.version_ids, - representation_ids=action_item.representation_ids, + project_name, + version_ids, + "version", ) def _on_selection_change(self): diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index d19ad306a3..17c429cb53 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -401,7 +401,7 @@ class RepresentationsWidget(QtWidgets.QWidget): self._controller.trigger_action_item( action_item.identifier, options, - action_item.project_name, - version_ids=action_item.version_ids, - representation_ids=action_item.representation_ids, + self._selected_project_name, + repre_ids, + "representation", ) From 53848ad366ca2451091223ca7871482ecaa75d2d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 17:53:03 +0200 Subject: [PATCH 005/108] keep entity ids and entity type on action item --- client/ayon_core/tools/loader/abstract.py | 11 ++++++- .../ayon_core/tools/loader/models/actions.py | 31 +++++++++++-------- .../tools/loader/ui/products_widget.py | 9 +++--- .../tools/loader/ui/repres_widget.py | 8 ++--- 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 04cf0c6037..55898e460f 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -318,17 +318,21 @@ class ActionItem: Args: identifier (str): Action identifier. + entity_ids (set[str]): Entity ids. + entity_type (str): Entity type. label (str): Action label. icon (dict[str, Any]): Action icon definition. tooltip (str): Action tooltip. options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): Action options. Note: 'qargparse' is considered as deprecated. order (int): Action order. - """ + """ def __init__( self, identifier, + entity_ids, + entity_type, label, icon, tooltip, @@ -336,6 +340,8 @@ class ActionItem: order, ): self.identifier = identifier + self.entity_ids = entity_ids + self.entity_type = entity_type self.label = label self.icon = icon self.tooltip = tooltip @@ -362,6 +368,8 @@ class ActionItem: options = self._options_to_data() return { "identifier": self.identifier, + "entity_ids": list(self.entity_ids), + "entity_type": self.entity_type, "label": self.label, "icon": self.icon, "tooltip": self.tooltip, @@ -375,6 +383,7 @@ class ActionItem: if options: options = deserialize_attr_defs(options) data["options"] = options + data["entity_ids"] = set(data["entity_ids"]) return cls(**data) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index ec0997685f..d8fd67234c 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -145,15 +145,16 @@ class LoaderActionsModel: ACTIONS_MODEL_SENDER, ) loader = self._get_loader_by_identifier(project_name, identifier) - if entity_type == "representation": - error_info = self._trigger_representation_loader( + + if entity_type == "version": + error_info = self._trigger_version_loader( loader, options, project_name, entity_ids, ) - elif entity_type == "version": - error_info = self._trigger_version_loader( + elif entity_type == "representation": + error_info = self._trigger_representation_loader( loader, options, project_name, @@ -277,6 +278,8 @@ class LoaderActionsModel: self, loader, contexts, + entity_ids, + entity_type, repre_name=None, ): label = self._get_action_label(loader) @@ -284,6 +287,8 @@ class LoaderActionsModel: label = "{} ({})".format(label, repre_name) return ActionItem( get_loader_identifier(loader), + entity_ids=entity_ids, + entity_type=entity_type, label=label, icon=self._get_action_icon(loader), tooltip=self._get_action_tooltip(loader), @@ -548,19 +553,16 @@ class LoaderActionsModel: if not filtered_repre_contexts: continue - repre_ids = set() - repre_version_ids = set() - repre_product_ids = set() - repre_folder_ids = set() - for repre_context in filtered_repre_contexts: - repre_ids.add(repre_context["representation"]["id"]) - repre_product_ids.add(repre_context["product"]["id"]) - repre_version_ids.add(repre_context["version"]["id"]) - repre_folder_ids.add(repre_context["folder"]["id"]) + repre_ids = { + repre_context["representation"]["id"] + for repre_context in filtered_repre_contexts + } item = self._create_loader_action_item( loader, repre_contexts, + repre_ids, + "representation", repre_name=repre_name, ) action_items.append(item) @@ -572,11 +574,14 @@ class LoaderActionsModel: product_ids.add(product_context["product"]["id"]) product_folder_ids.add(product_context["folder"]["id"]) + version_ids = set(version_context_by_id.keys()) version_contexts = list(version_context_by_id.values()) for loader in product_loaders: item = self._create_loader_action_item( loader, version_contexts, + version_ids, + "version", ) action_items.append(item) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index caa2ee82d0..4ed4368ab4 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -420,8 +420,9 @@ class ProductsWidget(QtWidgets.QWidget): if version_id is not None: version_ids.add(version_id) - action_items = self._controller.get_versions_action_items( - project_name, version_ids) + action_items = self._controller.get_action_items( + project_name, version_ids, "version" + ) # Prepare global point where to show the menu global_point = self._products_view.mapToGlobal(point) @@ -440,8 +441,8 @@ class ProductsWidget(QtWidgets.QWidget): action_item.identifier, options, project_name, - version_ids, - "version", + action_item.entity_ids, + action_item.entity_type, ) def _on_selection_change(self): diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index 17c429cb53..c0957d186c 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -384,8 +384,8 @@ class RepresentationsWidget(QtWidgets.QWidget): def _on_context_menu(self, point): repre_ids = self._get_selected_repre_ids() - action_items = self._controller.get_representations_action_items( - self._selected_project_name, repre_ids + action_items = self._controller.get_action_items( + self._selected_project_name, repre_ids, "representation" ) global_point = self._repre_view.mapToGlobal(point) result = show_actions_menu( @@ -402,6 +402,6 @@ class RepresentationsWidget(QtWidgets.QWidget): action_item.identifier, options, self._selected_project_name, - repre_ids, - "representation", + action_item.entity_ids, + action_item.entity_type, ) From 29b3794dd8625d547ee52fe51e632f9726f1717f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 17:59:56 +0200 Subject: [PATCH 006/108] only one method to get actions --- client/ayon_core/tools/loader/abstract.py | 28 +++------ client/ayon_core/tools/loader/control.py | 30 +++++----- .../ayon_core/tools/loader/models/actions.py | 59 ++++++------------- 3 files changed, 42 insertions(+), 75 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 55898e460f..baf6aabb69 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -970,33 +970,23 @@ class FrontendLoaderController(_BaseLoaderController): # Load action items @abstractmethod - def get_versions_action_items(self, project_name, version_ids): + def get_action_items( + self, + project_name: str, + entity_ids: set[str], + entity_type: str, + ) -> list[ActionItem]: """Action items for versions selection. Args: project_name (str): Project name. - version_ids (Iterable[str]): Version ids. + entity_ids (set[str]): Entity ids. + entity_type (str): Entity type. Returns: list[ActionItem]: List of action items. + """ - - pass - - @abstractmethod - def get_representations_action_items( - self, project_name, representation_ids - ): - """Action items for representations selection. - - Args: - project_name (str): Project name. - representation_ids (Iterable[str]): Representation ids. - - Returns: - list[ActionItem]: List of action items. - """ - pass @abstractmethod diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index a48fa7b853..f05914da17 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -21,7 +21,8 @@ from ayon_core.tools.common_models import ( from .abstract import ( BackendLoaderController, FrontendLoaderController, - ProductTypesFilter + ProductTypesFilter, + ActionItem, ) from .models import ( SelectionModel, @@ -287,21 +288,20 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): project_name, product_ids, group_name ) - def get_versions_action_items(self, project_name, version_ids): - return self._loader_actions_model.get_versions_action_items( - project_name, version_ids) - - def get_representations_action_items( - self, project_name, representation_ids): - action_items = ( - self._loader_actions_model.get_representations_action_items( - project_name, representation_ids) + def get_action_items( + self, + project_name: str, + entity_ids: set[str], + entity_type: str, + ) -> list[ActionItem]: + action_items = self._loader_actions_model.get_action_items( + project_name, entity_ids, entity_type ) - - action_items.extend(self._sitesync_model.get_sitesync_action_items( - project_name, representation_ids) - ) - + if entity_type == "representation": + site_sync_items = self._sitesync_model.get_sitesync_action_items( + project_name, entity_ids + ) + action_items.extend(site_sync_items) return action_items def trigger_action_item( diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index d8fd67234c..2ef20a7921 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -61,56 +61,33 @@ class LoaderActionsModel: self._product_loaders.reset() self._repre_loaders.reset() - def get_versions_action_items(self, project_name, version_ids): - """Get action items for given version ids. - Args: - project_name (str): Project name. - version_ids (Iterable[str]): Version ids. + def get_action_items( + self, + project_name: str, + entity_ids: set[str], + entity_type: str, + ) -> list[ActionItem]: + version_context_by_id = {} + repre_context_by_id = {} + if entity_type == "representation": + ( + version_context_by_id, + repre_context_by_id + ) = self._contexts_for_representations(project_name, entity_ids) - Returns: - list[ActionItem]: List of action items. - """ + if entity_type == "version": + ( + version_context_by_id, + repre_context_by_id + ) = self._contexts_for_versions(project_name, entity_ids) - ( - version_context_by_id, - repre_context_by_id - ) = self._contexts_for_versions( - project_name, - version_ids - ) return self._get_action_items_for_contexts( project_name, version_context_by_id, repre_context_by_id ) - def get_representations_action_items( - self, project_name, representation_ids - ): - """Get action items for given representation ids. - - Args: - project_name (str): Project name. - representation_ids (Iterable[str]): Representation ids. - - Returns: - list[ActionItem]: List of action items. - """ - - ( - product_context_by_id, - repre_context_by_id - ) = self._contexts_for_representations( - project_name, - representation_ids - ) - return self._get_action_items_for_contexts( - project_name, - product_context_by_id, - repre_context_by_id - ) - def trigger_action_item( self, identifier: str, From b3c5933042a6a50372810df15b78a61ca55a5ebf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 18:12:27 +0200 Subject: [PATCH 007/108] use version contexts instead of product contexts --- client/ayon_core/tools/loader/models/actions.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 2ef20a7921..c41119ac45 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -434,10 +434,10 @@ class LoaderActionsModel: representation contexts. """ - product_context_by_id = {} + version_context_by_id = {} repre_context_by_id = {} if not project_name and not repre_ids: - return product_context_by_id, repre_context_by_id + return version_context_by_id, repre_context_by_id repre_entities = list(ayon_api.get_representations( project_name, representation_ids=repre_ids @@ -468,13 +468,17 @@ class LoaderActionsModel: project_entity = ayon_api.get_project(project_name) - for product_id, product_entity in product_entities_by_id.items(): + version_context_by_id = {} + for version_id, version_entity in version_entities_by_id.items(): + product_id = version_entity["productId"] + product_entity = product_entities_by_id[product_id] folder_id = product_entity["folderId"] folder_entity = folder_entities_by_id[folder_id] - product_context_by_id[product_id] = { + version_context_by_id[version_id] = { "project": project_entity, "folder": folder_entity, "product": product_entity, + "version": version_entity, } for repre_entity in repre_entities: @@ -492,7 +496,7 @@ class LoaderActionsModel: "version": version_entity, "representation": repre_entity, } - return product_context_by_id, repre_context_by_id + return version_context_by_id, repre_context_by_id def _get_action_items_for_contexts( self, From dee1d51640decb7e14b84a041a2e38389316499c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 18:13:30 +0200 Subject: [PATCH 008/108] cache entities --- .../ayon_core/tools/loader/models/actions.py | 185 +++++++++++++++--- 1 file changed, 163 insertions(+), 22 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index c41119ac45..1e8bfe7ae1 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -5,6 +5,7 @@ import traceback import inspect import collections import uuid +from typing import Callable, Any import ayon_api @@ -53,6 +54,14 @@ class LoaderActionsModel: self._repre_loaders = NestedCacheItem( levels=1, lifetime=self.loaders_cache_lifetime) + self._projects_cache = NestedCacheItem(levels=1, lifetime=60) + self._folders_cache = NestedCacheItem(levels=2, lifetime=300) + self._tasks_cache = NestedCacheItem(levels=2, lifetime=300) + self._products_cache = NestedCacheItem(levels=2, lifetime=300) + self._versions_cache = NestedCacheItem(levels=2, lifetime=1200) + self._representations_cache = NestedCacheItem(levels=2, lifetime=1200) + self._repre_parents_cache = NestedCacheItem(levels=2, lifetime=1200) + def reset(self): """Reset the model with all cached items.""" @@ -61,6 +70,12 @@ class LoaderActionsModel: self._product_loaders.reset() self._repre_loaders.reset() + self._folders_cache.reset() + self._tasks_cache.reset() + self._products_cache.reset() + self._versions_cache.reset() + self._representations_cache.reset() + self._repre_parents_cache.reset() def get_action_items( self, @@ -358,8 +373,8 @@ class LoaderActionsModel: if not project_name and not version_ids: return version_context_by_id, repre_context_by_id - version_entities = ayon_api.get_versions( - project_name, version_ids=version_ids + version_entities = self._get_versions( + project_name, version_ids ) version_entities_by_id = {} version_entities_by_product_id = collections.defaultdict(list) @@ -370,18 +385,18 @@ class LoaderActionsModel: version_entities_by_product_id[product_id].append(version_entity) _product_ids = set(version_entities_by_product_id.keys()) - _product_entities = ayon_api.get_products( - project_name, product_ids=_product_ids + _product_entities = self._get_products( + project_name, _product_ids ) product_entities_by_id = {p["id"]: p for p in _product_entities} _folder_ids = {p["folderId"] for p in product_entities_by_id.values()} - _folder_entities = ayon_api.get_folders( - project_name, folder_ids=_folder_ids + _folder_entities = self._get_folders( + project_name, _folder_ids ) folder_entities_by_id = {f["id"]: f for f in _folder_entities} - project_entity = ayon_api.get_project(project_name) + project_entity = self._get_project(project_name) for version_id, version_entity in version_entities_by_id.items(): product_id = version_entity["productId"] @@ -395,8 +410,15 @@ class LoaderActionsModel: "version": version_entity, } - repre_entities = ayon_api.get_representations( - project_name, version_ids=version_ids) + all_repre_ids = set() + for repre_ids in self._get_repre_ids_by_version_ids( + project_name, version_ids + ).values(): + all_repre_ids |= repre_ids + + repre_entities = self._get_representations( + project_name, all_repre_ids + ) for repre_entity in repre_entities: version_id = repre_entity["versionId"] version_entity = version_entities_by_id[version_id] @@ -439,34 +461,35 @@ class LoaderActionsModel: if not project_name and not repre_ids: return version_context_by_id, repre_context_by_id - repre_entities = list(ayon_api.get_representations( - project_name, representation_ids=repre_ids - )) + repre_entities = self._get_representations( + project_name, repre_ids + ) version_ids = {r["versionId"] for r in repre_entities} - version_entities = ayon_api.get_versions( - project_name, version_ids=version_ids + version_entities = self._get_versions( + project_name, version_ids ) version_entities_by_id = { v["id"]: v for v in version_entities } product_ids = {v["productId"] for v in version_entities_by_id.values()} - product_entities = ayon_api.get_products( - project_name, product_ids=product_ids + product_entities = self._get_products( + project_name, product_ids + ) product_entities_by_id = { p["id"]: p for p in product_entities } folder_ids = {p["folderId"] for p in product_entities_by_id.values()} - folder_entities = ayon_api.get_folders( - project_name, folder_ids=folder_ids + folder_entities = self._get_folders( + project_name, folder_ids ) folder_entities_by_id = { f["id"]: f for f in folder_entities } - project_entity = ayon_api.get_project(project_name) + project_entity = self._get_project(project_name) version_context_by_id = {} for version_id, version_entity in version_entities_by_id.items(): @@ -498,6 +521,124 @@ class LoaderActionsModel: } return version_context_by_id, repre_context_by_id + def _get_project(self, project_name: str) -> dict[str, Any]: + cache = self._projects_cache[project_name] + if not cache.is_valid: + cache.update_data(ayon_api.get_project(project_name)) + return cache.get_data() + + def _get_folders( + self, project_name: str, folder_ids: set[str] + ) -> list[dict[str, Any]]: + """Get folders by ids.""" + return self._get_entities( + project_name, + folder_ids, + self._folders_cache, + ayon_api.get_folders, + "folder_ids", + ) + + def _get_products( + self, project_name: str, product_ids: set[str] + ) -> list[dict[str, Any]]: + """Get products by ids.""" + return self._get_entities( + project_name, + product_ids, + self._products_cache, + ayon_api.get_products, + "product_ids", + ) + + def _get_versions( + self, project_name: str, version_ids: set[str] + ) -> list[dict[str, Any]]: + """Get versions by ids.""" + return self._get_entities( + project_name, + version_ids, + self._versions_cache, + ayon_api.get_versions, + "version_ids", + ) + + def _get_representations( + self, project_name: str, representation_ids: set[str] + ) -> list[dict[str, Any]]: + """Get representations by ids.""" + return self._get_entities( + project_name, + representation_ids, + self._representations_cache, + ayon_api.get_representations, + "representation_ids", + ) + + def _get_repre_ids_by_version_ids( + self, project_name: str, version_ids: set[str] + ) -> dict[str, set[str]]: + output = {} + if not version_ids: + return output + + project_cache = self._repre_parents_cache[project_name] + missing_ids = set() + for version_id in version_ids: + cache = project_cache[version_id] + if cache.is_valid: + output[version_id] = cache.get_data() + else: + missing_ids.add(version_id) + + if missing_ids: + repre_cache = self._representations_cache[project_name] + repres_by_parent_id = collections.defaultdict(list) + for repre in ayon_api.get_representations( + project_name, version_ids=missing_ids + ): + version_id = repre["versionId"] + repre_cache[repre["id"]].update_data(repre) + repres_by_parent_id[version_id].append(repre) + + for version_id, repres in repres_by_parent_id.items(): + repre_ids = { + repre["id"] + for repre in repres + } + output[version_id] = set(repre_ids) + project_cache[version_id].update_data(repre_ids) + + return output + + def _get_entities( + self, + project_name: str, + entity_ids: set[str], + cache: NestedCacheItem, + getter: Callable, + filter_arg: str, + ) -> list[dict[str, Any]]: + entities = [] + if not entity_ids: + return entities + + missing_ids = set() + project_cache = cache[project_name] + for entity_id in entity_ids: + entity_cache = project_cache[entity_id] + if entity_cache.is_valid: + entities.append(entity_cache.get_data()) + else: + missing_ids.add(entity_id) + + if missing_ids: + for entity in getter(project_name, **{filter_arg: missing_ids}): + entities.append(entity) + entity_id = entity["id"] + project_cache[entity_id].update_data(entity) + return entities + def _get_action_items_for_contexts( self, project_name, @@ -601,12 +742,12 @@ class LoaderActionsModel: project_name, version_ids=version_ids )) product_ids = {v["productId"] for v in version_entities} - product_entities = ayon_api.get_products( - project_name, product_ids=product_ids + product_entities = self._get_products( + project_name, product_ids ) product_entities_by_id = {p["id"]: p for p in product_entities} folder_ids = {p["folderId"] for p in product_entities_by_id.values()} - folder_entities = ayon_api.get_folders( + folder_entities = self._get_folders( project_name, folder_ids=folder_ids ) folder_entities_by_id = {f["id"]: f for f in folder_entities} From 599716fe942952649d4bd66f99de712690199f59 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 11:23:10 +0200 Subject: [PATCH 009/108] base of loader action --- client/ayon_core/pipeline/actions/__init__.py | 18 + client/ayon_core/pipeline/actions/loader.py | 546 ++++++++++++++++++ 2 files changed, 564 insertions(+) create mode 100644 client/ayon_core/pipeline/actions/loader.py diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py index bda9b50ede..188414bdbe 100644 --- a/client/ayon_core/pipeline/actions/__init__.py +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -1,3 +1,13 @@ +from .loader import ( + LoaderActionForm, + LoaderActionResult, + LoaderActionItem, + LoaderActionPlugin, + LoaderActionSelection, + LoaderActionsContext, + SelectionEntitiesCache, +) + from .launcher import ( LauncherAction, LauncherActionSelection, @@ -18,6 +28,14 @@ from .inventory import ( __all__= ( + "LoaderActionForm", + "LoaderActionResult", + "LoaderActionItem", + "LoaderActionPlugin", + "LoaderActionSelection", + "LoaderActionsContext", + "SelectionEntitiesCache", + "LauncherAction", "LauncherActionSelection", "discover_launcher_actions", diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py new file mode 100644 index 0000000000..33f48b195c --- /dev/null +++ b/client/ayon_core/pipeline/actions/loader.py @@ -0,0 +1,546 @@ +from __future__ import annotations + +import os +import collections +import copy +from abc import ABC, abstractmethod +from typing import Optional, Any, Callable +from dataclasses import dataclass + +import ayon_api + +from ayon_core import AYON_CORE_ROOT +from ayon_core.lib import StrEnum, Logger, AbstractAttrDef +from ayon_core.addon import AddonsManager, IPluginPaths +from ayon_core.settings import get_studio_settings, get_project_settings +from ayon_core.pipeline.plugin_discover import discover_plugins + + +class EntityType(StrEnum): + """Selected entity type.""" + # folder = "folder" + # task = "task" + version = "version" + representation = "representation" + + +class SelectionEntitiesCache: + def __init__( + self, + project_name: str, + project_entity: Optional[dict[str, Any]] = None, + folders_by_id: Optional[dict[str, dict[str, Any]]] = None, + tasks_by_id: Optional[dict[str, dict[str, Any]]] = None, + products_by_id: Optional[dict[str, dict[str, Any]]] = None, + versions_by_id: Optional[dict[str, dict[str, Any]]] = None, + representations_by_id: Optional[dict[str, dict[str, Any]]] = None, + task_ids_by_folder_id: Optional[dict[str, str]] = None, + product_ids_by_folder_id: Optional[dict[str, str]] = None, + version_ids_by_product_id: Optional[dict[str, str]] = None, + version_id_by_task_id: Optional[dict[str, str]] = None, + representation_id_by_version_id: Optional[dict[str, str]] = None, + ): + self._project_name = project_name + self._project_entity = project_entity + self._folders_by_id = folders_by_id or {} + self._tasks_by_id = tasks_by_id or {} + self._products_by_id = products_by_id or {} + self._versions_by_id = versions_by_id or {} + self._representations_by_id = representations_by_id or {} + + self._task_ids_by_folder_id = task_ids_by_folder_id or {} + self._product_ids_by_folder_id = product_ids_by_folder_id or {} + self._version_ids_by_product_id = version_ids_by_product_id or {} + self._version_id_by_task_id = version_id_by_task_id or {} + self._representation_id_by_version_id = ( + representation_id_by_version_id or {} + ) + + def get_project(self) -> dict[str, Any]: + if self._project_entity is None: + self._project_entity = ayon_api.get_project(self._project_name) + return copy.deepcopy(self._project_entity) + + def get_folders( + self, folder_ids: set[str] + ) -> list[dict[str, Any]]: + return self._get_entities( + folder_ids, + self._folders_by_id, + "folder_ids", + ayon_api.get_folders, + ) + + def get_tasks( + self, task_ids: set[str] + ) -> list[dict[str, Any]]: + return self._get_entities( + task_ids, + self._tasks_by_id, + "task_ids", + ayon_api.get_tasks, + ) + + def get_products( + self, product_ids: set[str] + ) -> list[dict[str, Any]]: + return self._get_entities( + product_ids, + self._products_by_id, + "product_ids", + ayon_api.get_products, + ) + + def get_versions( + self, version_ids: set[str] + ) -> list[dict[str, Any]]: + return self._get_entities( + version_ids, + self._versions_by_id, + "version_ids", + ayon_api.get_versions, + ) + + def get_representations( + self, representation_ids: set[str] + ) -> list[dict[str, Any]]: + return self._get_entities( + representation_ids, + self._representations_by_id, + "representation_ids", + ayon_api.get_representations, + ) + + def get_folders_tasks( + self, folder_ids: set[str] + ) -> list[dict[str, Any]]: + task_ids = self._fill_parent_children_ids( + folder_ids, + "folderId", + "folder_ids", + self._task_ids_by_folder_id, + ayon_api.get_tasks, + ) + return self.get_tasks(task_ids) + + def get_folders_products( + self, folder_ids: set[str] + ) -> list[dict[str, Any]]: + product_ids = self._get_folders_products_ids(folder_ids) + return self.get_products(product_ids) + + def get_tasks_versions( + self, task_ids: set[str] + ) -> list[dict[str, Any]]: + folder_ids = { + task["folderId"] + for task in self.get_tasks(task_ids) + } + product_ids = self._get_folders_products_ids(folder_ids) + output = [] + for version in self.get_products_versions(product_ids): + task_id = version["taskId"] + if task_id in task_ids: + output.append(version) + return output + + def get_products_versions( + self, product_ids: set[str] + ) -> list[dict[str, Any]]: + version_ids = self._fill_parent_children_ids( + product_ids, + "productId", + "product_ids", + self._version_ids_by_product_id, + ayon_api.get_versions, + ) + return self.get_versions(version_ids) + + def get_versions_representations( + self, version_ids: set[str] + ) -> list[dict[str, Any]]: + repre_ids = self._fill_parent_children_ids( + version_ids, + "versionId", + "version_ids", + self._representation_id_by_version_id, + ayon_api.get_representations, + ) + return self.get_representations(repre_ids) + + def get_tasks_folders(self, task_ids: set[str]) -> list[dict[str, Any]]: + folder_ids = { + task["folderId"] + for task in self.get_tasks(task_ids) + } + return self.get_folders(folder_ids) + + def get_products_folders( + self, product_ids: set[str] + ) -> list[dict[str, Any]]: + folder_ids = { + product["folderId"] + for product in self.get_products(product_ids) + } + return self.get_folders(folder_ids) + + def get_versions_products( + self, version_ids: set[str] + ) -> list[dict[str, Any]]: + product_ids = { + version["productId"] + for version in self.get_versions(version_ids) + } + return self.get_products(product_ids) + + def get_versions_tasks( + self, version_ids: set[str] + ) -> list[dict[str, Any]]: + task_ids = { + version["taskId"] + for version in self.get_versions(version_ids) + if version["taskId"] + } + return self.get_tasks(task_ids) + + def get_representations_versions( + self, representation_ids: set[str] + ) -> list[dict[str, Any]]: + version_ids = { + repre["versionId"] + for repre in self.get_representations(representation_ids) + } + return self.get_versions(version_ids) + + def _get_folders_products_ids(self, folder_ids: set[str]) -> set[str]: + return self._fill_parent_children_ids( + folder_ids, + "folderId", + "folder_ids", + self._product_ids_by_folder_id, + ayon_api.get_products, + ) + + def _fill_parent_children_ids( + self, + entity_ids: set[str], + parent_key: str, + filter_attr: str, + parent_mapping: dict[str, set[str]], + getter: Callable, + ) -> set[str]: + if not entity_ids: + return set() + children_ids = set() + missing_ids = set() + for entity_id in entity_ids: + _children_ids = parent_mapping.get(entity_id) + if _children_ids is None: + missing_ids.add(entity_id) + else: + children_ids.update(_children_ids) + if missing_ids: + entities_by_parent_id = collections.defaultdict(set) + for entity in getter( + self._project_name, + fields={"id", parent_key}, + **{filter_attr: missing_ids}, + ): + child_id = entity["id"] + children_ids.add(child_id) + entities_by_parent_id[entity[parent_key]].add(child_id) + + for entity_id in missing_ids: + parent_mapping[entity_id] = entities_by_parent_id[entity_id] + + return children_ids + + def _get_entities( + self, + entity_ids: set[str], + cache_var: dict[str, Any], + filter_arg: str, + getter: Callable, + ) -> list[dict[str, Any]]: + if not entity_ids: + return [] + + output = [] + missing_ids: set[str] = set() + for entity_id in entity_ids: + entity = cache_var.get(entity_id) + if entity_id not in cache_var: + missing_ids.add(entity_id) + cache_var[entity_id] = None + elif entity: + output.append(entity) + + if missing_ids: + for entity in getter( + self._project_name, + **{filter_arg: missing_ids} + ): + output.append(entity) + cache_var[entity["id"]] = entity + return output + + +class LoaderActionSelection: + def __init__( + self, + project_name: str, + selected_ids: set[str], + selected_type: EntityType, + *, + project_anatomy: Optional["Anatomy"] = None, + project_settings: Optional[dict[str, Any]] = None, + entities_cache: Optional[SelectionEntitiesCache] = None, + ): + self._project_name = project_name + self._selected_ids = selected_ids + self._selected_type = selected_type + + self._project_anatomy = project_anatomy + self._project_settings = project_settings + + if entities_cache is None: + entities_cache = SelectionEntitiesCache(project_name) + self._entities_cache = entities_cache + + def get_entities_cache(self) -> SelectionEntitiesCache: + return self._entities_cache + + def get_project_name(self) -> str: + return self._project_name + + def get_selected_ids(self) -> set[str]: + return set(self._selected_ids) + + def get_selected_type(self) -> str: + return self._selected_type + + def get_project_settings(self) -> dict[str, Any]: + if self._project_settings is None: + self._project_settings = get_project_settings(self._project_name) + return copy.deepcopy(self._project_settings) + + def get_project_anatomy(self) -> dict[str, Any]: + if self._project_anatomy is None: + from ayon_core.pipeline import Anatomy + + self._project_anatomy = Anatomy( + self._project_name, + project_entity=self.get_entities_cache().get_project(), + ) + return self._project_anatomy + + project_name = property(get_project_name) + selected_ids = property(get_selected_ids) + selected_type = property(get_selected_type) + project_settings = property(get_project_settings) + project_anatomy = property(get_project_anatomy) + entities = property(get_entities_cache) + + +@dataclass +class LoaderActionItem: + identifier: str + entity_ids: set[str] + entity_type: EntityType + label: str + group_label: Optional[str] = None + # Is filled automatically + plugin_identifier: str = None + + +@dataclass +class LoaderActionForm: + title: str + fields: list[AbstractAttrDef] + submit_label: Optional[str] = "Submit" + submit_icon: Optional[str] = None + cancel_label: Optional[str] = "Cancel" + cancel_icon: Optional[str] = None + + +@dataclass +class LoaderActionResult: + message: Optional[str] = None + success: bool = True + form: Optional[LoaderActionForm] = None + + +class LoaderActionPlugin(ABC): + """Plugin for loader actions. + + Plugin is responsible for getting action items and executing actions. + + + """ + def __init__(self, studio_settings: dict[str, Any]): + self.apply_settings(studio_settings) + + def apply_settings(self, studio_settings: dict[str, Any]) -> None: + """Apply studio settings to the plugin. + + Args: + studio_settings (dict[str, Any]): Studio settings. + + """ + pass + + @property + def identifier(self) -> str: + """Identifier of the plugin. + + Returns: + str: Plugin identifier. + + """ + return self.__class__.__name__ + + @abstractmethod + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + """Action items for the selection. + + Args: + selection (LoaderActionSelection): Selection. + + Returns: + list[LoaderActionItem]: Action items. + + """ + pass + + @abstractmethod + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + """Execute an action. + + Args: + identifier (str): Action identifier. + entity_ids: (set[str]): Entity ids stored on action item. + entity_type: (str): Entity type stored on action item. + selection (LoaderActionSelection): Selection wrapper. Can be used + to get entities or get context of original selection. + form_values (dict[str, Any]): Attribute values. + + Returns: + Optional[LoaderActionResult]: Result of the action execution. + + """ + pass + + +class LoaderActionsContext: + def __init__( + self, + studio_settings: Optional[dict[str, Any]] = None, + addons_manager: Optional[AddonsManager] = None, + ) -> None: + self._log = Logger.get_logger(self.__class__.__name__) + + self._addons_manager = addons_manager + + self._studio_settings = studio_settings + self._plugins = None + + def reset( + self, studio_settings: Optional[dict[str, Any]] = None + ) -> None: + self._studio_settings = studio_settings + self._plugins = None + + def get_addons_manager(self) -> AddonsManager: + if self._addons_manager is None: + self._addons_manager = AddonsManager( + settings=self._get_studio_settings() + ) + return self._addons_manager + + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + output = [] + for plugin in self._get_plugins().values(): + try: + for action_item in plugin.get_action_items(selection): + action_item.identifier = plugin.identifier + output.append(action_item) + + except Exception: + self._log.warning( + "Failed to get action items for" + f" plugin '{plugin.identifier}'", + exc_info=True, + ) + return output + + def execute_action( + self, + plugin_identifier: str, + action_identifier: str, + entity_ids: set[str], + entity_type: EntityType, + selection: LoaderActionSelection, + attribute_values: dict[str, Any], + ) -> None: + plugins_by_id = self._get_plugins() + plugin = plugins_by_id[plugin_identifier] + plugin.execute_action( + action_identifier, + entity_ids, + entity_type, + selection, + attribute_values, + ) + + def _get_studio_settings(self) -> dict[str, Any]: + if self._studio_settings is None: + self._studio_settings = get_studio_settings() + return copy.deepcopy(self._studio_settings) + + def _get_plugins(self) -> dict[str, LoaderActionPlugin]: + if self._plugins is None: + addons_manager = self.get_addons_manager() + all_paths = [ + os.path.join(AYON_CORE_ROOT, "plugins", "loader") + ] + for addon in addons_manager.addons: + if not isinstance(addon, IPluginPaths): + continue + paths = addon.get_loader_action_plugin_paths() + if paths: + all_paths.extend(paths) + + studio_settings = self._get_studio_settings() + result = discover_plugins(LoaderActionPlugin, all_paths) + result.log_report() + plugins = {} + for cls in result.plugins: + try: + plugin = cls(studio_settings) + plugin_id = plugin.identifier + if plugin_id not in plugins: + plugins[plugin_id] = plugin + continue + + self._log.warning( + f"Duplicated plugins identifier found '{plugin_id}'." + ) + + except Exception: + self._log.warning( + f"Failed to initialize plugin '{cls.__name__}'", + exc_info=True, + ) + self._plugins = plugins + return self._plugins From 7b81cb1215e900637593c26aa4213db11e6dc038 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 13:54:38 +0200 Subject: [PATCH 010/108] added logger to action plugin --- client/ayon_core/pipeline/actions/loader.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 33f48b195c..b81b89a56a 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -3,6 +3,7 @@ from __future__ import annotations import os import collections import copy +import logging from abc import ABC, abstractmethod from typing import Optional, Any, Callable from dataclasses import dataclass @@ -377,6 +378,8 @@ class LoaderActionPlugin(ABC): """ + _log: Optional[logging.Logger] = None + def __init__(self, studio_settings: dict[str, Any]): self.apply_settings(studio_settings) @@ -389,6 +392,12 @@ class LoaderActionPlugin(ABC): """ pass + @property + def log(self) -> logging.Logger: + if self._log is None: + self._log = Logger.get_logger(self.__class__.__name__) + return self._log + @property def identifier(self) -> str: """Identifier of the plugin. From 0f65fe34a78a9f1e6c51677d03bdcf4c4f401b73 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 14:02:19 +0200 Subject: [PATCH 011/108] change entity type to str --- client/ayon_core/pipeline/actions/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index b81b89a56a..a6cceabd76 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -347,7 +347,7 @@ class LoaderActionSelection: class LoaderActionItem: identifier: str entity_ids: set[str] - entity_type: EntityType + entity_type: str label: str group_label: Optional[str] = None # Is filled automatically From 700006692a27793e8295b212bdea06e650d3078f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 14:15:02 +0200 Subject: [PATCH 012/108] added order and icon to --- client/ayon_core/pipeline/actions/loader.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index a6cceabd76..ccc81a2d73 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -349,7 +349,9 @@ class LoaderActionItem: entity_ids: set[str] entity_type: str label: str + order: int = 0 group_label: Optional[str] = None + icon: Optional[dict[str, Any]] = None # Is filled automatically plugin_identifier: str = None From e05ffe0263848aee9db112901202b99c45530bfc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:08:04 +0200 Subject: [PATCH 013/108] converted copy file action --- client/ayon_core/plugins/load/copy_file.py | 34 ------ .../ayon_core/plugins/load/copy_file_path.py | 29 ----- client/ayon_core/plugins/loader/copy_file.py | 115 ++++++++++++++++++ 3 files changed, 115 insertions(+), 63 deletions(-) delete mode 100644 client/ayon_core/plugins/load/copy_file.py delete mode 100644 client/ayon_core/plugins/load/copy_file_path.py create mode 100644 client/ayon_core/plugins/loader/copy_file.py diff --git a/client/ayon_core/plugins/load/copy_file.py b/client/ayon_core/plugins/load/copy_file.py deleted file mode 100644 index 08dad03be3..0000000000 --- a/client/ayon_core/plugins/load/copy_file.py +++ /dev/null @@ -1,34 +0,0 @@ -from ayon_core.style import get_default_entity_icon_color -from ayon_core.pipeline import load - - -class CopyFile(load.LoaderPlugin): - """Copy the published file to be pasted at the desired location""" - - representations = {"*"} - product_types = {"*"} - - label = "Copy File" - order = 10 - icon = "copy" - color = get_default_entity_icon_color() - - def load(self, context, name=None, namespace=None, data=None): - path = self.filepath_from_context(context) - self.log.info("Added copy to clipboard: {0}".format(path)) - self.copy_file_to_clipboard(path) - - @staticmethod - def copy_file_to_clipboard(path): - from qtpy import QtCore, QtWidgets - - clipboard = QtWidgets.QApplication.clipboard() - assert clipboard, "Must have running QApplication instance" - - # Build mime data for clipboard - data = QtCore.QMimeData() - url = QtCore.QUrl.fromLocalFile(path) - data.setUrls([url]) - - # Set to Clipboard - clipboard.setMimeData(data) diff --git a/client/ayon_core/plugins/load/copy_file_path.py b/client/ayon_core/plugins/load/copy_file_path.py deleted file mode 100644 index fdf31b5e02..0000000000 --- a/client/ayon_core/plugins/load/copy_file_path.py +++ /dev/null @@ -1,29 +0,0 @@ -import os - -from ayon_core.pipeline import load - - -class CopyFilePath(load.LoaderPlugin): - """Copy published file path to clipboard""" - representations = {"*"} - product_types = {"*"} - - label = "Copy File Path" - order = 20 - icon = "clipboard" - color = "#999999" - - def load(self, context, name=None, namespace=None, data=None): - path = self.filepath_from_context(context) - self.log.info("Added file path to clipboard: {0}".format(path)) - self.copy_path_to_clipboard(path) - - @staticmethod - def copy_path_to_clipboard(path): - from qtpy import QtWidgets - - clipboard = QtWidgets.QApplication.clipboard() - assert clipboard, "Must have running QApplication instance" - - # Set to Clipboard - clipboard.setText(os.path.normpath(path)) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py new file mode 100644 index 0000000000..54e92b0ab9 --- /dev/null +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -0,0 +1,115 @@ +import os +import collections + +from typing import Optional, Any + +from ayon_core.pipeline.load import get_representation_path_with_anatomy +from ayon_core.pipeline.actions import ( + LoaderActionPlugin, + LoaderActionItem, + LoaderActionSelection, + LoaderActionResult, +) + + +class CopyFileActionPlugin(LoaderActionPlugin): + """Copy published file path to clipboard""" + identifier = "core.copy-action" + + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + repres = [] + if selection.selected_type in "representations": + repres = selection.entities.get_representations( + selection.selected_ids + ) + + if selection.selected_type in "version": + repres = selection.entities.get_versions_representations( + selection.selected_ids + ) + + output = [] + if not repres: + return output + + repre_ids_by_name = collections.defaultdict(set) + for repre in repres: + repre_ids_by_name[repre["name"]].add(repre["id"]) + + for repre_name, repre_ids in repre_ids_by_name.items(): + output.append( + LoaderActionItem( + identifier="copy-path", + label=repre_name, + group_label="Copy file path", + entity_ids=repre_ids, + entity_type="representation", + icon={ + "type": "material-symbols", + "name": "content_copy", + "color": "#999999", + } + ) + ) + output.append( + LoaderActionItem( + identifier="copy-file", + label=repre_name, + group_label="Copy file", + entity_ids=repre_ids, + entity_type="representation", + icon={ + "type": "material-symbols", + "name": "file_copy", + "color": "#999999", + } + ) + ) + return output + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + from qtpy import QtWidgets, QtCore + + repre = next(iter(selection.entities.get_representations(entity_ids))) + path = get_representation_path_with_anatomy( + repre, selection.get_project_anatomy() + ) + self.log.info(f"Added file path to clipboard: {path}") + + clipboard = QtWidgets.QApplication.clipboard() + if not clipboard: + return LoaderActionResult( + "Failed to copy file path to clipboard", + success=False, + ) + + if identifier == "copy-path": + # Set to Clipboard + clipboard.setText(os.path.normpath(path)) + + return LoaderActionResult( + "Path stored to clipboard", + success=True, + ) + + # Build mime data for clipboard + data = QtCore.QMimeData() + url = QtCore.QUrl.fromLocalFile(path) + data.setUrls([url]) + + # Set to Clipboard + clipboard.setMimeData(data) + + return LoaderActionResult( + "File added to clipboard", + success=True, + ) From e7439a2d7fe093c7181fccfecd5ef170f62e945f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:08:33 +0200 Subject: [PATCH 014/108] fix fill of plugin identifier --- client/ayon_core/pipeline/actions/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index ccc81a2d73..96806809bd 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -484,7 +484,7 @@ class LoaderActionsContext: for plugin in self._get_plugins().values(): try: for action_item in plugin.get_action_items(selection): - action_item.identifier = plugin.identifier + action_item.plugin_identifier = plugin.identifier output.append(action_item) except Exception: From 422968315ebe7c0509612c6760138bce445e587b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:09:01 +0200 Subject: [PATCH 015/108] do not hard force plugin identifier --- client/ayon_core/pipeline/actions/loader.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 96806809bd..7822522496 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -484,7 +484,8 @@ class LoaderActionsContext: for plugin in self._get_plugins().values(): try: for action_item in plugin.get_action_items(selection): - action_item.plugin_identifier = plugin.identifier + if action_item.plugin_identifier is None: + action_item.plugin_identifier = plugin.identifier output.append(action_item) except Exception: From 3a65c56123936041f4fb8bfba2994f7ce6f1311e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:09:11 +0200 Subject: [PATCH 016/108] import Anatomy directly --- client/ayon_core/pipeline/actions/loader.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 7822522496..e2628da43c 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -14,6 +14,7 @@ from ayon_core import AYON_CORE_ROOT from ayon_core.lib import StrEnum, Logger, AbstractAttrDef from ayon_core.addon import AddonsManager, IPluginPaths from ayon_core.settings import get_studio_settings, get_project_settings +from ayon_core.pipeline import Anatomy from ayon_core.pipeline.plugin_discover import discover_plugins @@ -293,7 +294,7 @@ class LoaderActionSelection: selected_ids: set[str], selected_type: EntityType, *, - project_anatomy: Optional["Anatomy"] = None, + project_anatomy: Optional[Anatomy] = None, project_settings: Optional[dict[str, Any]] = None, entities_cache: Optional[SelectionEntitiesCache] = None, ): @@ -325,10 +326,8 @@ class LoaderActionSelection: self._project_settings = get_project_settings(self._project_name) return copy.deepcopy(self._project_settings) - def get_project_anatomy(self) -> dict[str, Any]: + def get_project_anatomy(self) -> Anatomy: if self._project_anatomy is None: - from ayon_core.pipeline import Anatomy - self._project_anatomy = Anatomy( self._project_name, project_entity=self.get_entities_cache().get_project(), From a22f378ed51dcd019531780524e58f531822c7dd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:10:03 +0200 Subject: [PATCH 017/108] added 'get_loader_action_plugin_paths' to 'IPluginPaths' --- client/ayon_core/addon/interfaces.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index bf08ccd48c..cc7e39218e 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -185,6 +185,10 @@ class IPluginPaths(AYONInterface): """ return self._get_plugin_paths_by_type("inventory") + def get_loader_action_plugin_paths(self) -> list[str]: + """Receive loader action plugin paths.""" + return [] + class ITrayAddon(AYONInterface): """Addon has special procedures when used in Tray tool. From db764619fc55fb7f0d4fd1c62c350d62ff4918e4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:06:39 +0200 Subject: [PATCH 018/108] sort actions at different place --- client/ayon_core/tools/loader/models/actions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 1e8bfe7ae1..5dda2ef51f 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -97,11 +97,13 @@ class LoaderActionsModel: repre_context_by_id ) = self._contexts_for_versions(project_name, entity_ids) - return self._get_action_items_for_contexts( + action_items = self._get_action_items_for_contexts( project_name, version_context_by_id, repre_context_by_id ) + action_items.sort(key=self._actions_sorter) + return action_items def trigger_action_item( self, @@ -706,8 +708,6 @@ class LoaderActionsModel: "version", ) action_items.append(item) - - action_items.sort(key=self._actions_sorter) return action_items def _trigger_version_loader( From b5ab3d3380e68c1f968189e65dac0332cbae701e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:17:01 +0200 Subject: [PATCH 019/108] different way how to set plugin id --- client/ayon_core/pipeline/actions/loader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index e2628da43c..c14c4bd0cb 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -480,11 +480,11 @@ class LoaderActionsContext: self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: output = [] - for plugin in self._get_plugins().values(): + for plugin_id, plugin in self._get_plugins().items(): try: for action_item in plugin.get_action_items(selection): if action_item.plugin_identifier is None: - action_item.plugin_identifier = plugin.identifier + action_item.plugin_identifier = plugin_id output.append(action_item) except Exception: From 39dc54b09e0aacd2f117fb1a996d407439de923a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:17:18 +0200 Subject: [PATCH 020/108] return output of execute action --- client/ayon_core/pipeline/actions/loader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index c14c4bd0cb..ed6a47502c 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -503,10 +503,10 @@ class LoaderActionsContext: entity_type: EntityType, selection: LoaderActionSelection, attribute_values: dict[str, Any], - ) -> None: + ) -> Optional[LoaderActionResult]: plugins_by_id = self._get_plugins() plugin = plugins_by_id[plugin_identifier] - plugin.execute_action( + return plugin.execute_action( action_identifier, entity_ids, entity_type, From 234ac09f42dc08715bc43a5228dfb3b1a4a84a80 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:17:29 +0200 Subject: [PATCH 021/108] added enabled option to plugin --- client/ayon_core/pipeline/actions/loader.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index ed6a47502c..be311dbdff 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -380,6 +380,7 @@ class LoaderActionPlugin(ABC): """ _log: Optional[logging.Logger] = None + enabled: bool = True def __init__(self, studio_settings: dict[str, Any]): self.apply_settings(studio_settings) @@ -539,6 +540,9 @@ class LoaderActionsContext: for cls in result.plugins: try: plugin = cls(studio_settings) + if not plugin.enabled: + continue + plugin_id = plugin.identifier if plugin_id not in plugins: plugins[plugin_id] = plugin From 12d4905b39e23e9a0eb11f78410e468111ea4201 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:41:30 +0200 Subject: [PATCH 022/108] base implementation in loader tool --- client/ayon_core/tools/loader/abstract.py | 16 ++- client/ayon_core/tools/loader/control.py | 29 ++-- .../ayon_core/tools/loader/models/actions.py | 127 ++++++++++++++---- .../ayon_core/tools/loader/models/sitesync.py | 42 +++--- .../tools/loader/ui/products_widget.py | 3 + .../tools/loader/ui/repres_widget.py | 3 + 6 files changed, 162 insertions(+), 58 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index baf6aabb69..9bff8dbb2d 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -317,6 +317,7 @@ class ActionItem: use 'identifier' and context, it necessary also use 'options'. Args: + plugin_identifier (str): Action identifier. identifier (str): Action identifier. entity_ids (set[str]): Entity ids. entity_type (str): Entity type. @@ -330,6 +331,7 @@ class ActionItem: """ def __init__( self, + plugin_identifier, identifier, entity_ids, entity_type, @@ -339,6 +341,7 @@ class ActionItem: options, order, ): + self.plugin_identifier = plugin_identifier self.identifier = identifier self.entity_ids = entity_ids self.entity_type = entity_type @@ -367,6 +370,7 @@ class ActionItem: def to_data(self): options = self._options_to_data() return { + "plugin_identifier": self.plugin_identifier, "identifier": self.identifier, "entity_ids": list(self.entity_ids), "entity_type": self.entity_type, @@ -992,11 +996,14 @@ class FrontendLoaderController(_BaseLoaderController): @abstractmethod def trigger_action_item( self, + plugin_identifier: str, identifier: str, options: dict[str, Any], project_name: str, entity_ids: set[str], entity_type: str, + selected_ids: set[str], + selected_entity_type: str, ): """Trigger action item. @@ -1014,11 +1021,14 @@ class FrontendLoaderController(_BaseLoaderController): } Args: - identifier (str): Action identifier. + plugin_identifier (sttr): Plugin identifier. + identifier (sttr): Action identifier. options (dict[str, Any]): Action option values from UI. project_name (str): Project name. - entity_ids (set[str]): Selected entity ids. - entity_type (str): Selected entity type. + entity_ids (set[str]): Entity ids stored on action item. + entity_type (str): Entity type stored on action item. + selected_ids (set[str]): Selected entity ids. + selected_entity_type (str): Selected entity type. """ pass diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index f05914da17..900eaf7656 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging import uuid +from typing import Any import ayon_api @@ -297,22 +298,25 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): action_items = self._loader_actions_model.get_action_items( project_name, entity_ids, entity_type ) - if entity_type == "representation": - site_sync_items = self._sitesync_model.get_sitesync_action_items( - project_name, entity_ids - ) - action_items.extend(site_sync_items) + + site_sync_items = self._sitesync_model.get_sitesync_action_items( + project_name, entity_ids, entity_type + ) + action_items.extend(site_sync_items) return action_items def trigger_action_item( self, - identifier, - options, - project_name, - entity_ids, - entity_type, + plugin_identifier: str, + identifier: str, + options: dict[str, Any], + project_name: str, + entity_ids: set[str], + entity_type: str, + selected_ids: set[str], + selected_entity_type: str, ): - if self._sitesync_model.is_sitesync_action(identifier): + if self._sitesync_model.is_sitesync_action(plugin_identifier): self._sitesync_model.trigger_action_item( identifier, project_name, @@ -321,11 +325,14 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): return self._loader_actions_model.trigger_action_item( + plugin_identifier, identifier, options, project_name, entity_ids, entity_type, + selected_ids, + selected_entity_type, ) # Selection model wrappers diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 5dda2ef51f..e6ac328f92 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -9,7 +9,12 @@ from typing import Callable, Any import ayon_api -from ayon_core.lib import NestedCacheItem +from ayon_core.lib import NestedCacheItem, Logger +from ayon_core.pipeline.actions import ( + LoaderActionsContext, + LoaderActionSelection, + SelectionEntitiesCache, +) from ayon_core.pipeline.load import ( discover_loader_plugins, ProductLoaderPlugin, @@ -24,6 +29,7 @@ from ayon_core.pipeline.load import ( from ayon_core.tools.loader.abstract import ActionItem ACTIONS_MODEL_SENDER = "actions.model" +LOADER_PLUGIN_ID = "__loader_plugin__" NOT_SET = object() @@ -45,6 +51,7 @@ class LoaderActionsModel: loaders_cache_lifetime = 30 def __init__(self, controller): + self._log = Logger.get_logger(self.__class__.__name__) self._controller = controller self._current_context_project = NOT_SET self._loaders_by_identifier = NestedCacheItem( @@ -53,6 +60,7 @@ class LoaderActionsModel: levels=1, lifetime=self.loaders_cache_lifetime) self._repre_loaders = NestedCacheItem( levels=1, lifetime=self.loaders_cache_lifetime) + self._loader_actions = LoaderActionsContext() self._projects_cache = NestedCacheItem(levels=1, lifetime=60) self._folders_cache = NestedCacheItem(levels=2, lifetime=300) @@ -69,6 +77,7 @@ class LoaderActionsModel: self._loaders_by_identifier.reset() self._product_loaders.reset() self._repre_loaders.reset() + self._loader_actions.reset() self._folders_cache.reset() self._tasks_cache.reset() @@ -102,16 +111,25 @@ class LoaderActionsModel: version_context_by_id, repre_context_by_id ) + action_items.extend(self._get_loader_action_items( + project_name, + entity_ids, + entity_type, + )) + action_items.sort(key=self._actions_sorter) return action_items def trigger_action_item( self, + plugin_identifier: str, identifier: str, options: dict[str, Any], project_name: str, entity_ids: set[str], entity_type: str, + selected_ids: set[str], + selected_entity_type: str, ): """Trigger action by identifier. @@ -122,14 +140,18 @@ class LoaderActionsModel: happened. Args: - identifier (str): Loader identifier. + plugin_identifier (str): Plugin identifier. + identifier (str): Action identifier. options (dict[str, Any]): Loader option values. project_name (str): Project name. - entity_ids (set[str]): Entity ids. - entity_type (str): Entity type. + entity_ids (set[str]): Entity ids on action item. + entity_type (str): Entity type on action item. + selected_ids (set[str]): Selected entity ids. + selected_entity_type (str): Selected entity type. """ event_data = { + "plugin_identifier": plugin_identifier, "identifier": identifier, "id": uuid.uuid4().hex, } @@ -138,27 +160,52 @@ class LoaderActionsModel: event_data, ACTIONS_MODEL_SENDER, ) - loader = self._get_loader_by_identifier(project_name, identifier) + if plugin_identifier != LOADER_PLUGIN_ID: + # TODO fill error infor if any happens + error_info = [] + try: + self._loader_actions.execute_action( + plugin_identifier, + identifier, + entity_ids, + entity_type, + LoaderActionSelection( + project_name, + selected_ids, + selected_entity_type, + ), + {}, + ) - if entity_type == "version": - error_info = self._trigger_version_loader( - loader, - options, - project_name, - entity_ids, - ) - elif entity_type == "representation": - error_info = self._trigger_representation_loader( - loader, - options, - project_name, - entity_ids, - ) + except Exception: + self._log.warning( + f"Failed to execute action '{identifier}'", + exc_info=True, + ) else: - raise NotImplementedError( - f"Invalid entity type '{entity_type}' to trigger action item" + loader = self._get_loader_by_identifier( + project_name, identifier ) + if entity_type == "version": + error_info = self._trigger_version_loader( + loader, + options, + project_name, + entity_ids, + ) + elif entity_type == "representation": + error_info = self._trigger_representation_loader( + loader, + options, + project_name, + entity_ids, + ) + else: + raise NotImplementedError( + f"Invalid entity type '{entity_type}' to trigger action item" + ) + event_data["error_info"] = error_info self._controller.emit_event( "load.finished", @@ -278,8 +325,9 @@ class LoaderActionsModel: ): label = self._get_action_label(loader) if repre_name: - label = "{} ({})".format(label, repre_name) + label = f"{label} ({repre_name})" return ActionItem( + LOADER_PLUGIN_ID, get_loader_identifier(loader), entity_ids=entity_ids, entity_type=entity_type, @@ -456,8 +504,8 @@ class LoaderActionsModel: Returns: tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and representation contexts. - """ + """ version_context_by_id = {} repre_context_by_id = {} if not project_name and not repre_ids: @@ -710,6 +758,39 @@ class LoaderActionsModel: action_items.append(item) return action_items + + def _get_loader_action_items( + self, + project_name: str, + entity_ids: set[str], + entity_type: str, + ) -> list[ActionItem]: + # TODO prepare cached entities + # entities_cache = SelectionEntitiesCache(project_name) + selection = LoaderActionSelection( + project_name, + entity_ids, + entity_type, + # entities_cache=entities_cache + ) + items = [] + for action in self._loader_actions.get_action_items(selection): + label = action.label + if action.group_label: + label = f"{action.group_label} ({label})" + items.append(ActionItem( + action.plugin_identifier, + action.identifier, + action.entity_ids, + action.entity_type, + label, + action.icon, + None, # action.tooltip, + None, # action.options, + action.order, + )) + return items + def _trigger_version_loader( self, loader, diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index 3a54a1b5f8..bab8a68132 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -246,26 +246,32 @@ class SiteSyncModel: output[repre_id] = repre_cache.get_data() return output - def get_sitesync_action_items(self, project_name, representation_ids): + def get_sitesync_action_items( + self, project_name, entity_ids, entity_type + ): """ Args: project_name (str): Project name. - representation_ids (Iterable[str]): Representation ids. + entity_ids (set[str]): Selected entity ids. + entity_type (str): Selected entity type. Returns: list[ActionItem]: Actions that can be shown in loader. + """ + if entity_type != "representation": + return [] if not self.is_sitesync_enabled(project_name): return [] repres_status = self.get_representations_sync_status( - project_name, representation_ids + project_name, entity_ids ) repre_ids_per_identifier = collections.defaultdict(set) - for repre_id in representation_ids: + for repre_id in entity_ids: repre_status = repres_status[repre_id] local_status, remote_status = repre_status @@ -293,27 +299,23 @@ class SiteSyncModel: return action_items - def is_sitesync_action(self, identifier): + def is_sitesync_action(self, plugin_identifier: str) -> bool: """Should be `identifier` handled by SiteSync. Args: - identifier (str): Action identifier. + plugin_identifier (str): Plugin identifier. Returns: bool: Should action be handled by SiteSync. - """ - return identifier in { - UPLOAD_IDENTIFIER, - DOWNLOAD_IDENTIFIER, - REMOVE_IDENTIFIER, - } + """ + return plugin_identifier == "sitesync.loader.action" def trigger_action_item( self, - identifier, - project_name, - representation_ids + identifier: str, + project_name: str, + representation_ids: set[str], ): """Resets status for site_name or remove local files. @@ -321,8 +323,8 @@ class SiteSyncModel: identifier (str): Action identifier. project_name (str): Project name. representation_ids (Iterable[str]): Representation ids. - """ + """ active_site = self.get_active_site(project_name) remote_site = self.get_remote_site(project_name) @@ -482,6 +484,7 @@ class SiteSyncModel: icon_name ): return ActionItem( + "sitesync.loader.action", identifier, label, icon={ @@ -492,11 +495,8 @@ class SiteSyncModel: tooltip=tooltip, options={}, order=1, - project_name=project_name, - folder_ids=[], - product_ids=[], - version_ids=[], - representation_ids=representation_ids, + entity_ids=representation_ids, + entity_type="representation", ) def _add_site(self, project_name, repre_entity, site_name, product_type): diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 4ed4368ab4..29bab7d0c5 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -438,11 +438,14 @@ class ProductsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( + action_item.plugin_identifier, action_item.identifier, options, project_name, action_item.entity_ids, action_item.entity_type, + version_ids, + "version", ) def _on_selection_change(self): diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index c0957d186c..d1d9c73a2b 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -399,9 +399,12 @@ class RepresentationsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( + action_item.plugin_identifier, action_item.identifier, options, self._selected_project_name, action_item.entity_ids, action_item.entity_type, + repre_ids, + "representation", ) From afc1af7e9579e7304a2b8e47da2db72bde5c7499 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:42:32 +0200 Subject: [PATCH 023/108] use kwargs --- client/ayon_core/tools/loader/models/sitesync.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index bab8a68132..67da36cd53 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -485,8 +485,8 @@ class SiteSyncModel: ): return ActionItem( "sitesync.loader.action", - identifier, - label, + identifier=identifier, + label=label, icon={ "type": "awesome-font", "name": icon_name, From d0cb16a1558a8bfeffe525b47aba4945b45d6a20 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:44:55 +0200 Subject: [PATCH 024/108] pass context to loader action plugins --- client/ayon_core/pipeline/actions/loader.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index be311dbdff..e62f10e7f2 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -382,8 +382,9 @@ class LoaderActionPlugin(ABC): _log: Optional[logging.Logger] = None enabled: bool = True - def __init__(self, studio_settings: dict[str, Any]): - self.apply_settings(studio_settings) + def __init__(self, context: "LoaderActionsContext") -> None: + self._context = context + self.apply_settings(context.get_studio_settings()) def apply_settings(self, studio_settings: dict[str, Any]) -> None: """Apply studio settings to the plugin. @@ -473,10 +474,15 @@ class LoaderActionsContext: def get_addons_manager(self) -> AddonsManager: if self._addons_manager is None: self._addons_manager = AddonsManager( - settings=self._get_studio_settings() + settings=self.get_studio_settings() ) return self._addons_manager + def get_studio_settings(self) -> dict[str, Any]: + if self._studio_settings is None: + self._studio_settings = get_studio_settings() + return copy.deepcopy(self._studio_settings) + def get_action_items( self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: @@ -515,11 +521,6 @@ class LoaderActionsContext: attribute_values, ) - def _get_studio_settings(self) -> dict[str, Any]: - if self._studio_settings is None: - self._studio_settings = get_studio_settings() - return copy.deepcopy(self._studio_settings) - def _get_plugins(self) -> dict[str, LoaderActionPlugin]: if self._plugins is None: addons_manager = self.get_addons_manager() @@ -533,13 +534,12 @@ class LoaderActionsContext: if paths: all_paths.extend(paths) - studio_settings = self._get_studio_settings() result = discover_plugins(LoaderActionPlugin, all_paths) result.log_report() plugins = {} for cls in result.plugins: try: - plugin = cls(studio_settings) + plugin = cls(self) if not plugin.enabled: continue From 8da213c5660b19a0b72536df37703ce06a21ffb7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:45:26 +0200 Subject: [PATCH 025/108] added host to the context --- client/ayon_core/pipeline/actions/loader.py | 23 ++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index e62f10e7f2..c3216064e3 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -11,12 +11,14 @@ from dataclasses import dataclass import ayon_api from ayon_core import AYON_CORE_ROOT +from ayon_core.host import AbstractHost from ayon_core.lib import StrEnum, Logger, AbstractAttrDef from ayon_core.addon import AddonsManager, IPluginPaths from ayon_core.settings import get_studio_settings, get_project_settings -from ayon_core.pipeline import Anatomy +from ayon_core.pipeline import Anatomy, registered_host from ayon_core.pipeline.plugin_discover import discover_plugins +_PLACEHOLDER = object() class EntityType(StrEnum): """Selected entity type.""" @@ -411,6 +413,11 @@ class LoaderActionPlugin(ABC): """ return self.__class__.__name__ + @property + def host_name(self) -> Optional[str]: + """Name of the current host.""" + return self._context.get_host_name() + @abstractmethod def get_action_items( self, selection: LoaderActionSelection @@ -457,11 +464,14 @@ class LoaderActionsContext: self, studio_settings: Optional[dict[str, Any]] = None, addons_manager: Optional[AddonsManager] = None, + host: Optional[AbstractHost] = _PLACEHOLDER, ) -> None: self._log = Logger.get_logger(self.__class__.__name__) self._addons_manager = addons_manager + self._host = host + # Attributes that are re-cached on reset self._studio_settings = studio_settings self._plugins = None @@ -478,6 +488,17 @@ class LoaderActionsContext: ) return self._addons_manager + def get_host(self) -> Optional[AbstractHost]: + if self._host is _PLACEHOLDER: + self._host = registered_host() + return self._host + + def get_host_name(self) -> Optional[str]: + host = self.get_host() + if host is None: + return None + return host.name + def get_studio_settings(self) -> dict[str, Any]: if self._studio_settings is None: self._studio_settings = get_studio_settings() From e30738d79b7b9fec3cf9b75f440b6f41fae9fe3a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:45:48 +0200 Subject: [PATCH 026/108] LoaderSelectedType is public --- client/ayon_core/pipeline/actions/__init__.py | 4 +++- client/ayon_core/pipeline/actions/loader.py | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py index 188414bdbe..247f64e890 100644 --- a/client/ayon_core/pipeline/actions/__init__.py +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -1,4 +1,5 @@ from .loader import ( + LoaderSelectedType, LoaderActionForm, LoaderActionResult, LoaderActionItem, @@ -27,7 +28,8 @@ from .inventory import ( ) -__all__= ( +__all__ = ( + "LoaderSelectedType", "LoaderActionForm", "LoaderActionResult", "LoaderActionItem", diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index c3216064e3..726ee6dcff 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -20,7 +20,8 @@ from ayon_core.pipeline.plugin_discover import discover_plugins _PLACEHOLDER = object() -class EntityType(StrEnum): + +class LoaderSelectedType(StrEnum): """Selected entity type.""" # folder = "folder" # task = "task" @@ -294,7 +295,7 @@ class LoaderActionSelection: self, project_name: str, selected_ids: set[str], - selected_type: EntityType, + selected_type: LoaderSelectedType, *, project_anatomy: Optional[Anatomy] = None, project_settings: Optional[dict[str, Any]] = None, @@ -528,7 +529,7 @@ class LoaderActionsContext: plugin_identifier: str, action_identifier: str, entity_ids: set[str], - entity_type: EntityType, + entity_type: LoaderSelectedType, selection: LoaderActionSelection, attribute_values: dict[str, Any], ) -> Optional[LoaderActionResult]: From 8bdfe806e0a896785b5f7f404aaaffc9b376274e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:46:27 +0200 Subject: [PATCH 027/108] result can contain form values This allows to re-open the same dialog having the same default values but with values already filled from user --- client/ayon_core/pipeline/actions/loader.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 726ee6dcff..9f0653a7f1 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -373,6 +373,7 @@ class LoaderActionResult: message: Optional[str] = None success: bool = True form: Optional[LoaderActionForm] = None + form_values: Optional[dict[str, Any]] = None class LoaderActionPlugin(ABC): From 2be5d3b72b9b0fac9e97cf581a3bd299fb0f1100 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:46:49 +0200 Subject: [PATCH 028/108] fix type comparison --- client/ayon_core/plugins/loader/copy_file.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 54e92b0ab9..d8424761e9 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -20,12 +20,12 @@ class CopyFileActionPlugin(LoaderActionPlugin): self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: repres = [] - if selection.selected_type in "representations": + if selection.selected_type == "representations": repres = selection.entities.get_representations( selection.selected_ids ) - if selection.selected_type in "version": + if selection.selected_type == "version": repres = selection.entities.get_versions_representations( selection.selected_ids ) From c6c642f37af190ffdbf5da8f88393fd3fff2c3fd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 10:47:20 +0200 Subject: [PATCH 029/108] added json conversions --- client/ayon_core/pipeline/actions/loader.py | 57 ++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 9f0653a7f1..b537655ada 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -11,11 +11,16 @@ from dataclasses import dataclass import ayon_api from ayon_core import AYON_CORE_ROOT +from ayon_core.lib import StrEnum, Logger +from ayon_core.lib.attribute_definitions import ( + AbstractAttrDef, + serialize_attr_defs, + deserialize_attr_defs, +) from ayon_core.host import AbstractHost -from ayon_core.lib import StrEnum, Logger, AbstractAttrDef from ayon_core.addon import AddonsManager, IPluginPaths from ayon_core.settings import get_studio_settings, get_project_settings -from ayon_core.pipeline import Anatomy, registered_host +from ayon_core.pipeline import Anatomy from ayon_core.pipeline.plugin_discover import discover_plugins _PLACEHOLDER = object() @@ -363,9 +368,29 @@ class LoaderActionForm: title: str fields: list[AbstractAttrDef] submit_label: Optional[str] = "Submit" - submit_icon: Optional[str] = None + submit_icon: Optional[dict[str, Any]] = None cancel_label: Optional[str] = "Cancel" - cancel_icon: Optional[str] = None + cancel_icon: Optional[dict[str, Any]] = None + + def to_json_data(self) -> dict[str, Any]: + fields = self.fields + if fields is not None: + fields = serialize_attr_defs(fields) + return { + "title": self.title, + "fields": fields, + "submit_label": self.submit_label, + "submit_icon": self.submit_icon, + "cancel_label": self.cancel_label, + "cancel_icon": self.cancel_icon, + } + + @classmethod + def from_json_data(cls, data: dict[str, Any]) -> "LoaderActionForm": + fields = data["fields"] + if fields is not None: + data["fields"] = deserialize_attr_defs(fields) + return cls(**data) @dataclass @@ -375,6 +400,24 @@ class LoaderActionResult: form: Optional[LoaderActionForm] = None form_values: Optional[dict[str, Any]] = None + def to_json_data(self) -> dict[str, Any]: + form = self.form + if form is not None: + form = form.to_json_data() + return { + "message": self.message, + "success": self.success, + "form": form, + "form_values": self.form_values, + } + + @classmethod + def from_json_data(cls, data: dict[str, Any]) -> "LoaderActionResult": + form = data["form"] + if form is not None: + data["form"] = LoaderActionForm.from_json_data(form) + return LoaderActionResult(**data) + class LoaderActionPlugin(ABC): """Plugin for loader actions. @@ -492,6 +535,8 @@ class LoaderActionsContext: def get_host(self) -> Optional[AbstractHost]: if self._host is _PLACEHOLDER: + from ayon_core.pipeline import registered_host + self._host = registered_host() return self._host @@ -532,7 +577,7 @@ class LoaderActionsContext: entity_ids: set[str], entity_type: LoaderSelectedType, selection: LoaderActionSelection, - attribute_values: dict[str, Any], + form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: plugins_by_id = self._get_plugins() plugin = plugins_by_id[plugin_identifier] @@ -541,7 +586,7 @@ class LoaderActionsContext: entity_ids, entity_type, selection, - attribute_values, + form_values, ) def _get_plugins(self) -> dict[str, LoaderActionPlugin]: From 856aa31231c364d1957e6e2e07e53cbc45532faf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 10:57:40 +0200 Subject: [PATCH 030/108] change order of arguments --- client/ayon_core/tools/loader/abstract.py | 14 ++++++++------ client/ayon_core/tools/loader/control.py | 6 ++++-- .../ayon_core/tools/loader/ui/products_widget.py | 3 ++- client/ayon_core/tools/loader/ui/repres_widget.py | 3 ++- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 9bff8dbb2d..a58ddf11d7 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -324,9 +324,9 @@ class ActionItem: label (str): Action label. icon (dict[str, Any]): Action icon definition. tooltip (str): Action tooltip. + order (int): Action order. options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): Action options. Note: 'qargparse' is considered as deprecated. - order (int): Action order. """ def __init__( @@ -338,8 +338,8 @@ class ActionItem: label, icon, tooltip, - options, order, + options, ): self.plugin_identifier = plugin_identifier self.identifier = identifier @@ -348,8 +348,8 @@ class ActionItem: self.label = label self.icon = icon self.tooltip = tooltip - self.options = options self.order = order + self.options = options def _options_to_data(self): options = self.options @@ -377,8 +377,8 @@ class ActionItem: "label": self.label, "icon": self.icon, "tooltip": self.tooltip, - "options": options, "order": self.order, + "options": options, } @classmethod @@ -998,12 +998,13 @@ class FrontendLoaderController(_BaseLoaderController): self, plugin_identifier: str, identifier: str, - options: dict[str, Any], project_name: str, entity_ids: set[str], entity_type: str, selected_ids: set[str], selected_entity_type: str, + options: dict[str, Any], + form_values: dict[str, Any], ): """Trigger action item. @@ -1023,12 +1024,13 @@ class FrontendLoaderController(_BaseLoaderController): Args: plugin_identifier (sttr): Plugin identifier. identifier (sttr): Action identifier. - options (dict[str, Any]): Action option values from UI. project_name (str): Project name. entity_ids (set[str]): Entity ids stored on action item. entity_type (str): Entity type stored on action item. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. + options (dict[str, Any]): Action option values from UI. + form_values (dict[str, Any]): Action form values from UI. """ pass diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 900eaf7656..7ca25976b9 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -309,12 +309,13 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): self, plugin_identifier: str, identifier: str, - options: dict[str, Any], project_name: str, entity_ids: set[str], entity_type: str, selected_ids: set[str], selected_entity_type: str, + options: dict[str, Any], + form_values: dict[str, Any], ): if self._sitesync_model.is_sitesync_action(plugin_identifier): self._sitesync_model.trigger_action_item( @@ -327,12 +328,13 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): self._loader_actions_model.trigger_action_item( plugin_identifier, identifier, - options, project_name, entity_ids, entity_type, selected_ids, selected_entity_type, + options, + form_values, ) # Selection model wrappers diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 29bab7d0c5..319108e8ea 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -440,12 +440,13 @@ class ProductsWidget(QtWidgets.QWidget): self._controller.trigger_action_item( action_item.plugin_identifier, action_item.identifier, - options, project_name, action_item.entity_ids, action_item.entity_type, version_ids, "version", + options, + {}, ) def _on_selection_change(self): diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index d1d9c73a2b..bfbcc73503 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -401,10 +401,11 @@ class RepresentationsWidget(QtWidgets.QWidget): self._controller.trigger_action_item( action_item.plugin_identifier, action_item.identifier, - options, self._selected_project_name, action_item.entity_ids, action_item.entity_type, repre_ids, "representation", + options, + {}, ) From 51beef8192a435edcd2e0c4b29802f161ab755f6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 11:22:22 +0200 Subject: [PATCH 031/108] handle the actions --- .../ayon_core/tools/loader/models/actions.py | 84 ++++++++------ .../ayon_core/tools/loader/models/sitesync.py | 2 +- client/ayon_core/tools/loader/ui/window.py | 103 +++++++++++++++++- 3 files changed, 154 insertions(+), 35 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index e6ac328f92..d3d053ae85 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -124,12 +124,13 @@ class LoaderActionsModel: self, plugin_identifier: str, identifier: str, - options: dict[str, Any], project_name: str, entity_ids: set[str], entity_type: str, selected_ids: set[str], selected_entity_type: str, + options: dict[str, Any], + form_values: dict[str, Any], ): """Trigger action by identifier. @@ -142,17 +143,23 @@ class LoaderActionsModel: Args: plugin_identifier (str): Plugin identifier. identifier (str): Action identifier. - options (dict[str, Any]): Loader option values. project_name (str): Project name. entity_ids (set[str]): Entity ids on action item. entity_type (str): Entity type on action item. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. + options (dict[str, Any]): Loader option values. + form_values (dict[str, Any]): Form values. """ event_data = { "plugin_identifier": plugin_identifier, "identifier": identifier, + "project_name": project_name, + "entity_ids": list(entity_ids), + "entity_type": entity_type, + "selected_ids": list(selected_ids), + "selected_entity_type": selected_entity_type, "id": uuid.uuid4().hex, } self._controller.emit_event( @@ -162,9 +169,10 @@ class LoaderActionsModel: ) if plugin_identifier != LOADER_PLUGIN_ID: # TODO fill error infor if any happens - error_info = [] + result = None + crashed = False try: - self._loader_actions.execute_action( + result = self._loader_actions.execute_action( plugin_identifier, identifier, entity_ids, @@ -174,37 +182,47 @@ class LoaderActionsModel: selected_ids, selected_entity_type, ), - {}, + form_values, ) except Exception: + crashed = True self._log.warning( f"Failed to execute action '{identifier}'", exc_info=True, ) - else: - loader = self._get_loader_by_identifier( - project_name, identifier - ) - if entity_type == "version": - error_info = self._trigger_version_loader( - loader, - options, - project_name, - entity_ids, - ) - elif entity_type == "representation": - error_info = self._trigger_representation_loader( - loader, - options, - project_name, - entity_ids, - ) - else: - raise NotImplementedError( - f"Invalid entity type '{entity_type}' to trigger action item" - ) + event_data["result"] = result + event_data["crashed"] = crashed + self._controller.emit_event( + "loader.action.finished", + event_data, + ACTIONS_MODEL_SENDER, + ) + return + + loader = self._get_loader_by_identifier( + project_name, identifier + ) + + if entity_type == "version": + error_info = self._trigger_version_loader( + loader, + options, + project_name, + entity_ids, + ) + elif entity_type == "representation": + error_info = self._trigger_representation_loader( + loader, + options, + project_name, + entity_ids, + ) + else: + raise NotImplementedError( + f"Invalid entity type '{entity_type}' to trigger action item" + ) event_data["error_info"] = error_info self._controller.emit_event( @@ -334,8 +352,8 @@ class LoaderActionsModel: label=label, icon=self._get_action_icon(loader), tooltip=self._get_action_tooltip(loader), - options=loader.get_options(contexts), order=loader.order, + options=loader.get_options(contexts), ) def _get_loaders(self, project_name): @@ -783,11 +801,11 @@ class LoaderActionsModel: action.identifier, action.entity_ids, action.entity_type, - label, - action.icon, - None, # action.tooltip, - None, # action.options, - action.order, + label=label, + icon=action.icon, + tooltip=None, # action.tooltip, + order=action.order, + options=None, # action.options, )) return items diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index 67da36cd53..ced4ac5d05 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -493,10 +493,10 @@ class SiteSyncModel: "color": "#999999" }, tooltip=tooltip, - options={}, order=1, entity_ids=representation_ids, entity_type="representation", + options={}, ) def _add_site(self, project_name, repre_entity, site_name, product_type): diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index df5beb708f..71679213e5 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -1,18 +1,24 @@ from __future__ import annotations +from typing import Optional + from qtpy import QtWidgets, QtCore, QtGui from ayon_core.resources import get_ayon_icon_filepath from ayon_core.style import load_stylesheet +from ayon_core.pipeline.actions import LoaderActionResult from ayon_core.tools.utils import ( PlaceholderLineEdit, + MessageOverlayObject, ErrorMessageBox, ThumbnailPainterWidget, RefreshButton, GoToCurrentButton, + ProjectsCombobox, + get_qt_icon, ) +from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog from ayon_core.tools.utils.lib import center_window -from ayon_core.tools.utils import ProjectsCombobox from ayon_core.tools.common_models import StatusItem from ayon_core.tools.loader.abstract import ProductTypeItem from ayon_core.tools.loader.control import LoaderController @@ -141,6 +147,8 @@ class LoaderWindow(QtWidgets.QWidget): if controller is None: controller = LoaderController() + overlay_object = MessageOverlayObject(self) + main_splitter = QtWidgets.QSplitter(self) context_splitter = QtWidgets.QSplitter(main_splitter) @@ -294,6 +302,12 @@ class LoaderWindow(QtWidgets.QWidget): "controller.reset.finished", self._on_controller_reset_finish, ) + controller.register_event_callback( + "loader.action.finished", + self._on_loader_action_finished, + ) + + self._overlay_object = overlay_object self._group_dialog = ProductGroupDialog(controller, self) @@ -406,6 +420,20 @@ class LoaderWindow(QtWidgets.QWidget): if self._reset_on_show: self.refresh() + def _show_toast_message( + self, + message: str, + success: bool = True, + message_id: Optional[str] = None, + ): + message_type = None + if not success: + message_type = "error" + + self._overlay_object.add_message( + message, message_type, message_id=message_id + ) + def _show_group_dialog(self): project_name = self._projects_combobox.get_selected_project_name() if not project_name: @@ -494,6 +522,79 @@ class LoaderWindow(QtWidgets.QWidget): box = LoadErrorMessageBox(error_info, self) box.show() + def _on_loader_action_finished(self, event): + crashed = event["crashed"] + if crashed: + self._show_toast_message( + "Action failed", + success=False, + ) + return + + result: Optional[LoaderActionResult] = event["result"] + if result is None: + return + + if result.message: + self._show_toast_message( + result.message, result.success + ) + + if result.form is None: + return + + form = result.form + dialog = AttributeDefinitionsDialog( + form.fields, + title=form.title, + parent=self, + ) + if result.form_values: + dialog.set_values(result.form_values) + submit_label = form.submit_label + submit_icon = form.submit_icon + cancel_label = form.cancel_label + cancel_icon = form.cancel_icon + + if submit_icon: + submit_icon = get_qt_icon(submit_icon) + if cancel_icon: + cancel_icon = get_qt_icon(cancel_icon) + + if submit_label: + dialog.set_submit_label(submit_label) + else: + dialog.set_submit_visible(False) + + if submit_icon: + dialog.set_submit_icon(submit_icon) + + if cancel_label: + dialog.set_cancel_label(cancel_label) + else: + dialog.set_cancel_visible(False) + + if cancel_icon: + dialog.set_cancel_icon(cancel_icon) + + dialog.setMinimumSize(300, 140) + result = dialog.exec_() + if result != QtWidgets.QDialog.Accepted: + return + + form_data = dialog.get_values() + self._controller.trigger_action_item( + event["plugin_identifier"], + event["identifier"], + event["project_name"], + event["entity_ids"], + event["entity_type"], + event["selected_ids"], + event["selected_entity_type"], + {}, + form_data, + ) + def _on_project_selection_changed(self, event): self._selected_project_name = event["project_name"] self._update_filters() From c2cdd4130edaaa78c3ef9eaf9cd99ea510f78c34 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 11:25:09 +0200 Subject: [PATCH 032/108] better stretch, margins and spacing --- client/ayon_core/tools/attribute_defs/dialog.py | 1 + client/ayon_core/tools/attribute_defs/widgets.py | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/attribute_defs/dialog.py b/client/ayon_core/tools/attribute_defs/dialog.py index 7423d58475..4d8e41199e 100644 --- a/client/ayon_core/tools/attribute_defs/dialog.py +++ b/client/ayon_core/tools/attribute_defs/dialog.py @@ -56,6 +56,7 @@ class AttributeDefinitionsDialog(QtWidgets.QDialog): btns_layout.addWidget(cancel_btn, 0) main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(10, 10, 10, 10) main_layout.addWidget(attrs_widget, 0) main_layout.addStretch(1) main_layout.addWidget(btns_widget, 0) diff --git a/client/ayon_core/tools/attribute_defs/widgets.py b/client/ayon_core/tools/attribute_defs/widgets.py index 1e948b2d28..f7766f50ac 100644 --- a/client/ayon_core/tools/attribute_defs/widgets.py +++ b/client/ayon_core/tools/attribute_defs/widgets.py @@ -182,6 +182,7 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): layout.deleteLater() new_layout = QtWidgets.QGridLayout() + new_layout.setContentsMargins(0, 0, 0, 0) new_layout.setColumnStretch(0, 0) new_layout.setColumnStretch(1, 1) self.setLayout(new_layout) @@ -210,12 +211,8 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): if not attr_def.visible: continue + col_num = 0 expand_cols = 2 - if attr_def.is_value_def and attr_def.is_label_horizontal: - expand_cols = 1 - - col_num = 2 - expand_cols - if attr_def.is_value_def and attr_def.label: label_widget = AttributeDefinitionsLabel( attr_def.id, attr_def.label, self @@ -233,9 +230,12 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): | QtCore.Qt.AlignVCenter ) layout.addWidget( - label_widget, row, 0, 1, expand_cols + label_widget, row, col_num, 1, 1 ) - if not attr_def.is_label_horizontal: + if attr_def.is_label_horizontal: + col_num += 1 + expand_cols = 1 + else: row += 1 if attr_def.is_value_def: From 270d7cbff9679bb434d586728191d5cae8613447 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 12:44:16 +0200 Subject: [PATCH 033/108] convert delete old versions actions --- .../plugins/load/delete_old_versions.py | 477 ------------------ .../plugins/loader/delete_old_versions.py | 393 +++++++++++++++ 2 files changed, 393 insertions(+), 477 deletions(-) delete mode 100644 client/ayon_core/plugins/load/delete_old_versions.py create mode 100644 client/ayon_core/plugins/loader/delete_old_versions.py diff --git a/client/ayon_core/plugins/load/delete_old_versions.py b/client/ayon_core/plugins/load/delete_old_versions.py deleted file mode 100644 index 3a42ccba7e..0000000000 --- a/client/ayon_core/plugins/load/delete_old_versions.py +++ /dev/null @@ -1,477 +0,0 @@ -import collections -import os -import uuid -from typing import List, Dict, Any - -import clique -import ayon_api -from ayon_api.operations import OperationsSession -import qargparse -from qtpy import QtWidgets, QtCore - -from ayon_core import style -from ayon_core.lib import format_file_size -from ayon_core.pipeline import load, Anatomy -from ayon_core.pipeline.load import ( - get_representation_path_with_anatomy, - InvalidRepresentationContext, -) - - -class DeleteOldVersions(load.ProductLoaderPlugin): - """Deletes specific number of old version""" - - is_multiple_contexts_compatible = True - sequence_splitter = "__sequence_splitter__" - - representations = ["*"] - product_types = {"*"} - tool_names = ["library_loader"] - - label = "Delete Old Versions" - order = 35 - icon = "trash" - color = "#d8d8d8" - - options = [ - qargparse.Integer( - "versions_to_keep", default=2, min=0, help="Versions to keep:" - ), - qargparse.Boolean( - "remove_publish_folder", help="Remove publish folder:" - ) - ] - - requires_confirmation = True - - def delete_whole_dir_paths(self, dir_paths, delete=True): - size = 0 - - for dir_path in dir_paths: - # Delete all files and folders in dir path - for root, dirs, files in os.walk(dir_path, topdown=False): - for name in files: - file_path = os.path.join(root, name) - size += os.path.getsize(file_path) - if delete: - os.remove(file_path) - self.log.debug("Removed file: {}".format(file_path)) - - for name in dirs: - if delete: - os.rmdir(os.path.join(root, name)) - - if not delete: - continue - - # Delete even the folder and it's parents folders if they are empty - while True: - if not os.path.exists(dir_path): - dir_path = os.path.dirname(dir_path) - continue - - if len(os.listdir(dir_path)) != 0: - break - - os.rmdir(os.path.join(dir_path)) - - return size - - def path_from_representation(self, representation, anatomy): - try: - context = representation["context"] - except KeyError: - return (None, None) - - try: - path = get_representation_path_with_anatomy( - representation, anatomy - ) - except InvalidRepresentationContext: - return (None, None) - - sequence_path = None - if "frame" in context: - context["frame"] = self.sequence_splitter - sequence_path = get_representation_path_with_anatomy( - representation, anatomy - ) - - if sequence_path: - sequence_path = sequence_path.normalized() - - return (path.normalized(), sequence_path) - - def delete_only_repre_files(self, dir_paths, file_paths, delete=True): - size = 0 - - for dir_id, dir_path in dir_paths.items(): - dir_files = os.listdir(dir_path) - collections, remainders = clique.assemble(dir_files) - for file_path, seq_path in file_paths[dir_id]: - file_path_base = os.path.split(file_path)[1] - # Just remove file if `frame` key was not in context or - # filled path is in remainders (single file sequence) - if not seq_path or file_path_base in remainders: - if not os.path.exists(file_path): - self.log.debug( - "File was not found: {}".format(file_path) - ) - continue - - size += os.path.getsize(file_path) - - if delete: - os.remove(file_path) - self.log.debug("Removed file: {}".format(file_path)) - - if file_path_base in remainders: - remainders.remove(file_path_base) - continue - - seq_path_base = os.path.split(seq_path)[1] - head, tail = seq_path_base.split(self.sequence_splitter) - - final_col = None - for collection in collections: - if head != collection.head or tail != collection.tail: - continue - final_col = collection - break - - if final_col is not None: - # Fill full path to head - final_col.head = os.path.join(dir_path, final_col.head) - for _file_path in final_col: - if os.path.exists(_file_path): - - size += os.path.getsize(_file_path) - - if delete: - os.remove(_file_path) - self.log.debug( - "Removed file: {}".format(_file_path) - ) - - _seq_path = final_col.format("{head}{padding}{tail}") - self.log.debug("Removed files: {}".format(_seq_path)) - collections.remove(final_col) - - elif os.path.exists(file_path): - size += os.path.getsize(file_path) - - if delete: - os.remove(file_path) - self.log.debug("Removed file: {}".format(file_path)) - else: - self.log.debug( - "File was not found: {}".format(file_path) - ) - - # Delete as much as possible parent folders - if not delete: - return size - - for dir_path in dir_paths.values(): - while True: - if not os.path.exists(dir_path): - dir_path = os.path.dirname(dir_path) - continue - - if len(os.listdir(dir_path)) != 0: - break - - self.log.debug("Removed folder: {}".format(dir_path)) - os.rmdir(dir_path) - - return size - - def message(self, text): - msgBox = QtWidgets.QMessageBox() - msgBox.setText(text) - msgBox.setStyleSheet(style.load_stylesheet()) - msgBox.setWindowFlags( - msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint - ) - msgBox.exec_() - - def _confirm_delete(self, - contexts: List[Dict[str, Any]], - versions_to_keep: int) -> bool: - """Prompt user for a deletion confirmation""" - - contexts_list = "\n".join(sorted( - "- {folder[name]} > {product[name]}".format_map(context) - for context in contexts - )) - num_contexts = len(contexts) - s = "s" if num_contexts > 1 else "" - text = ( - "Are you sure you want to delete versions?\n\n" - f"This will keep only the last {versions_to_keep} " - f"versions for the {num_contexts} selected product{s}." - ) - informative_text = "Warning: This will delete files from disk" - detailed_text = ( - f"Keep only {versions_to_keep} versions for:\n{contexts_list}" - ) - - messagebox = QtWidgets.QMessageBox() - messagebox.setIcon(QtWidgets.QMessageBox.Warning) - messagebox.setWindowTitle("Delete Old Versions") - messagebox.setText(text) - messagebox.setInformativeText(informative_text) - messagebox.setDetailedText(detailed_text) - messagebox.setStandardButtons( - QtWidgets.QMessageBox.Yes - | QtWidgets.QMessageBox.Cancel - ) - messagebox.setDefaultButton(QtWidgets.QMessageBox.Cancel) - messagebox.setStyleSheet(style.load_stylesheet()) - messagebox.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) - return messagebox.exec_() == QtWidgets.QMessageBox.Yes - - def get_data(self, context, versions_count): - product_entity = context["product"] - folder_entity = context["folder"] - project_name = context["project"]["name"] - anatomy = Anatomy(project_name, project_entity=context["project"]) - - version_fields = ayon_api.get_default_fields_for_type("version") - version_fields.add("tags") - versions = list(ayon_api.get_versions( - project_name, - product_ids=[product_entity["id"]], - active=None, - hero=False, - fields=version_fields - )) - self.log.debug( - "Version Number ({})".format(len(versions)) - ) - versions_by_parent = collections.defaultdict(list) - for ent in versions: - versions_by_parent[ent["productId"]].append(ent) - - def sort_func(ent): - return int(ent["version"]) - - all_last_versions = [] - for _parent_id, _versions in versions_by_parent.items(): - for idx, version in enumerate( - sorted(_versions, key=sort_func, reverse=True) - ): - if idx >= versions_count: - break - all_last_versions.append(version) - - self.log.debug("Collected versions ({})".format(len(versions))) - - # Filter latest versions - for version in all_last_versions: - versions.remove(version) - - # Update versions_by_parent without filtered versions - versions_by_parent = collections.defaultdict(list) - for ent in versions: - versions_by_parent[ent["productId"]].append(ent) - - # Filter already deleted versions - versions_to_pop = [] - for version in versions: - if "deleted" in version["tags"]: - versions_to_pop.append(version) - - for version in versions_to_pop: - msg = "Folder: \"{}\" | Product: \"{}\" | Version: \"{}\"".format( - folder_entity["path"], - product_entity["name"], - version["version"] - ) - self.log.debug(( - "Skipping version. Already tagged as inactive. < {} >" - ).format(msg)) - versions.remove(version) - - version_ids = [ent["id"] for ent in versions] - - self.log.debug( - "Filtered versions to delete ({})".format(len(version_ids)) - ) - - if not version_ids: - msg = "Skipping processing. Nothing to delete on {}/{}".format( - folder_entity["path"], product_entity["name"] - ) - self.log.info(msg) - print(msg) - return - - repres = list(ayon_api.get_representations( - project_name, version_ids=version_ids - )) - - self.log.debug( - "Collected representations to remove ({})".format(len(repres)) - ) - - dir_paths = {} - file_paths_by_dir = collections.defaultdict(list) - for repre in repres: - file_path, seq_path = self.path_from_representation( - repre, anatomy - ) - if file_path is None: - self.log.debug(( - "Could not format path for represenation \"{}\"" - ).format(str(repre))) - continue - - dir_path = os.path.dirname(file_path) - dir_id = None - for _dir_id, _dir_path in dir_paths.items(): - if _dir_path == dir_path: - dir_id = _dir_id - break - - if dir_id is None: - dir_id = uuid.uuid4() - dir_paths[dir_id] = dir_path - - file_paths_by_dir[dir_id].append([file_path, seq_path]) - - dir_ids_to_pop = [] - for dir_id, dir_path in dir_paths.items(): - if os.path.exists(dir_path): - continue - - dir_ids_to_pop.append(dir_id) - - # Pop dirs from both dictionaries - for dir_id in dir_ids_to_pop: - dir_paths.pop(dir_id) - paths = file_paths_by_dir.pop(dir_id) - # TODO report of missing directories? - paths_msg = ", ".join([ - "'{}'".format(path[0].replace("\\", "/")) for path in paths - ]) - self.log.debug(( - "Folder does not exist. Deleting its files skipped: {}" - ).format(paths_msg)) - - return { - "dir_paths": dir_paths, - "file_paths_by_dir": file_paths_by_dir, - "versions": versions, - "folder": folder_entity, - "product": product_entity, - "archive_product": versions_count == 0 - } - - def main(self, project_name, data, remove_publish_folder): - # Size of files. - size = 0 - if not data: - return size - - if remove_publish_folder: - size = self.delete_whole_dir_paths(data["dir_paths"].values()) - else: - size = self.delete_only_repre_files( - data["dir_paths"], data["file_paths_by_dir"] - ) - - op_session = OperationsSession() - for version in data["versions"]: - orig_version_tags = version["tags"] - version_tags = list(orig_version_tags) - changes = {} - if "deleted" not in version_tags: - version_tags.append("deleted") - changes["tags"] = version_tags - - if version["active"]: - changes["active"] = False - - if not changes: - continue - op_session.update_entity( - project_name, "version", version["id"], changes - ) - - op_session.commit() - - return size - - def load(self, contexts, name=None, namespace=None, options=None): - - # Get user options - versions_to_keep = 2 - remove_publish_folder = False - if options: - versions_to_keep = options.get( - "versions_to_keep", versions_to_keep - ) - remove_publish_folder = options.get( - "remove_publish_folder", remove_publish_folder - ) - - # Because we do not want this run by accident we will add an extra - # user confirmation - if ( - self.requires_confirmation - and not self._confirm_delete(contexts, versions_to_keep) - ): - return - - try: - size = 0 - for count, context in enumerate(contexts): - data = self.get_data(context, versions_to_keep) - if not data: - continue - project_name = context["project"]["name"] - size += self.main(project_name, data, remove_publish_folder) - print("Progressing {}/{}".format(count + 1, len(contexts))) - - msg = "Total size of files: {}".format(format_file_size(size)) - self.log.info(msg) - self.message(msg) - - except Exception: - self.log.error("Failed to delete versions.", exc_info=True) - - -class CalculateOldVersions(DeleteOldVersions): - """Calculate file size of old versions""" - label = "Calculate Old Versions" - order = 30 - tool_names = ["library_loader"] - - options = [ - qargparse.Integer( - "versions_to_keep", default=2, min=0, help="Versions to keep:" - ), - qargparse.Boolean( - "remove_publish_folder", help="Remove publish folder:" - ) - ] - - requires_confirmation = False - - def main(self, project_name, data, remove_publish_folder): - size = 0 - - if not data: - return size - - if remove_publish_folder: - size = self.delete_whole_dir_paths( - data["dir_paths"].values(), delete=False - ) - else: - size = self.delete_only_repre_files( - data["dir_paths"], data["file_paths_by_dir"], delete=False - ) - - return size diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py new file mode 100644 index 0000000000..31b0ff4bdf --- /dev/null +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import os +import collections +import json +import shutil +from typing import Optional, Any + +import clique +from ayon_api.operations import OperationsSession +from qtpy import QtWidgets, QtCore + +from ayon_core import style +from ayon_core.lib import ( + format_file_size, + AbstractAttrDef, + NumberDef, + BoolDef, + TextDef, + UILabelDef, +) +from ayon_core.pipeline import Anatomy +from ayon_core.pipeline.actions import ( + LoaderSelectedType, + LoaderActionPlugin, + LoaderActionItem, + LoaderActionSelection, + LoaderActionResult, + LoaderActionForm, +) + + +class DeleteOldVersions(LoaderActionPlugin): + """Deletes specific number of old version""" + + is_multiple_contexts_compatible = True + sequence_splitter = "__sequence_splitter__" + + requires_confirmation = True + + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + # Do not show in hosts + if self.host_name is not None: + return [] + + versions = None + if selection.selected_type == LoaderSelectedType.version: + versions = selection.entities.get_versions( + selection.selected_ids + ) + + if not versions: + return [] + + product_ids = { + version["productId"] + for version in versions + } + + return [ + LoaderActionItem( + identifier="delete-versions", + label="Delete Versions", + order=35, + entity_ids=product_ids, + entity_type="product", + icon={ + "type": "material-symbols", + "name": "delete", + "color": "#d8d8d8", + } + ), + LoaderActionItem( + identifier="calculate-versions-size", + label="Calculate Versions size", + order=30, + entity_ids=product_ids, + entity_type="product", + icon={ + "type": "material-symbols", + "name": "auto_delete", + "color": "#d8d8d8", + } + ) + ] + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + step = form_values.get("step") + versions_to_keep = form_values.get("versions_to_keep") + remove_publish_folder = form_values.get("remove_publish_folder") + if step is None: + return self._first_step( + identifier, + versions_to_keep, + remove_publish_folder, + ) + + if versions_to_keep is None: + versions_to_keep = 2 + if remove_publish_folder is None: + remove_publish_folder = False + + if step == "prepare-data": + return self._prepare_data_step( + identifier, + versions_to_keep, + remove_publish_folder, + entity_ids, + selection, + ) + + if step == "delete-versions": + return self._delete_versions_step( + selection.project_name, form_values + ) + return None + + def _first_step( + self, + identifier: str, + versions_to_keep: Optional[int], + remove_publish_folder: Optional[bool], + ) -> LoaderActionResult: + fields: list[AbstractAttrDef] = [ + TextDef( + "step", + visible=False, + ), + NumberDef( + "versions_to_keep", + label="Versions to keep", + minimum=0, + default=2, + ), + ] + if identifier == "delete-versions": + fields.append( + BoolDef( + "remove_publish_folder", + label="Remove publish folder", + default=False, + ) + ) + + form_values = { + key: value + for key, value in ( + ("remove_publish_folder", remove_publish_folder), + ("versions_to_keep", versions_to_keep), + ) + if value is not None + } + form_values["step"] = "prepare-data" + return LoaderActionResult( + form=LoaderActionForm( + title="Delete Old Versions", + fields=fields, + ), + form_values=form_values + ) + + def _prepare_data_step( + self, + identifier: str, + versions_to_keep: int, + remove_publish_folder: bool, + entity_ids: set[str], + selection: LoaderActionSelection, + ): + versions_by_product_id = collections.defaultdict(list) + for version in selection.entities.get_products_versions(entity_ids): + # Keep hero version + if versions_to_keep != 0 and version["version"] < 0: + continue + versions_by_product_id[version["productId"]].append(version) + + versions_to_delete = [] + for product_id, versions in versions_by_product_id.items(): + if versions_to_keep == 0: + versions_to_delete.extend(versions) + continue + + if len(versions) <= versions_to_keep: + continue + + versions.sort(key=lambda v: v["version"]) + for _ in range(versions_to_keep): + if not versions: + break + versions.pop(-1) + versions_to_delete.extend(versions) + + self.log.debug( + f"Collected versions to delete ({len(versions_to_delete)})" + ) + + version_ids = { + version["id"] + for version in versions_to_delete + } + if not version_ids: + return LoaderActionResult( + message="Skipping. Nothing to delete.", + success=False, + ) + + project = selection.entities.get_project() + anatomy = Anatomy(project["name"], project_entity=project) + + repres = selection.entities.get_versions_representations(version_ids) + + self.log.debug( + f"Collected representations to remove ({len(repres)})" + ) + + filepaths_by_repre_id = {} + repre_ids_by_version_id = { + version_id: [] + for version_id in version_ids + } + for repre in repres: + repre_ids_by_version_id[repre["versionId"]].append(repre["id"]) + filepaths_by_repre_id[repre["id"]] = [ + anatomy.fill_root(repre_file["path"]) + for repre_file in repre["files"] + ] + + size = 0 + for filepaths in filepaths_by_repre_id.values(): + for filepath in filepaths: + if os.path.exists(filepath): + size += os.path.getsize(filepath) + + if identifier == "calculate-versions-size": + return LoaderActionResult( + message="Calculated size", + success=True, + form=LoaderActionForm( + title="Calculated versions size", + fields=[ + UILabelDef( + f"Total size of files: {format_file_size(size)}" + ), + ], + submit_label=None, + cancel_label="Close", + ), + ) + + form, form_values = self._get_delete_form( + size, + remove_publish_folder, + list(version_ids), + repre_ids_by_version_id, + filepaths_by_repre_id, + ) + return LoaderActionResult( + form=form, + form_values=form_values + ) + + def _delete_versions_step( + self, project_name: str, form_values: dict[str, Any] + ) -> LoaderActionResult: + delete_data = json.loads(form_values["delete_data"]) + remove_publish_folder = form_values["remove_publish_folder"] + if form_values["delete_value"].lower() != "delete": + size = delete_data["size"] + form, form_values = self._get_delete_form( + size, + remove_publish_folder, + delete_data["version_ids"], + delete_data["repre_ids_by_version_id"], + delete_data["filepaths_by_repre_id"], + True, + ) + return LoaderActionResult( + form=form, + form_values=form_values, + ) + + version_ids = delete_data["version_ids"] + repre_ids_by_version_id = delete_data["repre_ids_by_version_id"] + filepaths_by_repre_id = delete_data["filepaths_by_repre_id"] + op_session = OperationsSession() + total_versions = len(version_ids) + try: + for version_idx, version_id in enumerate(version_ids): + self.log.info( + f"Progressing version {version_idx + 1}/{total_versions}" + ) + for repre_id in repre_ids_by_version_id[version_id]: + for filepath in filepaths_by_repre_id[repre_id]: + publish_folder = os.path.dirname(filepath) + if remove_publish_folder: + if os.path.exists(publish_folder): + shutil.rmtree(publish_folder, ignore_errors=True) + continue + + if os.path.exists(filepath): + os.remove(filepath) + + op_session.delete_entity( + project_name, "representation", repre_id + ) + op_session.delete_entity( + project_name, "version", version_id + ) + self.log.info("All done") + + except Exception: + self.log.error("Failed to delete versions.", exc_info=True) + return LoaderActionResult( + message="Failed to delete versions.", + success=False, + ) + + finally: + op_session.commit() + + return LoaderActionResult( + message="Deleted versions", + success=True, + ) + + def _get_delete_form( + self, + size: int, + remove_publish_folder: bool, + version_ids: list[str], + repre_ids_by_version_id: dict[str, list[str]], + filepaths_by_repre_id: dict[str, list[str]], + repeated: bool = False, + ) -> tuple[LoaderActionForm, dict[str, Any]]: + versions_len = len(repre_ids_by_version_id) + fields = [ + UILabelDef( + f"Going to delete {versions_len} versions
" + f"- total size of files: {format_file_size(size)}
" + ), + UILabelDef("Are you sure you want to continue?"), + TextDef( + "delete_value", + placeholder="Type 'delete' to confirm...", + ), + ] + if repeated: + fields.append(UILabelDef( + "*Please fill in '**delete**' to confirm deletion.*" + )) + fields.extend([ + TextDef( + "delete_data", + visible=False, + ), + TextDef( + "step", + visible=False, + ), + BoolDef( + "remove_publish_folder", + label="Remove publish folder", + default=False, + visible=False, + ) + ]) + + form = LoaderActionForm( + title="Delete versions", + submit_label="Delete", + cancel_label="Close", + fields=fields, + ) + form_values = { + "delete_data": json.dumps({ + "size": size, + "version_ids": version_ids, + "repre_ids_by_version_id": repre_ids_by_version_id, + "filepaths_by_repre_id": filepaths_by_repre_id, + }), + "step": "delete-versions", + "remove_publish_folder": remove_publish_folder, + } + return form, form_values From f06fbe159f4dcdf8b87586706a1a0d0f4810d17b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 15:00:10 +0200 Subject: [PATCH 034/108] added group label to 'ActionItem' --- client/ayon_core/tools/loader/abstract.py | 4 +++ .../ayon_core/tools/loader/models/actions.py | 18 ++-------- .../ayon_core/tools/loader/models/sitesync.py | 1 + .../tools/loader/ui/actions_utils.py | 34 ++++++++++++++++--- 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index a58ddf11d7..d3de8fb7c2 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -322,6 +322,7 @@ class ActionItem: entity_ids (set[str]): Entity ids. entity_type (str): Entity type. label (str): Action label. + group_label (str): Group label. icon (dict[str, Any]): Action icon definition. tooltip (str): Action tooltip. order (int): Action order. @@ -336,6 +337,7 @@ class ActionItem: entity_ids, entity_type, label, + group_label, icon, tooltip, order, @@ -346,6 +348,7 @@ class ActionItem: self.entity_ids = entity_ids self.entity_type = entity_type self.label = label + self.group_label = group_label self.icon = icon self.tooltip = tooltip self.order = order @@ -375,6 +378,7 @@ class ActionItem: "entity_ids": list(self.entity_ids), "entity_type": self.entity_type, "label": self.label, + "group_label": self.group_label, "icon": self.icon, "tooltip": self.tooltip, "order": self.order, diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index d3d053ae85..684adf36a9 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -116,8 +116,6 @@ class LoaderActionsModel: entity_ids, entity_type, )) - - action_items.sort(key=self._actions_sorter) return action_items def trigger_action_item( @@ -350,6 +348,7 @@ class LoaderActionsModel: entity_ids=entity_ids, entity_type=entity_type, label=label, + group_label=None, icon=self._get_action_icon(loader), tooltip=self._get_action_tooltip(loader), order=loader.order, @@ -407,15 +406,6 @@ class LoaderActionsModel: loaders_by_identifier = loaders_by_identifier_c.get_data() return loaders_by_identifier.get(identifier) - def _actions_sorter(self, action_item): - """Sort the Loaders by their order and then their name. - - Returns: - tuple[int, str]: Sort keys. - """ - - return action_item.order, action_item.label - def _contexts_for_versions(self, project_name, version_ids): """Get contexts for given version ids. @@ -793,15 +783,13 @@ class LoaderActionsModel: ) items = [] for action in self._loader_actions.get_action_items(selection): - label = action.label - if action.group_label: - label = f"{action.group_label} ({label})" items.append(ActionItem( action.plugin_identifier, action.identifier, action.entity_ids, action.entity_type, - label=label, + label=action.label, + group_label=action.group_label, icon=action.icon, tooltip=None, # action.tooltip, order=action.order, diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index ced4ac5d05..4d6ffcf9d4 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -487,6 +487,7 @@ class SiteSyncModel: "sitesync.loader.action", identifier=identifier, label=label, + group_label=None, icon={ "type": "awesome-font", "name": icon_name, diff --git a/client/ayon_core/tools/loader/ui/actions_utils.py b/client/ayon_core/tools/loader/ui/actions_utils.py index b601cd95bd..3281a170bd 100644 --- a/client/ayon_core/tools/loader/ui/actions_utils.py +++ b/client/ayon_core/tools/loader/ui/actions_utils.py @@ -1,6 +1,7 @@ import uuid +from typing import Optional, Any -from qtpy import QtWidgets, QtGui +from qtpy import QtWidgets, QtGui, QtCore import qtawesome from ayon_core.lib.attribute_definitions import AbstractAttrDef @@ -11,9 +12,26 @@ from ayon_core.tools.utils.widgets import ( OptionDialog, ) from ayon_core.tools.utils import get_qt_icon +from ayon_core.tools.loader.abstract import ActionItem -def show_actions_menu(action_items, global_point, one_item_selected, parent): +def _actions_sorter(item: tuple[str, ActionItem]): + """Sort the Loaders by their order and then their name. + + Returns: + tuple[int, str]: Sort keys. + + """ + label, action_item = item + return action_item.order, label + + +def show_actions_menu( + action_items: list[ActionItem], + global_point: QtCore.QPoint, + one_item_selected: bool, + parent: QtWidgets.QWidget, +) -> tuple[Optional[ActionItem], Optional[dict[str, Any]]]: selected_action_item = None selected_options = None @@ -26,15 +44,23 @@ def show_actions_menu(action_items, global_point, one_item_selected, parent): menu = OptionalMenu(parent) - action_items_by_id = {} + action_items_with_labels = [] for action_item in action_items: + label = action_item.label + if action_item.group_label: + label = f"{action_item.group_label} ({label})" + action_items_with_labels.append((label, action_item)) + + action_items_by_id = {} + for item in sorted(action_items_with_labels, key=_actions_sorter): + label, action_item = item item_id = uuid.uuid4().hex action_items_by_id[item_id] = action_item item_options = action_item.options icon = get_qt_icon(action_item.icon) use_option = bool(item_options) action = OptionalAction( - action_item.label, + label, icon, use_option, menu From 1768543b8bd05327168649036743c43c3c3323d0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:18:18 +0200 Subject: [PATCH 035/108] safe-guards for optional action and menu --- client/ayon_core/tools/utils/widgets.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index de2c42c91f..61a4886741 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -861,24 +861,26 @@ class OptionalMenu(QtWidgets.QMenu): def mouseReleaseEvent(self, event): """Emit option clicked signal if mouse released on it""" active = self.actionAt(event.pos()) - if active and active.use_option: + if isinstance(active, OptionalAction) and active.use_option: option = active.widget.option if option.is_hovered(event.globalPos()): option.clicked.emit() - super(OptionalMenu, self).mouseReleaseEvent(event) + super().mouseReleaseEvent(event) def mouseMoveEvent(self, event): """Add highlight to active action""" active = self.actionAt(event.pos()) for action in self.actions(): - action.set_highlight(action is active, event.globalPos()) - super(OptionalMenu, self).mouseMoveEvent(event) + if isinstance(action, OptionalAction): + action.set_highlight(action is active, event.globalPos()) + super().mouseMoveEvent(event) def leaveEvent(self, event): """Remove highlight from all actions""" for action in self.actions(): - action.set_highlight(False) - super(OptionalMenu, self).leaveEvent(event) + if isinstance(action, OptionalAction): + action.set_highlight(False) + super().leaveEvent(event) class OptionalAction(QtWidgets.QWidgetAction): @@ -890,7 +892,7 @@ class OptionalAction(QtWidgets.QWidgetAction): """ def __init__(self, label, icon, use_option, parent): - super(OptionalAction, self).__init__(parent) + super().__init__(parent) self.label = label self.icon = icon self.use_option = use_option @@ -951,7 +953,7 @@ class OptionalActionWidget(QtWidgets.QWidget): """Main widget class for `OptionalAction`""" def __init__(self, label, parent=None): - super(OptionalActionWidget, self).__init__(parent) + super().__init__(parent) body_widget = QtWidgets.QWidget(self) body_widget.setObjectName("OptionalActionBody") From f100a6c563b8acea1242c8ca2cc7f102db18de89 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:31:07 +0200 Subject: [PATCH 036/108] show grouped actions as menu --- client/ayon_core/tools/loader/abstract.py | 37 +++++++++---------- .../tools/loader/ui/actions_utils.py | 34 ++++++++++++----- 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index d3de8fb7c2..2e90a51a5b 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -322,9 +322,9 @@ class ActionItem: entity_ids (set[str]): Entity ids. entity_type (str): Entity type. label (str): Action label. - group_label (str): Group label. - icon (dict[str, Any]): Action icon definition. - tooltip (str): Action tooltip. + group_label (Optional[str]): Group label. + icon (Optional[dict[str, Any]]): Action icon definition. + tooltip (Optional[str]): Action tooltip. order (int): Action order. options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): Action options. Note: 'qargparse' is considered as deprecated. @@ -332,16 +332,16 @@ class ActionItem: """ def __init__( self, - plugin_identifier, - identifier, - entity_ids, - entity_type, - label, - group_label, - icon, - tooltip, - order, - options, + plugin_identifier: str, + identifier: str, + entity_ids: set[str], + entity_type: str, + label: str, + group_label: Optional[str], + icon: Optional[dict[str, Any]], + tooltip: Optional[str], + order: int, + options: Optional[list], ): self.plugin_identifier = plugin_identifier self.identifier = identifier @@ -364,13 +364,12 @@ class ActionItem: # future development of detached UI tools it would be better to be # prepared for it. raise NotImplementedError( - "{}.to_data is not implemented. Use Attribute definitions" - " from 'ayon_core.lib' instead of 'qargparse'.".format( - self.__class__.__name__ - ) + f"{self.__class__.__name__}.to_data is not implemented." + " Use Attribute definitions from 'ayon_core.lib'" + " instead of 'qargparse'." ) - def to_data(self): + def to_data(self) -> dict[str, Any]: options = self._options_to_data() return { "plugin_identifier": self.plugin_identifier, @@ -386,7 +385,7 @@ class ActionItem: } @classmethod - def from_data(cls, data): + def from_data(cls, data) -> "ActionItem": options = data["options"] if options: options = deserialize_attr_defs(options) diff --git a/client/ayon_core/tools/loader/ui/actions_utils.py b/client/ayon_core/tools/loader/ui/actions_utils.py index 3281a170bd..cf39bc348c 100644 --- a/client/ayon_core/tools/loader/ui/actions_utils.py +++ b/client/ayon_core/tools/loader/ui/actions_utils.py @@ -15,15 +15,18 @@ from ayon_core.tools.utils import get_qt_icon from ayon_core.tools.loader.abstract import ActionItem -def _actions_sorter(item: tuple[str, ActionItem]): +def _actions_sorter(item: tuple[ActionItem, str, str]): """Sort the Loaders by their order and then their name. Returns: tuple[int, str]: Sort keys. """ - label, action_item = item - return action_item.order, label + action_item, group_label, label = item + if group_label is None: + group_label = label + label = "" + return action_item.order, group_label, label def show_actions_menu( @@ -46,21 +49,21 @@ def show_actions_menu( action_items_with_labels = [] for action_item in action_items: - label = action_item.label - if action_item.group_label: - label = f"{action_item.group_label} ({label})" - action_items_with_labels.append((label, action_item)) + action_items_with_labels.append( + (action_item, action_item.group_label, action_item.label) + ) + group_menu_by_label = {} action_items_by_id = {} for item in sorted(action_items_with_labels, key=_actions_sorter): - label, action_item = item + action_item, _, _ = item item_id = uuid.uuid4().hex action_items_by_id[item_id] = action_item item_options = action_item.options icon = get_qt_icon(action_item.icon) use_option = bool(item_options) action = OptionalAction( - label, + action_item.label, icon, use_option, menu @@ -76,7 +79,18 @@ def show_actions_menu( action.setData(item_id) - menu.addAction(action) + group_label = action_item.group_label + if group_label: + group_menu = group_menu_by_label.get(group_label) + if group_menu is None: + group_menu = OptionalMenu(group_label, menu) + if icon is not None: + group_menu.setIcon(icon) + menu.addMenu(group_menu) + group_menu_by_label[group_label] = group_menu + group_menu.addAction(action) + else: + menu.addAction(action) action = menu.exec_(global_point) if action is not None: From 0ad0b3927ff70b0381974cc605242b5023f7e070 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:31:23 +0200 Subject: [PATCH 037/108] small enhancements of messages --- client/ayon_core/plugins/loader/copy_file.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index d8424761e9..716b4ab88f 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -88,7 +88,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): clipboard = QtWidgets.QApplication.clipboard() if not clipboard: return LoaderActionResult( - "Failed to copy file path to clipboard", + "Failed to copy file path to clipboard.", success=False, ) @@ -97,7 +97,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): clipboard.setText(os.path.normpath(path)) return LoaderActionResult( - "Path stored to clipboard", + "Path stored to clipboard...", success=True, ) @@ -110,6 +110,6 @@ class CopyFileActionPlugin(LoaderActionPlugin): clipboard.setMimeData(data) return LoaderActionResult( - "File added to clipboard", + "File added to clipboard...", success=True, ) From f784eeb17e4e78967bad1f6475d19e92c4aaba65 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:43:10 +0200 Subject: [PATCH 038/108] remove unused imports --- client/ayon_core/plugins/loader/delete_old_versions.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index 31b0ff4bdf..b0905954f1 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -6,11 +6,8 @@ import json import shutil from typing import Optional, Any -import clique from ayon_api.operations import OperationsSession -from qtpy import QtWidgets, QtCore -from ayon_core import style from ayon_core.lib import ( format_file_size, AbstractAttrDef, From 2a13074e6bbda03b63725b5fc4cd9ef4b9491c3a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:04:20 +0200 Subject: [PATCH 039/108] Converted push to project plugin --- .../ayon_core/plugins/load/push_to_project.py | 51 ------------- .../plugins/loader/push_to_project.py | 73 +++++++++++++++++++ 2 files changed, 73 insertions(+), 51 deletions(-) delete mode 100644 client/ayon_core/plugins/load/push_to_project.py create mode 100644 client/ayon_core/plugins/loader/push_to_project.py diff --git a/client/ayon_core/plugins/load/push_to_project.py b/client/ayon_core/plugins/load/push_to_project.py deleted file mode 100644 index dccac42444..0000000000 --- a/client/ayon_core/plugins/load/push_to_project.py +++ /dev/null @@ -1,51 +0,0 @@ -import os - -from ayon_core import AYON_CORE_ROOT -from ayon_core.lib import get_ayon_launcher_args, run_detached_process -from ayon_core.pipeline import load -from ayon_core.pipeline.load import LoadError - - -class PushToProject(load.ProductLoaderPlugin): - """Export selected versions to different project""" - - is_multiple_contexts_compatible = True - - representations = {"*"} - product_types = {"*"} - - label = "Push to project" - order = 35 - icon = "send" - color = "#d8d8d8" - - def load(self, contexts, name=None, namespace=None, options=None): - filtered_contexts = [ - context - for context in contexts - if context.get("project") and context.get("version") - ] - if not filtered_contexts: - raise LoadError("Nothing to push for your selection") - - if len(filtered_contexts) > 1: - raise LoadError("Please select only one item") - - context = tuple(filtered_contexts)[0] - - push_tool_script_path = os.path.join( - AYON_CORE_ROOT, - "tools", - "push_to_project", - "main.py" - ) - - project_name = context["project"]["name"] - version_id = context["version"]["id"] - - args = get_ayon_launcher_args( - push_tool_script_path, - "--project", project_name, - "--version", version_id - ) - run_detached_process(args) diff --git a/client/ayon_core/plugins/loader/push_to_project.py b/client/ayon_core/plugins/loader/push_to_project.py new file mode 100644 index 0000000000..ef1908f19c --- /dev/null +++ b/client/ayon_core/plugins/loader/push_to_project.py @@ -0,0 +1,73 @@ +import os +from typing import Optional, Any + +from ayon_core import AYON_CORE_ROOT +from ayon_core.lib import get_ayon_launcher_args, run_detached_process + +from ayon_core.pipeline.actions import ( + LoaderActionPlugin, + LoaderActionItem, + LoaderActionSelection, + LoaderActionResult, +) + + +class PushToProject(LoaderActionPlugin): + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + version_ids = set() + if selection.selected_type == "version": + version_ids = set(selection.selected_ids) + + output = [] + if len(version_ids) == 1: + output.append( + LoaderActionItem( + identifier="core.push-to-project", + label="Push to project", + order=35, + entity_ids=version_ids, + entity_type="version", + icon={ + "type": "material-symbols", + "name": "send", + "color": "#d8d8d8", + } + ) + ) + return output + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + if len(entity_ids) > 1: + return LoaderActionResult( + message="Please select only one version", + success=False, + ) + + push_tool_script_path = os.path.join( + AYON_CORE_ROOT, + "tools", + "push_to_project", + "main.py" + ) + + version_id = next(iter(entity_ids)) + + args = get_ayon_launcher_args( + push_tool_script_path, + "--project", selection.project_name, + "--version", version_id + ) + run_detached_process(args) + return LoaderActionResult( + message="Push to project tool opened...", + success=True, + ) From ed6247d23194f5cd1936b341418218651c49e9e4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:32:00 +0200 Subject: [PATCH 040/108] converted otio export action --- .../plugins/{load => loader}/export_otio.py | 145 +++++++++++++----- 1 file changed, 109 insertions(+), 36 deletions(-) rename client/ayon_core/plugins/{load => loader}/export_otio.py (86%) diff --git a/client/ayon_core/plugins/load/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py similarity index 86% rename from client/ayon_core/plugins/load/export_otio.py rename to client/ayon_core/plugins/loader/export_otio.py index 8094490246..bbbad3378f 100644 --- a/client/ayon_core/plugins/load/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -2,11 +2,10 @@ import logging import os from pathlib import Path from collections import defaultdict +from typing import Any, Optional from qtpy import QtWidgets, QtCore, QtGui -from ayon_api import get_representations -from ayon_core.pipeline import load, Anatomy from ayon_core import resources, style from ayon_core.lib.transcoding import ( IMAGE_EXTENSIONS, @@ -16,9 +15,17 @@ from ayon_core.lib import ( get_ffprobe_data, is_oiio_supported, ) +from ayon_core.pipeline import Anatomy from ayon_core.pipeline.load import get_representation_path_with_anatomy from ayon_core.tools.utils import show_message_dialog +from ayon_core.pipeline.actions import ( + LoaderActionPlugin, + LoaderActionItem, + LoaderActionSelection, + LoaderActionResult, +) + OTIO = None FRAME_SPLITTER = "__frame_splitter__" @@ -30,34 +37,116 @@ def _import_otio(): OTIO = opentimelineio -class ExportOTIO(load.ProductLoaderPlugin): - """Export selected versions to OpenTimelineIO.""" - is_multiple_contexts_compatible = True - sequence_splitter = "__sequence_splitter__" +class ExportOTIO(LoaderActionPlugin): + """Copy published file path to clipboard""" + identifier = "core.export-otio" - representations = {"*"} - product_types = {"*"} - tool_names = ["library_loader"] + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + # Don't show in hosts + if self.host_name is None: + return [] - label = "Export OTIO" - order = 35 - icon = "save" - color = "#d8d8d8" + version_ids = set() + if selection.selected_type == "version": + version_ids = set(selection.selected_ids) - def load(self, contexts, name=None, namespace=None, options=None): + output = [] + if version_ids: + output.append( + LoaderActionItem( + identifier="copy-path", + label="Export OTIO", + group_label=None, + order=35, + entity_ids=version_ids, + entity_type="version", + icon={ + "type": "material-symbols", + "name": "save", + "color": "#d8d8d8", + } + ) + ) + return output + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: _import_otio() + + versions_by_id = { + version["id"]: version + for version in selection.entities.get_versions(entity_ids) + } + product_ids = { + version["productId"] + for version in versions_by_id.values() + } + products_by_id = { + product["id"]: product + for product in selection.entities.get_products(product_ids) + } + folder_ids = { + product["folderId"] + for product in products_by_id.values() + } + folder_by_id = { + folder["id"]: folder + for folder in selection.entities.get_folders(folder_ids) + } + repre_entities = selection.entities.get_versions_representations( + entity_ids + ) + + version_path_by_id = {} + for version in versions_by_id.values(): + version_id = version["id"] + product_id = version["productId"] + product = products_by_id[product_id] + folder_id = product["folderId"] + folder = folder_by_id[folder_id] + + version_path_by_id[version_id] = "/".join([ + folder["path"], + product["name"], + version["name"] + ]) + try: - dialog = ExportOTIOOptionsDialog(contexts, self.log) + # TODO this should probably trigger a subprocess? + dialog = ExportOTIOOptionsDialog( + selection.project_name, + versions_by_id, + repre_entities, + version_path_by_id, + self.log + ) dialog.exec_() except Exception: self.log.error("Failed to export OTIO.", exc_info=True) + return LoaderActionResult() class ExportOTIOOptionsDialog(QtWidgets.QDialog): """Dialog to select template where to deliver selected representations.""" - def __init__(self, contexts, log=None, parent=None): + def __init__( + self, + project_name, + versions_by_id, + repre_entities, + version_path_by_id, + log=None, + parent=None + ): # Not all hosts have OpenTimelineIO available. self.log = log @@ -73,30 +162,14 @@ class ExportOTIOOptionsDialog(QtWidgets.QDialog): | QtCore.Qt.WindowMinimizeButtonHint ) - project_name = contexts[0]["project"]["name"] - versions_by_id = { - context["version"]["id"]: context["version"] - for context in contexts - } - repre_entities = list(get_representations( - project_name, version_ids=set(versions_by_id) - )) version_by_representation_id = { repre_entity["id"]: versions_by_id[repre_entity["versionId"]] for repre_entity in repre_entities } - version_path_by_id = {} - representations_by_version_id = {} - for context in contexts: - version_id = context["version"]["id"] - if version_id in version_path_by_id: - continue - representations_by_version_id[version_id] = [] - version_path_by_id[version_id] = "/".join([ - context["folder"]["path"], - context["product"]["name"], - context["version"]["name"] - ]) + representations_by_version_id = { + version_id: [] + for version_id in versions_by_id + } for repre_entity in repre_entities: representations_by_version_id[repre_entity["versionId"]].append( From 79ca56f3adde7a1fad81e2ebd7eed6e55747d160 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:33:43 +0200 Subject: [PATCH 041/108] added identifier to push to project plugin --- client/ayon_core/plugins/loader/push_to_project.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/plugins/loader/push_to_project.py b/client/ayon_core/plugins/loader/push_to_project.py index ef1908f19c..4435ecf0c6 100644 --- a/client/ayon_core/plugins/loader/push_to_project.py +++ b/client/ayon_core/plugins/loader/push_to_project.py @@ -13,6 +13,8 @@ from ayon_core.pipeline.actions import ( class PushToProject(LoaderActionPlugin): + identifier = "core.push-to-project" + def get_action_items( self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: From fc0232b7449f888f54568cae4955e0238ff737dc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:57:39 +0200 Subject: [PATCH 042/108] convert open file action --- client/ayon_core/plugins/load/open_file.py | 36 ----- client/ayon_core/plugins/loader/open_file.py | 133 +++++++++++++++++++ 2 files changed, 133 insertions(+), 36 deletions(-) delete mode 100644 client/ayon_core/plugins/load/open_file.py create mode 100644 client/ayon_core/plugins/loader/open_file.py diff --git a/client/ayon_core/plugins/load/open_file.py b/client/ayon_core/plugins/load/open_file.py deleted file mode 100644 index 3b5fbbc0c9..0000000000 --- a/client/ayon_core/plugins/load/open_file.py +++ /dev/null @@ -1,36 +0,0 @@ -import sys -import os -import subprocess - -from ayon_core.pipeline import load - - -def open(filepath): - """Open file with system default executable""" - if sys.platform.startswith('darwin'): - subprocess.call(('open', filepath)) - elif os.name == 'nt': - os.startfile(filepath) - elif os.name == 'posix': - subprocess.call(('xdg-open', filepath)) - - -class OpenFile(load.LoaderPlugin): - """Open Image Sequence or Video with system default""" - - product_types = {"render2d"} - representations = {"*"} - - label = "Open" - order = -10 - icon = "play-circle" - color = "orange" - - def load(self, context, name, namespace, data): - - path = self.filepath_from_context(context) - if not os.path.exists(path): - raise RuntimeError("File not found: {}".format(path)) - - self.log.info("Opening : {}".format(path)) - open(path) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py new file mode 100644 index 0000000000..a46bb31472 --- /dev/null +++ b/client/ayon_core/plugins/loader/open_file.py @@ -0,0 +1,133 @@ +import os +import sys +import subprocess +import collections +from typing import Optional, Any + +from ayon_core.pipeline.load import get_representation_path_with_anatomy +from ayon_core.pipeline.actions import ( + LoaderActionPlugin, + LoaderActionItem, + LoaderActionSelection, + LoaderActionResult, +) + + +def open_file(filepath: str) -> None: + """Open file with system default executable""" + if sys.platform.startswith("darwin"): + subprocess.call(("open", filepath)) + elif os.name == "nt": + os.startfile(filepath) + elif os.name == "posix": + subprocess.call(("xdg-open", filepath)) + + +class OpenFileAction(LoaderActionPlugin): + """Open Image Sequence or Video with system default""" + identifier = "core.open-file" + + product_types = {"render2d"} + + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + repres = [] + if selection.selected_type == "representations": + repres = selection.entities.get_representations( + selection.selected_ids + ) + + if selection.selected_type == "version": + repres = selection.entities.get_versions_representations( + selection.selected_ids + ) + + if not repres: + return [] + + repre_ids = {repre["id"] for repre in repres} + versions = selection.entities.get_representations_versions( + repre_ids + ) + product_ids = {version["productId"] for version in versions} + products = selection.entities.get_products(product_ids) + fitlered_product_ids = { + product["id"] + for product in products + if product["productType"] in self.product_types + } + if not fitlered_product_ids: + return [] + + versions_by_product_id = collections.defaultdict(list) + for version in versions: + versions_by_product_id[version["productId"]].append(version) + + repres_by_version_ids = collections.defaultdict(list) + for repre in repres: + repres_by_version_ids[repre["versionId"]].append(repre) + + filtered_repres = [] + for product_id in fitlered_product_ids: + for version in versions_by_product_id[product_id]: + for repre in repres_by_version_ids[version["id"]]: + filtered_repres.append(repre) + + repre_ids_by_name = collections.defaultdict(set) + for repre in filtered_repres: + repre_ids_by_name[repre["name"]].add(repre["id"]) + + return [ + LoaderActionItem( + identifier="open-file", + label=repre_name, + group_label="Open file", + order=-10, + entity_ids=repre_ids, + entity_type="representation", + icon={ + "type": "material-symbols", + "name": "play_circle", + "color": "#FFA500", + } + ) + for repre_name, repre_ids in repre_ids_by_name.items() + ] + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + path = None + repre_path = None + for repre in selection.entities.get_representations(entity_ids): + repre_path = get_representation_path_with_anatomy( + repre, selection.get_project_anatomy() + ) + if os.path.exists(repre_path): + path = repre_path + break + + if path is None: + if repre_path is None: + return LoaderActionResult( + "Failed to fill representation path...", + success=False, + ) + return LoaderActionResult( + "File to open was not found...", + success=False, + ) + + self.log.info(f"Opening: {path}") + open_file(path) + + return LoaderActionResult( + "File was opened...", + success=True, + ) From 062069028f4ae5dbe0925f8812b200b10987b81c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 18:05:28 +0200 Subject: [PATCH 043/108] convert delivery action --- .../plugins/{load => loader}/delivery.py | 93 +++++++++++++------ 1 file changed, 64 insertions(+), 29 deletions(-) rename client/ayon_core/plugins/{load => loader}/delivery.py (87%) diff --git a/client/ayon_core/plugins/load/delivery.py b/client/ayon_core/plugins/loader/delivery.py similarity index 87% rename from client/ayon_core/plugins/load/delivery.py rename to client/ayon_core/plugins/loader/delivery.py index 406040d936..fb668e5b10 100644 --- a/client/ayon_core/plugins/load/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -1,5 +1,6 @@ import platform from collections import defaultdict +from typing import Optional, Any import ayon_api from qtpy import QtWidgets, QtCore, QtGui @@ -10,7 +11,13 @@ from ayon_core.lib import ( collect_frames, get_datetime_data, ) -from ayon_core.pipeline import load, Anatomy +from ayon_core.pipeline import Anatomy +from ayon_core.pipeline.actions import ( + LoaderActionPlugin, + LoaderActionSelection, + LoaderActionItem, + LoaderActionResult, +) from ayon_core.pipeline.load import get_representation_path_with_anatomy from ayon_core.pipeline.delivery import ( get_format_dict, @@ -20,43 +27,74 @@ from ayon_core.pipeline.delivery import ( ) -class Delivery(load.ProductLoaderPlugin): - """Export selected versions to folder structure from Template""" +class DeliveryAction(LoaderActionPlugin): + identifier = "core.delivery" - is_multiple_contexts_compatible = True - sequence_splitter = "__sequence_splitter__" + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + if self.host_name is None: + return [] - representations = {"*"} - product_types = {"*"} - tool_names = ["library_loader"] + version_ids = set() + if selection.selected_type == "representations": + versions = selection.entities.get_representations_versions( + selection.selected_ids + ) + version_ids = {version["id"] for version in versions} - label = "Deliver Versions" - order = 35 - icon = "upload" - color = "#d8d8d8" + if selection.selected_type == "version": + version_ids = set(selection.selected_ids) - def message(self, text): - msgBox = QtWidgets.QMessageBox() - msgBox.setText(text) - msgBox.setStyleSheet(style.load_stylesheet()) - msgBox.setWindowFlags( - msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint - ) - msgBox.exec_() + if not version_ids: + return [] - def load(self, contexts, name=None, namespace=None, options=None): + return [ + LoaderActionItem( + identifier="deliver-versions", + label="Deliver Versions", + order=35, + entity_ids=version_ids, + entity_type="version", + icon={ + "type": "material-symbols", + "name": "upload", + "color": "#d8d8d8", + } + ) + ] + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: try: - dialog = DeliveryOptionsDialog(contexts, self.log) + # TODO run the tool in subprocess + dialog = DeliveryOptionsDialog( + selection.project_name, entity_ids, self.log + ) dialog.exec_() except Exception: self.log.error("Failed to deliver versions.", exc_info=True) + return LoaderActionResult() + class DeliveryOptionsDialog(QtWidgets.QDialog): """Dialog to select template where to deliver selected representations.""" - def __init__(self, contexts, log=None, parent=None): - super(DeliveryOptionsDialog, self).__init__(parent=parent) + def __init__( + self, + project_name, + version_ids, + log=None, + parent=None, + ): + super().__init__(parent=parent) self.setWindowTitle("AYON - Deliver versions") icon = QtGui.QIcon(resources.get_ayon_icon_filepath()) @@ -70,13 +108,12 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): self.setStyleSheet(style.load_stylesheet()) - project_name = contexts[0]["project"]["name"] self.anatomy = Anatomy(project_name) self._representations = None self.log = log self.currently_uploaded = 0 - self._set_representations(project_name, contexts) + self._set_representations(project_name, version_ids) dropdown = QtWidgets.QComboBox() self.templates = self._get_templates(self.anatomy) @@ -316,9 +353,7 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): return templates - def _set_representations(self, project_name, contexts): - version_ids = {context["version"]["id"] for context in contexts} - + def _set_representations(self, project_name, version_ids): repres = list(ayon_api.get_representations( project_name, version_ids=version_ids )) From b1a4d5dfc54c5141ad7ab878ae5ab16d03481b18 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 18:05:34 +0200 Subject: [PATCH 044/108] remove docstring --- client/ayon_core/plugins/loader/export_otio.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index bbbad3378f..6a9acc9730 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -39,7 +39,6 @@ def _import_otio(): class ExportOTIO(LoaderActionPlugin): - """Copy published file path to clipboard""" identifier = "core.export-otio" def get_action_items( From c4b47950a8fd944dd4412e2ebb6fd13df2fc21e2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 18:07:12 +0200 Subject: [PATCH 045/108] formatting fixes --- client/ayon_core/plugins/loader/delete_old_versions.py | 4 +++- client/ayon_core/plugins/loader/export_otio.py | 1 - client/ayon_core/tools/loader/models/actions.py | 2 -- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index b0905954f1..69b93cbb32 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -300,7 +300,9 @@ class DeleteOldVersions(LoaderActionPlugin): publish_folder = os.path.dirname(filepath) if remove_publish_folder: if os.path.exists(publish_folder): - shutil.rmtree(publish_folder, ignore_errors=True) + shutil.rmtree( + publish_folder, ignore_errors=True + ) continue if os.path.exists(filepath): diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index 6a9acc9730..b23021fc11 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -37,7 +37,6 @@ def _import_otio(): OTIO = opentimelineio - class ExportOTIO(LoaderActionPlugin): identifier = "core.export-otio" diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 684adf36a9..90f3613c24 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -13,7 +13,6 @@ from ayon_core.lib import NestedCacheItem, Logger from ayon_core.pipeline.actions import ( LoaderActionsContext, LoaderActionSelection, - SelectionEntitiesCache, ) from ayon_core.pipeline.load import ( discover_loader_plugins, @@ -766,7 +765,6 @@ class LoaderActionsModel: action_items.append(item) return action_items - def _get_loader_action_items( self, project_name: str, From cf62eede8a2386a4531d979eaffb6d9d14f57ac5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 26 Aug 2025 15:50:55 +0200 Subject: [PATCH 046/108] use already cached entities --- .../ayon_core/tools/loader/models/actions.py | 80 ++++++++++++++++++- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 90f3613c24..8aded40919 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -13,6 +13,7 @@ from ayon_core.lib import NestedCacheItem, Logger from ayon_core.pipeline.actions import ( LoaderActionsContext, LoaderActionSelection, + SelectionEntitiesCache, ) from ayon_core.pipeline.load import ( discover_loader_plugins, @@ -114,6 +115,8 @@ class LoaderActionsModel: project_name, entity_ids, entity_type, + version_context_by_id, + repre_context_by_id, )) return action_items @@ -165,7 +168,6 @@ class LoaderActionsModel: ACTIONS_MODEL_SENDER, ) if plugin_identifier != LOADER_PLUGIN_ID: - # TODO fill error infor if any happens result = None crashed = False try: @@ -770,14 +772,35 @@ class LoaderActionsModel: project_name: str, entity_ids: set[str], entity_type: str, + version_context_by_id: dict[str, dict[str, Any]], + repre_context_by_id: dict[str, dict[str, Any]], ) -> list[ActionItem]: - # TODO prepare cached entities - # entities_cache = SelectionEntitiesCache(project_name) + """ + + Args: + project_name (str): Project name. + entity_ids (set[str]): Selected entity ids. + entity_type (str): Selected entity type. + version_context_by_id (dict[str, dict[str, Any]]): Version context + by id. + repre_context_by_id (dict[str, dict[str, Any]]): Representation + context by id. + + Returns: + list[ActionItem]: List of action items. + + """ + entities_cache = self._prepare_entities_cache( + project_name, + entity_type, + version_context_by_id, + repre_context_by_id, + ) selection = LoaderActionSelection( project_name, entity_ids, entity_type, - # entities_cache=entities_cache + entities_cache=entities_cache ) items = [] for action in self._loader_actions.get_action_items(selection): @@ -795,6 +818,55 @@ class LoaderActionsModel: )) return items + def _prepare_entities_cache( + self, + project_name: str, + entity_type: str, + version_context_by_id: dict[str, dict[str, Any]], + repre_context_by_id: dict[str, dict[str, Any]], + ): + project_entity = None + folders_by_id = {} + products_by_id = {} + versions_by_id = {} + representations_by_id = {} + for context in version_context_by_id.values(): + if project_entity is None: + project_entity = context["project"] + folder_entity = context["folder"] + product_entity = context["product"] + version_entity = context["version"] + folders_by_id[folder_entity["id"]] = folder_entity + products_by_id[product_entity["id"]] = product_entity + versions_by_id[version_entity["id"]] = version_entity + + for context in repre_context_by_id.values(): + repre_entity = context["representation"] + representations_by_id[repre_entity["id"]] = repre_entity + + # Mapping has to be for all child entities which is available for + # representations only if version is selected + representation_ids_by_version_id = {} + if entity_type == "version": + representation_ids_by_version_id = { + version_id: set() + for version_id in versions_by_id + } + for context in repre_context_by_id.values(): + repre_entity = context["representation"] + v_id = repre_entity["versionId"] + representation_ids_by_version_id[v_id].add(repre_entity["id"]) + + return SelectionEntitiesCache( + project_name, + project_entity=project_entity, + folders_by_id=folders_by_id, + products_by_id=products_by_id, + versions_by_id=versions_by_id, + representations_by_id=representations_by_id, + representation_ids_by_version_id=representation_ids_by_version_id, + ) + def _trigger_version_loader( self, loader, From 751ad94343b8999873f1068c7c2492940c60162f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 26 Aug 2025 15:51:19 +0200 Subject: [PATCH 047/108] few fixes in entities cache --- client/ayon_core/pipeline/actions/loader.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index b537655ada..e04a64b240 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -44,11 +44,10 @@ class SelectionEntitiesCache: products_by_id: Optional[dict[str, dict[str, Any]]] = None, versions_by_id: Optional[dict[str, dict[str, Any]]] = None, representations_by_id: Optional[dict[str, dict[str, Any]]] = None, - task_ids_by_folder_id: Optional[dict[str, str]] = None, - product_ids_by_folder_id: Optional[dict[str, str]] = None, - version_ids_by_product_id: Optional[dict[str, str]] = None, - version_id_by_task_id: Optional[dict[str, str]] = None, - representation_id_by_version_id: Optional[dict[str, str]] = None, + task_ids_by_folder_id: Optional[dict[str, set[str]]] = None, + product_ids_by_folder_id: Optional[dict[str, set[str]]] = None, + version_ids_by_product_id: Optional[dict[str, set[str]]] = None, + representation_ids_by_version_id: Optional[dict[str, set[str]]] = None, ): self._project_name = project_name self._project_entity = project_entity @@ -61,9 +60,8 @@ class SelectionEntitiesCache: self._task_ids_by_folder_id = task_ids_by_folder_id or {} self._product_ids_by_folder_id = product_ids_by_folder_id or {} self._version_ids_by_product_id = version_ids_by_product_id or {} - self._version_id_by_task_id = version_id_by_task_id or {} - self._representation_id_by_version_id = ( - representation_id_by_version_id or {} + self._representation_ids_by_version_id = ( + representation_ids_by_version_id or {} ) def get_project(self) -> dict[str, Any]: @@ -173,7 +171,7 @@ class SelectionEntitiesCache: version_ids, "versionId", "version_ids", - self._representation_id_by_version_id, + self._representation_ids_by_version_id, ayon_api.get_representations, ) return self.get_representations(repre_ids) From 15a3f9d29aeea40419ece93b51925f3f38fd9066 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 28 Aug 2025 12:12:32 +0200 Subject: [PATCH 048/108] fix 'representations' -> 'representation' --- client/ayon_core/plugins/loader/copy_file.py | 2 +- client/ayon_core/plugins/loader/delivery.py | 2 +- client/ayon_core/plugins/loader/open_file.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 716b4ab88f..09875698bd 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -20,7 +20,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: repres = [] - if selection.selected_type == "representations": + if selection.selected_type == "representation": repres = selection.entities.get_representations( selection.selected_ids ) diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index fb668e5b10..3b39f2d3f6 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -37,7 +37,7 @@ class DeliveryAction(LoaderActionPlugin): return [] version_ids = set() - if selection.selected_type == "representations": + if selection.selected_type == "representation": versions = selection.entities.get_representations_versions( selection.selected_ids ) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index a46bb31472..f7a7167c9a 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -33,7 +33,7 @@ class OpenFileAction(LoaderActionPlugin): self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: repres = [] - if selection.selected_type == "representations": + if selection.selected_type == "representation": repres = selection.entities.get_representations( selection.selected_ids ) From b560bb356ed8fb3f687fddbec9628042a48f54f5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 28 Aug 2025 12:19:56 +0200 Subject: [PATCH 049/108] fix host name checks --- client/ayon_core/plugins/loader/delivery.py | 2 +- client/ayon_core/plugins/loader/export_otio.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index 3b39f2d3f6..d1fbb20afc 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -33,7 +33,7 @@ class DeliveryAction(LoaderActionPlugin): def get_action_items( self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: - if self.host_name is None: + if self.host_name is not None: return [] version_ids = set() diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index b23021fc11..8a142afdb5 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -44,7 +44,7 @@ class ExportOTIO(LoaderActionPlugin): self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: # Don't show in hosts - if self.host_name is None: + if self.host_name is not None: return [] version_ids = set() From 8bbd15c48244ceb9e11c4fae4afb018620026519 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:11:46 +0200 Subject: [PATCH 050/108] added some docstrings --- client/ayon_core/pipeline/actions/loader.py | 124 +++++++++++++++++++- 1 file changed, 121 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index e04a64b240..2c3ad39c48 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -35,6 +35,17 @@ class LoaderSelectedType(StrEnum): class SelectionEntitiesCache: + """Cache of entities used as helper in the selection wrapper. + + It is possible to get entities based on ids with helper methods to get + entities, their parents or their children's entities. + + The goal is to avoid multiple API calls for the same entity in multiple + action plugins. + + The cache is based on the selected project. Entities are fetched + if are not in cache yet. + """ def __init__( self, project_name: str, @@ -65,6 +76,7 @@ class SelectionEntitiesCache: ) def get_project(self) -> dict[str, Any]: + """Get project entity""" if self._project_entity is None: self._project_entity = ayon_api.get_project(self._project_name) return copy.deepcopy(self._project_entity) @@ -294,6 +306,15 @@ class SelectionEntitiesCache: class LoaderActionSelection: + """Selection of entities for loader actions. + + Selection tells action plugins what exactly is selected in the tool and + which ids. + + Contains entity cache which can be used to get entities by their ids. Or + to get project settings and anatomy. + + """ def __init__( self, project_name: str, @@ -350,9 +371,33 @@ class LoaderActionSelection: @dataclass class LoaderActionItem: + """Item of loader action. + + Action plugins return these items as possible actions to run for a given + context. + + Because the action item can be related to a specific entity + and not the whole selection, they also have to define the entity type + and ids to be executed on. + + Attributes: + identifier (str): Unique action identifier. What is sent to action + plugin when the action is executed. + entity_type (str): Entity type to which the action belongs. + entity_ids (set[str]): Entity ids to which the action belongs. + label (str): Text shown in UI. + order (int): Order of the action in UI. + group_label (Optional[str]): Label of the group to which the action + belongs. + icon (Optional[dict[str, Any]]): Icon definition. + plugin_identifier (Optional[str]): Identifier of the plugin which + created the action item. Is filled automatically. Is not changed + if is filled -> can lead to different plugin. + + """ identifier: str - entity_ids: set[str] entity_type: str + entity_ids: set[str] label: str order: int = 0 group_label: Optional[str] = None @@ -363,6 +408,25 @@ class LoaderActionItem: @dataclass class LoaderActionForm: + """Form for loader action. + + If an action needs to collect information from a user before or during of + the action execution, it can return a response with a form. When the + form is confirmed, a new execution of the action is triggered. + + Attributes: + title (str): Title of the form -> title of the window. + fields (list[AbstractAttrDef]): Fields of the form. + submit_label (Optional[str]): Label of the submit button. Is hidden + if is set to None. + submit_icon (Optional[dict[str, Any]]): Icon definition of the submit + button. + cancel_label (Optional[str]): Label of the cancel button. Is hidden + if is set to None. User can still close the window tho. + cancel_icon (Optional[dict[str, Any]]): Icon definition of the cancel + button. + + """ title: str fields: list[AbstractAttrDef] submit_label: Optional[str] = "Submit" @@ -393,6 +457,18 @@ class LoaderActionForm: @dataclass class LoaderActionResult: + """Result of loader action execution. + + Attributes: + message (Optional[str]): Message to show in UI. + success (bool): If the action was successful. Affects color of + the message. + form (Optional[LoaderActionForm]): Form to show in UI. + form_values (Optional[dict[str, Any]]): Values for the form. Can be + used if the same form is re-shown e.g. because a user forgot to + fill a required field. + + """ message: Optional[str] = None success: bool = True form: Optional[LoaderActionForm] = None @@ -422,7 +498,6 @@ class LoaderActionPlugin(ABC): Plugin is responsible for getting action items and executing actions. - """ _log: Optional[logging.Logger] = None enabled: bool = True @@ -503,6 +578,12 @@ class LoaderActionPlugin(ABC): class LoaderActionsContext: + """Wrapper for loader actions and their logic. + + Takes care about the public api of loader actions and internal logic like + discovery and initialization of plugins. + + """ def __init__( self, studio_settings: Optional[dict[str, Any]] = None, @@ -521,6 +602,15 @@ class LoaderActionsContext: def reset( self, studio_settings: Optional[dict[str, Any]] = None ) -> None: + """Reset context cache. + + Reset plugins and studio settings to reload them. + + Notes: + Does not reset the cache of AddonsManger because there should not + be a reason to do so. + + """ self._studio_settings = studio_settings self._plugins = None @@ -532,6 +622,14 @@ class LoaderActionsContext: return self._addons_manager def get_host(self) -> Optional[AbstractHost]: + """Get current host integration. + + Returns: + Optional[AbstractHost]: Host integration. Can be None if host + integration is not registered -> probably not used in the + host integration process. + + """ if self._host is _PLACEHOLDER: from ayon_core.pipeline import registered_host @@ -552,6 +650,12 @@ class LoaderActionsContext: def get_action_items( self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: + """Collect action items from all plugins for given selection. + + Args: + selection (LoaderActionSelection): Selection wrapper. + + """ output = [] for plugin_id, plugin in self._get_plugins().items(): try: @@ -572,11 +676,25 @@ class LoaderActionsContext: self, plugin_identifier: str, action_identifier: str, + entity_type: str, entity_ids: set[str], - entity_type: LoaderSelectedType, selection: LoaderActionSelection, form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: + """Trigger action execution. + + Args: + plugin_identifier (str): Identifier of the plugin. + action_identifier (str): Identifier of the action. + entity_type (str): Entity type defined on the action item. + entity_ids (set[str]): Entity ids defined on the action item. + selection (LoaderActionSelection): Selection wrapper. Can be used + to get what is selected in UI and to get access to entity + cache. + form_values (dict[str, Any]): Form values related to action. + Usually filled if action returned response with form. + + """ plugins_by_id = self._get_plugins() plugin = plugins_by_id[plugin_identifier] return plugin.execute_action( From a7b379059fdba2282ce1c9ccec50c98078f1bc23 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Sep 2025 15:43:59 +0200 Subject: [PATCH 051/108] allow to pass data into action items --- client/ayon_core/pipeline/actions/loader.py | 30 +++++++++---------- client/ayon_core/plugins/loader/copy_file.py | 12 ++++---- .../plugins/loader/delete_old_versions.py | 12 ++++---- client/ayon_core/plugins/loader/delivery.py | 8 ++--- .../ayon_core/plugins/loader/export_otio.py | 11 ++++--- client/ayon_core/plugins/loader/open_file.py | 9 +++--- .../plugins/loader/push_to_project.py | 11 ++++--- 7 files changed, 42 insertions(+), 51 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 2c3ad39c48..94e30c5114 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -5,6 +5,7 @@ import collections import copy import logging from abc import ABC, abstractmethod +import typing from typing import Optional, Any, Callable from dataclasses import dataclass @@ -23,6 +24,12 @@ from ayon_core.settings import get_studio_settings, get_project_settings from ayon_core.pipeline import Anatomy from ayon_core.pipeline.plugin_discover import discover_plugins +if typing.TYPE_CHECKING: + from typing import Union + + DataBaseType = Union[str, int, float, bool] + DataType = dict[str, Union[DataBaseType, list[DataBaseType]]] + _PLACEHOLDER = object() @@ -383,25 +390,23 @@ class LoaderActionItem: Attributes: identifier (str): Unique action identifier. What is sent to action plugin when the action is executed. - entity_type (str): Entity type to which the action belongs. - entity_ids (set[str]): Entity ids to which the action belongs. label (str): Text shown in UI. order (int): Order of the action in UI. group_label (Optional[str]): Label of the group to which the action belongs. - icon (Optional[dict[str, Any]]): Icon definition. + icon (Optional[dict[str, Any]): Icon definition. + data (Optional[DataType]): Action item data. plugin_identifier (Optional[str]): Identifier of the plugin which created the action item. Is filled automatically. Is not changed if is filled -> can lead to different plugin. """ identifier: str - entity_type: str - entity_ids: set[str] label: str order: int = 0 group_label: Optional[str] = None icon: Optional[dict[str, Any]] = None + data: Optional[DataType] = None # Is filled automatically plugin_identifier: str = None @@ -555,19 +560,17 @@ class LoaderActionPlugin(ABC): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: Optional[DataType], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: """Execute an action. Args: identifier (str): Action identifier. - entity_ids: (set[str]): Entity ids stored on action item. - entity_type: (str): Entity type stored on action item. selection (LoaderActionSelection): Selection wrapper. Can be used to get entities or get context of original selection. + data (Optional[DataType]): Additional action item data. form_values (dict[str, Any]): Attribute values. Returns: @@ -676,9 +679,8 @@ class LoaderActionsContext: self, plugin_identifier: str, action_identifier: str, - entity_type: str, - entity_ids: set[str], selection: LoaderActionSelection, + data: Optional[DataType], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: """Trigger action execution. @@ -686,11 +688,10 @@ class LoaderActionsContext: Args: plugin_identifier (str): Identifier of the plugin. action_identifier (str): Identifier of the action. - entity_type (str): Entity type defined on the action item. - entity_ids (set[str]): Entity ids defined on the action item. selection (LoaderActionSelection): Selection wrapper. Can be used to get what is selected in UI and to get access to entity cache. + data (Optional[DataType]): Additional action item data. form_values (dict[str, Any]): Form values related to action. Usually filled if action returned response with form. @@ -699,9 +700,8 @@ class LoaderActionsContext: plugin = plugins_by_id[plugin_identifier] return plugin.execute_action( action_identifier, - entity_ids, - entity_type, selection, + data, form_values, ) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 09875698bd..8253a772eb 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -44,8 +44,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): identifier="copy-path", label=repre_name, group_label="Copy file path", - entity_ids=repre_ids, - entity_type="representation", + data={"representation_ids": list(repre_ids)}, icon={ "type": "material-symbols", "name": "content_copy", @@ -58,8 +57,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): identifier="copy-file", label=repre_name, group_label="Copy file", - entity_ids=repre_ids, - entity_type="representation", + data={"representation_ids": list(repre_ids)}, icon={ "type": "material-symbols", "name": "file_copy", @@ -72,14 +70,14 @@ class CopyFileActionPlugin(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict, form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: from qtpy import QtWidgets, QtCore - repre = next(iter(selection.entities.get_representations(entity_ids))) + repre_ids = data["representation_ids"] + repre = next(iter(selection.entities.get_representations(repre_ids))) path = get_representation_path_with_anatomy( repre, selection.get_project_anatomy() ) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index 69b93cbb32..cc7d4d3fa6 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -61,8 +61,7 @@ class DeleteOldVersions(LoaderActionPlugin): identifier="delete-versions", label="Delete Versions", order=35, - entity_ids=product_ids, - entity_type="product", + data={"product_ids": list(product_ids)}, icon={ "type": "material-symbols", "name": "delete", @@ -73,8 +72,7 @@ class DeleteOldVersions(LoaderActionPlugin): identifier="calculate-versions-size", label="Calculate Versions size", order=30, - entity_ids=product_ids, - entity_type="product", + data={"product_ids": list(product_ids)}, icon={ "type": "material-symbols", "name": "auto_delete", @@ -86,9 +84,8 @@ class DeleteOldVersions(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: step = form_values.get("step") @@ -106,12 +103,13 @@ class DeleteOldVersions(LoaderActionPlugin): if remove_publish_folder is None: remove_publish_folder = False + product_ids = data["product_ids"] if step == "prepare-data": return self._prepare_data_step( identifier, versions_to_keep, remove_publish_folder, - entity_ids, + product_ids, selection, ) diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index d1fbb20afc..538bdec414 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -54,8 +54,7 @@ class DeliveryAction(LoaderActionPlugin): identifier="deliver-versions", label="Deliver Versions", order=35, - entity_ids=version_ids, - entity_type="version", + data={"version_ids": list(version_ids)}, icon={ "type": "material-symbols", "name": "upload", @@ -67,15 +66,14 @@ class DeliveryAction(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: try: # TODO run the tool in subprocess dialog = DeliveryOptionsDialog( - selection.project_name, entity_ids, self.log + selection.project_name, data["version_ids"], self.log ) dialog.exec_() except Exception: diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index 8a142afdb5..1ad9038c5e 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -59,8 +59,7 @@ class ExportOTIO(LoaderActionPlugin): label="Export OTIO", group_label=None, order=35, - entity_ids=version_ids, - entity_type="version", + data={"version_ids": list(version_ids)}, icon={ "type": "material-symbols", "name": "save", @@ -73,16 +72,16 @@ class ExportOTIO(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: _import_otio() + version_ids = data["version_ids"] versions_by_id = { version["id"]: version - for version in selection.entities.get_versions(entity_ids) + for version in selection.entities.get_versions(version_ids) } product_ids = { version["productId"] @@ -101,7 +100,7 @@ class ExportOTIO(LoaderActionPlugin): for folder in selection.entities.get_folders(folder_ids) } repre_entities = selection.entities.get_versions_representations( - entity_ids + version_ids ) version_path_by_id = {} diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index f7a7167c9a..1ed470c06e 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -84,8 +84,7 @@ class OpenFileAction(LoaderActionPlugin): label=repre_name, group_label="Open file", order=-10, - entity_ids=repre_ids, - entity_type="representation", + data={"representation_ids": list(repre_ids)}, icon={ "type": "material-symbols", "name": "play_circle", @@ -98,14 +97,14 @@ class OpenFileAction(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: path = None repre_path = None - for repre in selection.entities.get_representations(entity_ids): + repre_ids = data["representation_ids"] + for repre in selection.entities.get_representations(repre_ids): repre_path = get_representation_path_with_anatomy( repre, selection.get_project_anatomy() ) diff --git a/client/ayon_core/plugins/loader/push_to_project.py b/client/ayon_core/plugins/loader/push_to_project.py index bd0da71c0e..275f5de88d 100644 --- a/client/ayon_core/plugins/loader/push_to_project.py +++ b/client/ayon_core/plugins/loader/push_to_project.py @@ -42,8 +42,7 @@ class PushToProject(LoaderActionPlugin): identifier="core.push-to-project", label="Push to project", order=35, - entity_ids=version_ids, - entity_type="version", + data={"version_ids": list(version_ids)}, icon={ "type": "material-symbols", "name": "send", @@ -56,12 +55,12 @@ class PushToProject(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: - if len(entity_ids) > 1: + version_ids = data["version_ids"] + if len(version_ids) > 1: return LoaderActionResult( message="Please select only one version", success=False, @@ -77,7 +76,7 @@ class PushToProject(LoaderActionPlugin): args = get_ayon_launcher_args( push_tool_script_path, "--project", selection.project_name, - "--versions", ",".join(entity_ids) + "--versions", ",".join(version_ids) ) run_detached_process(args) return LoaderActionResult( From 8fdbda78ee6b0b3b8e27aa87d6b8907d86d88222 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:37:07 +0200 Subject: [PATCH 052/108] modify loader tool to match changes in backend --- client/ayon_core/tools/loader/abstract.py | 19 ++++------- client/ayon_core/tools/loader/control.py | 22 ++++++------ .../ayon_core/tools/loader/models/actions.py | 34 +++++++++---------- .../ayon_core/tools/loader/models/sitesync.py | 13 ++++--- .../tools/loader/ui/products_widget.py | 17 +++++----- .../tools/loader/ui/repres_widget.py | 17 +++++----- client/ayon_core/tools/loader/ui/window.py | 19 +++++------ 7 files changed, 65 insertions(+), 76 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 5de4560d3e..90371204f9 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -316,13 +316,12 @@ class ActionItem: Args: plugin_identifier (str): Action identifier. identifier (str): Action identifier. - entity_ids (set[str]): Entity ids. - entity_type (str): Entity type. label (str): Action label. group_label (Optional[str]): Group label. icon (Optional[dict[str, Any]]): Action icon definition. tooltip (Optional[str]): Action tooltip. order (int): Action order. + data (Optional[dict[str, Any]]): Additional action data. options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): Action options. Note: 'qargparse' is considered as deprecated. @@ -331,23 +330,21 @@ class ActionItem: self, plugin_identifier: str, identifier: str, - entity_ids: set[str], - entity_type: str, label: str, group_label: Optional[str], icon: Optional[dict[str, Any]], tooltip: Optional[str], order: int, + data: Optional[dict[str, Any]], options: Optional[list], ): self.plugin_identifier = plugin_identifier self.identifier = identifier - self.entity_ids = entity_ids - self.entity_type = entity_type self.label = label self.group_label = group_label self.icon = icon self.tooltip = tooltip + self.data = data self.order = order self.options = options @@ -371,13 +368,12 @@ class ActionItem: return { "plugin_identifier": self.plugin_identifier, "identifier": self.identifier, - "entity_ids": list(self.entity_ids), - "entity_type": self.entity_type, "label": self.label, "group_label": self.group_label, "icon": self.icon, "tooltip": self.tooltip, "order": self.order, + "data": self.data, "options": options, } @@ -387,7 +383,6 @@ class ActionItem: if options: options = deserialize_attr_defs(options) data["options"] = options - data["entity_ids"] = set(data["entity_ids"]) return cls(**data) @@ -1011,10 +1006,9 @@ class FrontendLoaderController(_BaseLoaderController): plugin_identifier: str, identifier: str, project_name: str, - entity_ids: set[str], - entity_type: str, selected_ids: set[str], selected_entity_type: str, + data: Optional[dict[str, Any]], options: dict[str, Any], form_values: dict[str, Any], ): @@ -1037,10 +1031,9 @@ class FrontendLoaderController(_BaseLoaderController): plugin_identifier (sttr): Plugin identifier. identifier (sttr): Action identifier. project_name (str): Project name. - entity_ids (set[str]): Entity ids stored on action item. - entity_type (str): Entity type stored on action item. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. + data (Optional[dict[str, Any]]): Additional action item data. options (dict[str, Any]): Action option values from UI. form_values (dict[str, Any]): Action form values from UI. diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 7a406fd2a3..e406b30fe0 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -318,10 +318,9 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): plugin_identifier: str, identifier: str, project_name: str, - entity_ids: set[str], - entity_type: str, selected_ids: set[str], selected_entity_type: str, + data: Optional[dict[str, Any]], options: dict[str, Any], form_values: dict[str, Any], ): @@ -329,20 +328,19 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): self._sitesync_model.trigger_action_item( identifier, project_name, - entity_ids, + data, ) return self._loader_actions_model.trigger_action_item( - plugin_identifier, - identifier, - project_name, - entity_ids, - entity_type, - selected_ids, - selected_entity_type, - options, - form_values, + plugin_identifier=plugin_identifier, + identifier=identifier, + project_name=project_name, + selected_ids=selected_ids, + selected_entity_type=selected_entity_type, + data=data, + options=options, + form_values=form_values, ) # Selection model wrappers diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 8aded40919..772befc22f 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -5,7 +5,7 @@ import traceback import inspect import collections import uuid -from typing import Callable, Any +from typing import Optional, Callable, Any import ayon_api @@ -125,10 +125,9 @@ class LoaderActionsModel: plugin_identifier: str, identifier: str, project_name: str, - entity_ids: set[str], - entity_type: str, selected_ids: set[str], selected_entity_type: str, + data: Optional[dict[str, Any]], options: dict[str, Any], form_values: dict[str, Any], ): @@ -144,10 +143,9 @@ class LoaderActionsModel: plugin_identifier (str): Plugin identifier. identifier (str): Action identifier. project_name (str): Project name. - entity_ids (set[str]): Entity ids on action item. - entity_type (str): Entity type on action item. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. + data (Optional[dict[str, Any]]): Additional action item data. options (dict[str, Any]): Loader option values. form_values (dict[str, Any]): Form values. @@ -156,10 +154,9 @@ class LoaderActionsModel: "plugin_identifier": plugin_identifier, "identifier": identifier, "project_name": project_name, - "entity_ids": list(entity_ids), - "entity_type": entity_type, "selected_ids": list(selected_ids), "selected_entity_type": selected_entity_type, + "data": data, "id": uuid.uuid4().hex, } self._controller.emit_event( @@ -172,16 +169,15 @@ class LoaderActionsModel: crashed = False try: result = self._loader_actions.execute_action( - plugin_identifier, - identifier, - entity_ids, - entity_type, - LoaderActionSelection( + plugin_identifier=plugin_identifier, + action_identifier=identifier, + selection=LoaderActionSelection( project_name, selected_ids, selected_entity_type, ), - form_values, + data=data, + form_values=form_values, ) except Exception: @@ -203,7 +199,8 @@ class LoaderActionsModel: loader = self._get_loader_by_identifier( project_name, identifier ) - + entity_type = data["entity_type"] + entity_ids = data["entity_ids"] if entity_type == "version": error_info = self._trigger_version_loader( loader, @@ -346,8 +343,10 @@ class LoaderActionsModel: return ActionItem( LOADER_PLUGIN_ID, get_loader_identifier(loader), - entity_ids=entity_ids, - entity_type=entity_type, + data={ + "entity_ids": entity_ids, + "entity_type": entity_type, + }, label=label, group_label=None, icon=self._get_action_icon(loader), @@ -807,13 +806,12 @@ class LoaderActionsModel: items.append(ActionItem( action.plugin_identifier, action.identifier, - action.entity_ids, - action.entity_type, label=action.label, group_label=action.group_label, icon=action.icon, tooltip=None, # action.tooltip, order=action.order, + data=action.data, options=None, # action.options, )) return items diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index 4d6ffcf9d4..2d0dcea5bf 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -1,6 +1,7 @@ from __future__ import annotations import collections +from typing import Any from ayon_api import ( get_representations, @@ -315,16 +316,17 @@ class SiteSyncModel: self, identifier: str, project_name: str, - representation_ids: set[str], + data: dict[str, Any], ): """Resets status for site_name or remove local files. Args: identifier (str): Action identifier. project_name (str): Project name. - representation_ids (Iterable[str]): Representation ids. + data (dict[str, Any]): Action item data. """ + representation_ids = data["representation_ids"] active_site = self.get_active_site(project_name) remote_site = self.get_remote_site(project_name) @@ -495,9 +497,10 @@ class SiteSyncModel: }, tooltip=tooltip, order=1, - entity_ids=representation_ids, - entity_type="representation", - options={}, + data={ + "representation_ids": representation_ids, + }, + options=None, ) def _add_site(self, project_name, repre_entity, site_name, product_type): diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 319108e8ea..384fed2ee9 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -438,15 +438,14 @@ class ProductsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( - action_item.plugin_identifier, - action_item.identifier, - project_name, - action_item.entity_ids, - action_item.entity_type, - version_ids, - "version", - options, - {}, + plugin_identifier=action_item.plugin_identifier, + identifier=action_item.identifier, + project_name=project_name, + selected_ids=version_ids, + selected_entity_type="version", + data=action_item.data, + options=options, + form_values={}, ) def _on_selection_change(self): diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index bfbcc73503..dcfcfea81b 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -399,13 +399,12 @@ class RepresentationsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( - action_item.plugin_identifier, - action_item.identifier, - self._selected_project_name, - action_item.entity_ids, - action_item.entity_type, - repre_ids, - "representation", - options, - {}, + plugin_identifier=action_item.plugin_identifier, + identifier=action_item.identifier, + project_name=self._selected_project_name, + selected_ids=repre_ids, + selected_entity_type="representation", + data=action_item.data, + options=options, + form_values={}, ) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 71679213e5..d2a4145707 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -582,17 +582,16 @@ class LoaderWindow(QtWidgets.QWidget): if result != QtWidgets.QDialog.Accepted: return - form_data = dialog.get_values() + form_values = dialog.get_values() self._controller.trigger_action_item( - event["plugin_identifier"], - event["identifier"], - event["project_name"], - event["entity_ids"], - event["entity_type"], - event["selected_ids"], - event["selected_entity_type"], - {}, - form_data, + plugin_identifier=event["plugin_identifier"], + identifier=event["identifier"], + project_name=event["project_name"], + selected_ids=event["selected_ids"], + selected_entity_type=event["selected_entity_type"], + options={}, + data=event["data"], + form_values=form_values, ) def _on_project_selection_changed(self, event): From 3945655f217fc7703c3d145520a7f35071c34318 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 30 Sep 2025 17:37:22 +0200 Subject: [PATCH 053/108] return type in docstring --- client/ayon_core/addon/interfaces.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index cc7e39218e..bc44fd2d2e 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -186,7 +186,12 @@ class IPluginPaths(AYONInterface): return self._get_plugin_paths_by_type("inventory") def get_loader_action_plugin_paths(self) -> list[str]: - """Receive loader action plugin paths.""" + """Receive loader action plugin paths. + + Returns: + list[str]: Paths to loader action plugins. + + """ return [] From 66b1a6e8adab48a299d5e52c396358dbdcc65e0b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 30 Sep 2025 17:48:07 +0200 Subject: [PATCH 054/108] add small explanation to the code --- client/ayon_core/pipeline/actions/loader.py | 65 ++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 94e30c5114..bb903b7c54 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -1,3 +1,61 @@ +"""API for actions for loader tool. + +Even though the api is meant for the loader tool, the api should be possible + to use in a standalone way out of the loader tool. + +To use add actions, make sure your addon does inherit from + 'IPluginPaths' and implements 'get_loader_action_plugin_paths' which + returns paths to python files with loader actions. + +The plugin is used to collect available actions for the given context and to + execute them. Selection is defined with 'LoaderActionSelection' object + that also contains a cache of entities and project anatomy. + +Implementing 'get_action_items' allows the plugin to define what actions + are shown and available for the selection. Because for a single selection + can be shown multiple actions with the same action identifier, the action + items also have 'data' attribute which can be used to store additional + data for the action (they have to be json-serializable). + +The action is triggered by calling the 'execute_action' method. Which takes + the action identifier, the selection, the additional data from the action + item and form values from the form if any. + +Using 'LoaderActionResult' as the output of 'execute_action' can trigger to + show a message in UI or to show an additional form ('LoaderActionForm') + which would retrigger the action with the values from the form on + submitting. That allows handling of multistep actions. + +It is also recommended that the plugin does override the 'identifier' + attribute. The identifier has to be unique across all plugins. + Class name is used by default. + +The selection wrapper currently supports the following types of entity types: + - version + - representation +It is planned to add 'folder' and 'task' selection in the future. + +NOTE: It is possible to trigger 'execute_action' without ever calling + 'get_action_items', that can be handy in automations. + +The whole logic is wrapped into 'LoaderActionsContext'. It takes care of + the discovery of plugins and wraps the collection and execution of + action items. Method 'execute_action' on context also requires plugin + identifier. + +The flow of the logic is (in the loader tool): + 1. User selects entities in the UI. + 2. Right-click the selected entities. + 3. Use 'LoaderActionsContext' to collect items using 'get_action_items'. + 4. Show a menu (with submenus) in the UI. + 5. If a user selects an action, the action is triggered using + 'execute_action'. + 5a. If the action returns 'LoaderActionResult', show a 'message' if it is + filled and show a form dialog if 'form' is filled. + 5b. If the user submitted the form, trigger the action again with the + values from the form and repeat from 5a. + +""" from __future__ import annotations import os @@ -388,7 +446,7 @@ class LoaderActionItem: and ids to be executed on. Attributes: - identifier (str): Unique action identifier. What is sent to action + identifier (str): Unique action identifier. What is sent to the action plugin when the action is executed. label (str): Text shown in UI. order (int): Order of the action in UI. @@ -417,7 +475,10 @@ class LoaderActionForm: If an action needs to collect information from a user before or during of the action execution, it can return a response with a form. When the - form is confirmed, a new execution of the action is triggered. + form is submitted, a new execution of the action is triggered. + + It is also possible to just show a label message without the submit + button to make sure the user has seen the message. Attributes: title (str): Title of the form -> title of the window. From 4c492b6d4bce55f79c60fd84d850e7d54a77c4ce Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 11:54:58 +0200 Subject: [PATCH 055/108] fetch only first representation --- client/ayon_core/plugins/loader/copy_file.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 8253a772eb..2c4a99dc4f 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -76,8 +76,8 @@ class CopyFileActionPlugin(LoaderActionPlugin): ) -> Optional[LoaderActionResult]: from qtpy import QtWidgets, QtCore - repre_ids = data["representation_ids"] - repre = next(iter(selection.entities.get_representations(repre_ids))) + repre_id = next(iter(data["representation_ids"])) + repre = next(iter(selection.entities.get_representations({repre_id}))) path = get_representation_path_with_anatomy( repre, selection.get_project_anatomy() ) From 76be69c4b2fb396600fc67a9c6f76ab7751e9b88 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:01:48 +0200 Subject: [PATCH 056/108] add simple action plugin --- client/ayon_core/pipeline/actions/__init__.py | 2 + client/ayon_core/pipeline/actions/loader.py | 76 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py index 247f64e890..6120fd6ac5 100644 --- a/client/ayon_core/pipeline/actions/__init__.py +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -7,6 +7,7 @@ from .loader import ( LoaderActionSelection, LoaderActionsContext, SelectionEntitiesCache, + LoaderSimpleActionPlugin, ) from .launcher import ( @@ -37,6 +38,7 @@ __all__ = ( "LoaderActionSelection", "LoaderActionsContext", "SelectionEntitiesCache", + "LoaderSimpleActionPlugin", "LauncherAction", "LauncherActionSelection", diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index bb903b7c54..a77eee82c7 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -804,3 +804,79 @@ class LoaderActionsContext: ) self._plugins = plugins return self._plugins + + +class LoaderSimpleActionPlugin(LoaderActionPlugin): + """Simple action plugin. + + This action will show exactly one action item defined by attributes + on the class. + + Attributes: + label: Label of the action item. + order: Order of the action item. + group_label: Label of the group to which the action belongs. + icon: Icon definition shown next to label. + + """ + + label: Optional[str] = None + order: int = 0 + group_label: Optional[str] = None + icon: Optional[dict[str, Any]] = None + + @abstractmethod + def is_compatible(self, selection: LoaderActionSelection) -> bool: + """Check if plugin is compatible with selection. + + Args: + selection (LoaderActionSelection): Selection information. + + Returns: + bool: True if plugin is compatible with selection. + + """ + pass + + @abstractmethod + def process( + self, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + """Process action based on selection. + + Args: + selection (LoaderActionSelection): Selection information. + form_values (dict[str, Any]): Values from a form if there are any. + + Returns: + Optional[LoaderActionResult]: Result of the action. + + """ + pass + + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + if self.is_compatible(selection): + label = self.label or self.__class__.__name__ + return [ + LoaderActionItem( + identifier=self.identifier, + label=label, + order=self.order, + group_label=self.group_label, + icon=self.icon, + ) + ] + return [] + + def execute_action( + self, + identifier: str, + selection: LoaderActionSelection, + data: Optional[DataType], + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + return self.process(selection, form_values) From af196dd049855dd1d0cf95ca2f11fffebbe62687 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:02:04 +0200 Subject: [PATCH 057/108] use simple plugin in export otio action --- .../ayon_core/plugins/loader/export_otio.py | 47 +++++++------------ 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index 1ad9038c5e..f8cdbed0a5 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -20,8 +20,7 @@ from ayon_core.pipeline.load import get_representation_path_with_anatomy from ayon_core.tools.utils import show_message_dialog from ayon_core.pipeline.actions import ( - LoaderActionPlugin, - LoaderActionItem, + LoaderSimpleActionPlugin, LoaderActionSelection, LoaderActionResult, ) @@ -37,47 +36,33 @@ def _import_otio(): OTIO = opentimelineio -class ExportOTIO(LoaderActionPlugin): +class ExportOTIO(LoaderSimpleActionPlugin): identifier = "core.export-otio" + label = "Export OTIO" + group_label = None + order = 35 + icon = { + "type": "material-symbols", + "name": "save", + "color": "#d8d8d8", + } - def get_action_items( + def is_compatible( self, selection: LoaderActionSelection - ) -> list[LoaderActionItem]: + ) -> bool: # Don't show in hosts if self.host_name is not None: - return [] + return False - version_ids = set() - if selection.selected_type == "version": - version_ids = set(selection.selected_ids) + return selection.versions_selected() - output = [] - if version_ids: - output.append( - LoaderActionItem( - identifier="copy-path", - label="Export OTIO", - group_label=None, - order=35, - data={"version_ids": list(version_ids)}, - icon={ - "type": "material-symbols", - "name": "save", - "color": "#d8d8d8", - } - ) - ) - return output - - def execute_action( + def process( self, - identifier: str, selection: LoaderActionSelection, - data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: _import_otio() - version_ids = data["version_ids"] + version_ids = set(selection.selected_ids) versions_by_id = { version["id"]: version From 90497bdd5924ce94a7d04cb35142567cf4b40985 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:14:07 +0200 Subject: [PATCH 058/108] added some helper methods --- client/ayon_core/pipeline/actions/loader.py | 47 +++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index a77eee82c7..7a5956160c 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -433,6 +433,53 @@ class LoaderActionSelection: project_anatomy = property(get_project_anatomy) entities = property(get_entities_cache) + # --- Helper methods --- + def versions_selected(self) -> bool: + """Selected entity type is version. + + Returns: + bool: True if selected entity type is version. + + """ + return self._selected_type == LoaderSelectedType.version + + def representations_selected(self) -> bool: + """Selected entity type is representation. + + Returns: + bool: True if selected entity type is representation. + + """ + return self._selected_type == LoaderSelectedType.representation + + def get_selected_version_entities(self) -> list[dict[str, Any]]: + """Retrieve selected version entities. + + An empty list is returned if 'version' is not the selected + entity type. + + Returns: + list[dict[str, Any]]: List of selected version entities. + + """ + if self.versions_selected(): + return self.entities.get_versions(self.selected_ids) + return [] + + def get_selected_representation_entities(self) -> list[dict[str, Any]]: + """Retrieve selected representation entities. + + An empty list is returned if 'representation' is not the selected + entity type. + + Returns: + list[dict[str, Any]]: List of selected representation entities. + + """ + if self.representations_selected(): + return self.entities.get_representations(self.selected_ids) + return [] + @dataclass class LoaderActionItem: From 365d0a95e032d3612560fe4473b759cb332c2dc8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:20:14 +0200 Subject: [PATCH 059/108] fix typo --- client/ayon_core/plugins/loader/open_file.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 1ed470c06e..5b21a359f8 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -52,12 +52,12 @@ class OpenFileAction(LoaderActionPlugin): ) product_ids = {version["productId"] for version in versions} products = selection.entities.get_products(product_ids) - fitlered_product_ids = { + filtered_product_ids = { product["id"] for product in products if product["productType"] in self.product_types } - if not fitlered_product_ids: + if not filtered_product_ids: return [] versions_by_product_id = collections.defaultdict(list) @@ -69,7 +69,7 @@ class OpenFileAction(LoaderActionPlugin): repres_by_version_ids[repre["versionId"]].append(repre) filtered_repres = [] - for product_id in fitlered_product_ids: + for product_id in filtered_product_ids: for version in versions_by_product_id[product_id]: for repre in repres_by_version_ids[version["id"]]: filtered_repres.append(repre) From 81a0b6764028024ee40966c51866b0d59fe22de8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 16:43:32 +0200 Subject: [PATCH 060/108] remove action identifier --- client/ayon_core/pipeline/actions/loader.py | 23 +++++----------- client/ayon_core/plugins/loader/copy_file.py | 16 +++++++----- .../plugins/loader/delete_old_versions.py | 26 +++++++++++-------- client/ayon_core/plugins/loader/delivery.py | 2 -- client/ayon_core/plugins/loader/open_file.py | 2 -- .../plugins/loader/push_to_project.py | 2 -- client/ayon_core/tools/loader/abstract.py | 8 +----- client/ayon_core/tools/loader/control.py | 5 +--- .../ayon_core/tools/loader/models/actions.py | 15 ++++------- .../ayon_core/tools/loader/models/sitesync.py | 19 +++++++------- .../tools/loader/ui/products_widget.py | 1 - .../tools/loader/ui/repres_widget.py | 1 - client/ayon_core/tools/loader/ui/window.py | 1 - 13 files changed, 48 insertions(+), 73 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 7a5956160c..ccdae302b9 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -493,27 +493,24 @@ class LoaderActionItem: and ids to be executed on. Attributes: - identifier (str): Unique action identifier. What is sent to the action - plugin when the action is executed. label (str): Text shown in UI. order (int): Order of the action in UI. group_label (Optional[str]): Label of the group to which the action belongs. icon (Optional[dict[str, Any]): Icon definition. data (Optional[DataType]): Action item data. - plugin_identifier (Optional[str]): Identifier of the plugin which + identifier (Optional[str]): Identifier of the plugin which created the action item. Is filled automatically. Is not changed if is filled -> can lead to different plugin. """ - identifier: str label: str order: int = 0 group_label: Optional[str] = None icon: Optional[dict[str, Any]] = None data: Optional[DataType] = None # Is filled automatically - plugin_identifier: str = None + identifier: str = None @dataclass @@ -667,7 +664,6 @@ class LoaderActionPlugin(ABC): @abstractmethod def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: Optional[DataType], form_values: dict[str, Any], @@ -675,7 +671,6 @@ class LoaderActionPlugin(ABC): """Execute an action. Args: - identifier (str): Action identifier. selection (LoaderActionSelection): Selection wrapper. Can be used to get entities or get context of original selection. data (Optional[DataType]): Additional action item data. @@ -771,8 +766,8 @@ class LoaderActionsContext: for plugin_id, plugin in self._get_plugins().items(): try: for action_item in plugin.get_action_items(selection): - if action_item.plugin_identifier is None: - action_item.plugin_identifier = plugin_id + if action_item.identifier is None: + action_item.identifier = plugin_id output.append(action_item) except Exception: @@ -785,8 +780,7 @@ class LoaderActionsContext: def execute_action( self, - plugin_identifier: str, - action_identifier: str, + identifier: str, selection: LoaderActionSelection, data: Optional[DataType], form_values: dict[str, Any], @@ -794,8 +788,7 @@ class LoaderActionsContext: """Trigger action execution. Args: - plugin_identifier (str): Identifier of the plugin. - action_identifier (str): Identifier of the action. + identifier (str): Identifier of the plugin. selection (LoaderActionSelection): Selection wrapper. Can be used to get what is selected in UI and to get access to entity cache. @@ -805,9 +798,8 @@ class LoaderActionsContext: """ plugins_by_id = self._get_plugins() - plugin = plugins_by_id[plugin_identifier] + plugin = plugins_by_id[identifier] return plugin.execute_action( - action_identifier, selection, data, form_values, @@ -910,7 +902,6 @@ class LoaderSimpleActionPlugin(LoaderActionPlugin): label = self.label or self.__class__.__name__ return [ LoaderActionItem( - identifier=self.identifier, label=label, order=self.order, group_label=self.group_label, diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 2c4a99dc4f..dd263383e4 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -41,10 +41,12 @@ class CopyFileActionPlugin(LoaderActionPlugin): for repre_name, repre_ids in repre_ids_by_name.items(): output.append( LoaderActionItem( - identifier="copy-path", label=repre_name, group_label="Copy file path", - data={"representation_ids": list(repre_ids)}, + data={ + "representation_ids": list(repre_ids), + "action": "copy-path", + }, icon={ "type": "material-symbols", "name": "content_copy", @@ -54,10 +56,12 @@ class CopyFileActionPlugin(LoaderActionPlugin): ) output.append( LoaderActionItem( - identifier="copy-file", label=repre_name, group_label="Copy file", - data={"representation_ids": list(repre_ids)}, + data={ + "representation_ids": list(repre_ids), + "action": "copy-file", + }, icon={ "type": "material-symbols", "name": "file_copy", @@ -69,13 +73,13 @@ class CopyFileActionPlugin(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: dict, form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: from qtpy import QtWidgets, QtCore + action = data["action"] repre_id = next(iter(data["representation_ids"])) repre = next(iter(selection.entities.get_representations({repre_id}))) path = get_representation_path_with_anatomy( @@ -90,7 +94,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): success=False, ) - if identifier == "copy-path": + if action == "copy-path": # Set to Clipboard clipboard.setText(os.path.normpath(path)) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index cc7d4d3fa6..f7f20fefef 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -58,10 +58,12 @@ class DeleteOldVersions(LoaderActionPlugin): return [ LoaderActionItem( - identifier="delete-versions", label="Delete Versions", order=35, - data={"product_ids": list(product_ids)}, + data={ + "product_ids": list(product_ids), + "action": "delete-versions", + }, icon={ "type": "material-symbols", "name": "delete", @@ -69,10 +71,12 @@ class DeleteOldVersions(LoaderActionPlugin): } ), LoaderActionItem( - identifier="calculate-versions-size", label="Calculate Versions size", order=30, - data={"product_ids": list(product_ids)}, + data={ + "product_ids": list(product_ids), + "action": "calculate-versions-size", + }, icon={ "type": "material-symbols", "name": "auto_delete", @@ -83,17 +87,17 @@ class DeleteOldVersions(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: step = form_values.get("step") + action = data["action"] versions_to_keep = form_values.get("versions_to_keep") remove_publish_folder = form_values.get("remove_publish_folder") if step is None: return self._first_step( - identifier, + action, versions_to_keep, remove_publish_folder, ) @@ -106,7 +110,7 @@ class DeleteOldVersions(LoaderActionPlugin): product_ids = data["product_ids"] if step == "prepare-data": return self._prepare_data_step( - identifier, + action, versions_to_keep, remove_publish_folder, product_ids, @@ -121,7 +125,7 @@ class DeleteOldVersions(LoaderActionPlugin): def _first_step( self, - identifier: str, + action: str, versions_to_keep: Optional[int], remove_publish_folder: Optional[bool], ) -> LoaderActionResult: @@ -137,7 +141,7 @@ class DeleteOldVersions(LoaderActionPlugin): default=2, ), ] - if identifier == "delete-versions": + if action == "delete-versions": fields.append( BoolDef( "remove_publish_folder", @@ -165,7 +169,7 @@ class DeleteOldVersions(LoaderActionPlugin): def _prepare_data_step( self, - identifier: str, + action: str, versions_to_keep: int, remove_publish_folder: bool, entity_ids: set[str], @@ -235,7 +239,7 @@ class DeleteOldVersions(LoaderActionPlugin): if os.path.exists(filepath): size += os.path.getsize(filepath) - if identifier == "calculate-versions-size": + if action == "calculate-versions-size": return LoaderActionResult( message="Calculated size", success=True, diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index 538bdec414..c39b791dbb 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -51,7 +51,6 @@ class DeliveryAction(LoaderActionPlugin): return [ LoaderActionItem( - identifier="deliver-versions", label="Deliver Versions", order=35, data={"version_ids": list(version_ids)}, @@ -65,7 +64,6 @@ class DeliveryAction(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: dict[str, Any], form_values: dict[str, Any], diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 5b21a359f8..9b5a6fec20 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -80,7 +80,6 @@ class OpenFileAction(LoaderActionPlugin): return [ LoaderActionItem( - identifier="open-file", label=repre_name, group_label="Open file", order=-10, @@ -96,7 +95,6 @@ class OpenFileAction(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: dict[str, Any], form_values: dict[str, Any], diff --git a/client/ayon_core/plugins/loader/push_to_project.py b/client/ayon_core/plugins/loader/push_to_project.py index 275f5de88d..215e63be86 100644 --- a/client/ayon_core/plugins/loader/push_to_project.py +++ b/client/ayon_core/plugins/loader/push_to_project.py @@ -39,7 +39,6 @@ class PushToProject(LoaderActionPlugin): if version_ids and len(folder_ids) == 1: output.append( LoaderActionItem( - identifier="core.push-to-project", label="Push to project", order=35, data={"version_ids": list(version_ids)}, @@ -54,7 +53,6 @@ class PushToProject(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: dict[str, Any], form_values: dict[str, Any], diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 90371204f9..3f86317e90 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -314,7 +314,6 @@ class ActionItem: use 'identifier' and context, it necessary also use 'options'. Args: - plugin_identifier (str): Action identifier. identifier (str): Action identifier. label (str): Action label. group_label (Optional[str]): Group label. @@ -328,7 +327,6 @@ class ActionItem: """ def __init__( self, - plugin_identifier: str, identifier: str, label: str, group_label: Optional[str], @@ -338,7 +336,6 @@ class ActionItem: data: Optional[dict[str, Any]], options: Optional[list], ): - self.plugin_identifier = plugin_identifier self.identifier = identifier self.label = label self.group_label = group_label @@ -366,7 +363,6 @@ class ActionItem: def to_data(self) -> dict[str, Any]: options = self._options_to_data() return { - "plugin_identifier": self.plugin_identifier, "identifier": self.identifier, "label": self.label, "group_label": self.group_label, @@ -1003,7 +999,6 @@ class FrontendLoaderController(_BaseLoaderController): @abstractmethod def trigger_action_item( self, - plugin_identifier: str, identifier: str, project_name: str, selected_ids: set[str], @@ -1028,8 +1023,7 @@ class FrontendLoaderController(_BaseLoaderController): } Args: - plugin_identifier (sttr): Plugin identifier. - identifier (sttr): Action identifier. + identifier (sttr): Plugin identifier. project_name (str): Project name. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index e406b30fe0..722cdf9653 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -315,7 +315,6 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): def trigger_action_item( self, - plugin_identifier: str, identifier: str, project_name: str, selected_ids: set[str], @@ -324,16 +323,14 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): options: dict[str, Any], form_values: dict[str, Any], ): - if self._sitesync_model.is_sitesync_action(plugin_identifier): + if self._sitesync_model.is_sitesync_action(identifier): self._sitesync_model.trigger_action_item( - identifier, project_name, data, ) return self._loader_actions_model.trigger_action_item( - plugin_identifier=plugin_identifier, identifier=identifier, project_name=project_name, selected_ids=selected_ids, diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 772befc22f..3db1792247 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -122,7 +122,6 @@ class LoaderActionsModel: def trigger_action_item( self, - plugin_identifier: str, identifier: str, project_name: str, selected_ids: set[str], @@ -140,8 +139,7 @@ class LoaderActionsModel: happened. Args: - plugin_identifier (str): Plugin identifier. - identifier (str): Action identifier. + identifier (str): Plugin identifier. project_name (str): Project name. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. @@ -151,7 +149,6 @@ class LoaderActionsModel: """ event_data = { - "plugin_identifier": plugin_identifier, "identifier": identifier, "project_name": project_name, "selected_ids": list(selected_ids), @@ -164,13 +161,12 @@ class LoaderActionsModel: event_data, ACTIONS_MODEL_SENDER, ) - if plugin_identifier != LOADER_PLUGIN_ID: + if identifier != LOADER_PLUGIN_ID: result = None crashed = False try: result = self._loader_actions.execute_action( - plugin_identifier=plugin_identifier, - action_identifier=identifier, + identifier=identifier, selection=LoaderActionSelection( project_name, selected_ids, @@ -197,7 +193,7 @@ class LoaderActionsModel: return loader = self._get_loader_by_identifier( - project_name, identifier + project_name, data["loader"] ) entity_type = data["entity_type"] entity_ids = data["entity_ids"] @@ -342,10 +338,10 @@ class LoaderActionsModel: label = f"{label} ({repre_name})" return ActionItem( LOADER_PLUGIN_ID, - get_loader_identifier(loader), data={ "entity_ids": entity_ids, "entity_type": entity_type, + "loader": get_loader_identifier(loader), }, label=label, group_label=None, @@ -804,7 +800,6 @@ class LoaderActionsModel: items = [] for action in self._loader_actions.get_action_items(selection): items.append(ActionItem( - action.plugin_identifier, action.identifier, label=action.label, group_label=action.group_label, diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index 2d0dcea5bf..a7bbda18a3 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -300,33 +300,32 @@ class SiteSyncModel: return action_items - def is_sitesync_action(self, plugin_identifier: str) -> bool: + def is_sitesync_action(self, identifier: str) -> bool: """Should be `identifier` handled by SiteSync. Args: - plugin_identifier (str): Plugin identifier. + identifier (str): Plugin identifier. Returns: bool: Should action be handled by SiteSync. """ - return plugin_identifier == "sitesync.loader.action" + return identifier == "sitesync.loader.action" def trigger_action_item( self, - identifier: str, project_name: str, data: dict[str, Any], ): """Resets status for site_name or remove local files. Args: - identifier (str): Action identifier. project_name (str): Project name. data (dict[str, Any]): Action item data. """ representation_ids = data["representation_ids"] + action_identifier = data["action_identifier"] active_site = self.get_active_site(project_name) remote_site = self.get_remote_site(project_name) @@ -350,17 +349,17 @@ class SiteSyncModel: for repre_id in representation_ids: repre_entity = repre_entities_by_id.get(repre_id) product_type = product_type_by_repre_id[repre_id] - if identifier == DOWNLOAD_IDENTIFIER: + if action_identifier == DOWNLOAD_IDENTIFIER: self._add_site( project_name, repre_entity, active_site, product_type ) - elif identifier == UPLOAD_IDENTIFIER: + elif action_identifier == UPLOAD_IDENTIFIER: self._add_site( project_name, repre_entity, remote_site, product_type ) - elif identifier == REMOVE_IDENTIFIER: + elif action_identifier == REMOVE_IDENTIFIER: self._sitesync_addon.remove_site( project_name, repre_id, @@ -480,14 +479,13 @@ class SiteSyncModel: self, project_name, representation_ids, - identifier, + action_identifier, label, tooltip, icon_name ): return ActionItem( "sitesync.loader.action", - identifier=identifier, label=label, group_label=None, icon={ @@ -499,6 +497,7 @@ class SiteSyncModel: order=1, data={ "representation_ids": representation_ids, + "action_identifier": action_identifier, }, options=None, ) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 384fed2ee9..ddd6ce8554 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -438,7 +438,6 @@ class ProductsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( - plugin_identifier=action_item.plugin_identifier, identifier=action_item.identifier, project_name=project_name, selected_ids=version_ids, diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index dcfcfea81b..33bbf46b34 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -399,7 +399,6 @@ class RepresentationsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( - plugin_identifier=action_item.plugin_identifier, identifier=action_item.identifier, project_name=self._selected_project_name, selected_ids=repre_ids, diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index d2a4145707..1c8b56f0c0 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -584,7 +584,6 @@ class LoaderWindow(QtWidgets.QWidget): form_values = dialog.get_values() self._controller.trigger_action_item( - plugin_identifier=event["plugin_identifier"], identifier=event["identifier"], project_name=event["project_name"], selected_ids=event["selected_ids"], From 0dfaa001655103b5690a0424a0ca987bac914242 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 18:06:39 +0200 Subject: [PATCH 061/108] remove unnecessary argument --- client/ayon_core/pipeline/actions/loader.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index ccdae302b9..c8b579614a 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -912,7 +912,6 @@ class LoaderSimpleActionPlugin(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: Optional[DataType], form_values: dict[str, Any], From 55828c73414f999d9280af9309f4aaeb24bb7936 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:58:21 +0200 Subject: [PATCH 062/108] move LoaderActionForm as ActionForm to structures --- client/ayon_core/pipeline/actions/__init__.py | 7 +- client/ayon_core/pipeline/actions/loader.py | 67 ++----------------- .../ayon_core/pipeline/actions/structures.py | 60 +++++++++++++++++ 3 files changed, 71 insertions(+), 63 deletions(-) create mode 100644 client/ayon_core/pipeline/actions/structures.py diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py index 6120fd6ac5..569047438c 100644 --- a/client/ayon_core/pipeline/actions/__init__.py +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -1,6 +1,8 @@ +from .structures import ( + ActionForm, +) from .loader import ( LoaderSelectedType, - LoaderActionForm, LoaderActionResult, LoaderActionItem, LoaderActionPlugin, @@ -30,8 +32,9 @@ from .inventory import ( __all__ = ( + "ActionForm", + "LoaderSelectedType", - "LoaderActionForm", "LoaderActionResult", "LoaderActionItem", "LoaderActionPlugin", diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index c8b579614a..13f243bf66 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -22,7 +22,7 @@ The action is triggered by calling the 'execute_action' method. Which takes item and form values from the form if any. Using 'LoaderActionResult' as the output of 'execute_action' can trigger to - show a message in UI or to show an additional form ('LoaderActionForm') + show a message in UI or to show an additional form ('ActionForm') which would retrigger the action with the values from the form on submitting. That allows handling of multistep actions. @@ -71,17 +71,14 @@ import ayon_api from ayon_core import AYON_CORE_ROOT from ayon_core.lib import StrEnum, Logger -from ayon_core.lib.attribute_definitions import ( - AbstractAttrDef, - serialize_attr_defs, - deserialize_attr_defs, -) from ayon_core.host import AbstractHost from ayon_core.addon import AddonsManager, IPluginPaths from ayon_core.settings import get_studio_settings, get_project_settings from ayon_core.pipeline import Anatomy from ayon_core.pipeline.plugin_discover import discover_plugins +from .structures import ActionForm + if typing.TYPE_CHECKING: from typing import Union @@ -513,58 +510,6 @@ class LoaderActionItem: identifier: str = None -@dataclass -class LoaderActionForm: - """Form for loader action. - - If an action needs to collect information from a user before or during of - the action execution, it can return a response with a form. When the - form is submitted, a new execution of the action is triggered. - - It is also possible to just show a label message without the submit - button to make sure the user has seen the message. - - Attributes: - title (str): Title of the form -> title of the window. - fields (list[AbstractAttrDef]): Fields of the form. - submit_label (Optional[str]): Label of the submit button. Is hidden - if is set to None. - submit_icon (Optional[dict[str, Any]]): Icon definition of the submit - button. - cancel_label (Optional[str]): Label of the cancel button. Is hidden - if is set to None. User can still close the window tho. - cancel_icon (Optional[dict[str, Any]]): Icon definition of the cancel - button. - - """ - title: str - fields: list[AbstractAttrDef] - submit_label: Optional[str] = "Submit" - submit_icon: Optional[dict[str, Any]] = None - cancel_label: Optional[str] = "Cancel" - cancel_icon: Optional[dict[str, Any]] = None - - def to_json_data(self) -> dict[str, Any]: - fields = self.fields - if fields is not None: - fields = serialize_attr_defs(fields) - return { - "title": self.title, - "fields": fields, - "submit_label": self.submit_label, - "submit_icon": self.submit_icon, - "cancel_label": self.cancel_label, - "cancel_icon": self.cancel_icon, - } - - @classmethod - def from_json_data(cls, data: dict[str, Any]) -> "LoaderActionForm": - fields = data["fields"] - if fields is not None: - data["fields"] = deserialize_attr_defs(fields) - return cls(**data) - - @dataclass class LoaderActionResult: """Result of loader action execution. @@ -573,7 +518,7 @@ class LoaderActionResult: message (Optional[str]): Message to show in UI. success (bool): If the action was successful. Affects color of the message. - form (Optional[LoaderActionForm]): Form to show in UI. + form (Optional[ActionForm]): Form to show in UI. form_values (Optional[dict[str, Any]]): Values for the form. Can be used if the same form is re-shown e.g. because a user forgot to fill a required field. @@ -581,7 +526,7 @@ class LoaderActionResult: """ message: Optional[str] = None success: bool = True - form: Optional[LoaderActionForm] = None + form: Optional[ActionForm] = None form_values: Optional[dict[str, Any]] = None def to_json_data(self) -> dict[str, Any]: @@ -599,7 +544,7 @@ class LoaderActionResult: def from_json_data(cls, data: dict[str, Any]) -> "LoaderActionResult": form = data["form"] if form is not None: - data["form"] = LoaderActionForm.from_json_data(form) + data["form"] = ActionForm.from_json_data(form) return LoaderActionResult(**data) diff --git a/client/ayon_core/pipeline/actions/structures.py b/client/ayon_core/pipeline/actions/structures.py new file mode 100644 index 0000000000..0283a7a272 --- /dev/null +++ b/client/ayon_core/pipeline/actions/structures.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass +from typing import Optional, Any + +from ayon_core.lib.attribute_definitions import ( + AbstractAttrDef, + serialize_attr_defs, + deserialize_attr_defs, +) + + +@dataclass +class ActionForm: + """Form for loader action. + + If an action needs to collect information from a user before or during of + the action execution, it can return a response with a form. When the + form is submitted, a new execution of the action is triggered. + + It is also possible to just show a label message without the submit + button to make sure the user has seen the message. + + Attributes: + title (str): Title of the form -> title of the window. + fields (list[AbstractAttrDef]): Fields of the form. + submit_label (Optional[str]): Label of the submit button. Is hidden + if is set to None. + submit_icon (Optional[dict[str, Any]]): Icon definition of the submit + button. + cancel_label (Optional[str]): Label of the cancel button. Is hidden + if is set to None. User can still close the window tho. + cancel_icon (Optional[dict[str, Any]]): Icon definition of the cancel + button. + + """ + title: str + fields: list[AbstractAttrDef] + submit_label: Optional[str] = "Submit" + submit_icon: Optional[dict[str, Any]] = None + cancel_label: Optional[str] = "Cancel" + cancel_icon: Optional[dict[str, Any]] = None + + def to_json_data(self) -> dict[str, Any]: + fields = self.fields + if fields is not None: + fields = serialize_attr_defs(fields) + return { + "title": self.title, + "fields": fields, + "submit_label": self.submit_label, + "submit_icon": self.submit_icon, + "cancel_label": self.cancel_label, + "cancel_icon": self.cancel_icon, + } + + @classmethod + def from_json_data(cls, data: dict[str, Any]) -> "ActionForm": + fields = data["fields"] + if fields is not None: + data["fields"] = deserialize_attr_defs(fields) + return cls(**data) From e9958811d44a46c832be8920587452c688386dc5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 2 Oct 2025 17:15:53 +0200 Subject: [PATCH 063/108] added helper conversion function for webaction fields --- client/ayon_core/pipeline/actions/__init__.py | 4 + client/ayon_core/pipeline/actions/utils.py | 83 +++++++++++++++++++ .../tools/launcher/ui/actions_widget.py | 83 +------------------ 3 files changed, 90 insertions(+), 80 deletions(-) create mode 100644 client/ayon_core/pipeline/actions/utils.py diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py index 569047438c..7af3ac1130 100644 --- a/client/ayon_core/pipeline/actions/__init__.py +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -1,6 +1,9 @@ from .structures import ( ActionForm, ) +from .utils import ( + webaction_fields_to_attribute_defs, +) from .loader import ( LoaderSelectedType, LoaderActionResult, @@ -33,6 +36,7 @@ from .inventory import ( __all__ = ( "ActionForm", + "webaction_fields_to_attribute_defs", "LoaderSelectedType", "LoaderActionResult", diff --git a/client/ayon_core/pipeline/actions/utils.py b/client/ayon_core/pipeline/actions/utils.py new file mode 100644 index 0000000000..00a8e91d68 --- /dev/null +++ b/client/ayon_core/pipeline/actions/utils.py @@ -0,0 +1,83 @@ +import uuid + +from ayon_core.lib.attribute_definitions import ( + AbstractAttrDef, + UILabelDef, + BoolDef, + TextDef, + NumberDef, + EnumDef, + HiddenDef, +) + + +def webaction_fields_to_attribute_defs(fields) -> list[AbstractAttrDef]: + attr_defs = [] + for field in fields: + field_type = field["type"] + attr_def = None + if field_type == "label": + label = field.get("value") + if label is None: + label = field.get("text") + attr_def = UILabelDef( + label, key=uuid.uuid4().hex + ) + elif field_type == "boolean": + value = field["value"] + if isinstance(value, str): + value = value.lower() == "true" + + attr_def = BoolDef( + field["name"], + default=value, + label=field.get("label"), + ) + elif field_type == "text": + attr_def = TextDef( + field["name"], + default=field.get("value"), + label=field.get("label"), + placeholder=field.get("placeholder"), + multiline=field.get("multiline", False), + regex=field.get("regex"), + # syntax=field["syntax"], + ) + elif field_type in ("integer", "float"): + value = field.get("value") + if isinstance(value, str): + if field_type == "integer": + value = int(value) + else: + value = float(value) + attr_def = NumberDef( + field["name"], + default=value, + label=field.get("label"), + decimals=0 if field_type == "integer" else 5, + # placeholder=field.get("placeholder"), + minimum=field.get("min"), + maximum=field.get("max"), + ) + elif field_type in ("select", "multiselect"): + attr_def = EnumDef( + field["name"], + items=field["options"], + default=field.get("value"), + label=field.get("label"), + multiselection=field_type == "multiselect", + ) + elif field_type == "hidden": + attr_def = HiddenDef( + field["name"], + default=field.get("value"), + ) + + if attr_def is None: + print(f"Unknown config field type: {field_type}") + attr_def = UILabelDef( + f"Unknown field type '{field_type}", + key=uuid.uuid4().hex + ) + attr_defs.append(attr_def) + return attr_defs diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 31b303ca2b..0e763a208a 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -1,22 +1,12 @@ import time -import uuid import collections from qtpy import QtWidgets, QtCore, QtGui from ayon_core.lib import Logger -from ayon_core.lib.attribute_definitions import ( - UILabelDef, - EnumDef, - TextDef, - BoolDef, - NumberDef, - HiddenDef, -) +from ayon_core.pipeline.actions import webaction_fields_to_attribute_defs from ayon_core.tools.flickcharm import FlickCharm -from ayon_core.tools.utils import ( - get_qt_icon, -) +from ayon_core.tools.utils import get_qt_icon from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog from ayon_core.tools.launcher.abstract import WebactionContext @@ -1173,74 +1163,7 @@ class ActionsWidget(QtWidgets.QWidget): float - 'label', 'value', 'placeholder', 'min', 'max' """ - attr_defs = [] - for config_field in config_fields: - field_type = config_field["type"] - attr_def = None - if field_type == "label": - label = config_field.get("value") - if label is None: - label = config_field.get("text") - attr_def = UILabelDef( - label, key=uuid.uuid4().hex - ) - elif field_type == "boolean": - value = config_field["value"] - if isinstance(value, str): - value = value.lower() == "true" - - attr_def = BoolDef( - config_field["name"], - default=value, - label=config_field.get("label"), - ) - elif field_type == "text": - attr_def = TextDef( - config_field["name"], - default=config_field.get("value"), - label=config_field.get("label"), - placeholder=config_field.get("placeholder"), - multiline=config_field.get("multiline", False), - regex=config_field.get("regex"), - # syntax=config_field["syntax"], - ) - elif field_type in ("integer", "float"): - value = config_field.get("value") - if isinstance(value, str): - if field_type == "integer": - value = int(value) - else: - value = float(value) - attr_def = NumberDef( - config_field["name"], - default=value, - label=config_field.get("label"), - decimals=0 if field_type == "integer" else 5, - # placeholder=config_field.get("placeholder"), - minimum=config_field.get("min"), - maximum=config_field.get("max"), - ) - elif field_type in ("select", "multiselect"): - attr_def = EnumDef( - config_field["name"], - items=config_field["options"], - default=config_field.get("value"), - label=config_field.get("label"), - multiselection=field_type == "multiselect", - ) - elif field_type == "hidden": - attr_def = HiddenDef( - config_field["name"], - default=config_field.get("value"), - ) - - if attr_def is None: - print(f"Unknown config field type: {field_type}") - attr_def = UILabelDef( - f"Unknown field type '{field_type}", - key=uuid.uuid4().hex - ) - attr_defs.append(attr_def) + attr_defs = webaction_fields_to_attribute_defs(config_fields) dialog = AttributeDefinitionsDialog( attr_defs, From 917c4e317cb9c36e1703857660864a2c7ca0e5e1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 2 Oct 2025 17:16:14 +0200 Subject: [PATCH 064/108] use ActionForm in delete old versions --- client/ayon_core/plugins/loader/delete_old_versions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index f7f20fefef..d6ddacf146 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -18,12 +18,12 @@ from ayon_core.lib import ( ) from ayon_core.pipeline import Anatomy from ayon_core.pipeline.actions import ( + ActionForm, LoaderSelectedType, LoaderActionPlugin, LoaderActionItem, LoaderActionSelection, LoaderActionResult, - LoaderActionForm, ) @@ -160,7 +160,7 @@ class DeleteOldVersions(LoaderActionPlugin): } form_values["step"] = "prepare-data" return LoaderActionResult( - form=LoaderActionForm( + form=ActionForm( title="Delete Old Versions", fields=fields, ), @@ -243,7 +243,7 @@ class DeleteOldVersions(LoaderActionPlugin): return LoaderActionResult( message="Calculated size", success=True, - form=LoaderActionForm( + form=ActionForm( title="Calculated versions size", fields=[ UILabelDef( @@ -341,7 +341,7 @@ class DeleteOldVersions(LoaderActionPlugin): repre_ids_by_version_id: dict[str, list[str]], filepaths_by_repre_id: dict[str, list[str]], repeated: bool = False, - ) -> tuple[LoaderActionForm, dict[str, Any]]: + ) -> tuple[ActionForm, dict[str, Any]]: versions_len = len(repre_ids_by_version_id) fields = [ UILabelDef( @@ -375,7 +375,7 @@ class DeleteOldVersions(LoaderActionPlugin): ) ]) - form = LoaderActionForm( + form = ActionForm( title="Delete versions", submit_label="Delete", cancel_label="Close", From eedd982a84c76169b746a64288e51aea1bf89fa5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:04:07 +0200 Subject: [PATCH 065/108] use first representation in action item collection --- client/ayon_core/plugins/loader/copy_file.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index dd263383e4..2380b465ed 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -39,12 +39,15 @@ class CopyFileActionPlugin(LoaderActionPlugin): repre_ids_by_name[repre["name"]].add(repre["id"]) for repre_name, repre_ids in repre_ids_by_name.items(): + repre_id = next(iter(repre_ids), None) + if not repre_id: + continue output.append( LoaderActionItem( label=repre_name, group_label="Copy file path", data={ - "representation_ids": list(repre_ids), + "representation_id": repre_id, "action": "copy-path", }, icon={ @@ -59,7 +62,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): label=repre_name, group_label="Copy file", data={ - "representation_ids": list(repre_ids), + "representation_id": repre_id, "action": "copy-file", }, icon={ @@ -80,7 +83,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): from qtpy import QtWidgets, QtCore action = data["action"] - repre_id = next(iter(data["representation_ids"])) + repre_id = data["representation_id"] repre = next(iter(selection.entities.get_representations({repre_id}))) path = get_representation_path_with_anatomy( repre, selection.get_project_anatomy() From 6d1d1e01d486c020c4fd5227bb6a23606cd22880 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:04:34 +0200 Subject: [PATCH 066/108] use 'get_selected_version_entities' in delete old versions --- client/ayon_core/plugins/loader/delete_old_versions.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index d6ddacf146..97e9d43628 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -42,12 +42,7 @@ class DeleteOldVersions(LoaderActionPlugin): if self.host_name is not None: return [] - versions = None - if selection.selected_type == LoaderSelectedType.version: - versions = selection.entities.get_versions( - selection.selected_ids - ) - + versions = selection.get_selected_version_entities() if not versions: return [] From d465e4a9b3a97c75e721012609cc904c86bcdfc7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:05:54 +0200 Subject: [PATCH 067/108] rename 'process' to 'execute_simple_action' --- client/ayon_core/pipeline/actions/loader.py | 4 ++-- client/ayon_core/plugins/loader/export_otio.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 13f243bf66..92de9c6cf8 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -823,7 +823,7 @@ class LoaderSimpleActionPlugin(LoaderActionPlugin): pass @abstractmethod - def process( + def execute_simple_action( self, selection: LoaderActionSelection, form_values: dict[str, Any], @@ -861,4 +861,4 @@ class LoaderSimpleActionPlugin(LoaderActionPlugin): data: Optional[DataType], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: - return self.process(selection, form_values) + return self.execute_simple_action(selection, form_values) diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index f8cdbed0a5..c86a72700e 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -56,7 +56,7 @@ class ExportOTIO(LoaderSimpleActionPlugin): return selection.versions_selected() - def process( + def execute_simple_action( self, selection: LoaderActionSelection, form_values: dict[str, Any], From 48cc1719e30c22de00d8c3c6e59850c4e8c1fffe Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:07:17 +0200 Subject: [PATCH 068/108] delivery action uses simple action --- client/ayon_core/plugins/loader/delivery.py | 54 +++++++++++---------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index c39b791dbb..1ac1c465dc 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -13,7 +13,7 @@ from ayon_core.lib import ( ) from ayon_core.pipeline import Anatomy from ayon_core.pipeline.actions import ( - LoaderActionPlugin, + LoaderSimpleActionPlugin, LoaderActionSelection, LoaderActionItem, LoaderActionResult, @@ -27,15 +27,33 @@ from ayon_core.pipeline.delivery import ( ) -class DeliveryAction(LoaderActionPlugin): +class DeliveryAction(LoaderSimpleActionPlugin): identifier = "core.delivery" + label = "Deliver Versions" + order = 35 + icon = { + "type": "material-symbols", + "name": "upload", + "color": "#d8d8d8", + } - def get_action_items( - self, selection: LoaderActionSelection - ) -> list[LoaderActionItem]: + def is_compatible(self, selection: LoaderActionSelection) -> bool: if self.host_name is not None: - return [] + return False + if not selection.selected_ids: + return False + + return ( + selection.versions_selected() + or selection.representations_selected() + ) + + def execute_simple_action( + self, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: version_ids = set() if selection.selected_type == "representation": versions = selection.entities.get_representations_versions( @@ -47,31 +65,15 @@ class DeliveryAction(LoaderActionPlugin): version_ids = set(selection.selected_ids) if not version_ids: - return [] - - return [ - LoaderActionItem( - label="Deliver Versions", - order=35, - data={"version_ids": list(version_ids)}, - icon={ - "type": "material-symbols", - "name": "upload", - "color": "#d8d8d8", - } + return LoaderActionResult( + message="No versions found in your selection", + success=False, ) - ] - def execute_action( - self, - selection: LoaderActionSelection, - data: dict[str, Any], - form_values: dict[str, Any], - ) -> Optional[LoaderActionResult]: try: # TODO run the tool in subprocess dialog = DeliveryOptionsDialog( - selection.project_name, data["version_ids"], self.log + selection.project_name, version_ids, self.log ) dialog.exec_() except Exception: From bc5c162a000fa928be1055156f6fd0ed75eba90a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:07:40 +0200 Subject: [PATCH 069/108] push to project uses simple action --- .../plugins/loader/push_to_project.py | 76 ++++++++----------- 1 file changed, 31 insertions(+), 45 deletions(-) diff --git a/client/ayon_core/plugins/loader/push_to_project.py b/client/ayon_core/plugins/loader/push_to_project.py index 215e63be86..d2ade736fd 100644 --- a/client/ayon_core/plugins/loader/push_to_project.py +++ b/client/ayon_core/plugins/loader/push_to_project.py @@ -5,65 +5,51 @@ from ayon_core import AYON_CORE_ROOT from ayon_core.lib import get_ayon_launcher_args, run_detached_process from ayon_core.pipeline.actions import ( - LoaderActionPlugin, - LoaderActionItem, + LoaderSimpleActionPlugin, LoaderActionSelection, LoaderActionResult, ) -class PushToProject(LoaderActionPlugin): +class PushToProject(LoaderSimpleActionPlugin): identifier = "core.push-to-project" + label = "Push to project" + order = 35 + icon = { + "type": "material-symbols", + "name": "send", + "color": "#d8d8d8", + } - def get_action_items( + def is_compatible( self, selection: LoaderActionSelection - ) -> list[LoaderActionItem]: - folder_ids = set() - version_ids = set() - if selection.selected_type == "version": - version_ids = set(selection.selected_ids) - product_ids = { - product["id"] - for product in selection.entities.get_versions_products( - version_ids - ) - } - folder_ids = { - folder["id"] - for folder in selection.entities.get_products_folders( - product_ids - ) - } + ) -> bool: + if not selection.versions_selected(): + return False - output = [] - if version_ids and len(folder_ids) == 1: - output.append( - LoaderActionItem( - label="Push to project", - order=35, - data={"version_ids": list(version_ids)}, - icon={ - "type": "material-symbols", - "name": "send", - "color": "#d8d8d8", - } - ) + version_ids = set(selection.selected_ids) + product_ids = { + product["id"] + for product in selection.entities.get_versions_products( + version_ids ) - return output + } + folder_ids = { + folder["id"] + for folder in selection.entities.get_products_folders( + product_ids + ) + } - def execute_action( + if len(folder_ids) == 1: + return True + return False + + def execute_simple_action( self, selection: LoaderActionSelection, - data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: - version_ids = data["version_ids"] - if len(version_ids) > 1: - return LoaderActionResult( - message="Please select only one version", - success=False, - ) - push_tool_script_path = os.path.join( AYON_CORE_ROOT, "tools", @@ -74,7 +60,7 @@ class PushToProject(LoaderActionPlugin): args = get_ayon_launcher_args( push_tool_script_path, "--project", selection.project_name, - "--versions", ",".join(version_ids) + "--versions", ",".join(selection.selected_ids) ) run_detached_process(args) return LoaderActionResult( From d81f6eaa3e7fe4504e0f7684cb0347c8993e8387 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:08:42 +0200 Subject: [PATCH 070/108] remove unused import --- client/ayon_core/plugins/loader/delivery.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index 1ac1c465dc..5141bb1d3b 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -15,7 +15,6 @@ from ayon_core.pipeline import Anatomy from ayon_core.pipeline.actions import ( LoaderSimpleActionPlugin, LoaderActionSelection, - LoaderActionItem, LoaderActionResult, ) from ayon_core.pipeline.load import get_representation_path_with_anatomy From 14fb34e4b64907a4fc2de6b2c7c5e3ef24c74ea8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:22:18 +0200 Subject: [PATCH 071/108] remove unused import --- client/ayon_core/plugins/loader/delete_old_versions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index 97e9d43628..7499650cbe 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -19,7 +19,6 @@ from ayon_core.lib import ( from ayon_core.pipeline import Anatomy from ayon_core.pipeline.actions import ( ActionForm, - LoaderSelectedType, LoaderActionPlugin, LoaderActionItem, LoaderActionSelection, From e59975fe95eebd0db025145a43c2ca46f9cce4e0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 17:10:17 +0200 Subject: [PATCH 072/108] add docstring --- client/ayon_core/pipeline/actions/utils.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/utils.py b/client/ayon_core/pipeline/actions/utils.py index 00a8e91d68..3502300ead 100644 --- a/client/ayon_core/pipeline/actions/utils.py +++ b/client/ayon_core/pipeline/actions/utils.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import uuid +from typing import Any from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, @@ -11,7 +14,21 @@ from ayon_core.lib.attribute_definitions import ( ) -def webaction_fields_to_attribute_defs(fields) -> list[AbstractAttrDef]: +def webaction_fields_to_attribute_defs( + fields: list[dict[str, Any]] +) -> list[AbstractAttrDef]: + """Helper function to convert fields definition from webactions form. + + Convert form fields to attribute definitions to be able to display them + using attribute definitions. + + Args: + fields (list[dict[str, Any]]): Fields from webaction form. + + Returns: + list[AbstractAttrDef]: Converted attribute definitions. + + """ attr_defs = [] for field in fields: field_type = field["type"] From 8ba1a4068500567ad5caf9b933806079e9bdef27 Mon Sep 17 00:00:00 2001 From: marvill85 <32180676+marvill85@users.noreply.github.com> Date: Fri, 7 Nov 2025 17:23:32 +0100 Subject: [PATCH 073/108] Update workfile_template_builder.py Add optional folder_path_regex filtering to linked folder retrieval --- .../pipeline/workfile/workfile_template_builder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 52e27baa80..461515987a 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -300,7 +300,7 @@ class AbstractTemplateBuilder(ABC): self._loaders_by_name = get_loaders_by_name() return self._loaders_by_name - def get_linked_folder_entities(self, link_type: Optional[str]): + def get_linked_folder_entities(self, link_type: Optional[str], folder_path_regex: Optional[str]): if not link_type: return [] project_name = self.project_name @@ -317,7 +317,7 @@ class AbstractTemplateBuilder(ABC): if link["entityType"] == "folder" } - return list(get_folders(project_name, folder_ids=linked_folder_ids)) + return list(get_folders(project_name, folder_path_regex=folder_path_regex, folder_ids=linked_folder_ids)) def _collect_creators(self): self._creators_by_name = { @@ -1638,7 +1638,7 @@ class PlaceholderLoadMixin(object): linked_folder_entity["id"] for linked_folder_entity in ( self.builder.get_linked_folder_entities( - link_type=link_type)) + link_type=link_type, folder_path_regex=folder_path_regex)) ] if not folder_ids: From d1ef11defa7f193bede9cbeebfe03771085fee02 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:06:56 +0100 Subject: [PATCH 074/108] define helper widget for folders filtering --- client/ayon_core/tools/utils/__init__.py | 2 + .../ayon_core/tools/utils/folders_widget.py | 40 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py index 111b7c614b..56989927ee 100644 --- a/client/ayon_core/tools/utils/__init__.py +++ b/client/ayon_core/tools/utils/__init__.py @@ -76,6 +76,7 @@ from .folders_widget import ( FoldersQtModel, FOLDERS_MODEL_SENDER_NAME, SimpleFoldersWidget, + FoldersFiltersWidget, ) from .tasks_widget import ( @@ -160,6 +161,7 @@ __all__ = ( "FoldersQtModel", "FOLDERS_MODEL_SENDER_NAME", "SimpleFoldersWidget", + "FoldersFiltersWidget", "TasksWidget", "TasksQtModel", diff --git a/client/ayon_core/tools/utils/folders_widget.py b/client/ayon_core/tools/utils/folders_widget.py index 7b71dd087c..12f4bebdae 100644 --- a/client/ayon_core/tools/utils/folders_widget.py +++ b/client/ayon_core/tools/utils/folders_widget.py @@ -15,6 +15,8 @@ from ayon_core.tools.common_models import ( from .models import RecursiveSortFilterProxyModel from .views import TreeView from .lib import RefreshThread, get_qt_icon +from .widgets import PlaceholderLineEdit +from .nice_checkbox import NiceCheckbox FOLDERS_MODEL_SENDER_NAME = "qt_folders_model" @@ -794,3 +796,41 @@ class SimpleFoldersWidget(FoldersWidget): event (Event): Triggered event. """ pass + + +class FoldersFiltersWidget(QtWidgets.QWidget): + """Helper widget for most commonly used filters in context selection.""" + text_changed = QtCore.Signal(str) + my_tasks_changed = QtCore.Signal(bool) + + def __init__(self, parent: QtWidgets.QWidget) -> None: + super().__init__(parent) + + folders_filter_input = PlaceholderLineEdit(self) + folders_filter_input.setPlaceholderText("Folder name filter...") + + my_tasks_tooltip = ( + "Filter folders and task to only those you are assigned to." + ) + my_tasks_label = QtWidgets.QLabel("My tasks", self) + my_tasks_label.setToolTip(my_tasks_tooltip) + + my_tasks_checkbox = NiceCheckbox(self) + my_tasks_checkbox.setChecked(False) + my_tasks_checkbox.setToolTip(my_tasks_tooltip) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + layout.addWidget(folders_filter_input, 1) + layout.addWidget(my_tasks_label, 0) + layout.addWidget(my_tasks_checkbox, 0) + + folders_filter_input.textChanged.connect(self.text_changed) + my_tasks_checkbox.stateChanged.connect(self._on_my_tasks_change) + + self._folders_filter_input = folders_filter_input + self._my_tasks_checkbox = my_tasks_checkbox + + def _on_my_tasks_change(self, _state: int) -> None: + self.my_tasks_changed.emit(self._my_tasks_checkbox.isChecked()) From cef3bc229a4508e0f19c2dd5fadcb2b0f96c2233 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:07:12 +0100 Subject: [PATCH 075/108] disable case sensitivity for folders proxy --- client/ayon_core/tools/utils/folders_widget.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/tools/utils/folders_widget.py b/client/ayon_core/tools/utils/folders_widget.py index 12f4bebdae..126363086b 100644 --- a/client/ayon_core/tools/utils/folders_widget.py +++ b/client/ayon_core/tools/utils/folders_widget.py @@ -345,6 +345,8 @@ class FoldersProxyModel(RecursiveSortFilterProxyModel): def __init__(self): super().__init__() + self.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + self._folder_ids_filter = None def set_folder_ids_filter(self, folder_ids: Optional[list[str]]): From 0dd47211c54850066d6440599b6fb540b388949d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:13:24 +0100 Subject: [PATCH 076/108] add 'get_current_username' to UsersModel --- client/ayon_core/tools/common_models/users.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/common_models/users.py b/client/ayon_core/tools/common_models/users.py index f7939e5cd3..42a76d8d7d 100644 --- a/client/ayon_core/tools/common_models/users.py +++ b/client/ayon_core/tools/common_models/users.py @@ -1,10 +1,13 @@ import json import collections +from typing import Optional import ayon_api from ayon_api.graphql import FIELD_VALUE, GraphQlQuery, fields_to_dict -from ayon_core.lib import NestedCacheItem +from ayon_core.lib import NestedCacheItem, get_ayon_username + +NOT_SET = object() # --- Implementation that should be in ayon-python-api --- @@ -105,9 +108,18 @@ class UserItem: class UsersModel: def __init__(self, controller): + self._current_username = NOT_SET self._controller = controller self._users_cache = NestedCacheItem(default_factory=list) + def get_current_username(self) -> Optional[str]: + if self._current_username is NOT_SET: + self._current_username = get_ayon_username() + return self._current_username + + def reset(self) -> None: + self._users_cache.reset() + def get_user_items(self, project_name): """Get user items. From ad83d827e2a1dcb092d191360a1756086813f9cf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:14:32 +0100 Subject: [PATCH 077/108] move private methods below public one --- client/ayon_core/tools/loader/control.py | 28 ++++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 9f159bfb21..9ec3e580d9 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -476,20 +476,6 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): def is_standard_projects_filter_enabled(self): return self._host is not None - def _get_project_anatomy(self, project_name): - if not project_name: - return None - cache = self._project_anatomy_cache[project_name] - if not cache.is_valid: - cache.update_data(Anatomy(project_name)) - return cache.get_data() - - def _create_event_system(self): - return QueuedEventSystem() - - def _emit_event(self, topic, data=None): - self._event_system.emit(topic, data or {}, "controller") - def get_product_types_filter(self): output = ProductTypesFilter( is_allow_list=False, @@ -545,3 +531,17 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): product_types=profile["filter_product_types"] ) return output + + def _create_event_system(self): + return QueuedEventSystem() + + def _emit_event(self, topic, data=None): + self._event_system.emit(topic, data or {}, "controller") + + def _get_project_anatomy(self, project_name): + if not project_name: + return None + cache = self._project_anatomy_cache[project_name] + if not cache.is_valid: + cache.update_data(Anatomy(project_name)) + return cache.get_data() From 91d44a833b7915b9bbbccb23ad81fd45df295db2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:22:44 +0100 Subject: [PATCH 078/108] implemented my tasks filter to browser --- client/ayon_core/tools/loader/abstract.py | 15 +++++++++ client/ayon_core/tools/loader/control.py | 20 +++++++++++- .../tools/loader/ui/folders_widget.py | 19 ++++++++--- .../ayon_core/tools/loader/ui/tasks_widget.py | 21 ++++++++++-- client/ayon_core/tools/loader/ui/window.py | 32 ++++++++++++++----- 5 files changed, 91 insertions(+), 16 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 9c7934d2db..089d298b2c 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -666,6 +666,21 @@ class FrontendLoaderController(_BaseLoaderController): """ pass + @abstractmethod + def get_my_tasks_entity_ids( + self, project_name: str + ) -> dict[str, list[str]]: + """Get entity ids for my tasks. + + Args: + project_name (str): Project name. + + Returns: + dict[str, list[str]]: Folder and task ids. + + """ + pass + @abstractmethod def get_available_tags_by_entity_type( self, project_name: str diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 9ec3e580d9..2a86a50b6d 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -8,7 +8,11 @@ import ayon_api from ayon_core.settings import get_project_settings from ayon_core.pipeline import get_current_host_name -from ayon_core.lib import NestedCacheItem, CacheItem, filter_profiles +from ayon_core.lib import ( + NestedCacheItem, + CacheItem, + filter_profiles, +) from ayon_core.lib.events import QueuedEventSystem from ayon_core.pipeline import Anatomy, get_current_context from ayon_core.host import ILoadHost @@ -18,6 +22,7 @@ from ayon_core.tools.common_models import ( ThumbnailsModel, TagItem, ProductTypeIconMapping, + UsersModel, ) from .abstract import ( @@ -32,6 +37,8 @@ from .models import ( SiteSyncModel ) +NOT_SET = object() + class ExpectedSelection: def __init__(self, controller): @@ -124,6 +131,7 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): self._loader_actions_model = LoaderActionsModel(self) self._thumbnails_model = ThumbnailsModel() self._sitesync_model = SiteSyncModel(self) + self._users_model = UsersModel(self) @property def log(self): @@ -160,6 +168,7 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): self._projects_model.reset() self._thumbnails_model.reset() self._sitesync_model.reset() + self._users_model.reset() self._projects_model.refresh() @@ -235,6 +244,15 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): output[folder_id] = label return output + def get_my_tasks_entity_ids(self, project_name: str): + username = self._users_model.get_current_username() + assignees = [] + if username: + assignees.append(username) + return self._hierarchy_model.get_entity_ids_for_assignees( + project_name, assignees + ) + def get_available_tags_by_entity_type( self, project_name: str ) -> dict[str, list[str]]: diff --git a/client/ayon_core/tools/loader/ui/folders_widget.py b/client/ayon_core/tools/loader/ui/folders_widget.py index f238eabcef..6de0b17ea2 100644 --- a/client/ayon_core/tools/loader/ui/folders_widget.py +++ b/client/ayon_core/tools/loader/ui/folders_widget.py @@ -1,11 +1,11 @@ +from typing import Optional + import qtpy from qtpy import QtWidgets, QtCore, QtGui -from ayon_core.tools.utils import ( - RecursiveSortFilterProxyModel, - DeselectableTreeView, -) from ayon_core.style import get_objected_colors +from ayon_core.tools.utils import DeselectableTreeView +from ayon_core.tools.utils.folders_widget import FoldersProxyModel from ayon_core.tools.utils import ( FoldersQtModel, @@ -260,7 +260,7 @@ class LoaderFoldersWidget(QtWidgets.QWidget): QtWidgets.QAbstractItemView.ExtendedSelection) folders_model = LoaderFoldersModel(controller) - folders_proxy_model = RecursiveSortFilterProxyModel() + folders_proxy_model = FoldersProxyModel() folders_proxy_model.setSourceModel(folders_model) folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) @@ -314,6 +314,15 @@ class LoaderFoldersWidget(QtWidgets.QWidget): if name: self._folders_view.expandAll() + def set_folder_ids_filter(self, folder_ids: Optional[list[str]]): + """Set filter of folder ids. + + Args: + folder_ids (list[str]): The list of folder ids. + + """ + self._folders_proxy_model.set_folder_ids_filter(folder_ids) + def set_merged_products_selection(self, items): """ diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py index cc7e2e9c95..3a38739cf0 100644 --- a/client/ayon_core/tools/loader/ui/tasks_widget.py +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -1,11 +1,11 @@ import collections import hashlib +from typing import Optional from qtpy import QtWidgets, QtCore, QtGui from ayon_core.style import get_default_entity_icon_color from ayon_core.tools.utils import ( - RecursiveSortFilterProxyModel, DeselectableTreeView, TasksQtModel, TASKS_MODEL_SENDER_NAME, @@ -15,9 +15,11 @@ from ayon_core.tools.utils.tasks_widget import ( ITEM_NAME_ROLE, PARENT_ID_ROLE, TASK_TYPE_ROLE, + TasksProxyModel, ) from ayon_core.tools.utils.lib import RefreshThread, get_qt_icon + # Role that can't clash with default 'tasks_widget' roles FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 100 NO_TASKS_ID = "--no-task--" @@ -295,7 +297,7 @@ class LoaderTasksQtModel(TasksQtModel): return super().data(index, role) -class LoaderTasksProxyModel(RecursiveSortFilterProxyModel): +class LoaderTasksProxyModel(TasksProxyModel): def lessThan(self, left, right): if left.data(ITEM_ID_ROLE) == NO_TASKS_ID: return False @@ -303,6 +305,12 @@ class LoaderTasksProxyModel(RecursiveSortFilterProxyModel): return True return super().lessThan(left, right) + def filterAcceptsRow(self, row, parent_index): + source_index = self.sourceModel().index(row, 0, parent_index) + if source_index.data(ITEM_ID_ROLE) == NO_TASKS_ID: + return True + return super().filterAcceptsRow(row, parent_index) + class LoaderTasksWidget(QtWidgets.QWidget): refreshed = QtCore.Signal() @@ -363,6 +371,15 @@ class LoaderTasksWidget(QtWidgets.QWidget): if name: self._tasks_view.expandAll() + def set_task_ids_filter(self, task_ids: Optional[list[str]]): + """Set filter of folder ids. + + Args: + task_ids (list[str]): The list of folder ids. + + """ + self._tasks_proxy_model.set_task_ids_filter(task_ids) + def refresh(self): self._tasks_model.refresh() diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index df5beb708f..d1d1222f51 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -5,14 +5,14 @@ from qtpy import QtWidgets, QtCore, QtGui from ayon_core.resources import get_ayon_icon_filepath from ayon_core.style import load_stylesheet from ayon_core.tools.utils import ( - PlaceholderLineEdit, ErrorMessageBox, ThumbnailPainterWidget, RefreshButton, GoToCurrentButton, + FoldersFiltersWidget, ) from ayon_core.tools.utils.lib import center_window -from ayon_core.tools.utils import ProjectsCombobox +from ayon_core.tools.utils import ProjectsCombobox, NiceCheckbox from ayon_core.tools.common_models import StatusItem from ayon_core.tools.loader.abstract import ProductTypeItem from ayon_core.tools.loader.control import LoaderController @@ -170,15 +170,14 @@ class LoaderWindow(QtWidgets.QWidget): context_top_layout.addWidget(go_to_current_btn, 0) context_top_layout.addWidget(refresh_btn, 0) - folders_filter_input = PlaceholderLineEdit(context_widget) - folders_filter_input.setPlaceholderText("Folder name filter...") + filters_widget = FoldersFiltersWidget(context_widget) folders_widget = LoaderFoldersWidget(controller, context_widget) context_layout = QtWidgets.QVBoxLayout(context_widget) context_layout.setContentsMargins(0, 0, 0, 0) context_layout.addWidget(context_top_widget, 0) - context_layout.addWidget(folders_filter_input, 0) + context_layout.addWidget(filters_widget, 0) context_layout.addWidget(folders_widget, 1) tasks_widget = LoaderTasksWidget(controller, context_widget) @@ -247,9 +246,12 @@ class LoaderWindow(QtWidgets.QWidget): projects_combobox.refreshed.connect(self._on_projects_refresh) folders_widget.refreshed.connect(self._on_folders_refresh) products_widget.refreshed.connect(self._on_products_refresh) - folders_filter_input.textChanged.connect( + filters_widget.text_changed.connect( self._on_folder_filter_change ) + filters_widget.my_tasks_changed.connect( + self._on_my_tasks_checkbox_state_changed + ) search_bar.filter_changed.connect(self._on_filter_change) product_group_checkbox.stateChanged.connect( self._on_product_group_change @@ -303,7 +305,7 @@ class LoaderWindow(QtWidgets.QWidget): self._refresh_btn = refresh_btn self._projects_combobox = projects_combobox - self._folders_filter_input = folders_filter_input + self._filters_widget = filters_widget self._folders_widget = folders_widget self._tasks_widget = tasks_widget @@ -421,9 +423,23 @@ class LoaderWindow(QtWidgets.QWidget): self._group_dialog.set_product_ids(project_name, product_ids) self._group_dialog.show() - def _on_folder_filter_change(self, text): + def _on_folder_filter_change(self, text: str) -> None: self._folders_widget.set_name_filter(text) + def _on_my_tasks_checkbox_state_changed(self, enabled: bool) -> None: + # self._folders_widget + folder_ids = None + task_ids = None + if enabled: + entity_ids = self._controller.get_my_tasks_entity_ids( + self._selected_project_name + ) + folder_ids = entity_ids["folder_ids"] + task_ids = entity_ids["task_ids"] + self._folders_widget.set_folder_ids_filter(folder_ids) + self._tasks_widget.set_task_ids_filter(task_ids) + + def _on_product_group_change(self): self._products_widget.set_enable_grouping( self._product_group_checkbox.isChecked() From e6325fa2e81580555cb07847e6e78cfac2c6376e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:27:49 +0100 Subject: [PATCH 079/108] use 'FoldersFiltersWidget' in launcher --- .../tools/launcher/ui/hierarchy_page.py | 37 +++---------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index dc535db5fc..8554a5af8c 100644 --- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py +++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py @@ -2,14 +2,13 @@ import qtawesome from qtpy import QtWidgets, QtCore from ayon_core.tools.utils import ( - PlaceholderLineEdit, SquareButton, RefreshButton, ProjectsCombobox, FoldersWidget, TasksWidget, - NiceCheckbox, ) +from ayon_core.tools.utils.folders_widget import FoldersFiltersWidget from ayon_core.tools.utils.lib import checkstate_int_to_enum from .workfiles_page import WorkfilesPage @@ -76,26 +75,7 @@ class HierarchyPage(QtWidgets.QWidget): content_body.setOrientation(QtCore.Qt.Horizontal) # - filters - filters_widget = QtWidgets.QWidget(self) - - folders_filter_text = PlaceholderLineEdit(filters_widget) - folders_filter_text.setPlaceholderText("Filter folders...") - - my_tasks_tooltip = ( - "Filter folders and task to only those you are assigned to." - ) - my_tasks_label = QtWidgets.QLabel("My tasks", filters_widget) - my_tasks_label.setToolTip(my_tasks_tooltip) - - my_tasks_checkbox = NiceCheckbox(filters_widget) - my_tasks_checkbox.setChecked(False) - my_tasks_checkbox.setToolTip(my_tasks_tooltip) - - filters_layout = QtWidgets.QHBoxLayout(filters_widget) - filters_layout.setContentsMargins(0, 0, 0, 0) - filters_layout.addWidget(folders_filter_text, 1) - filters_layout.addWidget(my_tasks_label, 0) - filters_layout.addWidget(my_tasks_checkbox, 0) + filters_widget = FoldersFiltersWidget(self) # - Folders widget folders_widget = LauncherFoldersWidget(controller, content_body) @@ -123,8 +103,8 @@ class HierarchyPage(QtWidgets.QWidget): btn_back.clicked.connect(self._on_back_clicked) refresh_btn.clicked.connect(self._on_refresh_clicked) - folders_filter_text.textChanged.connect(self._on_filter_text_changed) - my_tasks_checkbox.stateChanged.connect( + filters_widget.text_changed.connect(self._on_filter_text_changed) + filters_widget.my_tasks_changed.connect( self._on_my_tasks_checkbox_state_changed ) folders_widget.focused_in.connect(self._on_folders_focus) @@ -135,7 +115,6 @@ class HierarchyPage(QtWidgets.QWidget): self._btn_back = btn_back self._projects_combobox = projects_combobox - self._my_tasks_checkbox = my_tasks_checkbox self._folders_widget = folders_widget self._tasks_widget = tasks_widget self._workfiles_page = workfiles_page @@ -158,9 +137,6 @@ class HierarchyPage(QtWidgets.QWidget): self._folders_widget.refresh() self._tasks_widget.refresh() self._workfiles_page.refresh() - self._on_my_tasks_checkbox_state_changed( - self._my_tasks_checkbox.checkState() - ) def _on_back_clicked(self): self._controller.set_selected_project(None) @@ -171,11 +147,10 @@ class HierarchyPage(QtWidgets.QWidget): def _on_filter_text_changed(self, text): self._folders_widget.set_name_filter(text) - def _on_my_tasks_checkbox_state_changed(self, state): + def _on_my_tasks_checkbox_state_changed(self, enabled: bool) -> None: folder_ids = None task_ids = None - state = checkstate_int_to_enum(state) - if state == QtCore.Qt.Checked: + if enabled: entity_ids = self._controller.get_my_tasks_entity_ids( self._project_name ) From 9c3dec09c9382aa210f59583081e7224b0e8ec22 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:53:36 +0100 Subject: [PATCH 080/108] small cleanup --- client/ayon_core/tools/launcher/control.py | 23 +++++++++++----------- client/ayon_core/tools/loader/control.py | 4 +++- client/ayon_core/tools/loader/ui/window.py | 2 -- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/tools/launcher/control.py b/client/ayon_core/tools/launcher/control.py index 85b362f9d7..f4656de787 100644 --- a/client/ayon_core/tools/launcher/control.py +++ b/client/ayon_core/tools/launcher/control.py @@ -1,10 +1,14 @@ from typing import Optional -from ayon_core.lib import Logger, get_ayon_username +from ayon_core.lib import Logger from ayon_core.lib.events import QueuedEventSystem from ayon_core.addon import AddonsManager from ayon_core.settings import get_project_settings, get_studio_settings -from ayon_core.tools.common_models import ProjectsModel, HierarchyModel +from ayon_core.tools.common_models import ( + ProjectsModel, + HierarchyModel, + UsersModel, +) from .abstract import ( AbstractLauncherFrontEnd, @@ -30,13 +34,12 @@ class BaseLauncherController( self._addons_manager = None - self._username = NOT_SET - self._selection_model = LauncherSelectionModel(self) self._projects_model = ProjectsModel(self) self._hierarchy_model = HierarchyModel(self) self._actions_model = ActionsModel(self) self._workfiles_model = WorkfilesModel(self) + self._users_model = UsersModel(self) @property def log(self): @@ -209,6 +212,7 @@ class BaseLauncherController( self._projects_model.reset() self._hierarchy_model.reset() + self._users_model.reset() self._actions_model.refresh() self._projects_model.refresh() @@ -229,8 +233,10 @@ class BaseLauncherController( self._emit_event("controller.refresh.actions.finished") - def get_my_tasks_entity_ids(self, project_name: str): - username = self._get_my_username() + def get_my_tasks_entity_ids( + self, project_name: str + ) -> dict[str, list[str]]: + username = self._users_model.get_current_username() assignees = [] if username: assignees.append(username) @@ -238,10 +244,5 @@ class BaseLauncherController( project_name, assignees ) - def _get_my_username(self): - if self._username is NOT_SET: - self._username = get_ayon_username() - return self._username - def _emit_event(self, topic, data=None): self.emit_event(topic, data, "controller") diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 2a86a50b6d..d0cc9db2f5 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -244,7 +244,9 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): output[folder_id] = label return output - def get_my_tasks_entity_ids(self, project_name: str): + def get_my_tasks_entity_ids( + self, project_name: str + ) -> dict[str, list[str]]: username = self._users_model.get_current_username() assignees = [] if username: diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index d1d1222f51..48704e7481 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -427,7 +427,6 @@ class LoaderWindow(QtWidgets.QWidget): self._folders_widget.set_name_filter(text) def _on_my_tasks_checkbox_state_changed(self, enabled: bool) -> None: - # self._folders_widget folder_ids = None task_ids = None if enabled: @@ -439,7 +438,6 @@ class LoaderWindow(QtWidgets.QWidget): self._folders_widget.set_folder_ids_filter(folder_ids) self._tasks_widget.set_task_ids_filter(task_ids) - def _on_product_group_change(self): self._products_widget.set_enable_grouping( self._product_group_checkbox.isChecked() From 3a6ee43f22e94d9c3f304787820433b3277e3761 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:22:20 +0100 Subject: [PATCH 081/108] added doption to change filters --- client/ayon_core/tools/utils/folders_widget.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/ayon_core/tools/utils/folders_widget.py b/client/ayon_core/tools/utils/folders_widget.py index 126363086b..f506af5352 100644 --- a/client/ayon_core/tools/utils/folders_widget.py +++ b/client/ayon_core/tools/utils/folders_widget.py @@ -834,5 +834,11 @@ class FoldersFiltersWidget(QtWidgets.QWidget): self._folders_filter_input = folders_filter_input self._my_tasks_checkbox = my_tasks_checkbox + def set_text(self, text: str) -> None: + self._folders_filter_input.setText(text) + + def set_my_tasks_checked(self, checked: bool) -> None: + self._my_tasks_checkbox.setChecked(checked) + def _on_my_tasks_change(self, _state: int) -> None: self.my_tasks_changed.emit(self._my_tasks_checkbox.isChecked()) From f9f55b48b03fefa20cb2c361e676858afba1c78e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:23:19 +0100 Subject: [PATCH 082/108] added my tasks filtering to publisher --- client/ayon_core/tools/publisher/abstract.py | 15 +++ client/ayon_core/tools/publisher/control.py | 19 +++- .../widgets/create_context_widgets.py | 33 ++++-- .../tools/publisher/widgets/create_widget.py | 12 +- .../tools/publisher/widgets/folders_dialog.py | 107 ++++++++++-------- 5 files changed, 126 insertions(+), 60 deletions(-) diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 14da15793d..bfd0948519 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -295,6 +295,21 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): """Get folder id from folder path.""" pass + @abstractmethod + def get_my_tasks_entity_ids( + self, project_name: str + ) -> dict[str, list[str]]: + """Get entity ids for my tasks. + + Args: + project_name (str): Project name. + + Returns: + dict[str, list[str]]: Folder and task ids. + + """ + pass + # --- Create --- @abstractmethod def get_creator_items(self) -> Dict[str, "CreatorItem"]: diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index 038816c6fc..3d11131dc3 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -11,7 +11,11 @@ from ayon_core.pipeline import ( registered_host, get_process_id, ) -from ayon_core.tools.common_models import ProjectsModel, HierarchyModel +from ayon_core.tools.common_models import ( + ProjectsModel, + HierarchyModel, + UsersModel, +) from .models import ( PublishModel, @@ -101,6 +105,7 @@ class PublisherController( # Cacher of avalon documents self._projects_model = ProjectsModel(self) self._hierarchy_model = HierarchyModel(self) + self._users_model = UsersModel(self) @property def log(self): @@ -317,6 +322,17 @@ class PublisherController( return False return True + def get_my_tasks_entity_ids( + self, project_name: str + ) -> dict[str, list[str]]: + username = self._users_model.get_current_username() + assignees = [] + if username: + assignees.append(username) + return self._hierarchy_model.get_entity_ids_for_assignees( + project_name, assignees + ) + # --- Publish specific callbacks --- def get_context_title(self): """Get context title for artist shown at the top of main window.""" @@ -359,6 +375,7 @@ class PublisherController( self._emit_event("controller.reset.started") self._hierarchy_model.reset() + self._users_model.reset() # Publish part must be reset after plugins self._create_model.reset() diff --git a/client/ayon_core/tools/publisher/widgets/create_context_widgets.py b/client/ayon_core/tools/publisher/widgets/create_context_widgets.py index faf2248181..49d236353f 100644 --- a/client/ayon_core/tools/publisher/widgets/create_context_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/create_context_widgets.py @@ -1,10 +1,14 @@ from qtpy import QtWidgets, QtCore from ayon_core.lib.events import QueuedEventSystem -from ayon_core.tools.utils import PlaceholderLineEdit, GoToCurrentButton from ayon_core.tools.common_models import HierarchyExpectedSelection -from ayon_core.tools.utils import FoldersWidget, TasksWidget +from ayon_core.tools.utils import ( + FoldersWidget, + TasksWidget, + FoldersFiltersWidget, + GoToCurrentButton, +) from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend @@ -180,8 +184,7 @@ class CreateContextWidget(QtWidgets.QWidget): headers_widget = QtWidgets.QWidget(self) - folder_filter_input = PlaceholderLineEdit(headers_widget) - folder_filter_input.setPlaceholderText("Filter folders..") + filters_widget = FoldersFiltersWidget(headers_widget) current_context_btn = GoToCurrentButton(headers_widget) current_context_btn.setToolTip("Go to current context") @@ -189,7 +192,8 @@ class CreateContextWidget(QtWidgets.QWidget): headers_layout = QtWidgets.QHBoxLayout(headers_widget) headers_layout.setContentsMargins(0, 0, 0, 0) - headers_layout.addWidget(folder_filter_input, 1) + headers_layout.setSpacing(5) + headers_layout.addWidget(filters_widget, 1) headers_layout.addWidget(current_context_btn, 0) hierarchy_controller = CreateHierarchyController(controller) @@ -207,15 +211,16 @@ class CreateContextWidget(QtWidgets.QWidget): main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) main_layout.addWidget(headers_widget, 0) + main_layout.addSpacing(5) main_layout.addWidget(folders_widget, 2) main_layout.addWidget(tasks_widget, 1) folders_widget.selection_changed.connect(self._on_folder_change) tasks_widget.selection_changed.connect(self._on_task_change) current_context_btn.clicked.connect(self._on_current_context_click) - folder_filter_input.textChanged.connect(self._on_folder_filter_change) + filters_widget.text_changed.connect(self._on_folder_filter_change) + filters_widget.my_tasks_changed.connect(self._on_my_tasks_change) - self._folder_filter_input = folder_filter_input self._current_context_btn = current_context_btn self._folders_widget = folders_widget self._tasks_widget = tasks_widget @@ -303,5 +308,17 @@ class CreateContextWidget(QtWidgets.QWidget): self._last_project_name, folder_id, task_name ) - def _on_folder_filter_change(self, text): + def _on_folder_filter_change(self, text: str) -> None: self._folders_widget.set_name_filter(text) + + def _on_my_tasks_change(self, enabled: bool) -> None: + folder_ids = None + task_ids = None + if enabled: + entity_ids = self._controller.get_my_tasks_entity_ids( + self._last_project_name + ) + folder_ids = entity_ids["folder_ids"] + task_ids = entity_ids["task_ids"] + self._folders_widget.set_folder_ids_filter(folder_ids) + self._tasks_widget.set_task_ids_filter(task_ids) diff --git a/client/ayon_core/tools/publisher/widgets/create_widget.py b/client/ayon_core/tools/publisher/widgets/create_widget.py index b9b3afd895..d98bc95eb2 100644 --- a/client/ayon_core/tools/publisher/widgets/create_widget.py +++ b/client/ayon_core/tools/publisher/widgets/create_widget.py @@ -710,11 +710,13 @@ class CreateWidget(QtWidgets.QWidget): def _on_first_show(self): width = self.width() - part = int(width / 4) - rem_width = width - part - self._main_splitter_widget.setSizes([part, rem_width]) - rem_width = rem_width - part - self._creators_splitter.setSizes([part, rem_width]) + part = int(width / 9) + context_width = part * 3 + create_sel_width = part * 2 + rem_width = width - context_width + self._main_splitter_widget.setSizes([context_width, rem_width]) + rem_width -= create_sel_width + self._creators_splitter.setSizes([create_sel_width, rem_width]) def showEvent(self, event): super().showEvent(event) diff --git a/client/ayon_core/tools/publisher/widgets/folders_dialog.py b/client/ayon_core/tools/publisher/widgets/folders_dialog.py index d2eb68310e..e0d9c098d8 100644 --- a/client/ayon_core/tools/publisher/widgets/folders_dialog.py +++ b/client/ayon_core/tools/publisher/widgets/folders_dialog.py @@ -1,7 +1,10 @@ from qtpy import QtWidgets from ayon_core.lib.events import QueuedEventSystem -from ayon_core.tools.utils import PlaceholderLineEdit, FoldersWidget +from ayon_core.tools.utils import ( + FoldersWidget, + FoldersFiltersWidget, +) from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend @@ -43,8 +46,7 @@ class FoldersDialog(QtWidgets.QDialog): super().__init__(parent) self.setWindowTitle("Select folder") - filter_input = PlaceholderLineEdit(self) - filter_input.setPlaceholderText("Filter folders..") + filters_widget = FoldersFiltersWidget(self) folders_controller = FoldersDialogController(controller) folders_widget = FoldersWidget(folders_controller, self) @@ -59,7 +61,8 @@ class FoldersDialog(QtWidgets.QDialog): btns_layout.addWidget(cancel_btn) layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(filter_input, 0) + layout.setSpacing(5) + layout.addWidget(filters_widget, 0) layout.addWidget(folders_widget, 1) layout.addLayout(btns_layout, 0) @@ -68,12 +71,13 @@ class FoldersDialog(QtWidgets.QDialog): ) folders_widget.double_clicked.connect(self._on_ok_clicked) - filter_input.textChanged.connect(self._on_filter_change) + filters_widget.text_changed.connect(self._on_filter_change) + filters_widget.my_tasks_changed.connect(self._on_my_tasks_change) ok_btn.clicked.connect(self._on_ok_clicked) cancel_btn.clicked.connect(self._on_cancel_clicked) self._controller = controller - self._filter_input = filter_input + self._filters_widget = filters_widget self._ok_btn = ok_btn self._cancel_btn = cancel_btn @@ -88,6 +92,49 @@ class FoldersDialog(QtWidgets.QDialog): self._first_show = True self._default_height = 500 + self._project_name = None + + def showEvent(self, event): + """Refresh folders widget on show.""" + super().showEvent(event) + if self._first_show: + self._first_show = False + self._on_first_show() + # Refresh on show + self.reset(False) + + def reset(self, force=True): + """Reset widget.""" + if not force and not self._soft_reset_enabled: + return + + self._project_name = self._controller.get_current_project_name() + if self._soft_reset_enabled: + self._soft_reset_enabled = False + + self._folders_widget.set_project_name(self._project_name) + + def get_selected_folder_path(self): + """Get selected folder path.""" + return self._selected_folder_path + + def set_selected_folders(self, folder_paths: list[str]) -> None: + """Change preselected folder before showing the dialog. + + This also resets model and clean filter. + """ + self.reset(False) + self._filters_widget.set_text("") + self._filters_widget.set_my_tasks_checked(False) + + folder_id = None + for folder_path in folder_paths: + folder_id = self._controller.get_folder_id_from_path(folder_path) + if folder_id: + break + if folder_id: + self._folders_widget.set_selected_folder(folder_id) + def _on_first_show(self): center = self.rect().center() size = self.size() @@ -103,27 +150,6 @@ class FoldersDialog(QtWidgets.QDialog): # Change reset enabled so model is reset on show event self._soft_reset_enabled = True - def showEvent(self, event): - """Refresh folders widget on show.""" - super().showEvent(event) - if self._first_show: - self._first_show = False - self._on_first_show() - # Refresh on show - self.reset(False) - - def reset(self, force=True): - """Reset widget.""" - if not force and not self._soft_reset_enabled: - return - - if self._soft_reset_enabled: - self._soft_reset_enabled = False - - self._folders_widget.set_project_name( - self._controller.get_current_project_name() - ) - def _on_filter_change(self, text): """Trigger change of filter of folders.""" self._folders_widget.set_name_filter(text) @@ -137,22 +163,11 @@ class FoldersDialog(QtWidgets.QDialog): ) self.done(1) - def set_selected_folders(self, folder_paths): - """Change preselected folder before showing the dialog. - - This also resets model and clean filter. - """ - self.reset(False) - self._filter_input.setText("") - - folder_id = None - for folder_path in folder_paths: - folder_id = self._controller.get_folder_id_from_path(folder_path) - if folder_id: - break - if folder_id: - self._folders_widget.set_selected_folder(folder_id) - - def get_selected_folder_path(self): - """Get selected folder path.""" - return self._selected_folder_path + def _on_my_tasks_change(self, enabled: bool) -> None: + folder_ids = None + if enabled: + entity_ids = self._controller.get_my_tasks_entity_ids( + self._project_name + ) + folder_ids = entity_ids["folder_ids"] + self._folders_widget.set_folder_ids_filter(folder_ids) From ba4ecc6f80b0894e3c9345924106b0eeae6a109b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:04:29 +0100 Subject: [PATCH 083/108] use filters widget in workfiles tool --- .../tools/workfiles/widgets/window.py | 46 ++++++------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/client/ayon_core/tools/workfiles/widgets/window.py b/client/ayon_core/tools/workfiles/widgets/window.py index 00362ea866..811fe602d1 100644 --- a/client/ayon_core/tools/workfiles/widgets/window.py +++ b/client/ayon_core/tools/workfiles/widgets/window.py @@ -6,12 +6,11 @@ from ayon_core.tools.utils import ( FoldersWidget, GoToCurrentButton, MessageOverlayObject, - NiceCheckbox, PlaceholderLineEdit, RefreshButton, TasksWidget, + FoldersFiltersWidget, ) -from ayon_core.tools.utils.lib import checkstate_int_to_enum from ayon_core.tools.workfiles.control import BaseWorkfileController from .files_widget import FilesWidget @@ -69,7 +68,6 @@ class WorkfilesToolWindow(QtWidgets.QWidget): self._default_window_flags = flags self._folders_widget = None - self._folder_filter_input = None self._files_widget = None @@ -178,48 +176,33 @@ class WorkfilesToolWindow(QtWidgets.QWidget): col_widget = QtWidgets.QWidget(parent) header_widget = QtWidgets.QWidget(col_widget) - folder_filter_input = PlaceholderLineEdit(header_widget) - folder_filter_input.setPlaceholderText("Filter folders..") + filters_widget = FoldersFiltersWidget(header_widget) go_to_current_btn = GoToCurrentButton(header_widget) refresh_btn = RefreshButton(header_widget) + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(filters_widget, 1) + header_layout.addWidget(go_to_current_btn, 0) + header_layout.addWidget(refresh_btn, 0) + folder_widget = FoldersWidget( controller, col_widget, handle_expected_selection=True ) - my_tasks_tooltip = ( - "Filter folders and task to only those you are assigned to." - ) - - my_tasks_label = QtWidgets.QLabel("My tasks") - my_tasks_label.setToolTip(my_tasks_tooltip) - - my_tasks_checkbox = NiceCheckbox(folder_widget) - my_tasks_checkbox.setChecked(False) - my_tasks_checkbox.setToolTip(my_tasks_tooltip) - - header_layout = QtWidgets.QHBoxLayout(header_widget) - header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.addWidget(folder_filter_input, 1) - header_layout.addWidget(go_to_current_btn, 0) - header_layout.addWidget(refresh_btn, 0) - header_layout.addWidget(my_tasks_label, 0) - header_layout.addWidget(my_tasks_checkbox, 0) - col_layout = QtWidgets.QVBoxLayout(col_widget) col_layout.setContentsMargins(0, 0, 0, 0) col_layout.addWidget(header_widget, 0) col_layout.addWidget(folder_widget, 1) - folder_filter_input.textChanged.connect(self._on_folder_filter_change) - go_to_current_btn.clicked.connect(self._on_go_to_current_clicked) - refresh_btn.clicked.connect(self._on_refresh_clicked) - my_tasks_checkbox.stateChanged.connect( + filters_widget.text_changed.connect(self._on_folder_filter_change) + filters_widget.my_tasks_changed.connect( self._on_my_tasks_checkbox_state_changed ) + go_to_current_btn.clicked.connect(self._on_go_to_current_clicked) + refresh_btn.clicked.connect(self._on_refresh_clicked) - self._folder_filter_input = folder_filter_input self._folders_widget = folder_widget return col_widget @@ -403,11 +386,10 @@ class WorkfilesToolWindow(QtWidgets.QWidget): else: self.close() - def _on_my_tasks_checkbox_state_changed(self, state): + def _on_my_tasks_checkbox_state_changed(self, enabled: bool) -> None: folder_ids = None task_ids = None - state = checkstate_int_to_enum(state) - if state == QtCore.Qt.Checked: + if enabled: entity_ids = self._controller.get_my_tasks_entity_ids( self._project_name ) From dc7f1556750fdcece3b6391ae83fd8bb7e95eeac Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:07:24 +0100 Subject: [PATCH 084/108] remove unused imports --- client/ayon_core/tools/launcher/ui/hierarchy_page.py | 1 - client/ayon_core/tools/loader/ui/window.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index 8554a5af8c..3c8be4679e 100644 --- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py +++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py @@ -9,7 +9,6 @@ from ayon_core.tools.utils import ( TasksWidget, ) from ayon_core.tools.utils.folders_widget import FoldersFiltersWidget -from ayon_core.tools.utils.lib import checkstate_int_to_enum from .workfiles_page import WorkfilesPage diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 48704e7481..27e416b495 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -12,7 +12,7 @@ from ayon_core.tools.utils import ( FoldersFiltersWidget, ) from ayon_core.tools.utils.lib import center_window -from ayon_core.tools.utils import ProjectsCombobox, NiceCheckbox +from ayon_core.tools.utils import ProjectsCombobox from ayon_core.tools.common_models import StatusItem from ayon_core.tools.loader.abstract import ProductTypeItem from ayon_core.tools.loader.control import LoaderController From 7c8e7c23e9440c7a7d41de4973b8001cffd82bc0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 11 Nov 2025 14:19:04 +0100 Subject: [PATCH 085/108] Change code formatting --- .../workfile/workfile_template_builder.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 461515987a..9ce9579b58 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -300,7 +300,11 @@ class AbstractTemplateBuilder(ABC): self._loaders_by_name = get_loaders_by_name() return self._loaders_by_name - def get_linked_folder_entities(self, link_type: Optional[str], folder_path_regex: Optional[str]): + def get_linked_folder_entities( + self, + link_type: Optional[str], + folder_path_regex: Optional[str], + ): if not link_type: return [] project_name = self.project_name @@ -317,7 +321,11 @@ class AbstractTemplateBuilder(ABC): if link["entityType"] == "folder" } - return list(get_folders(project_name, folder_path_regex=folder_path_regex, folder_ids=linked_folder_ids)) + return list(get_folders( + project_name, + folder_path_regex=folder_path_regex, + folder_ids=linked_folder_ids, + )) def _collect_creators(self): self._creators_by_name = { @@ -1638,7 +1646,10 @@ class PlaceholderLoadMixin(object): linked_folder_entity["id"] for linked_folder_entity in ( self.builder.get_linked_folder_entities( - link_type=link_type, folder_path_regex=folder_path_regex)) + link_type=link_type, + folder_path_regex=folder_path_regex + ) + ) ] if not folder_ids: From 76cfa3e148e5a5d3064614ed82d83c1d71f111ea Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 11 Nov 2025 14:05:22 +0000 Subject: [PATCH 086/108] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 6aa30b935a..83a7d0a51d 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.7+dev" +__version__ = "1.6.8" diff --git a/package.py b/package.py index ff3fad5b19..b3e41b2e81 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.7+dev" +version = "1.6.8" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 6656f15249..212fe505b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.7+dev" +version = "1.6.8" description = "" authors = ["Ynput Team "] readme = "README.md" From 8fdc943553687c4d43dea9d855e906df1d8dbb4e Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 11 Nov 2025 14:05:58 +0000 Subject: [PATCH 087/108] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 83a7d0a51d..e481e81356 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.8" +__version__ = "1.6.8+dev" diff --git a/package.py b/package.py index b3e41b2e81..eb30148176 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.8" +version = "1.6.8+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 212fe505b9..6708432e85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.8" +version = "1.6.8+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From ccd54e16cc8be451c08b8b0da296f8ed10d43730 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 11 Nov 2025 14:07:19 +0000 Subject: [PATCH 088/108] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c79ca69fca..513e088fef 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.6.8 - 1.6.7 - 1.6.6 - 1.6.5 From f38a6dffba44e264aa06f9b424943378bbf4dd98 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 11 Nov 2025 16:40:59 +0100 Subject: [PATCH 089/108] Avoid repeating input channel names if e.g. R, G and B are reading from Y channel --- client/ayon_core/lib/transcoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 22396a5324..0255b5a9d4 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1545,7 +1545,7 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None): channels_arg += ",A={}".format(float(alpha_default)) input_channels.append("A") - input_channels_str = ",".join(input_channels) + input_channels_str = ",".join(set(input_channels)) subimages = oiio_input_info.get("subimages") input_arg = "-i" From e2c668769032b7a68753f31164887032a6b0ce2e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 11 Nov 2025 16:46:04 +0100 Subject: [PATCH 090/108] Preserve order when making unique to avoid error on `R,G,B` becoming `B,G,R` but the channels being using in `R,G,B` order in `--ch` argument --- client/ayon_core/lib/transcoding.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 0255b5a9d4..076ee79665 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1545,7 +1545,8 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None): channels_arg += ",A={}".format(float(alpha_default)) input_channels.append("A") - input_channels_str = ",".join(set(input_channels)) + # Make sure channels are unique, but preserve order to avoid oiiotool crash + input_channels_str = ",".join(list(dict.fromkeys(input_channels))) subimages = oiio_input_info.get("subimages") input_arg = "-i" From 42642ebd34f5b35bc42cf6e5ace0c7a6866a2426 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 09:41:55 +0100 Subject: [PATCH 091/108] use graphql to get projects --- .../ayon_core/tools/common_models/projects.py | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index 250c3b020d..3e090e18b8 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -1,11 +1,13 @@ from __future__ import annotations +import json import contextlib from abc import ABC, abstractmethod from typing import Any, Optional from dataclasses import dataclass import ayon_api +from ayon_api.graphql_queries import projects_graphql_query from ayon_core.style import get_default_entity_icon_color from ayon_core.lib import CacheItem, NestedCacheItem @@ -290,6 +292,7 @@ def _get_project_items_from_entitiy( return [ ProjectItem.from_entity(project) for project in projects + if project["active"] ] @@ -538,8 +541,32 @@ class ProjectsModel(object): self._projects_cache.update_data(project_items) return self._projects_cache.get_data() + def _fetch_projects_bc(self) -> list[dict[str, Any]]: + """Fetch projects using GraphQl. + + This method was added because ayon_api had a bug in 'get_projects'. + + Returns: + list[dict[str, Any]]: List of projects. + + """ + api = ayon_api.get_server_api_connection() + query = projects_graphql_query({"name", "active", "library", "data"}) + + projects = [] + for parsed_data in query.continuous_query(api): + for project in parsed_data["projects"]: + project_data = project["data"] + if project_data is None: + project["data"] = {} + elif isinstance(project_data, str): + project["data"] = json.loads(project_data) + projects.append(project) + return projects + def _query_projects(self) -> list[ProjectItem]: - projects = ayon_api.get_projects(fields=["name", "active", "library"]) + projects = self._fetch_projects_bc() + user = ayon_api.get_user() pinned_projects = ( user From 5ede9cb091504f0ff2c3d0f730039862b102fc66 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:21:34 +0100 Subject: [PATCH 092/108] add less/greater than to allowed chars --- server/settings/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/settings/tools.py b/server/settings/tools.py index f40c7c3627..3b75a9ba23 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -34,7 +34,7 @@ class ProductNameProfile(BaseSettingsModel): enum_resolver=task_types_enum ) tasks: list[str] = SettingsField(default_factory=list, title="Task names") - template: str = SettingsField("", title="Template") + template: str = SettingsField("", title="Template", regex=r"^[<>{}\[\]a-zA-Z0-9_.]+$") class FilterCreatorProfile(BaseSettingsModel): From ca8b776ce12f86dc9ce9709ac59f14d34be9718c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:23:26 +0100 Subject: [PATCH 093/108] added conversion function --- server/settings/conversion.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 34820b5b32..4620202346 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -1,8 +1,26 @@ +import re import copy from typing import Any from .publish_plugins import DEFAULT_PUBLISH_VALUES +PRODUCT_NAME_REPL_REGEX = re.compile(r"[^<>{}\[\]a-zA-Z0-9_.]") + + +def _convert_imageio_configs_1_6_5(overrides): + product_name_profiles = ( + overrides + .get("tools", {}) + .get("creator", {}) + .get("product_name_profiles") + ) + if isinstance(product_name_profiles, list): + for item in product_name_profiles: + # Remove unsupported product name characters + template = item.get("template") + if isinstance(template, str): + item["template"] = PRODUCT_NAME_REPL_REGEX.sub("", template) + def _convert_imageio_configs_0_4_5(overrides): """Imageio config settings did change to profiles since 0.4.5.""" From 2f893574f4ac4b4f67e39d11ba4f2a0f50a97cfa Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:24:02 +0100 Subject: [PATCH 094/108] change 'tasks' and 'hosts' to full attr names --- server/settings/conversion.py | 7 ++++++ server/settings/tools.py | 44 +++++++++++++++++------------------ 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 4620202346..846b91edab 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -21,6 +21,13 @@ def _convert_imageio_configs_1_6_5(overrides): if isinstance(template, str): item["template"] = PRODUCT_NAME_REPL_REGEX.sub("", template) + for new_key, old_key in ( + ("host_names", "hosts"), + ("task_names", "tasks"), + ): + if old_key in item: + item[new_key] = item.get(old_key) + def _convert_imageio_configs_0_4_5(overrides): """Imageio config settings did change to profiles since 0.4.5.""" diff --git a/server/settings/tools.py b/server/settings/tools.py index 3b75a9ba23..7e397d4874 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -27,13 +27,13 @@ class ProductNameProfile(BaseSettingsModel): product_types: list[str] = SettingsField( default_factory=list, title="Product types" ) - hosts: list[str] = SettingsField(default_factory=list, title="Hosts") + host_names: list[str] = SettingsField(default_factory=list, title="Host names") task_types: list[str] = SettingsField( default_factory=list, title="Task types", enum_resolver=task_types_enum ) - tasks: list[str] = SettingsField(default_factory=list, title="Task names") + task_names: list[str] = SettingsField(default_factory=list, title="Task names") template: str = SettingsField("", title="Template", regex=r"^[<>{}\[\]a-zA-Z0-9_.]+$") @@ -433,27 +433,27 @@ DEFAULT_TOOLS_VALUES = { "product_name_profiles": [ { "product_types": [], - "hosts": [], + "host_names": [], "task_types": [], - "tasks": [], + "task_names": [], "template": "{product[type]}{variant}" }, { "product_types": [ "workfile" ], - "hosts": [], + "host_names": [], "task_types": [], - "tasks": [], + "task_names": [], "template": "{product[type]}{Task[name]}" }, { "product_types": [ "render" ], - "hosts": [], + "host_names": [], "task_types": [], - "tasks": [], + "task_names": [], "template": "{product[type]}{Task[name]}{Variant}<_{Aov}>" }, { @@ -461,11 +461,11 @@ DEFAULT_TOOLS_VALUES = { "renderLayer", "renderPass" ], - "hosts": [ + "host_names": [ "tvpaint" ], "task_types": [], - "tasks": [], + "task_names": [], "template": ( "{product[type]}{Task[name]}_{Renderlayer}_{Renderpass}" ) @@ -475,65 +475,65 @@ DEFAULT_TOOLS_VALUES = { "review", "workfile" ], - "hosts": [ + "host_names": [ "aftereffects", "tvpaint" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "{product[type]}{Task[name]}" }, { "product_types": ["render"], - "hosts": [ + "host_names": [ "aftereffects" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "{product[type]}{Task[name]}{Composition}{Variant}" }, { "product_types": [ "staticMesh" ], - "hosts": [ + "host_names": [ "maya" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "S_{folder[name]}{variant}" }, { "product_types": [ "skeletalMesh" ], - "hosts": [ + "host_names": [ "maya" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "SK_{folder[name]}{variant}" }, { "product_types": [ "hda" ], - "hosts": [ + "host_names": [ "houdini" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "{folder[name]}_{variant}" }, { "product_types": [ "textureSet" ], - "hosts": [ + "host_names": [ "substancedesigner" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "T_{folder[name]}{variant}" } ], From 7622c150cf5c33a81b830cafce5330d2ddf2caed Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:49:48 +0100 Subject: [PATCH 095/108] fix formatting --- server/settings/tools.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/server/settings/tools.py b/server/settings/tools.py index 7e397d4874..da3b4ebff8 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -25,16 +25,27 @@ class ProductNameProfile(BaseSettingsModel): _layout = "expanded" product_types: list[str] = SettingsField( - default_factory=list, title="Product types" + default_factory=list, + title="Product types", + ) + host_names: list[str] = SettingsField( + default_factory=list, + title="Host names", ) - host_names: list[str] = SettingsField(default_factory=list, title="Host names") task_types: list[str] = SettingsField( default_factory=list, title="Task types", - enum_resolver=task_types_enum + enum_resolver=task_types_enum, + ) + task_names: list[str] = SettingsField( + default_factory=list, + title="Task names", + ) + template: str = SettingsField( + "", + title="Template", + regex=r"^[<>{}\[\]a-zA-Z0-9_.]+$", ) - task_names: list[str] = SettingsField(default_factory=list, title="Task names") - template: str = SettingsField("", title="Template", regex=r"^[<>{}\[\]a-zA-Z0-9_.]+$") class FilterCreatorProfile(BaseSettingsModel): From 1cdde6d7779785deafd4996000e025af6dfa4bce Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 18:03:23 +0100 Subject: [PATCH 096/108] fix typo Thanks @BigRoy --- client/ayon_core/tools/common_models/projects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index 3e090e18b8..d81b581894 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -277,7 +277,7 @@ class ProductTypeIconMapping: return self._definitions_by_name -def _get_project_items_from_entitiy( +def _get_project_items_from_entity( projects: list[dict[str, Any]] ) -> list[ProjectItem]: """ @@ -575,7 +575,7 @@ class ProjectsModel(object): .get("pinnedProjects") ) or [] pinned_projects = set(pinned_projects) - project_items = _get_project_items_from_entitiy(list(projects)) + project_items = _get_project_items_from_entity(list(projects)) for project in project_items: project.is_pinned = project.name in pinned_projects return project_items From be9b476151b455408d8d076ac944ebbe7bc1e3a4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 18:03:31 +0100 Subject: [PATCH 097/108] use better method name --- client/ayon_core/tools/common_models/projects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index d81b581894..0c1f912fd1 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -541,7 +541,7 @@ class ProjectsModel(object): self._projects_cache.update_data(project_items) return self._projects_cache.get_data() - def _fetch_projects_bc(self) -> list[dict[str, Any]]: + def _fetch_graphql_projects(self) -> list[dict[str, Any]]: """Fetch projects using GraphQl. This method was added because ayon_api had a bug in 'get_projects'. @@ -565,7 +565,7 @@ class ProjectsModel(object): return projects def _query_projects(self) -> list[ProjectItem]: - projects = self._fetch_projects_bc() + projects = self._fetch_graphql_projects() user = ayon_api.get_user() pinned_projects = ( From 26839fa5c1b52ac4535eb0fbf19283991d05b411 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 12 Nov 2025 17:07:05 +0000 Subject: [PATCH 098/108] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index e481e81356..869831b3ab 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.8+dev" +__version__ = "1.6.9" diff --git a/package.py b/package.py index eb30148176..cbfae1a4b3 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.8+dev" +version = "1.6.9" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 6708432e85..92c336770d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.8+dev" +version = "1.6.9" description = "" authors = ["Ynput Team "] readme = "README.md" From ea81e643f2d7158a50d4a6feebd6ee8ef3113ec4 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 12 Nov 2025 17:07:38 +0000 Subject: [PATCH 099/108] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 869831b3ab..da0cbff11d 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.9" +__version__ = "1.6.9+dev" diff --git a/package.py b/package.py index cbfae1a4b3..99524be8aa 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.9" +version = "1.6.9+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 92c336770d..f69f4f843a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.9" +version = "1.6.9+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 2ce5ba257502e279e9f5474ee88b27623fed75b5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 12 Nov 2025 17:08:36 +0000 Subject: [PATCH 100/108] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 513e088fef..e48e4b3b29 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.6.9 - 1.6.8 - 1.6.7 - 1.6.6 From 4d90d35fc7204e97e4588c9f7969d58e7037630a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 15:01:08 +0100 Subject: [PATCH 101/108] Extended open file possibilities --- client/ayon_core/plugins/loader/open_file.py | 286 ++++++++++++++++--- 1 file changed, 254 insertions(+), 32 deletions(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 9b5a6fec20..80ddf925d3 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -1,8 +1,10 @@ import os import sys import subprocess +import platform import collections -from typing import Optional, Any +import ctypes +from typing import Optional, Any, Callable from ayon_core.pipeline.load import get_representation_path_with_anatomy from ayon_core.pipeline.actions import ( @@ -13,6 +15,232 @@ from ayon_core.pipeline.actions import ( ) +WINDOWS_USER_REG_PATH = ( + r"Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts" + r"\{ext}\UserChoice" +) + + +class _Cache: + supported_exts: set[str] = set() + unsupported_exts: set[str] = set() + + @classmethod + def is_supported(cls, ext: str) -> bool: + return ext in cls.supported_exts + + @classmethod + def already_checked(cls, ext: str) -> bool: + return ( + ext in cls.supported_exts + or ext in cls.unsupported_exts + ) + + @classmethod + def set_ext_support(cls, ext: str, supported: bool) -> None: + if supported: + cls.supported_exts.add(ext) + else: + cls.unsupported_exts.add(ext) + + +def _extension_has_assigned_app_windows(ext: str) -> bool: + import winreg + progid = None + try: + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + WINDOWS_USER_REG_PATH.format(ext=ext), + ) as k: + progid, _ = winreg.QueryValueEx(k, "ProgId") + except OSError: + pass + + if progid: + return True + + try: + with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, ext) as k: + progid = winreg.QueryValueEx(k, None)[0] + except OSError: + pass + return bool(progid) + + +def _linux_find_desktop_file(desktop: str) -> Optional[str]: + for p in ( + os.path.join(os.path.expanduser("~/.local/share/applications"), desktop), + os.path.join("/usr/share/applications", desktop), + os.path.join("/usr/local/share/applications", desktop), + ): + if os.path.isfile(p): + return p + return None + + +def _extension_has_assigned_app_linux(ext: str) -> bool: + import mimetypes + + mime, _ = mimetypes.guess_type(f"file{ext}") + if not mime: + return False + + try: + # xdg-mime query default + desktop = subprocess.check_output( + ["xdg-mime", "query", "default", mime], + text=True + ).strip() or None + except Exception: + desktop = None + + if not desktop: + return False + + desktop_path = _linux_find_desktop_file(desktop) + if not desktop_path: + return False + if desktop_path and os.path.isfile(desktop_path): + return True + return False + + +def _extension_has_assigned_app_macos(ext: str): + # Uses CoreServices/LaunchServices and Uniform Type Identifiers via ctypes. + # Steps: ext -> UTI -> default handler bundle id for role 'all'. + cf = ctypes.cdll.LoadLibrary( + "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" + ) + ls = ctypes.cdll.LoadLibrary( + "/System/Library/Frameworks/CoreServices.framework/Frameworks" + "/LaunchServices.framework/LaunchServices" + ) + + # CFType/CFString helpers + CFStringRef = ctypes.c_void_p + CFURLRef = ctypes.c_void_p + CFAllocatorRef = ctypes.c_void_p + CFIndex = ctypes.c_long + UniChar = ctypes.c_ushort + + kCFStringEncodingUTF8 = 0x08000100 + + cf.CFStringCreateWithCString.argtypes = [CFAllocatorRef, ctypes.c_char_p, ctypes.c_uint32] + cf.CFStringCreateWithCString.restype = CFStringRef + + cf.CFStringGetCStringPtr.argtypes = [CFStringRef, ctypes.c_uint32] + cf.CFStringGetCStringPtr.restype = ctypes.c_char_p + + cf.CFStringGetCString.argtypes = [CFStringRef, ctypes.c_char_p, CFIndex, ctypes.c_uint32] + cf.CFStringGetCString.restype = ctypes.c_bool + + cf.CFRelease.argtypes = [ctypes.c_void_p] + cf.CFRelease.restype = None + + try: + UTTypeCreatePreferredIdentifierForTag = ctypes.cdll.LoadLibrary( + "/System/Library/Frameworks/CoreServices.framework/CoreServices" + ).UTTypeCreatePreferredIdentifierForTag + except OSError: + # Fallback path (older systems) + UTTypeCreatePreferredIdentifierForTag = ( + ls.UTTypeCreatePreferredIdentifierForTag + ) + UTTypeCreatePreferredIdentifierForTag.argtypes = [ + CFStringRef, CFStringRef, CFStringRef + ] + UTTypeCreatePreferredIdentifierForTag.restype = CFStringRef + + LSRolesMask = ctypes.c_uint + kLSRolesAll = 0xFFFFFFFF + ls.LSCopyDefaultRoleHandlerForContentType.argtypes = [ + CFStringRef, LSRolesMask + ] + ls.LSCopyDefaultRoleHandlerForContentType.restype = CFStringRef + + def cfstr(py_s: str) -> CFStringRef: + return cf.CFStringCreateWithCString( + None, py_s.encode("utf-8"), kCFStringEncodingUTF8 + ) + + def to_pystr(cf_s: CFStringRef) -> Optional[str]: + if not cf_s: + return None + # Try fast pointer + ptr = cf.CFStringGetCStringPtr(cf_s, kCFStringEncodingUTF8) + if ptr: + return ctypes.cast(ptr, ctypes.c_char_p).value.decode("utf-8") + + # Fallback buffer + buf_size = 1024 + buf = ctypes.create_string_buffer(buf_size) + ok = cf.CFStringGetCString( + cf_s, buf, buf_size, kCFStringEncodingUTF8 + ) + if ok: + return buf.value.decode("utf-8") + return None + + # Convert extension (without dot) to UTI + tag_class = cfstr("public.filename-extension") + tag_value = cfstr(ext.lstrip(".")) + + uti_ref = UTTypeCreatePreferredIdentifierForTag( + tag_class, tag_value, None + ) + uti = to_pystr(uti_ref) + + # Clean up temporary CFStrings + for ref in (tag_class, tag_value): + if ref: + cf.CFRelease(ref) + + bundle_id = None + if uti_ref: + # Get default handler for the UTI + default_bundle_ref = ls.LSCopyDefaultRoleHandlerForContentType( + uti_ref, kLSRolesAll + ) + bundle_id = to_pystr(default_bundle_ref) + if default_bundle_ref: + cf.CFRelease(default_bundle_ref) + cf.CFRelease(uti_ref) + return bundle_id is not None + + +def _filter_supported_exts( + extensions: set[str], test_func: Callable +) -> set[str]: + filtered_exs: set[str] = set() + for ext in extensions: + if not _Cache.already_checked(ext): + r = test_func(ext) + print(ext, r) + _Cache.set_ext_support(ext, r) + if _Cache.is_supported(ext): + filtered_exs.add(ext) + return filtered_exs + + +def filter_supported_exts(extensions: set[str]) -> set[str]: + if not extensions: + return set() + platform_name = platform.system().lower() + if platform_name == "windows": + return _filter_supported_exts( + extensions, _extension_has_assigned_app_windows + ) + if platform_name == "linux": + return _filter_supported_exts( + extensions, _extension_has_assigned_app_linux + ) + if platform_name == "darwin": + return _filter_supported_exts( + extensions, _extension_has_assigned_app_macos + ) + return set() + + def open_file(filepath: str) -> None: """Open file with system default executable""" if sys.platform.startswith("darwin"): @@ -27,8 +255,6 @@ class OpenFileAction(LoaderActionPlugin): """Open Image Sequence or Video with system default""" identifier = "core.open-file" - product_types = {"render2d"} - def get_action_items( self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: @@ -46,37 +272,32 @@ class OpenFileAction(LoaderActionPlugin): if not repres: return [] - repre_ids = {repre["id"] for repre in repres} - versions = selection.entities.get_representations_versions( - repre_ids - ) - product_ids = {version["productId"] for version in versions} - products = selection.entities.get_products(product_ids) - filtered_product_ids = { - product["id"] - for product in products - if product["productType"] in self.product_types - } - if not filtered_product_ids: + repres_by_ext = collections.defaultdict(list) + for repre in repres: + repre_context = repre.get("context") + if not repre_context: + continue + ext = repre_context.get("ext") + if not ext: + path = repre["attrib"].get("path") + if path: + ext = os.path.splitext(path)[1] + + if ext: + ext = ext.lower() + if not ext.startswith("."): + ext = f".{ext}" + repres_by_ext[ext.lower()].append(repre) + + if not repres_by_ext: return [] - versions_by_product_id = collections.defaultdict(list) - for version in versions: - versions_by_product_id[version["productId"]].append(version) - - repres_by_version_ids = collections.defaultdict(list) - for repre in repres: - repres_by_version_ids[repre["versionId"]].append(repre) - - filtered_repres = [] - for product_id in filtered_product_ids: - for version in versions_by_product_id[product_id]: - for repre in repres_by_version_ids[version["id"]]: - filtered_repres.append(repre) + filtered_exts = filter_supported_exts(set(repres_by_ext)) repre_ids_by_name = collections.defaultdict(set) - for repre in filtered_repres: - repre_ids_by_name[repre["name"]].add(repre["id"]) + for ext in filtered_exts: + for repre in repres_by_ext[ext]: + repre_ids_by_name[repre["name"]].add(repre["id"]) return [ LoaderActionItem( @@ -86,8 +307,8 @@ class OpenFileAction(LoaderActionPlugin): data={"representation_ids": list(repre_ids)}, icon={ "type": "material-symbols", - "name": "play_circle", - "color": "#FFA500", + "name": "file_open", + "color": "#ffffff", } ) for repre_name, repre_ids in repre_ids_by_name.items() @@ -122,6 +343,7 @@ class OpenFileAction(LoaderActionPlugin): ) self.log.info(f"Opening: {path}") + open_file(path) return LoaderActionResult( From 3936270266f67e5e4707a39a3ba845f9eda7d023 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 15:16:51 +0100 Subject: [PATCH 102/108] fix formatting --- client/ayon_core/plugins/loader/open_file.py | 24 ++++++++++++-------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 80ddf925d3..b29dfd1710 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -68,13 +68,14 @@ def _extension_has_assigned_app_windows(ext: str) -> bool: def _linux_find_desktop_file(desktop: str) -> Optional[str]: - for p in ( - os.path.join(os.path.expanduser("~/.local/share/applications"), desktop), - os.path.join("/usr/share/applications", desktop), - os.path.join("/usr/local/share/applications", desktop), + for dirpath in ( + os.path.expanduser("~/.local/share/applications"), + "/usr/share/applications", + "/usr/local/share/applications", ): - if os.path.isfile(p): - return p + path = os.path.join(dirpath, desktop) + if os.path.isfile(path): + return path return None @@ -106,7 +107,8 @@ def _extension_has_assigned_app_linux(ext: str) -> bool: def _extension_has_assigned_app_macos(ext: str): - # Uses CoreServices/LaunchServices and Uniform Type Identifiers via ctypes. + # Uses CoreServices/LaunchServices and Uniform Type Identifiers via + # ctypes. # Steps: ext -> UTI -> default handler bundle id for role 'all'. cf = ctypes.cdll.LoadLibrary( "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" @@ -125,13 +127,17 @@ def _extension_has_assigned_app_macos(ext: str): kCFStringEncodingUTF8 = 0x08000100 - cf.CFStringCreateWithCString.argtypes = [CFAllocatorRef, ctypes.c_char_p, ctypes.c_uint32] + cf.CFStringCreateWithCString.argtypes = [ + CFAllocatorRef, ctypes.c_char_p, ctypes.c_uint32 + ] cf.CFStringCreateWithCString.restype = CFStringRef cf.CFStringGetCStringPtr.argtypes = [CFStringRef, ctypes.c_uint32] cf.CFStringGetCStringPtr.restype = ctypes.c_char_p - cf.CFStringGetCString.argtypes = [CFStringRef, ctypes.c_char_p, CFIndex, ctypes.c_uint32] + cf.CFStringGetCString.argtypes = [ + CFStringRef, ctypes.c_char_p, CFIndex, ctypes.c_uint32 + ] cf.CFStringGetCString.restype = ctypes.c_bool cf.CFRelease.argtypes = [ctypes.c_void_p] From 84a40336065b93f057e616ddb7775640770b8687 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 15:21:14 +0100 Subject: [PATCH 103/108] remove unused variables --- client/ayon_core/plugins/loader/open_file.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index b29dfd1710..13d255a682 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -106,7 +106,7 @@ def _extension_has_assigned_app_linux(ext: str) -> bool: return False -def _extension_has_assigned_app_macos(ext: str): +def _extension_has_assigned_app_macos(ext: str) -> bool: # Uses CoreServices/LaunchServices and Uniform Type Identifiers via # ctypes. # Steps: ext -> UTI -> default handler bundle id for role 'all'. @@ -120,10 +120,8 @@ def _extension_has_assigned_app_macos(ext: str): # CFType/CFString helpers CFStringRef = ctypes.c_void_p - CFURLRef = ctypes.c_void_p CFAllocatorRef = ctypes.c_void_p CFIndex = ctypes.c_long - UniChar = ctypes.c_ushort kCFStringEncodingUTF8 = 0x08000100 @@ -194,7 +192,6 @@ def _extension_has_assigned_app_macos(ext: str): uti_ref = UTTypeCreatePreferredIdentifierForTag( tag_class, tag_value, None ) - uti = to_pystr(uti_ref) # Clean up temporary CFStrings for ref in (tag_class, tag_value): From bab249a54a4f50e018d4f403abf5b6f9e04b2b4a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:11:02 +0100 Subject: [PATCH 104/108] remove debug print Co-authored-by: Roy Nieterau --- client/ayon_core/plugins/loader/open_file.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 13d255a682..3118bfa3db 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -218,7 +218,6 @@ def _filter_supported_exts( for ext in extensions: if not _Cache.already_checked(ext): r = test_func(ext) - print(ext, r) _Cache.set_ext_support(ext, r) if _Cache.is_supported(ext): filtered_exs.add(ext) From 46b534cfcce245dd0a7231e86efdd9e2685629eb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:11:38 +0100 Subject: [PATCH 105/108] merge two lines into one --- client/ayon_core/plugins/loader/open_file.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 3118bfa3db..8bc4913da5 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -217,8 +217,7 @@ def _filter_supported_exts( filtered_exs: set[str] = set() for ext in extensions: if not _Cache.already_checked(ext): - r = test_func(ext) - _Cache.set_ext_support(ext, r) + _Cache.set_ext_support(ext, test_func(ext)) if _Cache.is_supported(ext): filtered_exs.add(ext) return filtered_exs From efa702405c75d016a46f692e8f678598adf9c91c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:26:55 +0100 Subject: [PATCH 106/108] tune out orders --- client/ayon_core/plugins/loader/copy_file.py | 2 ++ client/ayon_core/plugins/loader/delete_old_versions.py | 2 +- client/ayon_core/plugins/loader/open_file.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 2380b465ed..a1a98a2bf0 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -45,6 +45,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): output.append( LoaderActionItem( label=repre_name, + order=32, group_label="Copy file path", data={ "representation_id": repre_id, @@ -60,6 +61,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): output.append( LoaderActionItem( label=repre_name, + order=33, group_label="Copy file", data={ "representation_id": repre_id, diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index 7499650cbe..ce67df1c0c 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -66,7 +66,7 @@ class DeleteOldVersions(LoaderActionPlugin): ), LoaderActionItem( label="Calculate Versions size", - order=30, + order=34, data={ "product_ids": list(product_ids), "action": "calculate-versions-size", diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 8bc4913da5..ef92990f57 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -304,7 +304,7 @@ class OpenFileAction(LoaderActionPlugin): LoaderActionItem( label=repre_name, group_label="Open file", - order=-10, + order=30, data={"representation_ids": list(repre_ids)}, icon={ "type": "material-symbols", From 42b249a6b3732bafa3557abc5462857fe03e855e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:32:22 +0100 Subject: [PATCH 107/108] add note about caching --- client/ayon_core/plugins/loader/open_file.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index ef92990f57..018b9aeab0 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -22,6 +22,13 @@ WINDOWS_USER_REG_PATH = ( class _Cache: + """Cache extensions information. + + Notes: + The cache is cleared when loader tool is refreshed so it might be + moved to other place which is not cleared of refresh. + + """ supported_exts: set[str] = set() unsupported_exts: set[str] = set() From 8478899b67c7c3aeec4b62ee179ebaaba87bcc0a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 13 Nov 2025 17:40:47 +0100 Subject: [PATCH 108/108] Apply suggestion from @BigRoy --- client/ayon_core/plugins/loader/open_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 018b9aeab0..d226786bc2 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -26,7 +26,7 @@ class _Cache: Notes: The cache is cleared when loader tool is refreshed so it might be - moved to other place which is not cleared of refresh. + moved to other place which is not cleared on refresh. """ supported_exts: set[str] = set()