Merge pull request #1222 from ynput/feature/AY-2218_Plugin-hooks-Loader-and-Scene-Inventory

Pre and post loader hooks
This commit is contained in:
Petr Kalis 2025-06-19 12:42:50 +02:00 committed by GitHub
commit b61d36867a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 196 additions and 11 deletions

View file

@ -49,6 +49,11 @@ from .plugins import (
deregister_loader_plugin_path,
register_loader_plugin_path,
deregister_loader_plugin,
register_loader_hook_plugin,
deregister_loader_hook_plugin,
register_loader_hook_plugin_path,
deregister_loader_hook_plugin_path,
)
@ -103,4 +108,10 @@ __all__ = (
"deregister_loader_plugin_path",
"register_loader_plugin_path",
"deregister_loader_plugin",
"register_loader_hook_plugin",
"deregister_loader_hook_plugin",
"register_loader_hook_plugin_path",
"deregister_loader_hook_plugin_path",
)

View file

@ -1,5 +1,8 @@
from __future__ import annotations
import os
import logging
from typing import Any, Type, Optional
from abc import abstractmethod
from ayon_core.settings import get_project_settings
from ayon_core.pipeline.plugin_discover import (
@ -251,15 +254,94 @@ class ProductLoaderPlugin(LoaderPlugin):
"""
class LoaderHookPlugin:
"""Plugin that runs before and post specific Loader in 'loaders'
Should be used as non-invasive method to enrich core loading process.
Any studio might want to modify loaded data before or after
they are loaded without need to override existing core plugins.
The post methods are called after the loader's methods and receive the
return value of the loader's method as `result` argument.
"""
order = 0
@classmethod
@abstractmethod
def is_compatible(cls, Loader: Type[LoaderPlugin]) -> bool:
pass
@abstractmethod
def pre_load(
self,
plugin: LoaderPlugin,
context: dict,
name: Optional[str],
namespace: Optional[str],
options: Optional[dict],
):
pass
@abstractmethod
def post_load(
self,
plugin: LoaderPlugin,
result: Any,
context: dict,
name: Optional[str],
namespace: Optional[str],
options: Optional[dict],
):
pass
@abstractmethod
def pre_update(
self,
plugin: LoaderPlugin,
container: dict, # (ayon:container-3.0)
context: dict,
):
pass
@abstractmethod
def post_update(
self,
plugin: LoaderPlugin,
result: Any,
container: dict, # (ayon:container-3.0)
context: dict,
):
pass
@abstractmethod
def pre_remove(
self,
plugin: LoaderPlugin,
container: dict, # (ayon:container-3.0)
):
pass
@abstractmethod
def post_remove(
self,
plugin: LoaderPlugin,
result: Any,
container: dict, # (ayon:container-3.0)
):
pass
def discover_loader_plugins(project_name=None):
from ayon_core.lib import Logger
from ayon_core.pipeline import get_current_project_name
log = Logger.get_logger("LoaderDiscover")
plugins = discover(LoaderPlugin)
if not project_name:
project_name = get_current_project_name()
project_settings = get_project_settings(project_name)
plugins = discover(LoaderPlugin)
hooks = discover(LoaderHookPlugin)
sorted_hooks = sorted(hooks, key=lambda hook: hook.order)
for plugin in plugins:
try:
plugin.apply_settings(project_settings)
@ -268,11 +350,58 @@ def discover_loader_plugins(project_name=None):
"Failed to apply settings to loader {}".format(
plugin.__name__
),
exc_info=True
exc_info=True,
)
compatible_hooks = []
for hook_cls in sorted_hooks:
if hook_cls.is_compatible(plugin):
compatible_hooks.append(hook_cls)
add_hooks_to_loader(plugin, compatible_hooks)
return plugins
def add_hooks_to_loader(
loader_class: LoaderPlugin, compatible_hooks: list[Type[LoaderHookPlugin]]
) -> None:
"""Monkey patch method replacing Loader.load|update|remove methods
It wraps applicable loaders with pre/post hooks. Discovery is called only
once per loaders discovery.
"""
loader_class._load_hooks = compatible_hooks
def wrap_method(method_name: str):
original_method = getattr(loader_class, method_name)
def wrapped_method(self, *args, **kwargs):
# Call pre_<method_name> on all hooks
pre_hook_name = f"pre_{method_name}"
hooks: list[LoaderHookPlugin] = []
for cls in loader_class._load_hooks:
hook = cls() # Instantiate the hook
hooks.append(hook)
pre_hook = getattr(hook, pre_hook_name, None)
if callable(pre_hook):
pre_hook(self, *args, **kwargs)
# Call original method
result = original_method(self, *args, **kwargs)
# Call post_<method_name> on all hooks
post_hook_name = f"post_{method_name}"
for hook in hooks:
post_hook = getattr(hook, post_hook_name, None)
if callable(post_hook):
post_hook(self, result, *args, **kwargs)
return result
setattr(loader_class, method_name, wrapped_method)
for method in ("load", "update", "remove"):
if hasattr(loader_class, method):
wrap_method(method)
def register_loader_plugin(plugin):
return register_plugin(LoaderPlugin, plugin)
@ -287,3 +416,19 @@ def deregister_loader_plugin_path(path):
def register_loader_plugin_path(path):
return register_plugin_path(LoaderPlugin, path)
def register_loader_hook_plugin(plugin):
return register_plugin(LoaderHookPlugin, plugin)
def deregister_loader_hook_plugin(plugin):
deregister_plugin(LoaderHookPlugin, plugin)
def register_loader_hook_plugin_path(path):
return register_plugin_path(LoaderHookPlugin, path)
def deregister_loader_hook_plugin_path(path):
deregister_plugin_path(LoaderHookPlugin, path)

View file

@ -288,7 +288,12 @@ def get_representation_context(project_name, representation):
def load_with_repre_context(
Loader, repre_context, namespace=None, name=None, options=None, **kwargs
Loader,
repre_context,
namespace=None,
name=None,
options=None,
**kwargs
):
# Ensure the Loader is compatible for the representation
@ -320,7 +325,12 @@ def load_with_repre_context(
def load_with_product_context(
Loader, product_context, namespace=None, name=None, options=None, **kwargs
Loader,
product_context,
namespace=None,
name=None,
options=None,
**kwargs
):
# Ensure options is a dictionary when no explicit options provided
@ -343,7 +353,12 @@ def load_with_product_context(
def load_with_product_contexts(
Loader, product_contexts, namespace=None, name=None, options=None, **kwargs
Loader,
product_contexts,
namespace=None,
name=None,
options=None,
**kwargs
):
# Ensure options is a dictionary when no explicit options provided
@ -553,15 +568,20 @@ def update_container(container, version=-1):
return Loader().update(container, context)
def switch_container(container, representation, loader_plugin=None):
def switch_container(
container,
representation,
loader_plugin=None,
):
"""Switch a container to representation
Args:
container (dict): container information
representation (dict): representation entity
loader_plugin (LoaderPlugin)
Returns:
function call
return from function call
"""
from ayon_core.pipeline import get_current_project_name

View file

@ -322,7 +322,6 @@ class LoaderActionsModel:
available_loaders = self._filter_loaders_by_tool_name(
project_name, discover_loader_plugins(project_name)
)
repre_loaders = []
product_loaders = []
loaders_by_identifier = {}
@ -340,6 +339,7 @@ class LoaderActionsModel:
loaders_by_identifier_c.update_data(loaders_by_identifier)
product_loaders_c.update_data(product_loaders)
repre_loaders_c.update_data(repre_loaders)
return product_loaders, repre_loaders
def _get_loader_by_identifier(self, project_name, identifier):
@ -719,7 +719,12 @@ class LoaderActionsModel:
loader, repre_contexts, options
)
def _load_representations_by_loader(self, loader, repre_contexts, options):
def _load_representations_by_loader(
self,
loader,
repre_contexts,
options
):
"""Loops through list of repre_contexts and loads them with one loader
Args:
@ -770,7 +775,12 @@ class LoaderActionsModel:
))
return error_info
def _load_products_by_loader(self, loader, version_contexts, options):
def _load_products_by_loader(
self,
loader,
version_contexts,
options
):
"""Triggers load with ProductLoader type of loaders.
Warning:
@ -796,7 +806,6 @@ class LoaderActionsModel:
version_contexts,
options=options
)
except Exception as exc:
formatted_traceback = None
if not isinstance(exc, LoadError):