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 01/89] 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 02/89] 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 03/89] 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 04/89] 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 05/89] 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 06/89] 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 07/89] 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 08/89] 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 09/89] 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 10/89] 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 11/89] 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 12/89] 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 13/89] 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 14/89] 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 15/89] 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 16/89] 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 17/89] 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 18/89] 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 19/89] 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 20/89] 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 21/89] 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 22/89] 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 23/89] 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 24/89] 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 25/89] 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 26/89] 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 27/89] 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 28/89] 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 29/89] 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 30/89] 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 31/89] 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 32/89] 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 33/89] 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 34/89] 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 35/89] 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 36/89] 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 37/89] 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 38/89] 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 39/89] 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 40/89] 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 41/89] 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 42/89] 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 43/89] 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 44/89] 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 45/89] 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 46/89] 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 47/89] 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 48/89] 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 49/89] 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 50/89] 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 51/89] 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 52/89] 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 53/89] 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 54/89] 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 55/89] 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 56/89] 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 57/89] 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 58/89] 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 59/89] 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 60/89] 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 61/89] 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 62/89] 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 63/89] 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 64/89] 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 65/89] 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 66/89] 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 67/89] 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 68/89] 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 69/89] 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 70/89] 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 71/89] 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 72/89] 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 73/89] 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 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 74/89] 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 75/89] [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 76/89] [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 77/89] 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 78/89] 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 79/89] 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 80/89] 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 81/89] 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 82/89] 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 83/89] 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 84/89] 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 85/89] 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 86/89] 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 87/89] [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 88/89] [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 89/89] 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