diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 41bcd0dbd1..0a805b16dc 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -42,6 +42,16 @@ register_loader_plugin_path, deregister_loader_plugin, + register_loader_pre_hook_plugin, + deregister_loader_pre_hook_plugin, + register_loader_pre_hook_plugin_path, + deregister_loader_pre_hook_plugin_path, + + register_loader_post_hook_plugin, + deregister_loader_post_hook_plugin, + register_loader_post_hook_plugin_path, + deregister_loader_post_hook_plugin_path, + load_container, remove_container, update_container, @@ -51,6 +61,7 @@ get_representation_path, get_representation_context, get_repres_contexts, + get_hook_loaders_by_identifier ) from .publish import ( @@ -160,6 +171,16 @@ "register_loader_plugin_path", "deregister_loader_plugin", + "register_loader_pre_hook_plugin", + "deregister_loader_pre_hook_plugin", + "register_loader_pre_hook_plugin_path", + "deregister_loader_pre_hook_plugin_path", + + "register_loader_post_hook_plugin", + "deregister_loader_post_hook_plugin", + "register_loader_post_hook_plugin_path", + "deregister_loader_post_hook_plugin_path", + "load_container", "remove_container", "update_container", @@ -220,6 +241,8 @@ "register_workfile_build_plugin_path", "deregister_workfile_build_plugin_path", + "get_hook_loaders_by_identifier", + # Backwards compatible function names "install", "uninstall", diff --git a/client/ayon_core/pipeline/load/__init__.py b/client/ayon_core/pipeline/load/__init__.py index bdc5ece620..eaba8cd78d 100644 --- a/client/ayon_core/pipeline/load/__init__.py +++ b/client/ayon_core/pipeline/load/__init__.py @@ -24,6 +24,7 @@ get_loader_identifier, get_loaders_by_name, + get_hook_loaders_by_identifier, get_representation_path_from_context, get_representation_path, @@ -49,6 +50,16 @@ deregister_loader_plugin_path, register_loader_plugin_path, deregister_loader_plugin, + + register_loader_pre_hook_plugin, + deregister_loader_pre_hook_plugin, + register_loader_pre_hook_plugin_path, + deregister_loader_pre_hook_plugin_path, + + register_loader_post_hook_plugin, + deregister_loader_post_hook_plugin, + register_loader_post_hook_plugin_path, + deregister_loader_post_hook_plugin_path, ) @@ -79,6 +90,7 @@ "get_loader_identifier", "get_loaders_by_name", + "get_hook_loaders_by_identifier", "get_representation_path_from_context", "get_representation_path", @@ -103,4 +115,14 @@ "deregister_loader_plugin_path", "register_loader_plugin_path", "deregister_loader_plugin", + + "register_loader_pre_hook_plugin", + "deregister_loader_pre_hook_plugin", + "register_loader_pre_hook_plugin_path", + "deregister_loader_pre_hook_plugin_path", + + "register_loader_post_hook_plugin", + "deregister_loader_post_hook_plugin", + "register_loader_post_hook_plugin_path", + "deregister_loader_post_hook_plugin_path", ) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 4a11b929cc..62d376c6d1 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -1,5 +1,7 @@ +from __future__ import annotations import os import logging +from typing import ClassVar from ayon_core.settings import get_project_settings from ayon_core.pipeline.plugin_discover import ( @@ -251,6 +253,51 @@ class ProductLoaderPlugin(LoaderPlugin): """ +class PreLoaderHookPlugin: + """Plugin that should be run before any Loaders in 'loaders' + + Should be used as non-invasive method to enrich core loading process. + Any external studio might want to modify loaded data before or after + they are loaded without need to override existing core plugins. + """ + loader_identifiers: ClassVar[set[str]] + + def process(self, context, name=None, namespace=None, options=None): + pass + + def update(self, container, context): + pass + + def switch(self, container, context): + pass + + +class PostLoaderHookPlugin: + """Plugin that should be run after any Loaders in 'loaders' + + Should be used as non-invasive method to enrich core loading process. + Any external studio might want to modify loaded data before or after + they are loaded without need to override existing core plugins. + """ + loader_identifiers: ClassVar[set[str]] + + def process( + self, + container, + context, + name=None, + namespace=None, + options=None + ): + pass + + def update(self, container, context): + pass + + def switch(self, container, context): + pass + + def discover_loader_plugins(project_name=None): from ayon_core.lib import Logger from ayon_core.pipeline import get_current_project_name @@ -287,3 +334,35 @@ def deregister_loader_plugin_path(path): def register_loader_plugin_path(path): return register_plugin_path(LoaderPlugin, path) + + +def register_loader_pre_hook_plugin(plugin): + return register_plugin(PreLoaderHookPlugin, plugin) + + +def deregister_loader_pre_hook_plugin(plugin): + deregister_plugin(PreLoaderHookPlugin, plugin) + + +def register_loader_pre_hook_plugin_path(path): + return register_plugin_path(PreLoaderHookPlugin, path) + + +def deregister_loader_pre_hook_plugin_path(path): + deregister_plugin_path(PreLoaderHookPlugin, path) + + +def register_loader_post_hook_plugin(plugin): + return register_plugin(PostLoaderHookPlugin, plugin) + + +def deregister_loader_post_hook_plugin(plugin): + deregister_plugin(PostLoaderHookPlugin, plugin) + + +def register_loader_post_hook_plugin_path(path): + return register_plugin_path(PostLoaderHookPlugin, path) + + +def deregister_loader_post_hook_plugin_path(path): + deregister_plugin_path(PostLoaderHookPlugin, path) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index b130161190..186cc8f0d7 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -288,7 +288,13 @@ 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, + hooks=None, + **kwargs ): # Ensure the Loader is compatible for the representation @@ -316,11 +322,24 @@ def load_with_repre_context( ) loader = Loader() - return loader.load(repre_context, name, namespace, options) + return _load_context( + Loader, + repre_context, + name, + namespace, + options, + hooks + ) def load_with_product_context( - Loader, product_context, namespace=None, name=None, options=None, **kwargs + Loader, + product_context, + namespace=None, + name=None, + options=None, + hooks=None, + **kwargs ): # Ensure options is a dictionary when no explicit options provided @@ -338,12 +357,24 @@ def load_with_product_context( Loader.__name__, product_context["folder"]["path"] ) ) - - return Loader().load(product_context, name, namespace, options) + return _load_context( + Loader, + product_context, + name, + namespace, + options, + hooks + ) def load_with_product_contexts( - Loader, product_contexts, namespace=None, name=None, options=None, **kwargs + Loader, + product_contexts, + namespace=None, + name=None, + options=None, + hooks=None, + **kwargs ): # Ensure options is a dictionary when no explicit options provided @@ -365,8 +396,38 @@ def load_with_product_contexts( Loader.__name__, joined_product_names ) ) + return _load_context( + Loader, + product_contexts, + name, + namespace, + options, + hooks + ) - return Loader().load(product_contexts, name, namespace, options) + +def _load_context(Loader, contexts, name, namespace, options, hooks): + """Helper function to wrap hooks around generic load function. + + Only dynamic part is different context(s) to be loaded. + """ + for hook_plugin_cls in hooks.get("pre", []): + hook_plugin_cls().process( + contexts, + name, + namespace, + options, + ) + loaded_container = Loader().load(contexts, name, namespace, options) + for hook_plugin_cls in hooks.get("post", []): + hook_plugin_cls().process( + loaded_container, + contexts, + name, + namespace, + options, + ) + return loaded_container def load_container( @@ -454,7 +515,7 @@ def remove_container(container): return Loader().remove(container) -def update_container(container, version=-1): +def update_container(container, version=-1, hooks_by_identifier=None): """Update a container""" from ayon_core.pipeline import get_current_project_name @@ -550,18 +611,38 @@ def update_container(container, version=-1): if not path or not os.path.exists(path): raise ValueError("Path {} doesn't exist".format(path)) - return Loader().update(container, context) + loader_identifier = get_loader_identifier(Loader) + hooks = hooks_by_identifier.get(loader_identifier, {}) + for hook_plugin_cls in hooks.get("pre", []): + hook_plugin_cls().update( + context, + container + ) + updated_container = Loader().update(container, context) + for hook_plugin_cls in hooks.get("post", []): + hook_plugin_cls().update( + context, + container + ) + return updated_container -def switch_container(container, representation, loader_plugin=None): +def switch_container( + container, + representation, + loader_plugin=None, + hooks_by_identifier=None +): """Switch a container to representation Args: container (dict): container information representation (dict): representation entity + loader_plugin (LoaderPlugin) + hooks_by_identifier (dict): {"pre": [PreHookPlugin1], "post":[]} Returns: - function call + return from function call """ from ayon_core.pipeline import get_current_project_name @@ -600,7 +681,21 @@ def switch_container(container, representation, loader_plugin=None): loader = loader_plugin(context) - return loader.switch(container, context) + loader_identifier = get_loader_identifier(loader) + hooks = hooks_by_identifier.get(loader_identifier, {}) + for hook_plugin_cls in hooks.get("pre", []): + hook_plugin_cls().switch( + context, + container + ) + switched_container = loader.switch(container, context) + for hook_plugin_cls in hooks.get("post", []): + hook_plugin_cls().switch( + context, + container + ) + + return switched_container def _fix_representation_context_compatibility(repre_context): @@ -1083,3 +1178,37 @@ def filter_containers(containers, project_name): uptodate_containers.append(container) return output + + +def get_hook_loaders_by_identifier(): + """Discovers pre/post hooks for loader plugins. + + Returns: + (dict) {"LoaderName": {"pre": ["PreLoader1"], "post":["PreLoader2]} + """ + # beware of circular imports! + from .plugins import PreLoadHookPlugin, PostLoadHookPlugin + + hook_loaders_by_identifier = {} + _get_hook_loaders(hook_loaders_by_identifier, PreLoadHookPlugin, "pre") + _get_hook_loaders(hook_loaders_by_identifier, PostLoadHookPlugin, "post") + return hook_loaders_by_identifier + + +def _get_hook_loaders(hook_loaders_by_identifier, loader_plugin, loader_type): + from ..plugin_discover import discover + + load_hook_plugins = discover(loader_plugin) + loaders_by_name = get_loaders_by_name() + for hook_plugin_cls in load_hook_plugins: + for load_plugin_name in hook_plugin_cls.loader_identifiers: + load_plugin = loaders_by_name.get(load_plugin_name) + if not load_plugin: + continue + if not load_plugin.enabled: + continue + identifier = get_loader_identifier(load_plugin) + (hook_loaders_by_identifier.setdefault(identifier, {}) + .setdefault(loader_type, []).append( + hook_plugin_cls) + ) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index cfe91cadab..07195f4b05 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -17,6 +17,9 @@ load_with_product_contexts, LoadError, IncompatibleLoaderError, + get_loaders_by_name, + get_hook_loaders_by_identifier + ) from ayon_core.tools.loader.abstract import ActionItem @@ -50,6 +53,8 @@ def __init__(self, controller): levels=1, lifetime=self.loaders_cache_lifetime) self._repre_loaders = NestedCacheItem( levels=1, lifetime=self.loaders_cache_lifetime) + self._hook_loaders_by_identifier = NestedCacheItem( + levels=1, lifetime=self.loaders_cache_lifetime) def reset(self): """Reset the model with all cached items.""" @@ -58,6 +63,7 @@ def reset(self): self._loaders_by_identifier.reset() self._product_loaders.reset() self._repre_loaders.reset() + self._hook_loaders_by_identifier.reset() def get_versions_action_items(self, project_name, version_ids): """Get action items for given version ids. @@ -143,12 +149,14 @@ def trigger_action_item( ACTIONS_MODEL_SENDER, ) loader = self._get_loader_by_identifier(project_name, identifier) + hooks = self._get_hook_loaders_by_identifier(project_name, identifier) if representation_ids is not None: error_info = self._trigger_representation_loader( loader, options, project_name, representation_ids, + hooks ) elif version_ids is not None: error_info = self._trigger_version_loader( @@ -156,6 +164,7 @@ def trigger_action_item( options, project_name, version_ids, + hooks ) else: raise NotImplementedError( @@ -307,22 +316,29 @@ def _get_loaders(self, project_name): we want to show loaders for? Returns: - tuple[list[ProductLoaderPlugin], list[LoaderPlugin]]: Discovered - loader plugins. + tuple( + list[ProductLoaderPlugin], + list[LoaderPlugin], + ): Discovered loader plugins. """ loaders_by_identifier_c = self._loaders_by_identifier[project_name] product_loaders_c = self._product_loaders[project_name] repre_loaders_c = self._repre_loaders[project_name] + hook_loaders_by_identifier_c = self._hook_loaders_by_identifier[project_name] if loaders_by_identifier_c.is_valid: - return product_loaders_c.get_data(), repre_loaders_c.get_data() + return ( + product_loaders_c.get_data(), + repre_loaders_c.get_data(), + hook_loaders_by_identifier_c.get_data() + ) # Get all representation->loader combinations available for the # index under the cursor, so we can list the user the options. available_loaders = self._filter_loaders_by_tool_name( project_name, discover_loader_plugins(project_name) ) - + hook_loaders_by_identifier = get_hook_loaders_by_identifier() repre_loaders = [] product_loaders = [] loaders_by_identifier = {} @@ -340,6 +356,8 @@ def _get_loaders(self, project_name): loaders_by_identifier_c.update_data(loaders_by_identifier) product_loaders_c.update_data(product_loaders) repre_loaders_c.update_data(repre_loaders) + hook_loaders_by_identifier_c.update_data(hook_loaders_by_identifier) + return product_loaders, repre_loaders def _get_loader_by_identifier(self, project_name, identifier): @@ -349,6 +367,13 @@ def _get_loader_by_identifier(self, project_name, identifier): loaders_by_identifier = loaders_by_identifier_c.get_data() return loaders_by_identifier.get(identifier) + def _get_hook_loaders_by_identifier(self, project_name, identifier): + if not self._hook_loaders_by_identifier[project_name].is_valid: + self._get_loaders(project_name) + hook_loaders_by_identifier_c = self._hook_loaders_by_identifier[project_name] + hook_loaders_by_identifier_c = hook_loaders_by_identifier_c.get_data() + return hook_loaders_by_identifier_c.get(identifier) + def _actions_sorter(self, action_item): """Sort the Loaders by their order and then their name. @@ -606,6 +631,7 @@ def _trigger_version_loader( options, project_name, version_ids, + hooks=None ): """Trigger version loader. @@ -655,7 +681,7 @@ def _trigger_version_loader( }) return self._load_products_by_loader( - loader, product_contexts, options + loader, product_contexts, options, hooks=hooks ) def _trigger_representation_loader( @@ -664,6 +690,7 @@ def _trigger_representation_loader( options, project_name, representation_ids, + hooks ): """Trigger representation loader. @@ -716,10 +743,16 @@ def _trigger_representation_loader( }) return self._load_representations_by_loader( - loader, repre_contexts, options + loader, repre_contexts, options, hooks ) - def _load_representations_by_loader(self, loader, repre_contexts, options): + def _load_representations_by_loader( + self, + loader, + repre_contexts, + options, + hooks=None + ): """Loops through list of repre_contexts and loads them with one loader Args: @@ -737,10 +770,12 @@ def _load_representations_by_loader(self, loader, repre_contexts, options): if version < 0: version = "Hero" try: + load_with_repre_context( loader, repre_context, - options=options + options=options, + hooks=hooks ) except IncompatibleLoaderError as exc: @@ -770,7 +805,13 @@ def _load_representations_by_loader(self, loader, repre_contexts, options): )) return error_info - def _load_products_by_loader(self, loader, version_contexts, options): + def _load_products_by_loader( + self, + loader, + version_contexts, + options, + hooks=None + ): """Triggers load with ProductLoader type of loaders. Warning: @@ -794,9 +835,9 @@ def _load_products_by_loader(self, loader, version_contexts, options): load_with_product_contexts( loader, version_contexts, - options=options + options=options, + hooks=hooks ) - except Exception as exc: formatted_traceback = None if not isinstance(exc, LoadError): @@ -820,7 +861,8 @@ def _load_products_by_loader(self, loader, version_contexts, options): load_with_product_context( loader, version_context, - options=options + options=options, + hooks=hooks ) except Exception as exc: diff --git a/client/ayon_core/tools/sceneinventory/control.py b/client/ayon_core/tools/sceneinventory/control.py index 60d9bc77a9..c4e59699c3 100644 --- a/client/ayon_core/tools/sceneinventory/control.py +++ b/client/ayon_core/tools/sceneinventory/control.py @@ -5,6 +5,7 @@ from ayon_core.pipeline import ( registered_host, get_current_context, + get_hook_loaders_by_identifier ) from ayon_core.tools.common_models import HierarchyModel, ProjectsModel @@ -35,6 +36,8 @@ def __init__(self, host=None): self._projects_model = ProjectsModel(self) self._event_system = self._create_event_system() + self._hooks_by_identifier = None + def get_host(self) -> HostBase: return self._host @@ -115,6 +118,16 @@ def get_version_items(self, project_name, product_ids): return self._containers_model.get_version_items( project_name, product_ids) + def get_hook_loaders_by_identifier(self): + """Returns lists of pre|post hooks per Loader identifier. + + Returns: + (dict) {"LoaderName": {"pre": ["PreLoader1"], "post":["PreLoader2]} + """ + if self._hooks_by_identifier is None: + self._hooks_by_identifier = get_hook_loaders_by_identifier() + return self._hooks_by_identifier + # Site Sync methods def is_sitesync_enabled(self): return self._sitesync_model.is_sitesync_enabled() diff --git a/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py b/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py index a6d88ed44a..c878cad079 100644 --- a/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py +++ b/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py @@ -1339,8 +1339,14 @@ def _switch_container( repre_entity = repres_by_name[container_repre_name] error = None + hook_loaders_by_id = self._controller.get_hook_loaders_by_identifier() try: - switch_container(container, repre_entity, loader) + switch_container( + container, + repre_entity, + loader, + hook_loaders_by_id + ) except ( LoaderSwitchNotImplementedError, IncompatibleLoaderError, diff --git a/client/ayon_core/tools/sceneinventory/view.py b/client/ayon_core/tools/sceneinventory/view.py index bb95e37d4e..75d3d9a680 100644 --- a/client/ayon_core/tools/sceneinventory/view.py +++ b/client/ayon_core/tools/sceneinventory/view.py @@ -1100,11 +1100,16 @@ def _update_containers(self, item_ids, versions): containers_by_id = self._controller.get_containers_by_item_ids( item_ids ) + hook_loaders_by_id = self._controller.get_hook_loaders_by_identifier() try: for item_id, item_version in zip(item_ids, versions): container = containers_by_id[item_id] try: - update_container(container, item_version) + update_container( + container, + item_version, + hook_loaders_by_id + ) except AssertionError: log.warning("Update failed", exc_info=True) self._show_version_error_dialog(