From 6c5c1d6ca65585b0052bb1346cbab83c395b2a04 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 6 Feb 2024 11:39:31 +0100 Subject: [PATCH] created 'AddonsManager' --- client/ayon_core/addon/__init__.py | 29 + client/ayon_core/addon/base.py | 1329 ++++++++++++++++++++++ client/ayon_core/addon/interfaces.py | 385 +++++++ client/ayon_core/modules/__init__.py | 6 +- client/ayon_core/modules/base.py | 1443 +----------------------- client/ayon_core/modules/interfaces.py | 476 +------- 6 files changed, 1774 insertions(+), 1894 deletions(-) create mode 100644 client/ayon_core/addon/__init__.py create mode 100644 client/ayon_core/addon/base.py create mode 100644 client/ayon_core/addon/interfaces.py diff --git a/client/ayon_core/addon/__init__.py b/client/ayon_core/addon/__init__.py new file mode 100644 index 0000000000..ad79d4a5cc --- /dev/null +++ b/client/ayon_core/addon/__init__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from .interfaces import ( + IPluginPaths, + ITrayAddon, + ITrayAction, + ITrayService, + IHostAddon, +) + +from .base import ( + AYONAddon, + AddonsManager, + TrayAddonsManager, + load_addons, +) + + +__all__ = ( + "IPluginPaths", + "ITrayAddon", + "ITrayAction", + "ITrayService", + "IHostAddon", + + "AYONAddon", + "AddonsManager", + "TrayAddonsManager", + "load_addons", +) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py new file mode 100644 index 0000000000..fd096dea56 --- /dev/null +++ b/client/ayon_core/addon/base.py @@ -0,0 +1,1329 @@ +# -*- coding: utf-8 -*- +"""Base class for AYON addons.""" +import copy +import os +import sys +import time +import inspect +import logging +import threading +import collections + +from uuid import uuid4 +from abc import ABCMeta, abstractmethod + +import six +import appdirs + +from ayon_core.lib import Logger +from ayon_core.client import get_ayon_server_api_connection +from ayon_core.settings.ayon_settings import ( + is_dev_mode_enabled, + get_ayon_settings, +) + +from .interfaces import ( + IPluginPaths, + IHostAddon, + ITrayAddon, + ITrayService +) + +# Files that will be always ignored on addons import +IGNORED_FILENAMES = ( + "__pycache__", +) +# Files ignored on addons import from "./openpype/modules" +IGNORED_DEFAULT_FILENAMES = ( + "__init__.py", + "base.py", + "interfaces.py", + "click_wrap.py", + "example_addons", + "default_modules", +) +IGNORED_HOSTS_IN_AYON = { + "flame", + "harmony", +} +IGNORED_MODULES_IN_AYON = set() + + +# Inherit from `object` for Python 2 hosts +class _ModuleClass(object): + """Fake module class for storing AYON addons. + + Object of this class can be stored to `sys.modules` and used for storing + dynamically imported modules. + """ + + def __init__(self, name): + # Call setattr on super class + super(_ModuleClass, self).__setattr__("name", name) + super(_ModuleClass, self).__setattr__("__name__", name) + + # Where modules and interfaces are stored + super(_ModuleClass, self).__setattr__("__attributes__", dict()) + super(_ModuleClass, self).__setattr__("__defaults__", set()) + + super(_ModuleClass, self).__setattr__("_log", None) + + def __getattr__(self, attr_name): + if attr_name not in self.__attributes__: + if attr_name in ("__path__", "__file__"): + return None + raise AttributeError("'{}' has not attribute '{}'".format( + self.name, attr_name + )) + return self.__attributes__[attr_name] + + def __iter__(self): + for module in self.values(): + yield module + + def __setattr__(self, attr_name, value): + if attr_name in self.__attributes__: + self.log.warning( + "Duplicated name \"{}\" in {}. Overriding.".format( + attr_name, self.name + ) + ) + self.__attributes__[attr_name] = value + + def __setitem__(self, key, value): + self.__setattr__(key, value) + + def __getitem__(self, key): + return getattr(self, key) + + @property + def log(self): + if self._log is None: + super(_ModuleClass, self).__setattr__( + "_log", Logger.get_logger(self.name) + ) + return self._log + + def get(self, key, default=None): + return self.__attributes__.get(key, default) + + def keys(self): + return self.__attributes__.keys() + + def values(self): + return self.__attributes__.values() + + def items(self): + return self.__attributes__.items() + + +class _LoadCache: + addons_lock = threading.Lock() + addons_loaded = False + + +def load_addons(force=False): + """Load AYON addons as python modules. + + Modules does not load only classes (like in Interfaces) because there must + be ability to use inner code of addon and be able to import it from one + defined place. + + With this it is possible to import addon's content from predefined module. + + Args: + force (bool): Force to load addons even if are already loaded. + This won't update already loaded and used (cached) modules. + """ + + if _LoadCache.addons_loaded and not force: + return + + if not _LoadCache.addons_lock.locked(): + with _LoadCache.addons_lock: + _load_addons() + _LoadCache.addons_loaded = True + else: + # If lock is locked wait until is finished + while _LoadCache.addons_lock.locked(): + time.sleep(0.1) + + +def _get_ayon_bundle_data(): + con = get_ayon_server_api_connection() + bundles = con.get_bundles()["bundles"] + + bundle_name = os.getenv("AYON_BUNDLE_NAME") + + return next( + ( + bundle + for bundle in bundles + if bundle["name"] == bundle_name + ), + None + ) + + +def _get_ayon_addons_information(bundle_info): + """Receive information about addons to use from server. + + Todos: + Actually ask server for the information. + Allow project name as optional argument to be able to query information + about used addons for specific project. + + Returns: + List[Dict[str, Any]]: List of addon information to use. + """ + + output = [] + bundle_addons = bundle_info["addons"] + con = get_ayon_server_api_connection() + addons = con.get_addons_info()["addons"] + for addon in addons: + name = addon["name"] + versions = addon.get("versions") + addon_version = bundle_addons.get(name) + if addon_version is None or not versions: + continue + version = versions.get(addon_version) + if version: + version = copy.deepcopy(version) + version["name"] = name + version["version"] = addon_version + output.append(version) + return output + + +def _load_ayon_addons(openpype_modules, modules_key, log): + """Load AYON addons based on information from server. + + This function should not trigger downloading of any addons but only use + what is already available on the machine (at least in first stages of + development). + + Args: + openpype_modules (_ModuleClass): Module object where modules are + stored. + modules_key (str): Key under which will be modules imported in + `sys.modules`. + log (logging.Logger): Logger object. + + Returns: + List[str]: List of v3 addons to skip to load because v4 alternative is + imported. + """ + + addons_to_skip_in_core = [] + + bundle_info = _get_ayon_bundle_data() + addons_info = _get_ayon_addons_information(bundle_info) + if not addons_info: + return addons_to_skip_in_core + + addons_dir = os.environ.get("AYON_ADDONS_DIR") + if not addons_dir: + addons_dir = os.path.join( + appdirs.user_data_dir("AYON", "Ynput"), + "addons" + ) + + dev_mode_enabled = is_dev_mode_enabled() + dev_addons_info = {} + if dev_mode_enabled: + # Get dev addons info only when dev mode is enabled + dev_addons_info = bundle_info.get("addonDevelopment", dev_addons_info) + + addons_dir_exists = os.path.exists(addons_dir) + if not addons_dir_exists: + log.warning("Addons directory does not exists. Path \"{}\"".format( + addons_dir + )) + + for addon_info in addons_info: + addon_name = addon_info["name"] + addon_version = addon_info["version"] + + # ayon_core addon does not have any addon object + if addon_name in ("openpype", "ayon_core"): + continue + + dev_addon_info = dev_addons_info.get(addon_name, {}) + use_dev_path = dev_addon_info.get("enabled", False) + + addon_dir = None + if use_dev_path: + addon_dir = dev_addon_info["path"] + if not addon_dir or not os.path.exists(addon_dir): + log.warning(( + "Dev addon {} {} path does not exists. Path \"{}\"" + ).format(addon_name, addon_version, addon_dir)) + continue + + elif addons_dir_exists: + folder_name = "{}_{}".format(addon_name, addon_version) + addon_dir = os.path.join(addons_dir, folder_name) + if not os.path.exists(addon_dir): + log.debug(( + "No localized client code found for addon {} {}." + ).format(addon_name, addon_version)) + continue + + if not addon_dir: + continue + + sys.path.insert(0, addon_dir) + imported_modules = [] + for name in os.listdir(addon_dir): + # Ignore of files is implemented to be able to run code from code + # where usually is more files than just the addon + # Ignore start and setup scripts + if name in ("setup.py", "start.py", "__pycache__"): + continue + + path = os.path.join(addon_dir, name) + basename, ext = os.path.splitext(name) + # Ignore folders/files with dot in name + # - dot names cannot be imported in Python + if "." in basename: + continue + is_dir = os.path.isdir(path) + is_py_file = ext.lower() == ".py" + if not is_py_file and not is_dir: + continue + + try: + mod = __import__(basename, fromlist=("",)) + for attr_name in dir(mod): + attr = getattr(mod, attr_name) + if ( + inspect.isclass(attr) + and issubclass(attr, AYONAddon) + ): + imported_modules.append(mod) + break + + except BaseException: + log.warning( + "Failed to import \"{}\"".format(basename), + exc_info=True + ) + + if not imported_modules: + log.warning("Addon {} {} has no content to import".format( + addon_name, addon_version + )) + continue + + if len(imported_modules) > 1: + log.warning(( + "Skipping addon '{}'." + " Multiple modules were found ({}) in dir {}." + ).format( + addon_name, + ", ".join([m.__name__ for m in imported_modules]), + addon_dir, + )) + continue + + mod = imported_modules[0] + addon_alias = getattr(mod, "V3_ALIAS", None) + if not addon_alias: + addon_alias = addon_name + addons_to_skip_in_core.append(addon_alias) + new_import_str = "{}.{}".format(modules_key, addon_alias) + + sys.modules[new_import_str] = mod + setattr(openpype_modules, addon_alias, mod) + + return addons_to_skip_in_core + + +def _load_addons_in_core( + ignore_addon_names, openpype_modules, modules_key, log +): + # Add current directory at first place + # - has small differences in import logic + current_dir = os.path.abspath(os.path.dirname(__file__)) + hosts_dir = os.path.join(os.path.dirname(current_dir), "hosts") + modules_dir = os.path.join(os.path.dirname(current_dir), "modules") + + ignored_host_names = set(IGNORED_HOSTS_IN_AYON) + ignored_module_dir_filenames = ( + set(IGNORED_DEFAULT_FILENAMES) + | IGNORED_MODULES_IN_AYON + ) + + for dirpath in {hosts_dir, modules_dir}: + if not os.path.exists(dirpath): + log.warning(( + "Could not find path when loading AYON addons \"{}\"" + ).format(dirpath)) + continue + + is_in_modules_dir = dirpath == modules_dir + if is_in_modules_dir: + ignored_filenames = ignored_module_dir_filenames + else: + ignored_filenames = ignored_host_names + + for filename in os.listdir(dirpath): + # Ignore filenames + if filename in IGNORED_FILENAMES or filename in ignored_filenames: + continue + + fullpath = os.path.join(dirpath, filename) + basename, ext = os.path.splitext(filename) + + if basename in ignore_addon_names: + continue + + # Validations + if os.path.isdir(fullpath): + # Check existence of init file + init_path = os.path.join(fullpath, "__init__.py") + if not os.path.exists(init_path): + log.debug(( + "Addon directory does not contain __init__.py" + " file {}" + ).format(fullpath)) + continue + + elif ext not in (".py", ): + continue + + # TODO add more logic how to define if folder is addon or not + # - check manifest and content of manifest + try: + # Don't import dynamically current directory modules + new_import_str = "{}.{}".format(modules_key, basename) + if is_in_modules_dir: + import_str = "ayon_core.modules.{}".format(basename) + default_module = __import__(import_str, fromlist=("", )) + sys.modules[new_import_str] = default_module + setattr(openpype_modules, basename, default_module) + + else: + import_str = "ayon_core.hosts.{}".format(basename) + # Until all hosts are converted to be able use them as + # modules is this error check needed + try: + default_module = __import__( + import_str, fromlist=("", ) + ) + sys.modules[new_import_str] = default_module + setattr(openpype_modules, basename, default_module) + + except Exception: + log.warning( + "Failed to import host folder {}".format(basename), + exc_info=True + ) + + except Exception: + if is_in_modules_dir: + msg = "Failed to import in-core addon '{}'.".format( + basename + ) + else: + msg = "Failed to import addon '{}'.".format(fullpath) + log.error(msg, exc_info=True) + + +def _load_addons(): + # Support to use 'openpype' imports + sys.modules["openpype"] = sys.modules["ayon_core"] + + # Key under which will be modules imported in `sys.modules` + modules_key = "openpype_modules" + + # Change `sys.modules` + sys.modules[modules_key] = openpype_modules = _ModuleClass(modules_key) + + log = Logger.get_logger("AddonsLoader") + + ignore_addon_names = _load_ayon_addons( + openpype_modules, modules_key, log + ) + _load_addons_in_core( + ignore_addon_names, openpype_modules, modules_key, log + ) + + +@six.add_metaclass(ABCMeta) +class AYONAddon(object): + """Base class of AYON addon. + + Attributes: + id (UUID): Addon object id. + enabled (bool): Is addon enabled. + name (str): Addon name. + + Args: + manager (AddonsManager): Manager object who discovered addon. + settings (dict[str, Any]): AYON settings. + """ + + enabled = True + _id = None + + def __init__(self, manager, settings): + self.manager = manager + + self.log = Logger.get_logger(self.name) + + self.initialize(settings) + + @property + def id(self): + """Random id of addon object. + + Returns: + str: Object id. + """ + + if self._id is None: + self._id = uuid4() + return self._id + + @property + @abstractmethod + def name(self): + """Addon name. + + Returns: + str: Addon name. + """ + + pass + + def initialize(self, settings): + """Initialization of addon attributes. + + It is not recommended to override __init__ that's why specific method + was implemented. + + Args: + settings (dict[str, Any]): Settings. + """ + + pass + + def connect_with_addons(self, enabled_addons): + """Connect with other enabled addons. + + Args: + enabled_addons (list[AYONAddon]): Addons that are enabled. + """ + + pass + + def get_global_environments(self): + """Get global environments values of addon. + + Environment variables that can be get only from system settings. + + Returns: + dict[str, str]: Environment variables. + """ + + return {} + + def modify_application_launch_arguments(self, application, env): + """Give option to modify launch environments before application launch. + + Implementation is optional. To change environments modify passed + dictionary of environments. + + Args: + application (Application): Application that is launched. + env (dict[str, str]): Current environment variables. + """ + + pass + + def on_host_install(self, host, host_name, project_name): + """Host was installed which gives option to handle in-host logic. + + It is a good option to register in-host event callbacks which are + specific for the addon. The addon is kept in memory for rest of + the process. + + Arguments may change in future. E.g. 'host_name' should be possible + to receive from 'host' object. + + Args: + host (Union[ModuleType, HostBase]): Access to installed/registered + host object. + host_name (str): Name of host. + project_name (str): Project name which is main part of host + context. + """ + + pass + + def cli(self, addon_click_group): + """Add commands to click group. + + The best practise is to create click group for whole addon which is + used to separate commands. + + Example: + class MyPlugin(AYONAddon): + ... + def cli(self, addon_click_group): + addon_click_group.add_command(cli_main) + + + @click.group(, help="") + def cli_main(): + pass + + @cli_main.command() + def mycommand(): + print("my_command") + + Args: + addon_click_group (click.Group): Group to which can be added + commands. + """ + + pass + + +class AddonsManager: + """Manager of addons that helps to load and prepare them to work. + + Args: + settings (Optional[dict[str, Any]]): AYON studio settings. + """ + + # Helper attributes for report + _report_total_key = "Total" + _log = None + + def __init__(self, settings=None, initialize=True): + self._settings = settings + + self._addons = [] + self._addons_by_id = {} + self._addons_by_name = {} + # For report of time consumption + self._report = {} + + if initialize: + self.initialize_addons() + self.connect_addons() + + def __getitem__(self, addon_name): + return self._addons_by_name[addon_name] + + @property + def log(self): + if self._log is None: + self._log = logging.getLogger(self.__class__.__name__) + return self._log + + def get(self, addon_name, default=None): + """Access addon by name. + + Args: + addon_name (str): Name of addon which should be returned. + default (Any): Default output if addon is not available. + + Returns: + Union[AYONAddon, Any]: Addon found by name or `default`. + """ + + return self._addons_by_name.get(addon_name, default) + + def addons(self): + return list(self._addons) + + def addons_by_id(self): + return dict(self._addons_by_id) + + def addons_by_name(self): + return dict(self._addons_by_name) + + def get_enabled_addon(self, addon_name, default=None): + """Fast access to enabled addon. + + If addon is available but is not enabled default value is returned. + + Args: + addon_name (str): Name of addon which should be returned. + default (Any): Default output if addon is not available or is + not enabled. + + Returns: + Union[AYONAddon, None]: Enabled addon found by name or None. + """ + + addon = self.get(addon_name) + if addon is not None and addon.enabled: + return addon + return default + + def get_enabled_addons(self): + """Enabled addons initialized by the manager. + + Returns: + list[AYONAddon]: Initialized and enabled addons. + """ + + return [ + addon + for addon in self._addons + if addon.enabled + ] + + def initialize_addons(self): + """Import and initialize addons.""" + # Make sure modules are loaded + load_addons() + + import openpype_modules + + self.log.debug("*** AYON addons initialization.") + + # Prepare settings for addons + settings = self._settings + if settings is None: + settings = get_ayon_settings() + + report = {} + time_start = time.time() + prev_start_time = time_start + + addon_classes = [] + for module in openpype_modules: + # Go through globals in `pype.modules` + for name in dir(module): + modules_item = getattr(module, name, None) + # Filter globals that are not classes which inherit from + # AYONAddon + if ( + not inspect.isclass(modules_item) + or modules_item is AYONAddon + or modules_item is OpenPypeModule + or modules_item is OpenPypeAddOn + or not issubclass(modules_item, AYONAddon) + ): + continue + + # Check if class is abstract (Developing purpose) + if inspect.isabstract(modules_item): + # Find abstract attributes by convention on `abc` module + not_implemented = [] + for attr_name in dir(modules_item): + attr = getattr(modules_item, attr_name, None) + abs_method = getattr( + attr, "__isabstractmethod__", None + ) + if attr and abs_method: + not_implemented.append(attr_name) + + # Log missing implementations + self.log.warning(( + "Skipping abstract Class: {}." + " Missing implementations: {}" + ).format(name, ", ".join(not_implemented))) + continue + + addon_classes.append(modules_item) + + for addon_cls in addon_classes: + name = addon_cls.__name__ + if issubclass(addon_cls, OpenPypeModule): + self.log.warning(( + "Addon '{}' is inherited from 'OpenPypeModule'." + " Please use 'AYONAddon'." + ).format(name)) + + try: + # Try initialize module + addon = addon_cls(self, settings) + # Store initialized object + self._addons.append(addon) + self._addons_by_id[addon.id] = addon + self._addons_by_name[addon.name] = addon + enabled_str = "X" + if not addon.enabled: + enabled_str = " " + self.log.debug("[{}] {}".format(enabled_str, name)) + + now = time.time() + report[addon.__class__.__name__] = now - prev_start_time + prev_start_time = now + + except Exception: + self.log.warning( + "Initialization of addon '{}' failed.".format(name), + exc_info=True + ) + + if self._report is not None: + report[self._report_total_key] = time.time() - time_start + self._report["Initialization"] = report + + def connect_addons(self): + """Trigger connection with other enabled addons. + + Addons should handle their interfaces in `connect_with_addons`. + """ + report = {} + time_start = time.time() + prev_start_time = time_start + enabled_modules = self.get_enabled_addons() + self.log.debug("Has {} enabled modules.".format(len(enabled_modules))) + for module in enabled_modules: + try: + if hasattr(module, "connect_with_modules"): + self.log.warning(( + "DEPRECATION WARNING: Addon '{}' still uses" + " 'connect_with_modules' method. Please switch to use" + " 'connect_with_addons' method." + ).format(module.name)) + module.connect_with_modules(enabled_modules) + else: + module.connect_with_addons(enabled_modules) + except Exception: + self.log.error( + "BUG: Module failed on connection with other modules.", + exc_info=True + ) + + now = time.time() + report[module.__class__.__name__] = now - prev_start_time + prev_start_time = now + + if self._report is not None: + report[self._report_total_key] = time.time() - time_start + self._report["Connect modules"] = report + + def collect_global_environments(self): + """Helper to collect global environment variabled from modules. + + Returns: + dict: Global environment variables from enabled modules. + + Raises: + AssertionError: Global environment variables must be unique for + all modules. + """ + module_envs = {} + for module in self.get_enabled_addons(): + # Collect global module's global environments + _envs = module.get_global_environments() + for key, value in _envs.items(): + if key in module_envs: + # TODO better error message + raise AssertionError( + "Duplicated environment key {}".format(key) + ) + module_envs[key] = value + return module_envs + + def collect_plugin_paths(self): + """Helper to collect all plugins from modules inherited IPluginPaths. + + Unknown keys are logged out. + + Returns: + dict: Output is dictionary with keys "publish", "create", "load", + "actions" and "inventory" each containing list of paths. + """ + # Output structure + output = { + "publish": [], + "create": [], + "load": [], + "actions": [], + "inventory": [] + } + unknown_keys_by_addon = {} + for addon in self.get_enabled_addons(): + # Skip module that do not inherit from `IPluginPaths` + if not isinstance(addon, IPluginPaths): + continue + plugin_paths = addon.get_plugin_paths() + for key, value in plugin_paths.items(): + # Filter unknown keys + if key not in output: + if addon.name not in unknown_keys_by_addon: + unknown_keys_by_addon[addon.name] = [] + unknown_keys_by_addon[addon.name].append(key) + continue + + # Skip if value is empty + if not value: + continue + + # Convert to list if value is not list + if not isinstance(value, (list, tuple, set)): + value = [value] + output[key].extend(value) + + # Report unknown keys (Developing purposes) + if unknown_keys_by_addon: + expected_keys = ", ".join([ + "\"{}\"".format(key) for key in output.keys() + ]) + msg_template = "Addon: \"{}\" - got key {}" + msg_items = [] + for addon_name, keys in unknown_keys_by_addon.items(): + joined_keys = ", ".join([ + "\"{}\"".format(key) for key in keys + ]) + msg_items.append(msg_template.format(addon_name, joined_keys)) + self.log.warning(( + "Expected keys from `get_plugin_paths` are {}. {}" + ).format(expected_keys, " | ".join(msg_items))) + return output + + def _collect_plugin_paths(self, method_name, *args, **kwargs): + output = [] + for addon in self.get_enabled_addons(): + # Skip addon that do not inherit from `IPluginPaths` + if not isinstance(addon, IPluginPaths): + continue + + method = getattr(addon, method_name) + try: + paths = method(*args, **kwargs) + except Exception: + self.log.warning( + ( + "Failed to get plugin paths from addon" + " '{}' using '{}'." + ).format(addon.__class__.__name__, method_name), + exc_info=True + ) + continue + + if paths: + # Convert to list if value is not list + if not isinstance(paths, (list, tuple, set)): + paths = [paths] + output.extend(paths) + return output + + def collect_create_plugin_paths(self, host_name): + """Helper to collect creator plugin paths from addons. + + Args: + host_name (str): For which host are creators meant. + + Returns: + list: List of creator plugin paths. + """ + + return self._collect_plugin_paths( + "get_create_plugin_paths", + host_name + ) + + collect_creator_plugin_paths = collect_create_plugin_paths + + def collect_load_plugin_paths(self, host_name): + """Helper to collect load plugin paths from addons. + + Args: + host_name (str): For which host are load plugins meant. + + Returns: + list: List of load plugin paths. + """ + + return self._collect_plugin_paths( + "get_load_plugin_paths", + host_name + ) + + def collect_publish_plugin_paths(self, host_name): + """Helper to collect load plugin paths from addons. + + Args: + host_name (str): For which host are load plugins meant. + + Returns: + list: List of pyblish plugin paths. + """ + + return self._collect_plugin_paths( + "get_publish_plugin_paths", + host_name + ) + + def collect_inventory_action_paths(self, host_name): + """Helper to collect load plugin paths from addons. + + Args: + host_name (str): For which host are load plugins meant. + + Returns: + list: List of pyblish plugin paths. + """ + + return self._collect_plugin_paths( + "get_inventory_action_paths", + host_name + ) + + def get_host_addon(self, host_name): + """Find host addon by host name. + + Args: + host_name (str): Host name for which is found host addon. + + Returns: + Union[AYONAddon, None]: Found host addon by name or `None`. + """ + + for addon in self.get_enabled_addons(): + if ( + isinstance(addon, IHostAddon) + and addon.host_name == host_name + ): + return addon + return None + + def get_host_names(self): + """List of available host names based on host addons. + + Returns: + Iterable[str]: All available host names based on enabled addons + inheriting 'IHostAddon'. + """ + + return { + addon.host_name + for addon in self.get_enabled_addons() + if isinstance(addon, IHostAddon) + } + + def print_report(self): + """Print out report of time spent on addons initialization parts. + + Reporting is not automated must be implemented for each initialization + part separatelly. Reports must be stored to `_report` attribute. + Print is skipped if `_report` is empty. + + Attribute `_report` is dictionary where key is "label" describing + the processed part and value is dictionary where key is addon's + class name and value is time delta of it's processing. + + It is good idea to add total time delta on processed part under key + which is defined in attribute `_report_total_key`. By default has value + `"Total"` but use the attribute please. + + ```javascript + { + "Initialization": { + "FtrackAddon": 0.003, + ... + "Total": 1.003, + }, + ... + } + ``` + """ + + if not self._report: + return + + available_col_names = set() + for addon_names in self._report.values(): + available_col_names |= set(addon_names.keys()) + + # Prepare ordered dictionary for columns + cols = collections.OrderedDict() + # Add addon names to first columnt + cols["Addon name"] = list(sorted( + addon.__class__.__name__ + for addon in self.addons + if addon.__class__.__name__ in available_col_names + )) + # Add total key (as last addon) + cols["Addon name"].append(self._report_total_key) + + # Add columns from report + for label in self._report.keys(): + cols[label] = [] + + total_addon_times = {} + for addon_name in cols["Addon name"]: + total_addon_times[addon_name] = 0 + + for label, reported in self._report.items(): + for addon_name in cols["Addon name"]: + col_time = reported.get(addon_name) + if col_time is None: + cols[label].append("N/A") + continue + cols[label].append("{:.3f}".format(col_time)) + total_addon_times[addon_name] += col_time + + # Add to also total column that should sum the row + cols[self._report_total_key] = [] + for addon_name in cols["Addon name"]: + cols[self._report_total_key].append( + "{:.3f}".format(total_addon_times[addon_name]) + ) + + # Prepare column widths and total row count + # - column width is by + col_widths = {} + total_rows = None + for key, values in cols.items(): + if total_rows is None: + total_rows = 1 + len(values) + max_width = len(key) + for value in values: + value_length = len(value) + if value_length > max_width: + max_width = value_length + col_widths[key] = max_width + + rows = [] + for _idx in range(total_rows): + rows.append([]) + + for key, values in cols.items(): + width = col_widths[key] + idx = 0 + rows[idx].append(key.ljust(width)) + for value in values: + idx += 1 + rows[idx].append(value.ljust(width)) + + filler_parts = [] + for width in col_widths.values(): + filler_parts.append(width * "-") + filler = "+".join(filler_parts) + + formatted_rows = [filler] + last_row_idx = len(rows) - 1 + for idx, row in enumerate(rows): + # Add filler before last row + if idx == last_row_idx: + formatted_rows.append(filler) + + formatted_rows.append("|".join(row)) + + # Add filler after first row + if idx == 0: + formatted_rows.append(filler) + + # Join rows with newline char and add new line at the end + output = "\n".join(formatted_rows) + "\n" + print(output) + + # DEPRECATED - Module compatibility + @property + def modules(self): + return self.addons + + @property + def modules_by_id(self): + return self.addons_by_id + + @property + def modules_by_name(self): + return self.addons_by_name + + def get_enabled_module(self, *args, **kwargs): + return self.get_enabled_addon(*args, **kwargs) + + def initialize_modules(self): + self.initialize_addons() + + def get_enabled_modules(self): + return self.get_enabled_addons() + + def get_host_module(self, host_name): + return self.get_host_addon(host_name) + + +class TrayAddonsManager(AddonsManager): + # Define order of addons in menu + # TODO find better way how to define order + addons_menu_order = ( + "user", + "ftrack", + "kitsu", + "launcher_tool", + "avalon", + "clockify", + "traypublish_tool", + "log_viewer", + ) + + def __init__(self, settings=None): + super(TrayAddonsManager, self).__init__(settings, initialize=False) + + self.tray_manager = None + + self.doubleclick_callbacks = {} + self.doubleclick_callback = None + + def add_doubleclick_callback(self, addon, callback): + """Register doubleclick callbacks on tray icon. + + Currently there is no way how to determine which is launched. Name of + callback can be defined with `doubleclick_callback` attribute. + + Missing feature how to define default callback. + + Args: + addon (AYONAddon): Addon object. + callback (FunctionType): Function callback. + """ + + callback_name = "_".join([addon.name, callback.__name__]) + if callback_name not in self.doubleclick_callbacks: + self.doubleclick_callbacks[callback_name] = callback + if self.doubleclick_callback is None: + self.doubleclick_callback = callback_name + return + + self.log.warning(( + "Callback with name \"{}\" is already registered." + ).format(callback_name)) + + def initialize(self, tray_manager, tray_menu): + self.tray_manager = tray_manager + self.initialize_addons() + self.tray_init() + self.connect_addons() + self.tray_menu(tray_menu) + + def get_enabled_tray_addons(self): + """Enabled tray addons. + + Returns: + list[AYONAddon]: Enabled addons that inherit from tray interface. + """ + + return [ + addon + for addon in self.get_enabled_addons() + if isinstance(addon, ITrayAddon) + ] + + def restart_tray(self): + if self.tray_manager: + self.tray_manager.restart() + + def tray_init(self): + report = {} + time_start = time.time() + prev_start_time = time_start + for addon in self.get_enabled_tray_addons(): + try: + addon._tray_manager = self.tray_manager + addon.tray_init() + addon.tray_initialized = True + except Exception: + self.log.warning( + "Addon \"{}\" crashed on `tray_init`.".format( + addon.name + ), + exc_info=True + ) + + now = time.time() + report[addon.__class__.__name__] = now - prev_start_time + prev_start_time = now + + if self._report is not None: + report[self._report_total_key] = time.time() - time_start + self._report["Tray init"] = report + + def tray_menu(self, tray_menu): + ordered_addons = [] + enabled_by_name = { + addon.name: addon + for addon in self.get_enabled_tray_addons() + } + + for name in self.addons_menu_order: + addon_by_name = enabled_by_name.pop(name, None) + if addon_by_name: + ordered_addons.append(addon_by_name) + ordered_addons.extend(enabled_by_name.values()) + + report = {} + time_start = time.time() + prev_start_time = time_start + for addon in ordered_addons: + if not addon.tray_initialized: + continue + + try: + addon.tray_menu(tray_menu) + except Exception: + # Unset initialized mark + addon.tray_initialized = False + self.log.warning( + "Addon \"{}\" crashed on `tray_menu`.".format( + addon.name + ), + exc_info=True + ) + now = time.time() + report[addon.__class__.__name__] = now - prev_start_time + prev_start_time = now + + if self._report is not None: + report[self._report_total_key] = time.time() - time_start + self._report["Tray menu"] = report + + def start_addons(self): + report = {} + time_start = time.time() + prev_start_time = time_start + for addon in self.get_enabled_tray_addons(): + if not addon.tray_initialized: + if isinstance(addon, ITrayService): + addon.set_service_failed_icon() + continue + + try: + addon.tray_start() + except Exception: + self.log.warning( + "Addon \"{}\" crashed on `tray_start`.".format( + addon.name + ), + exc_info=True + ) + now = time.time() + report[addon.__class__.__name__] = now - prev_start_time + prev_start_time = now + + if self._report is not None: + report[self._report_total_key] = time.time() - time_start + self._report["Addons start"] = report + + def on_exit(self): + for addon in self.get_enabled_tray_addons(): + if addon.tray_initialized: + try: + addon.tray_exit() + except Exception: + self.log.warning( + "Addon \"{}\" crashed on `tray_exit`.".format( + addon.name + ), + exc_info=True + ) + + # DEPRECATED + def get_enabled_tray_modules(self): + return self.get_enabled_tray_addons() + + def start_modules(self): + self.start_addons() diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py new file mode 100644 index 0000000000..b68189ddcc --- /dev/null +++ b/client/ayon_core/addon/interfaces.py @@ -0,0 +1,385 @@ +from abc import ABCMeta, abstractmethod + +import six + +from openpype import resources + + +class _AYONInterfaceMeta(ABCMeta): + """AYONInterface meta class to print proper string.""" + + def __str__(self): + return "<'AYONInterface.{}'>".format(self.__name__) + + def __repr__(self): + return str(self) + + +@six.add_metaclass(_AYONInterfaceMeta) +class AYONInterface: + """Base class of Interface that can be used as Mixin with abstract parts. + + This is way how AYON addon can define that contains specific predefined + functionality. + + Child classes of AYONInterface may be used as mixin in different + AYON addons which means they have to have implemented methods defined + in the interface. By default, interface does not have any abstract parts. + """ + + pass + + +class IPluginPaths(AYONInterface): + """Addon has plugin paths to return. + + Expected result is dictionary with keys "publish", "create", "load", + "actions" or "inventory" and values as list or string. + { + "publish": ["path/to/publish_plugins"] + } + """ + + @abstractmethod + def get_plugin_paths(self): + pass + + def _get_plugin_paths_by_type(self, plugin_type): + paths = self.get_plugin_paths() + if not paths or plugin_type not in paths: + return [] + + paths = paths[plugin_type] + if not paths: + return [] + + if not isinstance(paths, (list, tuple, set)): + paths = [paths] + return paths + + def get_create_plugin_paths(self, host_name): + """Receive create plugin paths. + + Give addons ability to add create plugin paths based on host name. + + Notes: + Default implementation uses 'get_plugin_paths' and always return + all create plugin paths. + + Args: + host_name (str): For which host are the plugins meant. + """ + + return self._get_plugin_paths_by_type("create") + + def get_load_plugin_paths(self, host_name): + """Receive load plugin paths. + + Give addons ability to add load plugin paths based on host name. + + Notes: + Default implementation uses 'get_plugin_paths' and always return + all load plugin paths. + + Args: + host_name (str): For which host are the plugins meant. + """ + + return self._get_plugin_paths_by_type("load") + + def get_publish_plugin_paths(self, host_name): + """Receive publish plugin paths. + + Give addons ability to add publish plugin paths based on host name. + + Notes: + Default implementation uses 'get_plugin_paths' and always return + all publish plugin paths. + + Args: + host_name (str): For which host are the plugins meant. + """ + + return self._get_plugin_paths_by_type("publish") + + def get_inventory_action_paths(self, host_name): + """Receive inventory action paths. + + Give addons ability to add inventory action plugin paths. + + Notes: + Default implementation uses 'get_plugin_paths' and always return + all publish plugin paths. + + Args: + host_name (str): For which host are the plugins meant. + """ + + return self._get_plugin_paths_by_type("inventory") + + +class ITrayAddon(AYONInterface): + """Addon has special procedures when used in Tray tool. + + IMPORTANT: + The addon. still must be usable if is not used in tray even if + would do nothing. + """ + + tray_initialized = False + _tray_manager = None + + @abstractmethod + def tray_init(self): + """Initialization part of tray implementation. + + Triggered between `initialization` and `connect_with_addons`. + + This is where GUIs should be loaded or tray specific parts should be + prepared. + """ + + pass + + @abstractmethod + def tray_menu(self, tray_menu): + """Add addon's action to tray menu.""" + + pass + + @abstractmethod + def tray_start(self): + """Start procedure in tray tool.""" + + pass + + @abstractmethod + def tray_exit(self): + """Cleanup method which is executed on tray shutdown. + + This is place where all threads should be shut. + """ + + pass + + def execute_in_main_thread(self, callback): + """ Pushes callback to the queue or process 'callback' on a main thread + + Some callbacks need to be processed on main thread (menu actions + must be added on main thread or they won't get triggered etc.) + """ + + if not self.tray_initialized: + # TODO Called without initialized tray, still main thread needed + try: + callback() + + except Exception: + self.log.warning( + "Failed to execute {} in main thread".format(callback), + exc_info=True) + + return + self.manager.tray_manager.execute_in_main_thread(callback) + + def show_tray_message(self, title, message, icon=None, msecs=None): + """Show tray message. + + Args: + title (str): Title of message. + message (str): Content of message. + icon (QSystemTrayIcon.MessageIcon): Message's icon. Default is + Information icon, may differ by Qt version. + msecs (int): Duration of message visibility in milliseconds. + Default is 10000 msecs, may differ by Qt version. + """ + + if self._tray_manager: + self._tray_manager.show_tray_message(title, message, icon, msecs) + + def add_doubleclick_callback(self, callback): + if hasattr(self.manager, "add_doubleclick_callback"): + self.manager.add_doubleclick_callback(self, callback) + + +class ITrayAction(ITrayAddon): + """Implementation of Tray action. + + Add action to tray menu which will trigger `on_action_trigger`. + It is expected to be used for showing tools. + + Methods `tray_start`, `tray_exit` and `connect_with_addons` are overridden + as it's not expected that action will use them. But it is possible if + necessary. + """ + + admin_action = False + _admin_submenu = None + _action_item = None + + @property + @abstractmethod + def label(self): + """Service label showed in menu.""" + pass + + @abstractmethod + def on_action_trigger(self): + """What happens on actions click.""" + pass + + def tray_menu(self, tray_menu): + from qtpy import QtWidgets + + if self.admin_action: + menu = self.admin_submenu(tray_menu) + action = QtWidgets.QAction(self.label, menu) + menu.addAction(action) + if not menu.menuAction().isVisible(): + menu.menuAction().setVisible(True) + + else: + action = QtWidgets.QAction(self.label, tray_menu) + tray_menu.addAction(action) + + action.triggered.connect(self.on_action_trigger) + self._action_item = action + + def tray_start(self): + return + + def tray_exit(self): + return + + @staticmethod + def admin_submenu(tray_menu): + if ITrayAction._admin_submenu is None: + from qtpy import QtWidgets + + admin_submenu = QtWidgets.QMenu("Admin", tray_menu) + admin_submenu.menuAction().setVisible(False) + ITrayAction._admin_submenu = admin_submenu + return ITrayAction._admin_submenu + + +class ITrayService(ITrayAddon): + # Module's property + menu_action = None + + # Class properties + _services_submenu = None + _icon_failed = None + _icon_running = None + _icon_idle = None + + @property + @abstractmethod + def label(self): + """Service label showed in menu.""" + pass + + # TODO be able to get any sort of information to show/print + # @abstractmethod + # def get_service_info(self): + # pass + + @staticmethod + def services_submenu(tray_menu): + if ITrayService._services_submenu is None: + from qtpy import QtWidgets + + services_submenu = QtWidgets.QMenu("Services", tray_menu) + services_submenu.menuAction().setVisible(False) + ITrayService._services_submenu = services_submenu + return ITrayService._services_submenu + + @staticmethod + def add_service_action(action): + ITrayService._services_submenu.addAction(action) + if not ITrayService._services_submenu.menuAction().isVisible(): + ITrayService._services_submenu.menuAction().setVisible(True) + + @staticmethod + def _load_service_icons(): + from qtpy import QtGui + + ITrayService._failed_icon = QtGui.QIcon( + resources.get_resource("icons", "circle_red.png") + ) + ITrayService._icon_running = QtGui.QIcon( + resources.get_resource("icons", "circle_green.png") + ) + ITrayService._icon_idle = QtGui.QIcon( + resources.get_resource("icons", "circle_orange.png") + ) + + @staticmethod + def get_icon_running(): + if ITrayService._icon_running is None: + ITrayService._load_service_icons() + return ITrayService._icon_running + + @staticmethod + def get_icon_idle(): + if ITrayService._icon_idle is None: + ITrayService._load_service_icons() + return ITrayService._icon_idle + + @staticmethod + def get_icon_failed(): + if ITrayService._failed_icon is None: + ITrayService._load_service_icons() + return ITrayService._failed_icon + + def tray_menu(self, tray_menu): + from qtpy import QtWidgets + + action = QtWidgets.QAction( + self.label, + self.services_submenu(tray_menu) + ) + self.menu_action = action + + self.add_service_action(action) + + self.set_service_running_icon() + + def set_service_running_icon(self): + """Change icon of an QAction to green circle.""" + + if self.menu_action: + self.menu_action.setIcon(self.get_icon_running()) + + def set_service_failed_icon(self): + """Change icon of an QAction to red circle.""" + + if self.menu_action: + self.menu_action.setIcon(self.get_icon_failed()) + + def set_service_idle_icon(self): + """Change icon of an QAction to orange circle.""" + + if self.menu_action: + self.menu_action.setIcon(self.get_icon_idle()) + + +class IHostAddon(AYONInterface): + """Addon which also contain a host implementation.""" + + @property + @abstractmethod + def host_name(self): + """Name of host which addon represents.""" + + pass + + def get_workfile_extensions(self): + """Define workfile extensions for host. + + Not all hosts support workfiles thus this is optional implementation. + + Returns: + List[str]: Extensions used for workfiles with dot. + """ + + return [] diff --git a/client/ayon_core/modules/__init__.py b/client/ayon_core/modules/__init__.py index 78da98ba85..0dfd7d663c 100644 --- a/client/ayon_core/modules/__init__.py +++ b/client/ayon_core/modules/__init__.py @@ -1,12 +1,11 @@ # -*- coding: utf-8 -*- from . import click_wrap from .interfaces import ( - ILaunchHookPaths, IPluginPaths, + ITrayAddon, ITrayModule, ITrayAction, ITrayService, - ISettingsChangeListener, IHostAddon, ) @@ -25,12 +24,11 @@ from .base import ( __all__ = ( "click_wrap", - "ILaunchHookPaths", "IPluginPaths", + "ITrayAddon", "ITrayModule", "ITrayAction", "ITrayService", - "ISettingsChangeListener", "IHostAddon", "AYONAddon", diff --git a/client/ayon_core/modules/base.py b/client/ayon_core/modules/base.py index 10a21bc0b8..060098fac6 100644 --- a/client/ayon_core/modules/base.py +++ b/client/ayon_core/modules/base.py @@ -1,741 +1,13 @@ -# -*- coding: utf-8 -*- -"""Base class for AYON addons.""" -import copy -import os -import sys -import time -import inspect -import logging -import threading -import collections -import traceback - -from uuid import uuid4 -from abc import ABCMeta, abstractmethod - -import six -import appdirs - -from ayon_core.client import get_ayon_server_api_connection -from ayon_core.settings import get_system_settings - -from ayon_core.settings.ayon_settings import ( - is_dev_mode_enabled, - get_ayon_settings, +from ayon_core.addon import ( + AYONAddon, + AddonsManager, + TrayAddonsManager, + load_addons, ) -from ayon_core.lib import ( - Logger, - import_filepath, - import_module_from_dirpath, -) - -from .interfaces import ( - OpenPypeInterface, - IPluginPaths, - IHostAddon, - ITrayModule, - ITrayService -) - -# Files that will be always ignored on addons import -IGNORED_FILENAMES = ( - "__pycache__", -) -# Files ignored on addons import from "./openpype/modules" -IGNORED_DEFAULT_FILENAMES = ( - "__init__.py", - "base.py", - "interfaces.py", - "example_addons", - "default_modules", -) -# Addons that won't be loaded in AYON mode from "./openpype/modules" -# - the same addons are ignored in "./server_addon/create_ayon_addons.py" -IGNORED_FILENAMES_IN_AYON = { - "ftrack", - "shotgrid", - "sync_server", - "slack", - "kitsu", -} -IGNORED_HOSTS_IN_AYON = { - "flame", - "harmony", -} - - -# Inherit from `object` for Python 2 hosts -class _ModuleClass(object): - """Fake module class for storing OpenPype modules. - - Object of this class can be stored to `sys.modules` and used for storing - dynamically imported modules. - """ - - def __init__(self, name): - # Call setattr on super class - super(_ModuleClass, self).__setattr__("name", name) - super(_ModuleClass, self).__setattr__("__name__", name) - - # Where modules and interfaces are stored - super(_ModuleClass, self).__setattr__("__attributes__", dict()) - super(_ModuleClass, self).__setattr__("__defaults__", set()) - - super(_ModuleClass, self).__setattr__("_log", None) - - def __getattr__(self, attr_name): - if attr_name not in self.__attributes__: - if attr_name in ("__path__", "__file__"): - return None - raise AttributeError("'{}' has not attribute '{}'".format( - self.name, attr_name - )) - return self.__attributes__[attr_name] - - def __iter__(self): - for module in self.values(): - yield module - - def __setattr__(self, attr_name, value): - if attr_name in self.__attributes__: - self.log.warning( - "Duplicated name \"{}\" in {}. Overriding.".format( - attr_name, self.name - ) - ) - self.__attributes__[attr_name] = value - - def __setitem__(self, key, value): - self.__setattr__(key, value) - - def __getitem__(self, key): - return getattr(self, key) - - @property - def log(self): - if self._log is None: - super(_ModuleClass, self).__setattr__( - "_log", Logger.get_logger(self.name) - ) - return self._log - - def get(self, key, default=None): - return self.__attributes__.get(key, default) - - def keys(self): - return self.__attributes__.keys() - - def values(self): - return self.__attributes__.values() - - def items(self): - return self.__attributes__.items() - - -class _InterfacesClass(_ModuleClass): - """Fake module class for storing OpenPype interfaces. - - MissingInterface object is returned if interfaces does not exists. - - this is because interfaces must be available even if are missing - implementation - """ - - def __getattr__(self, attr_name): - if attr_name not in self.__attributes__: - if attr_name in ("__path__", "__file__"): - return None - - raise AttributeError(( - "cannot import name '{}' from 'openpype_interfaces'" - ).format(attr_name)) - - if _LoadCache.interfaces_loaded and attr_name != "log": - stack = list(traceback.extract_stack()) - stack.pop(-1) - self.log.warning(( - "Using deprecated import of \"{}\" from 'openpype_interfaces'." - " Please switch to use import" - " from 'ayon_core.modules.interfaces'" - " (will be removed after 3.16.x).{}" - ).format(attr_name, "".join(traceback.format_list(stack)))) - return self.__attributes__[attr_name] - - -class _LoadCache: - interfaces_lock = threading.Lock() - modules_lock = threading.Lock() - interfaces_loaded = False - modules_loaded = False - - -def get_default_modules_dir(): - """Path to default OpenPype modules.""" - - current_dir = os.path.dirname(os.path.abspath(__file__)) - - output = [] - for folder_name in ("default_modules", ): - path = os.path.join(current_dir, folder_name) - if os.path.exists(path) and os.path.isdir(path): - output.append(path) - - return output - - -def get_module_dirs(): - """List of paths where OpenPype modules can be found.""" - _dirpaths = [] - _dirpaths.extend(get_default_modules_dir()) - - dirpaths = [] - for path in _dirpaths: - if not path: - continue - normalized = os.path.normpath(path) - if normalized not in dirpaths: - dirpaths.append(normalized) - return dirpaths - - -def load_interfaces(force=False): - """Load interfaces from modules into `openpype_interfaces`. - - Only classes which inherit from `OpenPypeInterface` are loaded and stored. - - Args: - force(bool): Force to load interfaces even if are already loaded. - This won't update already loaded and used (cached) interfaces. - """ - - if _LoadCache.interfaces_loaded and not force: - return - - if not _LoadCache.interfaces_lock.locked(): - with _LoadCache.interfaces_lock: - _load_interfaces() - _LoadCache.interfaces_loaded = True - else: - # If lock is locked wait until is finished - while _LoadCache.interfaces_lock.locked(): - time.sleep(0.1) - - -def _load_interfaces(): - # Key under which will be modules imported in `sys.modules` - modules_key = "openpype_interfaces" - - sys.modules[modules_key] = openpype_interfaces = ( - _InterfacesClass(modules_key) - ) - - from . import interfaces - - for attr_name in dir(interfaces): - attr = getattr(interfaces, attr_name) - if ( - not inspect.isclass(attr) - or attr is OpenPypeInterface - or not issubclass(attr, OpenPypeInterface) - ): - continue - setattr(openpype_interfaces, attr_name, attr) - - -def load_modules(force=False): - """Load OpenPype modules as python modules. - - Modules does not load only classes (like in Interfaces) because there must - be ability to use inner code of module and be able to import it from one - defined place. - - With this it is possible to import module's content from predefined module. - - Function makes sure that `load_interfaces` was triggered. Modules import - has specific order which can't be changed. - - Args: - force(bool): Force to load modules even if are already loaded. - This won't update already loaded and used (cached) modules. - """ - - if _LoadCache.modules_loaded and not force: - return - - # First load interfaces - # - modules must not be imported before interfaces - load_interfaces(force) - - if not _LoadCache.modules_lock.locked(): - with _LoadCache.modules_lock: - _load_modules() - _LoadCache.modules_loaded = True - else: - # If lock is locked wait until is finished - while _LoadCache.modules_lock.locked(): - time.sleep(0.1) - - -def _get_ayon_bundle_data(): - con = get_ayon_server_api_connection() - bundles = con.get_bundles()["bundles"] - - bundle_name = os.getenv("AYON_BUNDLE_NAME") - - return next( - ( - bundle - for bundle in bundles - if bundle["name"] == bundle_name - ), - None - ) - - -def _get_ayon_addons_information(bundle_info): - """Receive information about addons to use from server. - - Todos: - Actually ask server for the information. - Allow project name as optional argument to be able to query information - about used addons for specific project. - - Returns: - List[Dict[str, Any]]: List of addon information to use. - """ - - output = [] - bundle_addons = bundle_info["addons"] - con = get_ayon_server_api_connection() - addons = con.get_addons_info()["addons"] - for addon in addons: - name = addon["name"] - versions = addon.get("versions") - addon_version = bundle_addons.get(name) - if addon_version is None or not versions: - continue - version = versions.get(addon_version) - if version: - version = copy.deepcopy(version) - version["name"] = name - version["version"] = addon_version - output.append(version) - return output - - -def _load_ayon_addons(openpype_modules, modules_key, log): - """Load AYON addons based on information from server. - - This function should not trigger downloading of any addons but only use - what is already available on the machine (at least in first stages of - development). - - Args: - openpype_modules (_ModuleClass): Module object where modules are - stored. - log (logging.Logger): Logger object. - - Returns: - List[str]: List of v3 addons to skip to load because v4 alternative is - imported. - """ - - v3_addons_to_skip = [] - - bundle_info = _get_ayon_bundle_data() - addons_info = _get_ayon_addons_information(bundle_info) - if not addons_info: - return v3_addons_to_skip - - addons_dir = os.environ.get("AYON_ADDONS_DIR") - if not addons_dir: - addons_dir = os.path.join( - appdirs.user_data_dir("AYON", "Ynput"), - "addons" - ) - - dev_mode_enabled = is_dev_mode_enabled() - dev_addons_info = {} - if dev_mode_enabled: - # Get dev addons info only when dev mode is enabled - dev_addons_info = bundle_info.get("addonDevelopment", dev_addons_info) - - addons_dir_exists = os.path.exists(addons_dir) - if not addons_dir_exists: - log.warning("Addons directory does not exists. Path \"{}\"".format( - addons_dir - )) - - for addon_info in addons_info: - addon_name = addon_info["name"] - addon_version = addon_info["version"] - - # OpenPype addon does not have any addon object - if addon_name == "openpype": - continue - - dev_addon_info = dev_addons_info.get(addon_name, {}) - use_dev_path = dev_addon_info.get("enabled", False) - - addon_dir = None - if use_dev_path: - addon_dir = dev_addon_info["path"] - if not addon_dir or not os.path.exists(addon_dir): - log.warning(( - "Dev addon {} {} path does not exists. Path \"{}\"" - ).format(addon_name, addon_version, addon_dir)) - continue - - elif addons_dir_exists: - folder_name = "{}_{}".format(addon_name, addon_version) - addon_dir = os.path.join(addons_dir, folder_name) - if not os.path.exists(addon_dir): - log.debug(( - "No localized client code found for addon {} {}." - ).format(addon_name, addon_version)) - continue - - if not addon_dir: - continue - - sys.path.insert(0, addon_dir) - imported_modules = [] - for name in os.listdir(addon_dir): - # Ignore of files is implemented to be able to run code from code - # where usually is more files than just the addon - # Ignore start and setup scripts - if name in ("setup.py", "start.py", "__pycache__"): - continue - - path = os.path.join(addon_dir, name) - basename, ext = os.path.splitext(name) - # Ignore folders/files with dot in name - # - dot names cannot be imported in Python - if "." in basename: - continue - is_dir = os.path.isdir(path) - is_py_file = ext.lower() == ".py" - if not is_py_file and not is_dir: - continue - - try: - mod = __import__(basename, fromlist=("",)) - for attr_name in dir(mod): - attr = getattr(mod, attr_name) - if ( - inspect.isclass(attr) - and issubclass(attr, AYONAddon) - ): - imported_modules.append(mod) - break - - except BaseException: - log.warning( - "Failed to import \"{}\"".format(basename), - exc_info=True - ) - - if not imported_modules: - log.warning("Addon {} {} has no content to import".format( - addon_name, addon_version - )) - continue - - if len(imported_modules) > 1: - log.warning(( - "Skipping addon '{}'." - " Multiple modules were found ({}) in dir {}." - ).format( - addon_name, - ", ".join([m.__name__ for m in imported_modules]), - addon_dir, - )) - continue - - mod = imported_modules[0] - addon_alias = getattr(mod, "V3_ALIAS", None) - if not addon_alias: - addon_alias = addon_name - v3_addons_to_skip.append(addon_alias) - new_import_str = "{}.{}".format(modules_key, addon_alias) - - sys.modules[new_import_str] = mod - setattr(openpype_modules, addon_alias, mod) - - return v3_addons_to_skip - - -def _load_modules(): - # Key under which will be modules imported in `sys.modules` - modules_key = "openpype_modules" - - # Change `sys.modules` - sys.modules[modules_key] = openpype_modules = _ModuleClass(modules_key) - - log = Logger.get_logger("ModulesLoader") - - ignore_addon_names = _load_ayon_addons( - openpype_modules, modules_key, log - ) - - # Look for OpenPype modules in paths defined with `get_module_dirs` - # - dynamically imported OpenPype modules and addons - module_dirs = get_module_dirs() - - # Add current directory at first place - # - has small differences in import logic - current_dir = os.path.abspath(os.path.dirname(__file__)) - hosts_dir = os.path.join(os.path.dirname(current_dir), "hosts") - module_dirs.insert(0, hosts_dir) - module_dirs.insert(0, current_dir) - - addons_dir = os.path.join(os.path.dirname(current_dir), "addons") - if os.path.exists(addons_dir): - module_dirs.append(addons_dir) - - ignored_host_names = set(IGNORED_HOSTS_IN_AYON) - ignored_current_dir_filenames = set(IGNORED_DEFAULT_FILENAMES) - - ignored_current_dir_filenames |= IGNORED_FILENAMES_IN_AYON - - processed_paths = set() - for dirpath in frozenset(module_dirs): - # Skip already processed paths - if dirpath in processed_paths: - continue - processed_paths.add(dirpath) - - if not os.path.exists(dirpath): - log.warning(( - "Could not find path when loading OpenPype modules \"{}\"" - ).format(dirpath)) - continue - - is_in_current_dir = dirpath == current_dir - is_in_host_dir = dirpath == hosts_dir - - for filename in os.listdir(dirpath): - # Ignore filenames - if filename in IGNORED_FILENAMES: - continue - - if ( - is_in_current_dir - and filename in ignored_current_dir_filenames - ): - continue - - if ( - is_in_host_dir - and filename in ignored_host_names - ): - continue - - fullpath = os.path.join(dirpath, filename) - basename, ext = os.path.splitext(filename) - - if basename in ignore_addon_names: - continue - - # Validations - if os.path.isdir(fullpath): - # Check existence of init file - init_path = os.path.join(fullpath, "__init__.py") - if not os.path.exists(init_path): - log.debug(( - "Module directory does not contain __init__.py" - " file {}" - ).format(fullpath)) - continue - - elif ext not in (".py", ): - continue - - # TODO add more logic how to define if folder is module or not - # - check manifest and content of manifest - try: - # Don't import dynamically current directory modules - if is_in_current_dir: - import_str = "ayon_core.modules.{}".format(basename) - new_import_str = "{}.{}".format(modules_key, basename) - default_module = __import__(import_str, fromlist=("", )) - sys.modules[new_import_str] = default_module - setattr(openpype_modules, basename, default_module) - - elif is_in_host_dir: - import_str = "ayon_core.hosts.{}".format(basename) - new_import_str = "{}.{}".format(modules_key, basename) - # Until all hosts are converted to be able use them as - # modules is this error check needed - try: - default_module = __import__( - import_str, fromlist=("", ) - ) - sys.modules[new_import_str] = default_module - setattr(openpype_modules, basename, default_module) - - except Exception: - log.warning( - "Failed to import host folder {}".format(basename), - exc_info=True - ) - - elif os.path.isdir(fullpath): - import_module_from_dirpath(dirpath, filename, modules_key) - - else: - module = import_filepath(fullpath) - setattr(openpype_modules, basename, module) - - except Exception: - if is_in_current_dir: - msg = "Failed to import default module '{}'.".format( - basename - ) - else: - msg = "Failed to import module '{}'.".format(fullpath) - log.error(msg, exc_info=True) - - -@six.add_metaclass(ABCMeta) -class AYONAddon(object): - """Base class of AYON addon. - - Attributes: - id (UUID): Addon object id. - enabled (bool): Is addon enabled. - name (str): Addon name. - - Args: - manager (ModulesManager): Manager object who discovered addon. - settings (dict[str, Any]): AYON settings. - """ - - enabled = True - _id = None - - def __init__(self, manager, settings): - self.manager = manager - - self.log = Logger.get_logger(self.name) - - self.initialize(settings) - - @property - def id(self): - """Random id of addon object. - - Returns: - str: Object id. - """ - - if self._id is None: - self._id = uuid4() - return self._id - - @property - @abstractmethod - def name(self): - """Addon name. - - Returns: - str: Addon name. - """ - - pass - - def initialize(self, settings): - """Initialization of module attributes. - - It is not recommended to override __init__ that's why specific method - was implemented. - - Args: - settings (dict[str, Any]): Settings. - """ - - pass - - def connect_with_modules(self, enabled_addons): - """Connect with other enabled addons. - - Args: - enabled_addons (list[AYONAddon]): Addons that are enabled. - """ - - pass - - def get_global_environments(self): - """Get global environments values of module. - - Environment variables that can be get only from system settings. - - Returns: - dict[str, str]: Environment variables. - """ - - return {} - - def modify_application_launch_arguments(self, application, env): - """Give option to modify launch environments before application launch. - - Implementation is optional. To change environments modify passed - dictionary of environments. - - Args: - application (Application): Application that is launched. - env (dict[str, str]): Current environment variables. - """ - - pass - - def on_host_install(self, host, host_name, project_name): - """Host was installed which gives option to handle in-host logic. - - It is a good option to register in-host event callbacks which are - specific for the module. The module is kept in memory for rest of - the process. - - Arguments may change in future. E.g. 'host_name' should be possible - to receive from 'host' object. - - Args: - host (Union[ModuleType, HostBase]): Access to installed/registered - host object. - host_name (str): Name of host. - project_name (str): Project name which is main part of host - context. - """ - - pass - - def cli(self, module_click_group): - """Add commands to click group. - - The best practise is to create click group for whole module which is - used to separate commands. - - Example: - class MyPlugin(AYONAddon): - ... - def cli(self, module_click_group): - module_click_group.add_command(cli_main) - - - @click.group(, help="") - def cli_main(): - pass - - @cli_main.command() - def mycommand(): - print("my_command") - - Args: - module_click_group (click.Group): Group to which can be added - commands. - """ - - pass +ModulesManager = AddonsManager +TrayModulesManager = TrayAddonsManager +load_modules = load_addons class OpenPypeModule(AYONAddon): @@ -744,8 +16,8 @@ class OpenPypeModule(AYONAddon): Instead of 'AYONAddon' are passed in module settings. Args: - manager (ModulesManager): Manager object who discovered addon. - settings (dict[str, Any]): OpenPype settings. + manager (AddonsManager): Manager object who discovered addon. + settings (dict[str, Any]): Settings. """ # Disable by default @@ -755,698 +27,3 @@ class OpenPypeModule(AYONAddon): class OpenPypeAddOn(OpenPypeModule): # Enable Addon by default enabled = True - - -class ModulesManager: - """Manager of Pype modules helps to load and prepare them to work. - - Args: - system_settings (Optional[dict[str, Any]]): OpenPype system settings. - ayon_settings (Optional[dict[str, Any]]): AYON studio settings. - """ - - # Helper attributes for report - _report_total_key = "Total" - _system_settings = None - _ayon_settings = None - - def __init__(self, system_settings=None, ayon_settings=None): - self.log = logging.getLogger(self.__class__.__name__) - - self._system_settings = system_settings - self._ayon_settings = ayon_settings - - self.modules = [] - self.modules_by_id = {} - self.modules_by_name = {} - # For report of time consumption - self._report = {} - - self.initialize_modules() - self.connect_modules() - - def __getitem__(self, module_name): - return self.modules_by_name[module_name] - - def get(self, module_name, default=None): - """Access module by name. - - Args: - module_name (str): Name of module which should be returned. - default (Any): Default output if module is not available. - - Returns: - Union[AYONAddon, None]: Module found by name or None. - """ - - return self.modules_by_name.get(module_name, default) - - def get_enabled_module(self, module_name, default=None): - """Fast access to enabled module. - - If module is available but is not enabled default value is returned. - - Args: - module_name (str): Name of module which should be returned. - default (Any): Default output if module is not available or is - not enabled. - - Returns: - Union[AYONAddon, None]: Enabled module found by name or None. - """ - - module = self.get(module_name) - if module is not None and module.enabled: - return module - return default - - def initialize_modules(self): - """Import and initialize modules.""" - # Make sure modules are loaded - load_modules() - - import openpype_modules - - self.log.debug("*** AYON addons initialization.") - # Prepare settings for modules - system_settings = self._system_settings - if system_settings is None: - system_settings = get_system_settings() - - ayon_settings = self._ayon_settings - if ayon_settings is None: - ayon_settings = get_ayon_settings() - - modules_settings = system_settings["modules"] - - report = {} - time_start = time.time() - prev_start_time = time_start - - module_classes = [] - for module in openpype_modules: - # Go through globals in `pype.modules` - for name in dir(module): - modules_item = getattr(module, name, None) - # Filter globals that are not classes which inherit from - # AYONAddon - if ( - not inspect.isclass(modules_item) - or modules_item is AYONAddon - or modules_item is OpenPypeModule - or modules_item is OpenPypeAddOn - or not issubclass(modules_item, AYONAddon) - ): - continue - - # Check if class is abstract (Developing purpose) - if inspect.isabstract(modules_item): - # Find abstract attributes by convention on `abc` module - not_implemented = [] - for attr_name in dir(modules_item): - attr = getattr(modules_item, attr_name, None) - abs_method = getattr( - attr, "__isabstractmethod__", None - ) - if attr and abs_method: - not_implemented.append(attr_name) - - # Log missing implementations - self.log.warning(( - "Skipping abstract Class: {}." - " Missing implementations: {}" - ).format(name, ", ".join(not_implemented))) - continue - module_classes.append(modules_item) - - for modules_item in module_classes: - is_openpype_module = issubclass(modules_item, OpenPypeModule) - settings = ( - modules_settings if is_openpype_module else ayon_settings - ) - name = modules_item.__name__ - try: - # Try initialize module - module = modules_item(self, settings) - # Store initialized object - self.modules.append(module) - self.modules_by_id[module.id] = module - self.modules_by_name[module.name] = module - enabled_str = "X" - if not module.enabled: - enabled_str = " " - self.log.debug("[{}] {}".format(enabled_str, name)) - - now = time.time() - report[module.__class__.__name__] = now - prev_start_time - prev_start_time = now - - except Exception: - self.log.warning( - "Initialization of module {} failed.".format(name), - exc_info=True - ) - - if self._report is not None: - report[self._report_total_key] = time.time() - time_start - self._report["Initialization"] = report - - def connect_modules(self): - """Trigger connection with other enabled modules. - - Modules should handle their interfaces in `connect_with_modules`. - """ - report = {} - time_start = time.time() - prev_start_time = time_start - enabled_modules = self.get_enabled_modules() - self.log.debug("Has {} enabled modules.".format(len(enabled_modules))) - for module in enabled_modules: - try: - module.connect_with_modules(enabled_modules) - except Exception: - self.log.error( - "BUG: Module failed on connection with other modules.", - exc_info=True - ) - - now = time.time() - report[module.__class__.__name__] = now - prev_start_time - prev_start_time = now - - if self._report is not None: - report[self._report_total_key] = time.time() - time_start - self._report["Connect modules"] = report - - def get_enabled_modules(self): - """Enabled modules initialized by the manager. - - Returns: - list[AYONAddon]: Initialized and enabled modules. - """ - - return [ - module - for module in self.modules - if module.enabled - ] - - def collect_global_environments(self): - """Helper to collect global environment variabled from modules. - - Returns: - dict: Global environment variables from enabled modules. - - Raises: - AssertionError: Global environment variables must be unique for - all modules. - """ - module_envs = {} - for module in self.get_enabled_modules(): - # Collect global module's global environments - _envs = module.get_global_environments() - for key, value in _envs.items(): - if key in module_envs: - # TODO better error message - raise AssertionError( - "Duplicated environment key {}".format(key) - ) - module_envs[key] = value - return module_envs - - def collect_plugin_paths(self): - """Helper to collect all plugins from modules inherited IPluginPaths. - - Unknown keys are logged out. - - Returns: - dict: Output is dictionary with keys "publish", "create", "load", - "actions" and "inventory" each containing list of paths. - """ - # Output structure - output = { - "publish": [], - "create": [], - "load": [], - "actions": [], - "inventory": [] - } - unknown_keys_by_module = {} - for module in self.get_enabled_modules(): - # Skip module that do not inherit from `IPluginPaths` - if not isinstance(module, IPluginPaths): - continue - plugin_paths = module.get_plugin_paths() - for key, value in plugin_paths.items(): - # Filter unknown keys - if key not in output: - if module.name not in unknown_keys_by_module: - unknown_keys_by_module[module.name] = [] - unknown_keys_by_module[module.name].append(key) - continue - - # Skip if value is empty - if not value: - continue - - # Convert to list if value is not list - if not isinstance(value, (list, tuple, set)): - value = [value] - output[key].extend(value) - - # Report unknown keys (Developing purposes) - if unknown_keys_by_module: - expected_keys = ", ".join([ - "\"{}\"".format(key) for key in output.keys() - ]) - msg_template = "Module: \"{}\" - got key {}" - msg_items = [] - for module_name, keys in unknown_keys_by_module.items(): - joined_keys = ", ".join([ - "\"{}\"".format(key) for key in keys - ]) - msg_items.append(msg_template.format(module_name, joined_keys)) - self.log.warning(( - "Expected keys from `get_plugin_paths` are {}. {}" - ).format(expected_keys, " | ".join(msg_items))) - return output - - def _collect_plugin_paths(self, method_name, *args, **kwargs): - output = [] - for module in self.get_enabled_modules(): - # Skip module that do not inherit from `IPluginPaths` - if not isinstance(module, IPluginPaths): - continue - - method = getattr(module, method_name) - try: - paths = method(*args, **kwargs) - except Exception: - self.log.warning( - ( - "Failed to get plugin paths from module" - " '{}' using '{}'." - ).format(module.__class__.__name__, method_name), - exc_info=True - ) - continue - - if paths: - # Convert to list if value is not list - if not isinstance(paths, (list, tuple, set)): - paths = [paths] - output.extend(paths) - return output - - def collect_create_plugin_paths(self, host_name): - """Helper to collect creator plugin paths from modules. - - Args: - host_name (str): For which host are creators meant. - - Returns: - list: List of creator plugin paths. - """ - - return self._collect_plugin_paths( - "get_create_plugin_paths", - host_name - ) - - collect_creator_plugin_paths = collect_create_plugin_paths - - def collect_load_plugin_paths(self, host_name): - """Helper to collect load plugin paths from modules. - - Args: - host_name (str): For which host are load plugins meant. - - Returns: - list: List of load plugin paths. - """ - - return self._collect_plugin_paths( - "get_load_plugin_paths", - host_name - ) - - def collect_publish_plugin_paths(self, host_name): - """Helper to collect load plugin paths from modules. - - Args: - host_name (str): For which host are load plugins meant. - - Returns: - list: List of pyblish plugin paths. - """ - - return self._collect_plugin_paths( - "get_publish_plugin_paths", - host_name - ) - - def collect_inventory_action_paths(self, host_name): - """Helper to collect load plugin paths from modules. - - Args: - host_name (str): For which host are load plugins meant. - - Returns: - list: List of pyblish plugin paths. - """ - - return self._collect_plugin_paths( - "get_inventory_action_paths", - host_name - ) - - def get_host_module(self, host_name): - """Find host module by host name. - - Args: - host_name (str): Host name for which is found host module. - - Returns: - AYONAddon: Found host module by name. - None: There was not found module inheriting IHostAddon which has - host name set to passed 'host_name'. - """ - - for module in self.get_enabled_modules(): - if ( - isinstance(module, IHostAddon) - and module.host_name == host_name - ): - return module - return None - - def get_host_names(self): - """List of available host names based on host modules. - - Returns: - Iterable[str]: All available host names based on enabled modules - inheriting 'IHostAddon'. - """ - - return { - module.host_name - for module in self.get_enabled_modules() - if isinstance(module, IHostAddon) - } - - def print_report(self): - """Print out report of time spent on modules initialization parts. - - Reporting is not automated must be implemented for each initialization - part separatelly. Reports must be stored to `_report` attribute. - Print is skipped if `_report` is empty. - - Attribute `_report` is dictionary where key is "label" describing - the processed part and value is dictionary where key is module's - class name and value is time delta of it's processing. - - It is good idea to add total time delta on processed part under key - which is defined in attribute `_report_total_key`. By default has value - `"Total"` but use the attribute please. - - ```javascript - { - "Initialization": { - "FtrackModule": 0.003, - ... - "Total": 1.003, - }, - ... - } - ``` - """ - if not self._report: - return - - available_col_names = set() - for module_names in self._report.values(): - available_col_names |= set(module_names.keys()) - - # Prepare ordered dictionary for columns - cols = collections.OrderedDict() - # Add module names to first columnt - cols["Module name"] = list(sorted( - module.__class__.__name__ - for module in self.modules - if module.__class__.__name__ in available_col_names - )) - # Add total key (as last module) - cols["Module name"].append(self._report_total_key) - - # Add columns from report - for label in self._report.keys(): - cols[label] = [] - - total_module_times = {} - for module_name in cols["Module name"]: - total_module_times[module_name] = 0 - - for label, reported in self._report.items(): - for module_name in cols["Module name"]: - col_time = reported.get(module_name) - if col_time is None: - cols[label].append("N/A") - continue - cols[label].append("{:.3f}".format(col_time)) - total_module_times[module_name] += col_time - - # Add to also total column that should sum the row - cols[self._report_total_key] = [] - for module_name in cols["Module name"]: - cols[self._report_total_key].append( - "{:.3f}".format(total_module_times[module_name]) - ) - - # Prepare column widths and total row count - # - column width is by - col_widths = {} - total_rows = None - for key, values in cols.items(): - if total_rows is None: - total_rows = 1 + len(values) - max_width = len(key) - for value in values: - value_length = len(value) - if value_length > max_width: - max_width = value_length - col_widths[key] = max_width - - rows = [] - for _idx in range(total_rows): - rows.append([]) - - for key, values in cols.items(): - width = col_widths[key] - idx = 0 - rows[idx].append(key.ljust(width)) - for value in values: - idx += 1 - rows[idx].append(value.ljust(width)) - - filler_parts = [] - for width in col_widths.values(): - filler_parts.append(width * "-") - filler = "+".join(filler_parts) - - formatted_rows = [filler] - last_row_idx = len(rows) - 1 - for idx, row in enumerate(rows): - # Add filler before last row - if idx == last_row_idx: - formatted_rows.append(filler) - - formatted_rows.append("|".join(row)) - - # Add filler after first row - if idx == 0: - formatted_rows.append(filler) - - # Join rows with newline char and add new line at the end - output = "\n".join(formatted_rows) + "\n" - print(output) - - -class TrayModulesManager(ModulesManager): - # Define order of modules in menu - modules_menu_order = ( - "user", - "ftrack", - "kitsu", - "launcher_tool", - "library_tool", - "clockify", - "standalonepublish_tool", - "traypublish_tool", - "log_viewer", - "local_settings", - "settings" - ) - - def __init__(self): - self.log = Logger.get_logger(self.__class__.__name__) - - self.modules = [] - self.modules_by_id = {} - self.modules_by_name = {} - self._report = {} - - self.tray_manager = None - - self.doubleclick_callbacks = {} - self.doubleclick_callback = None - - def add_doubleclick_callback(self, module, callback): - """Register doubleclick callbacks on tray icon. - - Currently there is no way how to determine which is launched. Name of - callback can be defined with `doubleclick_callback` attribute. - - Missing feature how to define default callback. - - Args: - addon (AYONAddon): Addon object. - callback (FunctionType): Function callback. - """ - callback_name = "_".join([module.name, callback.__name__]) - if callback_name not in self.doubleclick_callbacks: - self.doubleclick_callbacks[callback_name] = callback - if self.doubleclick_callback is None: - self.doubleclick_callback = callback_name - return - - self.log.warning(( - "Callback with name \"{}\" is already registered." - ).format(callback_name)) - - def initialize(self, tray_manager, tray_menu): - self.tray_manager = tray_manager - self.initialize_modules() - self.tray_init() - self.connect_modules() - self.tray_menu(tray_menu) - - def get_enabled_tray_modules(self): - """Enabled tray modules. - - Returns: - list[AYONAddon]: Enabled addons that inherit from tray interface. - """ - - return [ - module - for module in self.modules - if module.enabled and isinstance(module, ITrayModule) - ] - - def restart_tray(self): - if self.tray_manager: - self.tray_manager.restart() - - def tray_init(self): - report = {} - time_start = time.time() - prev_start_time = time_start - for module in self.get_enabled_tray_modules(): - try: - module._tray_manager = self.tray_manager - module.tray_init() - module.tray_initialized = True - except Exception: - self.log.warning( - "Module \"{}\" crashed on `tray_init`.".format( - module.name - ), - exc_info=True - ) - - now = time.time() - report[module.__class__.__name__] = now - prev_start_time - prev_start_time = now - - if self._report is not None: - report[self._report_total_key] = time.time() - time_start - self._report["Tray init"] = report - - def tray_menu(self, tray_menu): - ordered_modules = [] - enabled_by_name = { - module.name: module - for module in self.get_enabled_tray_modules() - } - - for name in self.modules_menu_order: - module_by_name = enabled_by_name.pop(name, None) - if module_by_name: - ordered_modules.append(module_by_name) - ordered_modules.extend(enabled_by_name.values()) - - report = {} - time_start = time.time() - prev_start_time = time_start - for module in ordered_modules: - if not module.tray_initialized: - continue - - try: - module.tray_menu(tray_menu) - except Exception: - # Unset initialized mark - module.tray_initialized = False - self.log.warning( - "Module \"{}\" crashed on `tray_menu`.".format( - module.name - ), - exc_info=True - ) - now = time.time() - report[module.__class__.__name__] = now - prev_start_time - prev_start_time = now - - if self._report is not None: - report[self._report_total_key] = time.time() - time_start - self._report["Tray menu"] = report - - def start_modules(self): - report = {} - time_start = time.time() - prev_start_time = time_start - for module in self.get_enabled_tray_modules(): - if not module.tray_initialized: - if isinstance(module, ITrayService): - module.set_service_failed_icon() - continue - - try: - module.tray_start() - except Exception: - self.log.warning( - "Module \"{}\" crashed on `tray_start`.".format( - module.name - ), - exc_info=True - ) - now = time.time() - report[module.__class__.__name__] = now - prev_start_time - prev_start_time = now - - if self._report is not None: - report[self._report_total_key] = time.time() - time_start - self._report["Modules start"] = report - - def on_exit(self): - for module in self.get_enabled_tray_modules(): - if module.tray_initialized: - try: - module.tray_exit() - except Exception: - self.log.warning( - "Module \"{}\" crashed on `tray_exit`.".format( - module.name - ), - exc_info=True - ) diff --git a/client/ayon_core/modules/interfaces.py b/client/ayon_core/modules/interfaces.py index 863961c026..26adb02f91 100644 --- a/client/ayon_core/modules/interfaces.py +++ b/client/ayon_core/modules/interfaces.py @@ -1,457 +1,19 @@ -from abc import ABCMeta, abstractmethod, abstractproperty - -import six - -from ayon_core import resources - - -class _OpenPypeInterfaceMeta(ABCMeta): - """OpenPypeInterface meta class to print proper string.""" - - def __str__(self): - return "<'OpenPypeInterface.{}'>".format(self.__name__) - - def __repr__(self): - return str(self) - - -@six.add_metaclass(_OpenPypeInterfaceMeta) -class OpenPypeInterface: - """Base class of Interface that can be used as Mixin with abstract parts. - - This is way how OpenPype module or addon can tell OpenPype that contain - implementation for specific functionality. - - Child classes of OpenPypeInterface may be used as mixin in different - OpenPype modules which means they have to have implemented methods defined - in the interface. By default, interface does not have any abstract parts. - """ - - pass - - -class IPluginPaths(OpenPypeInterface): - """Module has plugin paths to return. - - Expected result is dictionary with keys "publish", "create", "load", - "actions" or "inventory" and values as list or string. - { - "publish": ["path/to/publish_plugins"] - } - """ - - @abstractmethod - def get_plugin_paths(self): - pass - - def _get_plugin_paths_by_type(self, plugin_type): - paths = self.get_plugin_paths() - if not paths or plugin_type not in paths: - return [] - - paths = paths[plugin_type] - if not paths: - return [] - - if not isinstance(paths, (list, tuple, set)): - paths = [paths] - return paths - - def get_create_plugin_paths(self, host_name): - """Receive create plugin paths. - - Give addons ability to add create plugin paths based on host name. - - Notes: - Default implementation uses 'get_plugin_paths' and always return - all create plugin paths. - - Args: - host_name (str): For which host are the plugins meant. - """ - - if hasattr(self, "get_creator_plugin_paths"): - # TODO remove in 3.16 - self.log.warning(( - "DEPRECATION WARNING: Using method 'get_creator_plugin_paths'" - " which was renamed to 'get_create_plugin_paths'." - )) - return self.get_creator_plugin_paths(host_name) - return self._get_plugin_paths_by_type("create") - - def get_load_plugin_paths(self, host_name): - """Receive load plugin paths. - - Give addons ability to add load plugin paths based on host name. - - Notes: - Default implementation uses 'get_plugin_paths' and always return - all load plugin paths. - - Args: - host_name (str): For which host are the plugins meant. - """ - - return self._get_plugin_paths_by_type("load") - - def get_publish_plugin_paths(self, host_name): - """Receive publish plugin paths. - - Give addons ability to add publish plugin paths based on host name. - - Notes: - Default implementation uses 'get_plugin_paths' and always return - all publish plugin paths. - - Args: - host_name (str): For which host are the plugins meant. - """ - - return self._get_plugin_paths_by_type("publish") - - def get_inventory_action_paths(self, host_name): - """Receive inventory action paths. - - Give addons ability to add inventory action plugin paths. - - Notes: - Default implementation uses 'get_plugin_paths' and always return - all publish plugin paths. - - Args: - host_name (str): For which host are the plugins meant. - """ - - return self._get_plugin_paths_by_type("inventory") - - -class ILaunchHookPaths(OpenPypeInterface): - """Module has launch hook paths to return. - - Modules don't have to inherit from this interface (changed 8.11.2022). - Module just have to have implemented 'get_launch_hook_paths' to be able to - use the advantage. - - Expected result is list of paths. - ["path/to/launch_hooks_dir"] - - Deprecated: - This interface is not needed since OpenPype 3.14.*. Addon just have to - implement 'get_launch_hook_paths' which can expect Application object - or nothing as argument. - - Interface class will be removed after 3.16.*. - """ - - @abstractmethod - def get_launch_hook_paths(self, app): - """Paths to directory with application launch hooks. - - Method can be also defined without arguments. - ```python - def get_launch_hook_paths(self): - return [] - ``` - - Args: - app (Application): Application object which can be used for - filtering of which launch hook paths are returned. - - Returns: - Iterable[str]: Path to directories where launch hooks can be found. - """ - - pass - - -class ITrayModule(OpenPypeInterface): - """Module has special procedures when used in Pype Tray. - - IMPORTANT: - The module still must be usable if is not used in tray even if - would do nothing. - """ - - tray_initialized = False - _tray_manager = None - - @abstractmethod - def tray_init(self): - """Initialization part of tray implementation. - - Triggered between `initialization` and `connect_with_modules`. - - This is where GUIs should be loaded or tray specific parts should be - prepared. - """ - - pass - - @abstractmethod - def tray_menu(self, tray_menu): - """Add module's action to tray menu.""" - - pass - - @abstractmethod - def tray_start(self): - """Start procedure in Pype tray.""" - - pass - - @abstractmethod - def tray_exit(self): - """Cleanup method which is executed on tray shutdown. - - This is place where all threads should be shut. - """ - - pass - - def execute_in_main_thread(self, callback): - """ Pushes callback to the queue or process 'callback' on a main thread - - Some callbacks need to be processed on main thread (menu actions - must be added on main thread or they won't get triggered etc.) - """ - - if not self.tray_initialized: - # TODO Called without initialized tray, still main thread needed - try: - callback() - - except Exception: - self.log.warning( - "Failed to execute {} in main thread".format(callback), - exc_info=True) - - return - self.manager.tray_manager.execute_in_main_thread(callback) - - def show_tray_message(self, title, message, icon=None, msecs=None): - """Show tray message. - - Args: - title (str): Title of message. - message (str): Content of message. - icon (QSystemTrayIcon.MessageIcon): Message's icon. Default is - Information icon, may differ by Qt version. - msecs (int): Duration of message visibility in milliseconds. - Default is 10000 msecs, may differ by Qt version. - """ - - if self._tray_manager: - self._tray_manager.show_tray_message(title, message, icon, msecs) - - def add_doubleclick_callback(self, callback): - if hasattr(self.manager, "add_doubleclick_callback"): - self.manager.add_doubleclick_callback(self, callback) - - -class ITrayAction(ITrayModule): - """Implementation of Tray action. - - Add action to tray menu which will trigger `on_action_trigger`. - It is expected to be used for showing tools. - - Methods `tray_start`, `tray_exit` and `connect_with_modules` are overridden - as it's not expected that action will use them. But it is possible if - necessary. - """ - - admin_action = False - _admin_submenu = None - _action_item = None - - @property - @abstractmethod - def label(self): - """Service label showed in menu.""" - pass - - @abstractmethod - def on_action_trigger(self): - """What happens on actions click.""" - pass - - def tray_menu(self, tray_menu): - from qtpy import QtWidgets - - if self.admin_action: - menu = self.admin_submenu(tray_menu) - action = QtWidgets.QAction(self.label, menu) - menu.addAction(action) - if not menu.menuAction().isVisible(): - menu.menuAction().setVisible(True) - - else: - action = QtWidgets.QAction(self.label, tray_menu) - tray_menu.addAction(action) - - action.triggered.connect(self.on_action_trigger) - self._action_item = action - - def tray_start(self): - return - - def tray_exit(self): - return - - @staticmethod - def admin_submenu(tray_menu): - if ITrayAction._admin_submenu is None: - from qtpy import QtWidgets - - admin_submenu = QtWidgets.QMenu("Admin", tray_menu) - admin_submenu.menuAction().setVisible(False) - ITrayAction._admin_submenu = admin_submenu - return ITrayAction._admin_submenu - - -class ITrayService(ITrayModule): - # Module's property - menu_action = None - - # Class properties - _services_submenu = None - _icon_failed = None - _icon_running = None - _icon_idle = None - - @property - @abstractmethod - def label(self): - """Service label showed in menu.""" - pass - - # TODO be able to get any sort of information to show/print - # @abstractmethod - # def get_service_info(self): - # pass - - @staticmethod - def services_submenu(tray_menu): - if ITrayService._services_submenu is None: - from qtpy import QtWidgets - - services_submenu = QtWidgets.QMenu("Services", tray_menu) - services_submenu.menuAction().setVisible(False) - ITrayService._services_submenu = services_submenu - return ITrayService._services_submenu - - @staticmethod - def add_service_action(action): - ITrayService._services_submenu.addAction(action) - if not ITrayService._services_submenu.menuAction().isVisible(): - ITrayService._services_submenu.menuAction().setVisible(True) - - @staticmethod - def _load_service_icons(): - from qtpy import QtGui - - ITrayService._failed_icon = QtGui.QIcon( - resources.get_resource("icons", "circle_red.png") - ) - ITrayService._icon_running = QtGui.QIcon( - resources.get_resource("icons", "circle_green.png") - ) - ITrayService._icon_idle = QtGui.QIcon( - resources.get_resource("icons", "circle_orange.png") - ) - - @staticmethod - def get_icon_running(): - if ITrayService._icon_running is None: - ITrayService._load_service_icons() - return ITrayService._icon_running - - @staticmethod - def get_icon_idle(): - if ITrayService._icon_idle is None: - ITrayService._load_service_icons() - return ITrayService._icon_idle - - @staticmethod - def get_icon_failed(): - if ITrayService._failed_icon is None: - ITrayService._load_service_icons() - return ITrayService._failed_icon - - def tray_menu(self, tray_menu): - from qtpy import QtWidgets - - action = QtWidgets.QAction( - self.label, - self.services_submenu(tray_menu) - ) - self.menu_action = action - - self.add_service_action(action) - - self.set_service_running_icon() - - def set_service_running_icon(self): - """Change icon of an QAction to green circle.""" - - if self.menu_action: - self.menu_action.setIcon(self.get_icon_running()) - - def set_service_failed_icon(self): - """Change icon of an QAction to red circle.""" - - if self.menu_action: - self.menu_action.setIcon(self.get_icon_failed()) - - def set_service_idle_icon(self): - """Change icon of an QAction to orange circle.""" - - if self.menu_action: - self.menu_action.setIcon(self.get_icon_idle()) - - -class ISettingsChangeListener(OpenPypeInterface): - """Module tries to listen to settings changes. - - Only settings changes in the current process are propagated. - Changes made in other processes or machines won't trigger the callbacks. - - """ - - @abstractmethod - def on_system_settings_save( - self, old_value, new_value, changes, new_value_metadata - ): - pass - - @abstractmethod - def on_project_settings_save( - self, old_value, new_value, changes, project_name, new_value_metadata - ): - pass - - @abstractmethod - def on_project_anatomy_save( - self, old_value, new_value, changes, project_name, new_value_metadata - ): - pass - - -class IHostAddon(OpenPypeInterface): - """Addon which also contain a host implementation.""" - - @abstractproperty - def host_name(self): - """Name of host which module represents.""" - - pass - - def get_workfile_extensions(self): - """Define workfile extensions for host. - - Not all hosts support workfiles thus this is optional implementation. - - Returns: - List[str]: Extensions used for workfiles with dot. - """ - - return [] +from ayon_core.addon.interfaces import ( + IPluginPaths, + ITrayAddon, + ITrayAction, + ITrayService, + IHostAddon, +) + +ITrayModule = ITrayAddon + + +__all__ = ( + "IPluginPaths", + "ITrayAddon", + "ITrayAction", + "ITrayService", + "IHostAddon", + "ITrayModule", +)