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