From 5ab0f82a3a2eda94de62199fedc4ae367f555086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 9 Dec 2024 16:25:21 +0100 Subject: [PATCH] :art: add traits addon interface --- client/ayon_core/addon/__init__.py | 2 + client/ayon_core/addon/interfaces.py | 216 +++++++++++++-------- client/ayon_core/pipeline/traits/README.md | 42 +++- 3 files changed, 175 insertions(+), 85 deletions(-) diff --git a/client/ayon_core/addon/__init__.py b/client/ayon_core/addon/__init__.py index 6a7ce8a3cb..fd25dcb9d9 100644 --- a/client/ayon_core/addon/__init__.py +++ b/client/ayon_core/addon/__init__.py @@ -6,6 +6,7 @@ from .interfaces import ( ITrayAction, ITrayService, IHostAddon, + ITraits, ) from .base import ( @@ -30,6 +31,7 @@ __all__ = ( "ITrayAction", "ITrayService", "IHostAddon", + "ITraits", "ProcessPreparationError", "ProcessContext", diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index b273e7839b..5e2d1b13d7 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -1,13 +1,24 @@ +from __future__ import annotations + +import logging from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING, Callable, Optional, Type from ayon_core import resources +if TYPE_CHECKING: + from qtpy import QtWidgets + + from ayon_core.addon import AddonsManager + from ayon_core.pipeline.traits import TraitBase + from ayon_core.tools.tray import TrayManager + class _AYONInterfaceMeta(ABCMeta): - """AYONInterface meta class to print proper string.""" + """AYONInterface metaclass to print proper string.""" def __str__(self): - return "<'AYONInterface.{}'>".format(self.__name__) + return f"<'AYONInterface.{self.__name__}'>" def __repr__(self): return str(self) @@ -24,7 +35,11 @@ class AYONInterface(metaclass=_AYONInterfaceMeta): in the interface. By default, interface does not have any abstract parts. """ - pass + log = None + + def __init__(self): + """Initialize interface.""" + self.log = logging.getLogger(self.__class__.__name__) class IPluginPaths(AYONInterface): @@ -38,10 +53,25 @@ class IPluginPaths(AYONInterface): """ @abstractmethod - def get_plugin_paths(self): - pass + def get_plugin_paths(self) -> dict[str, list[str]]: + """Return plugin paths for addon. - def _get_plugin_paths_by_type(self, plugin_type): + Returns: + dict[str, list[str]]: Plugin paths for addon. + + """ + + def _get_plugin_paths_by_type( + self, plugin_type: str) -> list[str]: + """Get plugin paths by type. + + Args: + plugin_type (str): Type of plugin paths to get. + + Returns: + list[str]: List of plugin paths. + + """ paths = self.get_plugin_paths() if not paths or plugin_type not in paths: return [] @@ -54,7 +84,7 @@ class IPluginPaths(AYONInterface): paths = [paths] return paths - def get_create_plugin_paths(self, host_name): + def get_create_plugin_paths(self, host_name: str) -> list[str]: """Receive create plugin paths. Give addons ability to add create plugin paths based on host name. @@ -65,11 +95,11 @@ class IPluginPaths(AYONInterface): 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): + def get_load_plugin_paths(self, host_name: str) -> list[str]: """Receive load plugin paths. Give addons ability to add load plugin paths based on host name. @@ -80,11 +110,11 @@ class IPluginPaths(AYONInterface): 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): + def get_publish_plugin_paths(self, host_name: str) -> list[str]: """Receive publish plugin paths. Give addons ability to add publish plugin paths based on host name. @@ -95,11 +125,11 @@ class IPluginPaths(AYONInterface): 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): + def get_inventory_action_paths(self, host_name: str) -> list[str]: """Receive inventory action paths. Give addons ability to add inventory action plugin paths. @@ -110,76 +140,84 @@ class IPluginPaths(AYONInterface): 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. - """ + Important: + The addon. still must be usable if is not used in tray even if it + would do nothing. + """ tray_initialized = False - _tray_manager = None + manager: AddonsManager = None + _tray_manager: TrayManager = None @abstractmethod - def tray_init(self): + def tray_init(self) -> None: """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. + prepared + """ - - pass + raise NotImplementedError @abstractmethod - def tray_menu(self, tray_menu): + def tray_menu(self, tray_menu: QtWidgets.QMenu) -> None: """Add addon's action to tray menu.""" + raise NotImplementedError - pass @abstractmethod - def tray_start(self): + def tray_start(self) -> None: """Start procedure in tray tool.""" - - pass + raise NotImplementedError @abstractmethod - def tray_exit(self): + def tray_exit(self) -> None: """Cleanup method which is executed on tray shutdown. This is place where all threads should be shut. + """ + raise NotImplementedError - pass + def execute_in_main_thread(self, callback: Callable) -> None: + """Pushes callback to the queue or process 'callback' on a main thread. - 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 else they won't get triggered etc.) + + Args: + callback (Callable): Function to be executed on 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 + # TODO: Called without initialized tray, still main thread needed try: callback() - except Exception: + except Exception: # noqa: BLE001 self.log.warning( - "Failed to execute {} in main thread".format(callback), - exc_info=True) + "Failed to execute %s callback in main thread", + str(callback), exc_info=True) return - self.manager.tray_manager.execute_in_main_thread(callback) + self._tray_manager.tray_manager.execute_in_main_thread(callback) - def show_tray_message(self, title, message, icon=None, msecs=None): + def show_tray_message( + self, + title: str, + message: str, + icon: Optional[QtWidgets.QSystemTrayIcon]=None, + msecs: Optional[int]=None) -> None: """Show tray message. Args: @@ -190,11 +228,11 @@ class ITrayAddon(AYONInterface): 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): + def add_doubleclick_callback(self, callback: Callable) -> None: + """Add callback to be triggered on tray icon double click.""" if hasattr(self.manager, "add_doubleclick_callback"): self.manager.add_doubleclick_callback(self, callback) @@ -216,16 +254,17 @@ class ITrayAction(ITrayAddon): @property @abstractmethod - def label(self): + def label(self) -> str: """Service label showed in menu.""" - pass + raise NotImplementedError @abstractmethod - def on_action_trigger(self): + def on_action_trigger(self) -> None: """What happens on actions click.""" - pass + raise NotImplementedError - def tray_menu(self, tray_menu): + def tray_menu(self, tray_menu: QtWidgets.QMenu) -> None: + """Add action to tray menu.""" from qtpy import QtWidgets if self.admin_action: @@ -242,14 +281,17 @@ class ITrayAction(ITrayAddon): action.triggered.connect(self.on_action_trigger) self._action_item = action - def tray_start(self): + def tray_start(self) -> None: + """Start procedure in tray tool.""" return - def tray_exit(self): + def tray_exit(self) -> None: + """Cleanup method which is executed on tray shutdown.""" return @staticmethod - def admin_submenu(tray_menu): + def admin_submenu(tray_menu: QtWidgets.QMenu) -> QtWidgets.QMenu: + """Get or create admin submenu.""" if ITrayAction._admin_submenu is None: from qtpy import QtWidgets @@ -260,20 +302,21 @@ class ITrayAction(ITrayAddon): class ITrayService(ITrayAddon): + """Tray service Interface.""" # Module's property - menu_action = None + menu_action: QtWidgets.QAction = None # Class properties - _services_submenu = None - _icon_failed = None - _icon_running = None - _icon_idle = None + _services_submenu: QtWidgets.QMenu = None + _icon_failed: QtWidgets.QIcon = None + _icon_running: QtWidgets.QIcon = None + _icon_idle: QtWidgets.QIcon = None @property @abstractmethod - def label(self): + def label(self) -> str: """Service label showed in menu.""" - pass + raise NotImplementedError # TODO be able to get any sort of information to show/print # @abstractmethod @@ -281,7 +324,8 @@ class ITrayService(ITrayAddon): # pass @staticmethod - def services_submenu(tray_menu): + def services_submenu(tray_menu: QtWidgets.QMenu) -> QtWidgets.QMenu: + """Get or create services submenu.""" if ITrayService._services_submenu is None: from qtpy import QtWidgets @@ -291,13 +335,15 @@ class ITrayService(ITrayAddon): return ITrayService._services_submenu @staticmethod - def add_service_action(action): + def add_service_action(action: QtWidgets.QAction) -> None: + """Add service action to services submenu.""" ITrayService._services_submenu.addAction(action) if not ITrayService._services_submenu.menuAction().isVisible(): ITrayService._services_submenu.menuAction().setVisible(True) @staticmethod - def _load_service_icons(): + def _load_service_icons() -> None: + """Load service icons.""" from qtpy import QtGui ITrayService._failed_icon = QtGui.QIcon( @@ -311,24 +357,28 @@ class ITrayService(ITrayAddon): ) @staticmethod - def get_icon_running(): + def get_icon_running() -> QtWidgets.QIcon: + """Get running icon.""" if ITrayService._icon_running is None: ITrayService._load_service_icons() return ITrayService._icon_running @staticmethod - def get_icon_idle(): + def get_icon_idle() -> QtWidgets.QIcon: + """Get idle icon.""" 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: + def get_icon_failed() -> QtWidgets.QIcon: + """Get failed icon.""" + if ITrayService._icon_failed is None: ITrayService._load_service_icons() - return ITrayService._failed_icon + return ITrayService._icon_failed - def tray_menu(self, tray_menu): + def tray_menu(self, tray_menu: QtWidgets.QMenu) -> None: + """Add service to tray menu.""" from qtpy import QtWidgets action = QtWidgets.QAction( @@ -341,21 +391,18 @@ class ITrayService(ITrayAddon): self.set_service_running_icon() - def set_service_running_icon(self): + def set_service_running_icon(self) -> None: """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): + def set_service_failed_icon(self) -> None: """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): + def set_service_idle_icon(self) -> None: """Change icon of an QAction to orange circle.""" - if self.menu_action: self.menu_action.setIcon(self.get_icon_idle()) @@ -365,18 +412,31 @@ class IHostAddon(AYONInterface): @property @abstractmethod - def host_name(self): + def host_name(self) -> str: """Name of host which addon represents.""" + raise NotImplementedError - pass - - def get_workfile_extensions(self): + def get_workfile_extensions(self) -> list[str]: """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 [] + + +class ITraits(AYONInterface): + """Interface for traits.""" + + @abstractmethod + def get_addon_traits(self) -> list[Type[TraitBase]]: + """Get trait classes for the addon. + + Returns: + list[Type[TraitBase]]: Traits for the addon. + + """ + raise NotImplementedError diff --git a/client/ayon_core/pipeline/traits/README.md b/client/ayon_core/pipeline/traits/README.md index 4387109cbe..676aabb029 100644 --- a/client/ayon_core/pipeline/traits/README.md +++ b/client/ayon_core/pipeline/traits/README.md @@ -271,13 +271,41 @@ rep = Representation.from_dict(name="image", rep_dict) ``` -## Future -Apart of some new additions to traits if needed, there are few thing that needs to be done. +## Addon specific traits -### Traits plugin system +Addon can define its own traits. To do so, it needs to implement `ITraits` interface: -Traits are now ordinary python classes, but to extend its usability more, it would be good to -have addon level API to expose traits defined by individual addons. This API would then be used not -only by discovery logic but also by the AYON server that can display and work with the information -defined by them. \ No newline at end of file +```python +from ayon_core.pipeline.traits import TraitBase +from ayon_core.addon import ( + AYONAddon, + ITraits, +) + +class MyTraitFoo(TraitBase): + id = "myaddon.mytrait.foo.v1" + name = "My Trait Foo" + description = "This is my trait foo" + persistent = True + + +class MyTraitBar(TraitBase): + id = "myaddon.mytrait.bar.v1" + name = "My Trait Bar" + description = "This is my trait bar" + persistent = True + + +class MyAddon(AYONAddon, ITraits): + def __init__(self): + super().__init__() + + def get_addon_traits(self): + return [ + MyTraitFoo, + MyTraitBar, + ] + + +```