diff --git a/.gitignore b/.gitignore index 72c4204dc0..4b2dbb6b63 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,7 @@ poetry.lock .editorconfig .pre-commit-config.yaml mypy.ini +poetry.lock .github_changelog_generator diff --git a/client/ayon_core/addon/__init__.py b/client/ayon_core/addon/__init__.py index 6a7ce8a3cb..a8cf51ae25 100644 --- a/client/ayon_core/addon/__init__.py +++ b/client/ayon_core/addon/__init__.py @@ -1,42 +1,38 @@ -# -*- coding: utf-8 -*- +"""Addons for AYON.""" from . import click_wrap -from .interfaces import ( - IPluginPaths, - ITrayAddon, - ITrayAction, - ITrayService, - IHostAddon, -) - from .base import ( - ProcessPreparationError, - ProcessContext, - AYONAddon, AddonsManager, + AYONAddon, + ProcessContext, + ProcessPreparationError, load_addons, ) - +from .interfaces import ( + IHostAddon, + IPluginPaths, + ITraits, + ITrayAction, + ITrayAddon, + ITrayService, +) from .utils import ( ensure_addons_are_process_context_ready, ensure_addons_are_process_ready, ) - __all__ = ( - "click_wrap", - - "IPluginPaths", - "ITrayAddon", - "ITrayAction", - "ITrayService", - "IHostAddon", - - "ProcessPreparationError", - "ProcessContext", "AYONAddon", "AddonsManager", - "load_addons", - + "IHostAddon", + "IPluginPaths", + "ITraits", + "ITrayAction", + "ITrayAddon", + "ITrayService", + "ProcessContext", + "ProcessPreparationError", + "click_wrap", "ensure_addons_are_process_context_ready", "ensure_addons_are_process_ready", + "load_addons", ) diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index 72191e3453..232c056fb4 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -1,16 +1,27 @@ +"""Addon interfaces for AYON.""" +from __future__ import annotations + 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.base import AddonsManager + from ayon_core.pipeline.traits import TraitBase + from ayon_core.tools.tray.ui.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__) + def __str__(cls): + return f"<'AYONInterface.{cls.__name__}'>" - def __repr__(self): - return str(self) + def __repr__(cls): + return str(cls) class AYONInterface(metaclass=_AYONInterfaceMeta): @@ -24,7 +35,7 @@ class AYONInterface(metaclass=_AYONInterfaceMeta): in the interface. By default, interface does not have any abstract parts. """ - pass + log = None class IPluginPaths(AYONInterface): @@ -38,10 +49,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,14 +80,18 @@ class IPluginPaths(AYONInterface): paths = [paths] return paths - def get_launcher_action_paths(self): + def get_launcher_action_paths(self) -> list[str]: """Receive launcher actions paths. Give addons ability to add launcher actions paths. + + Returns: + list[str]: List of launcher action paths. + """ return self._get_plugin_paths_by_type("actions") - 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. @@ -72,11 +102,14 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. - """ + Returns: + list[str]: List of create plugin paths. + + """ 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. @@ -87,11 +120,14 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. - """ + Returns: + list[str]: List of load plugin paths. + + """ 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. @@ -102,11 +138,14 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. - """ + Returns: + list[str]: List of publish plugin paths. + + """ 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. @@ -117,77 +156,84 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. - """ + Returns: + list[str]: List of inventory action plugin paths. + + """ 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. + """ + manager: AddonsManager tray_initialized = False - _tray_manager = None + _tray_manager: TrayManager = None _admin_submenu = 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 - @abstractmethod - def tray_menu(self, tray_menu): + def tray_menu(self, tray_menu: QtWidgets.QMenu) -> None: """Add addon's action to tray menu.""" - pass - @abstractmethod - def tray_start(self): + def tray_start(self) -> None: """Start procedure in tray tool.""" - pass - @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. + """ - 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 (Illicit): 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: @@ -198,16 +244,22 @@ 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) @staticmethod - def admin_submenu(tray_menu): + def admin_submenu(tray_menu: QtWidgets.QMenu) -> QtWidgets.QMenu: + """Get or create admin submenu. + + Returns: + QtWidgets.QMenu: Admin submenu. + + """ if ITrayAddon._admin_submenu is None: from qtpy import QtWidgets @@ -217,7 +269,18 @@ class ITrayAddon(AYONInterface): return ITrayAddon._admin_submenu @staticmethod - def add_action_to_admin_submenu(label, tray_menu): + def add_action_to_admin_submenu( + label: str, tray_menu: QtWidgets.QMenu) -> QtWidgets.QAction: + """Add action to admin submenu. + + Args: + label (str): Label of action. + tray_menu (QtWidgets.QMenu): Tray menu to add action to. + + Returns: + QtWidgets.QAction: Action added to admin submenu + + """ from qtpy import QtWidgets menu = ITrayAddon.admin_submenu(tray_menu) @@ -244,16 +307,15 @@ class ITrayAction(ITrayAddon): @property @abstractmethod - def label(self): + def label(self) -> str: """Service label showed in menu.""" - pass @abstractmethod - def on_action_trigger(self): + def on_action_trigger(self) -> None: """What happens on actions click.""" - pass - 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: @@ -265,36 +327,44 @@ class ITrayAction(ITrayAddon): action.triggered.connect(self.on_action_trigger) self._action_item = action - def tray_start(self): + def tray_start(self) -> None: # noqa: PLR6301 + """Start procedure in tray tool.""" return - def tray_exit(self): + def tray_exit(self) -> None: # noqa: PLR6301 + """Cleanup method which is executed on tray shutdown.""" return 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 - # TODO be able to get any sort of information to show/print + # TODO (Illicit): be able to get any sort of information to show/print # @abstractmethod # def get_service_info(self): # pass @staticmethod - def services_submenu(tray_menu): + def services_submenu(tray_menu: QtWidgets.QMenu) -> QtWidgets.QMenu: + """Get or create services submenu. + + Returns: + QtWidgets.QMenu: Services submenu. + + """ if ITrayService._services_submenu is None: from qtpy import QtWidgets @@ -304,13 +374,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( @@ -324,24 +396,43 @@ class ITrayService(ITrayAddon): ) @staticmethod - def get_icon_running(): + def get_icon_running() -> QtWidgets.QIcon: + """Get running icon. + + Returns: + QtWidgets.QIcon: Returns "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. + + Returns: + QtWidgets.QIcon: Returns "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: - ITrayService._load_service_icons() - return ITrayService._failed_icon + def get_icon_failed() -> QtWidgets.QIcon: + """Get failed icon. - def tray_menu(self, tray_menu): + Returns: + QtWidgets.QIcon: Returns "failed" icon. + + """ + if ITrayService._icon_failed is None: + ITrayService._load_service_icons() + return ITrayService._icon_failed + + def tray_menu(self, tray_menu: QtWidgets.QMenu) -> None: + """Add service to tray menu.""" from qtpy import QtWidgets action = QtWidgets.QAction( @@ -354,21 +445,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()) @@ -378,18 +466,29 @@ class IHostAddon(AYONInterface): @property @abstractmethod - def host_name(self): + def host_name(self) -> str: """Name of host which addon represents.""" - pass - - def get_workfile_extensions(self): + def get_workfile_extensions(self) -> list[str]: # noqa: PLR6301 """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. + + """ diff --git a/client/ayon_core/hooks/pre_global_host_data.py b/client/ayon_core/hooks/pre_global_host_data.py index 23f725901c..83c4118136 100644 --- a/client/ayon_core/hooks/pre_global_host_data.py +++ b/client/ayon_core/hooks/pre_global_host_data.py @@ -32,8 +32,8 @@ class GlobalHostDataHook(PreLaunchHook): "app": app, "project_entity": self.data["project_entity"], - "folder_entity": self.data["folder_entity"], - "task_entity": self.data["task_entity"], + "folder_entity": self.data.get("folder_entity"), + "task_entity": self.data.get("task_entity"), "anatomy": self.data["anatomy"], diff --git a/client/ayon_core/pipeline/load/__init__.py b/client/ayon_core/pipeline/load/__init__.py index bdc5ece620..2a33fa119b 100644 --- a/client/ayon_core/pipeline/load/__init__.py +++ b/client/ayon_core/pipeline/load/__init__.py @@ -49,6 +49,11 @@ from .plugins import ( deregister_loader_plugin_path, register_loader_plugin_path, deregister_loader_plugin, + + register_loader_hook_plugin, + deregister_loader_hook_plugin, + register_loader_hook_plugin_path, + deregister_loader_hook_plugin_path, ) @@ -103,4 +108,10 @@ __all__ = ( "deregister_loader_plugin_path", "register_loader_plugin_path", "deregister_loader_plugin", + + "register_loader_hook_plugin", + "deregister_loader_hook_plugin", + "register_loader_hook_plugin_path", + "deregister_loader_hook_plugin_path", + ) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 4a11b929cc..1dac8a4048 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -1,5 +1,8 @@ +from __future__ import annotations import os import logging +from typing import Any, Type, Optional +from abc import abstractmethod from ayon_core.settings import get_project_settings from ayon_core.pipeline.plugin_discover import ( @@ -251,15 +254,94 @@ class ProductLoaderPlugin(LoaderPlugin): """ +class LoaderHookPlugin: + """Plugin that runs before and post specific Loader in 'loaders' + + Should be used as non-invasive method to enrich core loading process. + Any studio might want to modify loaded data before or after + they are loaded without need to override existing core plugins. + + The post methods are called after the loader's methods and receive the + return value of the loader's method as `result` argument. + """ + order = 0 + + @classmethod + @abstractmethod + def is_compatible(cls, Loader: Type[LoaderPlugin]) -> bool: + pass + + @abstractmethod + def pre_load( + self, + plugin: LoaderPlugin, + context: dict, + name: Optional[str], + namespace: Optional[str], + options: Optional[dict], + ): + pass + + @abstractmethod + def post_load( + self, + plugin: LoaderPlugin, + result: Any, + context: dict, + name: Optional[str], + namespace: Optional[str], + options: Optional[dict], + ): + pass + + @abstractmethod + def pre_update( + self, + plugin: LoaderPlugin, + container: dict, # (ayon:container-3.0) + context: dict, + ): + pass + + @abstractmethod + def post_update( + self, + plugin: LoaderPlugin, + result: Any, + container: dict, # (ayon:container-3.0) + context: dict, + ): + pass + + @abstractmethod + def pre_remove( + self, + plugin: LoaderPlugin, + container: dict, # (ayon:container-3.0) + ): + pass + + @abstractmethod + def post_remove( + self, + plugin: LoaderPlugin, + result: Any, + container: dict, # (ayon:container-3.0) + ): + pass + + def discover_loader_plugins(project_name=None): from ayon_core.lib import Logger from ayon_core.pipeline import get_current_project_name log = Logger.get_logger("LoaderDiscover") - plugins = discover(LoaderPlugin) if not project_name: project_name = get_current_project_name() project_settings = get_project_settings(project_name) + plugins = discover(LoaderPlugin) + hooks = discover(LoaderHookPlugin) + sorted_hooks = sorted(hooks, key=lambda hook: hook.order) for plugin in plugins: try: plugin.apply_settings(project_settings) @@ -268,11 +350,58 @@ def discover_loader_plugins(project_name=None): "Failed to apply settings to loader {}".format( plugin.__name__ ), - exc_info=True + exc_info=True, ) + compatible_hooks = [] + for hook_cls in sorted_hooks: + if hook_cls.is_compatible(plugin): + compatible_hooks.append(hook_cls) + add_hooks_to_loader(plugin, compatible_hooks) return plugins +def add_hooks_to_loader( + loader_class: LoaderPlugin, compatible_hooks: list[Type[LoaderHookPlugin]] +) -> None: + """Monkey patch method replacing Loader.load|update|remove methods + + It wraps applicable loaders with pre/post hooks. Discovery is called only + once per loaders discovery. + """ + loader_class._load_hooks = compatible_hooks + + def wrap_method(method_name: str): + original_method = getattr(loader_class, method_name) + + def wrapped_method(self, *args, **kwargs): + # Call pre_ on all hooks + pre_hook_name = f"pre_{method_name}" + + hooks: list[LoaderHookPlugin] = [] + for cls in loader_class._load_hooks: + hook = cls() # Instantiate the hook + hooks.append(hook) + pre_hook = getattr(hook, pre_hook_name, None) + if callable(pre_hook): + pre_hook(self, *args, **kwargs) + # Call original method + result = original_method(self, *args, **kwargs) + # Call post_ on all hooks + post_hook_name = f"post_{method_name}" + for hook in hooks: + post_hook = getattr(hook, post_hook_name, None) + if callable(post_hook): + post_hook(self, result, *args, **kwargs) + + return result + + setattr(loader_class, method_name, wrapped_method) + + for method in ("load", "update", "remove"): + if hasattr(loader_class, method): + wrap_method(method) + + def register_loader_plugin(plugin): return register_plugin(LoaderPlugin, plugin) @@ -287,3 +416,19 @@ def deregister_loader_plugin_path(path): def register_loader_plugin_path(path): return register_plugin_path(LoaderPlugin, path) + + +def register_loader_hook_plugin(plugin): + return register_plugin(LoaderHookPlugin, plugin) + + +def deregister_loader_hook_plugin(plugin): + deregister_plugin(LoaderHookPlugin, plugin) + + +def register_loader_hook_plugin_path(path): + return register_plugin_path(LoaderHookPlugin, path) + + +def deregister_loader_hook_plugin_path(path): + deregister_plugin_path(LoaderHookPlugin, path) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index b130161190..3c50d76fb5 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -288,7 +288,12 @@ def get_representation_context(project_name, representation): def load_with_repre_context( - Loader, repre_context, namespace=None, name=None, options=None, **kwargs + Loader, + repre_context, + namespace=None, + name=None, + options=None, + **kwargs ): # Ensure the Loader is compatible for the representation @@ -320,7 +325,12 @@ def load_with_repre_context( def load_with_product_context( - Loader, product_context, namespace=None, name=None, options=None, **kwargs + Loader, + product_context, + namespace=None, + name=None, + options=None, + **kwargs ): # Ensure options is a dictionary when no explicit options provided @@ -343,7 +353,12 @@ def load_with_product_context( def load_with_product_contexts( - Loader, product_contexts, namespace=None, name=None, options=None, **kwargs + Loader, + product_contexts, + namespace=None, + name=None, + options=None, + **kwargs ): # Ensure options is a dictionary when no explicit options provided @@ -553,15 +568,20 @@ def update_container(container, version=-1): return Loader().update(container, context) -def switch_container(container, representation, loader_plugin=None): +def switch_container( + container, + representation, + loader_plugin=None, +): """Switch a container to representation Args: container (dict): container information representation (dict): representation entity + loader_plugin (LoaderPlugin) Returns: - function call + return from function call """ from ayon_core.pipeline import get_current_project_name diff --git a/client/ayon_core/pipeline/publish/__init__.py b/client/ayon_core/pipeline/publish/__init__.py index 5363e0b378..ede7fc3a35 100644 --- a/client/ayon_core/pipeline/publish/__init__.py +++ b/client/ayon_core/pipeline/publish/__init__.py @@ -46,6 +46,11 @@ from .lib import ( get_publish_instance_families, main_cli_publish, + + add_trait_representations, + get_trait_representations, + has_trait_representations, + set_trait_representations, ) from .abstract_expected_files import ExpectedFiles @@ -104,4 +109,9 @@ __all__ = ( "RenderInstance", "AbstractCollectRender", + + "add_trait_representations", + "get_trait_representations", + "has_trait_representations", + "set_trait_representations", ) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 49ecab2221..464b2b6d8f 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -6,7 +6,7 @@ import inspect import copy import warnings import xml.etree.ElementTree -from typing import Optional, Union, List +from typing import TYPE_CHECKING, Optional, Union, List import ayon_api import pyblish.util @@ -27,6 +27,12 @@ from .constants import ( DEFAULT_HERO_PUBLISH_TEMPLATE, ) +if TYPE_CHECKING: + from ayon_core.pipeline.traits import Representation + + +TRAIT_INSTANCE_KEY: str = "representations_with_traits" + def get_template_name_profiles( project_name, project_settings=None, logger=None @@ -1062,3 +1068,66 @@ def main_cli_publish( sys.exit(1) log.info("Publish finished.") + + +def has_trait_representations( + instance: pyblish.api.Instance) -> bool: + """Check if instance has trait representation. + + Args: + instance (pyblish.api.Instance): Instance to check. + + Returns: + True: Instance has trait representation. + False: Instance does not have trait representation. + + """ + return TRAIT_INSTANCE_KEY in instance.data + + +def add_trait_representations( + instance: pyblish.api.Instance, + representations: list[Representation] +) -> None: + """Add trait representations to instance. + + Args: + instance (pyblish.api.Instance): Instance to add trait + representations to. + representations (list[Representation]): List of representation + trait based representations to add. + + """ + repres = instance.data.setdefault(TRAIT_INSTANCE_KEY, []) + repres.extend(representations) + + +def set_trait_representations( + instance: pyblish.api.Instance, + representations: list[Representation] +) -> None: + """Set trait representations to instance. + + Args: + instance (pyblish.api.Instance): Instance to set trait + representations to. + representations (list[Representation]): List of trait + based representations. + + """ + instance.data[TRAIT_INSTANCE_KEY] = representations + + +def get_trait_representations( + instance: pyblish.api.Instance) -> list[Representation]: + """Get trait representations from instance. + + Args: + instance (pyblish.api.Instance): Instance to get trait + representations from. + + Returns: + list[Representation]: List of representation names. + + """ + return instance.data.get(TRAIT_INSTANCE_KEY, []) diff --git a/client/ayon_core/pipeline/traits/README.md b/client/ayon_core/pipeline/traits/README.md new file mode 100644 index 0000000000..96ced3692c --- /dev/null +++ b/client/ayon_core/pipeline/traits/README.md @@ -0,0 +1,453 @@ +# Representations and traits + +## Introduction + +The Representation is the lowest level entity, describing the concrete data chunk that +pipeline can act on. It can be a specific file or just a set of metadata. Idea is that one +product version can have multiple representations - **Image** product can be jpeg or tiff, both formats are representation of the same source. + +### Brief look into the past (and current state) + +So far, representation was defined as a dict-like structure: +```python +{ + "name": "foo", + "ext": "exr", + "files": ["foo_001.exr", "foo_002.exr"], + "stagingDir": "/bar/dir" +} +``` + +This is minimal form, but it can have additional keys like `frameStart`, `fps`, `resolutionWidth`, and more. Thare is also `tags` key that can hold `review`, `thumbnail`, `delete`, `toScanline` and other tags that are controlling the processing. + +This will be *"translated"* to the similar structure in the database: + +```python +{ + "name": "foo", + "version_id": "...", + "files": [ + { + "id": ..., + "hash": ..., + "name": "foo_001.exr", + "path": "{root[work]}/bar/dir/foo_001.exr", + "size": 1234, + "hash_type": "...", + }, + ... + ], + "attrib": { + "path": "root/bar/dir/foo_001.exr", + "template": "{root[work]}/{project[name]}...", + }, + "data": { + "context": { + "ext": "exr", + "root": {...}, + ... + }, + "active": True + ... + +} +``` + +There are also some assumptions and limitations - like that if `files` in the +representation are list they need to be sequence of files (it can't be a bunch of +unrelated files). + +This system is very flexible in one way, but it lacks a few very important things: + +- it is not clearly defined — you can add easily keys, values, tags but without +unforeseeable +consequences +- it cannot handle "bundles" — multiple files that need to be versioned together and +belong together +- it cannot describe important information that you can't get from the file itself, or +it is very expensive (like axis orientation and units from alembic files) + + +### New Representation model + +The idea about a new representation model is about solving points mentioned +above and also adding some benefits, like consistent IDE hints, typing, built-in + validators and much more. + +### Design + +The new representation is "just" a dictionary of traits. Trait can be anything provided +it is based on `TraitBase`. It shouldn't really duplicate information that is +available at the moment of loading (or any usage) by other means. It should contain +information that couldn't be determined by the file, or the AYON context. Some of +those traits are aligned with [OpenAssetIO Media Creation](https://github.com/OpenAssetIO/OpenAssetIO-MediaCreation) with hopes of maintained compatibility (it +should be easy enough to convert between OpenAssetIO Traits and AYON Traits). + +#### Details: Representation + +`Representation` has methods to deal with adding, removing, getting +traits. It has all the usual stuff like `get_trait()`, `add_trait()`, +`remove_trait()`, etc. But it also has plural forms so you can get/set +several traits at the same time with `get_traits()` and so on. +`Representation` also behaves like dictionary. so you can access/set +traits in the same way as you would do with dict: + +```python +# import Image trait +from ayon_core.pipeline.traits import Image, Tagged, Representation + + +# create new representation with name "foo" and add Image trait to it +rep = Representation(name="foo", traits=[Image()]) + +# you can add another trait like so +rep.add_trait(Tagged(tags=["tag"])) + +# or you can +rep[Tagged.id] = Tagged(tags=["tag"]) + +# and getting them in analogous +image = rep.get_trait(Image) + +# or +image = rep[Image.id] +``` + +> [!NOTE] +> Trait and their ids — every Trait has its id as a string with a +> version appended - so **Image** has `ayon.2d.Image.v1`. This is used on +> several places (you see its use above for indexing traits). When querying, +> you can also omit the version at the end, and it will try its best to find +> the latest possible version. More on that in [Traits]() + +You can construct the `Representation` from dictionary (for example, +serialized as JSON) using `Representation.from_dict()`, or you can +serialize `Representation` to dict to store with `Representation.traits_as_dict()`. + +Every time representation is created, a new id is generated. You can pass existing +id when creating the new representation instance. + +##### Equality + +Two Representations are equal if: +- their names are the same +- their IDs are the same +- they have the same traits +- the traits have the same values + +##### Validation + +Representation has `validate()` method that will run `validate()` on +all it's traits. + +#### Details: Traits + +As mentioned there are several traits defined directly in **ayon-core**. They are namespaced +to different packages based on their use: + +| namespace | trait | description | +|-------------------|----------------------|----------------------------------------------------------------------------------------------------------| +| color | ColorManaged | hold color management information | +| content | MimeType | use MIME type (RFC 2046) to describe content (like image/jpeg) | +| | LocatableContent | describe some location (file or URI) | +| | FileLocation | path to file, with size and checksum | +| | FileLocations | list of `FileLocation` | +| | RootlessLocation | Path where root is replaced with AYON root token | +| | Compressed | describes compression (of file or other) | +| | Bundle | list of list of Traits - compound of inseparable "sub-representations" | +| | Fragment | compound type marking the representation as a part of larger group of representations | +| cryptography | DigitallySigned | Type traits marking data to be digitally signed | +| | PGPSigned | Representation is signed by [PGP](https://www.openpgp.org/) | +| lifecycle | Transient | Marks the representation to be temporary - not to be stored. | +| | Persistent | Representation should be integrated (stored). Opposite of Transient. | +| meta | Tagged | holds list of tag strings. | +| | TemplatePath | Template consisted of tokens/keys and data to be used to resolve the template into string | +| | Variant | Used to differentiate between data variants of the same output (mp4 as h.264 and h.265 for example) | +| | KeepOriginalLocation | Marks the representation to keep the original location of the file | +| | KeepOriginalName | Marks the representation to keep the original name of the file | +| | SourceApplication | Holds information about producing application, about it's version, variant and platform. | +| | IntendedUse | For specifying the intended use of the representation if it cannot be easily determined by other traits. | +| three dimensional | Spatial | Spatial information like up-axis, units and handedness. | +| | Geometry | Type trait to mark the representation as a geometry. | +| | Shader | Type trait to mark the representation as a Shader. | +| | Lighting | Type trait to mark the representation as Lighting. | +| | IESProfile | States that the representation is IES Profile. | +| time | FrameRanged | Contains start and end frame information with in and out. | +| | Handless | define additional frames at the end or beginning and if those frames are inclusive of the range or not. | +| | Sequence | Describes sequence of frames and how the frames are defined in that sequence. | +| | SMPTETimecode | Adds timecode information in SMPTE format. | +| | Static | Marks the content as not time-variant. | +| two dimensional | Image | Type traits of image. | +| | PixelBased | Defines resolution and pixel aspect for the image data. | +| | Planar | Whether pixel data is in planar configuration or packed. | +| | Deep | Image encodes deep pixel data. | +| | Overscan | holds overscan/underscan information (added pixels to bottom/sides). | +| | UDIM | Representation is UDIM tile set. | + +Traits are Python data classes with optional +validation and helper methods. If they implement `TraitBase.validate(Representation)` method, they can validate against all other traits +in the representation if needed. + +> [!NOTE] +> They could be easily converted to [Pydantic models](https://docs.pydantic.dev/latest/) but since this must run in diverse Python environments inside DCC, we cannot +> easily resolve pydantic-core dependency (as it is binary written in Rust). + +> [!NOTE] +> Every trait has id, name and some human-readable description. Every trait +> also has `persistent` property that is by default set to True. This +> Controls whether this trait should be stored with the persistent representation +> or not. Useful for traits to be used just to control the publishing process. + +## Examples + +Create a simple image representation to be integrated by AYON: + +```python +from pathlib import Path +from ayon_core.pipeline.traits import ( + FileLocation, + Image, + PixelBased, + Persistent, + Representation, + Static, + + TraitValidationError, +) + +rep = Representation(name="reference image", traits=[ + FileLocation( + file_path=Path("/foo/bar/baz.exr"), + file_size=1234, + file_hash="sha256:...", + ), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0, + ), + Persistent(), + Static() +]) + +# validate the representation + +try: + rep.validate() +except TraitValidationError as e: + print(f"Representation {rep.name} is invalid: {e}") + +``` + +To work with the resolution of such representation: + +```python + +try: + width = rep.get_trait(PixelBased).display_window_width + # or like this: + height = rep[PixelBased.id].display_window_height +except MissingTraitError: + print(f"resolution isn't set on {rep.name}") +``` + +Accessing non-existent traits will result in an exception. To test if +the representation has some specific trait, you can use `.contains_trait()` method. + + +You can also prepare the whole representation data as a dict and +create it from it: + +```python +rep_dict = { + "ayon.content.FileLocation.v1": { + "file_path": Path("/path/to/file"), + "file_size": 1024, + "file_hash": None, + }, + "ayon.two_dimensional.Image": {}, + "ayon.two_dimensional.PixelBased": { + "display_window_width": 1920, + "display_window_height": 1080, + "pixel_aspect_ratio": 1.0, + }, + "ayon.two_dimensional.Planar": { + "planar_configuration": "RGB", + } +} + +rep = Representation.from_dict(name="image", rep_dict) + +``` + + +## Addon specific traits + +Addon can define its own traits. To do so, it needs to implement `ITraits` interface: + +```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, + ] +``` +## Usage in Loaders + +In loaders, you can implement `is_compatible_loader()` method to check if the +representation is compatible with the loader. You can use `Representation.from_dict()` to +create the representation from the context. You can also use `Representation.contains_traits()` +to check if the representation contains the required traits. You can even check for specific +values in the traits. + +You can use similar concepts directly in the `load()` method to get the traits. Here is +an example of how to use the traits in the hypothetical Maya loader: + +```python +"""Alembic loader using traits.""" +from __future__ import annotations +import json +from typing import Any, TypeVar, Type +from ayon_maya.api.plugin import MayaLoader +from ayon_core.pipeline.traits import ( + FileLocation, + Spatial, + + Representation, + TraitBase, +) + +T = TypeVar("T", bound=TraitBase) + + +class AlembicTraitLoader(MayaLoader): + """Alembic loader using traits.""" + label = "Alembic Trait Loader" + ... + + required_traits: list[T] = [ + FileLocation, + Spatial, + ] + + @staticmethod + def is_compatible_loader(context: dict[str, Any]) -> bool: + traits_raw = context["representation"].get("traits") + if not traits_raw: + return False + + # construct Representation object from the context + representation = Representation.from_dict( + name=context["representation"]["name"], + representation_id=context["representation"]["id"], + trait_data=json.loads(traits_raw), + ) + + # check if the representation is compatible with this loader + if representation.contains_traits(AlembicTraitLoader.required_traits): + # you can also check for specific values in traits here + return True + return False + + ... +``` + +## Usage Publishing plugins + +You can create the representations in the same way as mentioned in the examples above. +Straightforward way is to use `Representation` class and add the traits to it. Collect +traits in the list and then pass them to the `Representation` constructor. You should add +the new Representation to the instance data using `add_trait_representations()` function. + +```python +class SomeExtractor(Extractor): + """Some extractor.""" + ... + + def extract(self, instance: Instance) -> None: + """Extract the data.""" + # get the path to the file + path = self.get_path(instance) + + # create the representation + traits: list[TraitBase] = [ + Geometry(), + MimeType(mime_type="application/abc"), + Persistent(), + Spatial( + up_axis=cmds.upAxis(q=True, axis=True), + meters_per_unit=maya_units_to_meters_per_unit( + instance.context.data["linearUnits"]), + handedness="right", + ), + ] + + if instance.data.get("frameStart"): + traits.append( + FrameRanged( + frame_start=instance.data["frameStart"], + frame_end=instance.data["frameEnd"], + frames_per_second=instance.context.data["fps"], + ) + ) + + representation = Representation( + name="alembic", + traits=[ + FileLocation( + file_path=Path(path), + file_size=os.path.getsize(path), + file_hash=get_file_hash(Path(path)) + ), + *traits], + ) + + add_trait_representations( + instance, + [representation], + ) + ... +``` + +## Developer notes + +Adding new trait-based representations in to the publishing Instance and working with them is using +a set of helper function defined in `ayon_core.pipeline.publish` module. These are: + +* add_trait_representations +* get_trait_representations +* has_trait_representations +* set_trait_representations + +And their main purpose is to handle the key under which the representation +is stored in the instance data. This is done to avoid name clashes with +other representations. The key is defined in the `AYON_PUBLISH_REPRESENTATION_KEY`. +It is strongly recommended to use those functions instead of +directly accessing the instance data. This is to ensure that the +code will work even if the key is changed in the future. + diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py new file mode 100644 index 0000000000..645064d59f --- /dev/null +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -0,0 +1,112 @@ +"""Trait classes for the pipeline.""" +from .color import ColorManaged +from .content import ( + Bundle, + Compressed, + FileLocation, + FileLocations, + Fragment, + LocatableContent, + MimeType, + RootlessLocation, +) +from .cryptography import DigitallySigned, PGPSigned +from .lifecycle import Persistent, Transient +from .meta import ( + IntendedUse, + KeepOriginalLocation, + SourceApplication, + Tagged, + TemplatePath, + Variant, +) +from .representation import Representation +from .temporal import ( + FrameRanged, + GapPolicy, + Handles, + Sequence, + SMPTETimecode, + Static, +) +from .three_dimensional import Geometry, IESProfile, Lighting, Shader, Spatial +from .trait import ( + MissingTraitError, + TraitBase, + TraitValidationError, +) +from .two_dimensional import ( + UDIM, + Deep, + Image, + Overscan, + PixelBased, + Planar, +) +from .utils import ( + get_sequence_from_files, +) + +__all__ = [ # noqa: RUF022 + # base + "Representation", + "TraitBase", + "MissingTraitError", + "TraitValidationError", + + # color + "ColorManaged", + + # content + "Bundle", + "Compressed", + "FileLocation", + "FileLocations", + "Fragment", + "LocatableContent", + "MimeType", + "RootlessLocation", + + # cryptography + "DigitallySigned", + "PGPSigned", + + # life cycle + "Persistent", + "Transient", + + # meta + "IntendedUse", + "KeepOriginalLocation", + "SourceApplication", + "Tagged", + "TemplatePath", + "Variant", + + # temporal + "FrameRanged", + "GapPolicy", + "Handles", + "Sequence", + "SMPTETimecode", + "Static", + + # three-dimensional + "Geometry", + "IESProfile", + "Lighting", + "Shader", + "Spatial", + + # two-dimensional + "Compressed", + "Deep", + "Image", + "Overscan", + "PixelBased", + "Planar", + "UDIM", + + # utils + "get_sequence_from_files", +] diff --git a/client/ayon_core/pipeline/traits/color.py b/client/ayon_core/pipeline/traits/color.py new file mode 100644 index 0000000000..6da7b86ae7 --- /dev/null +++ b/client/ayon_core/pipeline/traits/color.py @@ -0,0 +1,30 @@ +"""Color-management-related traits.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar, Optional + +from .trait import TraitBase + + +@dataclass +class ColorManaged(TraitBase): + """Color managed trait. + + Holds color management information. Can be used with Image-related + traits to define color space and config. + + Sync with OpenAssetIO MediaCreation Traits. + + Attributes: + color_space (str): An OCIO colorspace name available + in the "current" OCIO context. + config (str): An OCIO config name defining color space. + """ + + id: ClassVar[str] = "ayon.color.ColorManaged.v1" + name: ClassVar[str] = "ColorManaged" + color_space: str + description: ClassVar[str] = "Color Managed trait." + persistent: ClassVar[bool] = True + config: Optional[str] = None diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py new file mode 100644 index 0000000000..42c162d28f --- /dev/null +++ b/client/ayon_core/pipeline/traits/content.py @@ -0,0 +1,485 @@ +"""Content traits for the pipeline.""" +from __future__ import annotations + +import contextlib +import re +from dataclasses import dataclass + +# TCH003 is there because Path in TYPECHECKING will fail in tests +from pathlib import Path # noqa: TCH003 +from typing import ClassVar, Generator, Optional + +from .representation import Representation +from .temporal import FrameRanged, Handles, Sequence +from .trait import ( + MissingTraitError, + TraitBase, + TraitValidationError, +) +from .two_dimensional import UDIM +from .utils import get_sequence_from_files + + +@dataclass +class MimeType(TraitBase): + """MimeType trait model. + + This model represents a mime type trait. For example, image/jpeg. + It is used to describe the type of content in a representation regardless + of the file extension. + + For more information, see RFC 2046 and RFC 4288 (and related RFCs). + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + mime_type (str): Mime type like image/jpeg. + """ + + name: ClassVar[str] = "MimeType" + description: ClassVar[str] = "MimeType Trait Model" + id: ClassVar[str] = "ayon.content.MimeType.v1" + persistent: ClassVar[bool] = True + mime_type: str + + +@dataclass +class LocatableContent(TraitBase): + """LocatableContent trait model. + + This model represents a locatable content trait. Locatable content + is content that has a location. It doesn't have to be a file - it could + be a URL or some other location. + + Sync with OpenAssetIO MediaCreation Traits. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + location (str): Location. + is_templated (Optional[bool]): Is the location templated? + Default is None. + """ + + name: ClassVar[str] = "LocatableContent" + description: ClassVar[str] = "LocatableContent Trait Model" + id: ClassVar[str] = "ayon.content.LocatableContent.v1" + persistent: ClassVar[bool] = True + location: str + is_templated: Optional[bool] = None + + +@dataclass +class FileLocation(TraitBase): + """FileLocation trait model. + + This model represents a file path. It is a specialization of the + LocatableContent trait. It is adding optional file size and file hash + for easy access to file information. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + file_path (str): File path. + file_size (Optional[int]): File size in bytes. + file_hash (Optional[str]): File hash. + """ + + name: ClassVar[str] = "FileLocation" + description: ClassVar[str] = "FileLocation Trait Model" + id: ClassVar[str] = "ayon.content.FileLocation.v1" + persistent: ClassVar[bool] = True + file_path: Path + file_size: Optional[int] = None + file_hash: Optional[str] = None + + +@dataclass +class FileLocations(TraitBase): + """FileLocation trait model. + + This model represents a file path. It is a specialization of the + LocatableContent trait. It is adding optional file size and file hash + for easy access to file information. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + file_paths (list of FileLocation): File locations. + + """ + + name: ClassVar[str] = "FileLocations" + description: ClassVar[str] = "FileLocations Trait Model" + id: ClassVar[str] = "ayon.content.FileLocations.v1" + persistent: ClassVar[bool] = True + file_paths: list[FileLocation] + + def get_files(self) -> Generator[Path, None, None]: + """Get all file paths from the trait. + + This method will return all file paths from the trait. + + Yields: + Path: List of file paths. + + """ + for file_location in self.file_paths: + yield file_location.file_path + + def get_file_location_for_frame( + self, + frame: int, + sequence_trait: Optional[Sequence] = None, + ) -> Optional[FileLocation]: + """Get a file location for a frame. + + This method will return the file location for a given frame. If the + frame is not found in the file paths, it will return None. + + Args: + frame (int): Frame to get the file location for. + sequence_trait (Sequence): Sequence trait to get the + frame range specs from. + + Returns: + Optional[FileLocation]: File location for the frame. + + """ + frame_regex = re.compile(r"\.(?P(?P0*)\d+)\.\D+\d?$") + if sequence_trait and sequence_trait.frame_regex: + frame_regex = sequence_trait.get_frame_pattern() + + for location in self.file_paths: + result = re.search(frame_regex, location.file_path.name) + if result: + frame_index = int(result.group("index")) + if frame_index == frame: + return location + return None + + def validate_trait(self, representation: Representation) -> None: + """Validate the trait. + + This method validates the trait against others in the representation. + In particular, it checks that the sequence trait is present, and if + so, it will compare the frame range to the file paths. + + Args: + representation (Representation): Representation to validate. + + Raises: + TraitValidationError: If the trait is invalid within the + representation. + + """ + super().validate_trait(representation) + if len(self.file_paths) == 0: + # If there are no file paths, we can't validate + msg = "No file locations defined (empty list)" + raise TraitValidationError(self.name, msg) + if representation.contains_trait(FrameRanged): + self._validate_frame_range(representation) + if not representation.contains_trait(Sequence) \ + and not representation.contains_trait(UDIM): + # we have multiple files, but it is not a sequence + # or UDIM tile set what is it then? If the files are not related + # to each other, then this representation is invalid. + msg = ( + "Multiple file locations defined, but no Sequence " + "or UDIM trait defined. If the files are not related to " + "each other, the representation is invalid." + ) + raise TraitValidationError(self.name, msg) + + def _validate_frame_range(self, representation: Representation) -> None: + """Validate the frame range against the file paths. + + If the representation contains a FrameRanged trait, this method will + validate the frame range against the file paths. If the frame range + does not match the file paths, the trait is invalid. It takes into + account the Handles and Sequence traits. + + Args: + representation (Representation): Representation to validate. + + Raises: + TraitValidationError: If the trait is invalid within the + representation. + + """ + tmp_frame_ranged: FrameRanged = get_sequence_from_files( + [f.file_path for f in self.file_paths]) + + frames_from_spec: list[int] = [] + with contextlib.suppress(MissingTraitError): + sequence: Sequence = representation.get_trait(Sequence) + frame_regex = sequence.get_frame_pattern() + if sequence.frame_spec: + frames_from_spec = sequence.get_frame_list( + self, frame_regex) + + frame_start_with_handles, frame_end_with_handles = \ + self._get_frame_info_with_handles(representation, frames_from_spec) + + if frame_start_with_handles \ + and tmp_frame_ranged.frame_start != frame_start_with_handles: + # If the detected frame range does not match the combined + # FrameRanged and Handles trait, the + # trait is invalid. + msg = ( + f"Frame range defined by {self.name} " + f"({tmp_frame_ranged.frame_start}-" + f"{tmp_frame_ranged.frame_end}) " + "in files does not match " + "frame range " + f"({frame_start_with_handles}-" + f"{frame_end_with_handles}) defined in FrameRanged trait." + ) + + raise TraitValidationError(self.name, msg) + + if frames_from_spec: + if len(frames_from_spec) != len(self.file_paths): + # If the number of file paths does not match the frame range, + # the trait is invalid + msg = ( + f"Number of file locations ({len(self.file_paths)}) " + "does not match frame range defined by frame spec " + "on Sequence trait: " + f"({len(frames_from_spec)})" + ) + raise TraitValidationError(self.name, msg) + # if there is a frame spec on the Sequence trait, + # we should not validate the frame range from the files. + # the rest is validated by Sequence validators. + return + + length_with_handles: int = ( + frame_end_with_handles - frame_start_with_handles + 1 + ) + + if len(self.file_paths) != length_with_handles: + # If the number of file paths does not match the frame range, + # the trait is invalid + msg = ( + f"Number of file locations ({len(self.file_paths)}) " + "does not match frame range " + f"({length_with_handles})" + ) + raise TraitValidationError(self.name, msg) + + frame_ranged: FrameRanged = representation.get_trait(FrameRanged) + + if frame_start_with_handles != tmp_frame_ranged.frame_start or \ + frame_end_with_handles != tmp_frame_ranged.frame_end: + # If the frame range does not match the FrameRanged trait, the + # trait is invalid. Note that we don't check the frame rate + # because it is not stored in the file paths and is not + # determined by `get_sequence_from_files`. + msg = ( + "Frame range " + f"({frame_ranged.frame_start}-{frame_ranged.frame_end}) " + "in sequence trait does not match " + "frame range " + f"({tmp_frame_ranged.frame_start}-" + f"{tmp_frame_ranged.frame_end}) " + ) + raise TraitValidationError(self.name, msg) + + @staticmethod + def _get_frame_info_with_handles( + representation: Representation, + frames_from_spec: list[int]) -> tuple[int, int]: + """Get the frame range with handles from the representation. + + This will return frame start and frame end with handles calculated + in if there actually is the Handles trait in the representation. + + Args: + representation (Representation): Representation to get the frame + range from. + frames_from_spec (list[int]): List of frames from the frame spec. + This list is modified in place to take into + account the handles. + + Mutates: + frames_from_spec: List of frames from the frame spec. + + Returns: + tuple[int, int]: Start and end frame with handles. + + """ + frame_start = frame_end = 0 + frame_start_handle = frame_end_handle = 0 + # If there is no sequence trait, we can't validate it + if frames_from_spec and representation.contains_trait(FrameRanged): + # if there is no FrameRanged trait (but really there should be) + # we can use the frame range from the frame spec + frame_start = min(frames_from_spec) + frame_end = max(frames_from_spec) + + # Handle the frame range + with contextlib.suppress(MissingTraitError): + frame_start = representation.get_trait(FrameRanged).frame_start + frame_end = representation.get_trait(FrameRanged).frame_end + + # Handle the handles :P + with contextlib.suppress(MissingTraitError): + handles: Handles = representation.get_trait(Handles) + if not handles.inclusive: + # if handless are exclusive, we need to adjust the frame range + frame_start_handle = handles.frame_start_handle or 0 + frame_end_handle = handles.frame_end_handle or 0 + if frames_from_spec: + frames_from_spec.extend( + range(frame_start - frame_start_handle, frame_start) + ) + frames_from_spec.extend( + range(frame_end + 1, frame_end_handle + frame_end + 1) + ) + + frame_start_with_handles = frame_start - frame_start_handle + frame_end_with_handles = frame_end + frame_end_handle + + return frame_start_with_handles, frame_end_with_handles + + +@dataclass +class RootlessLocation(TraitBase): + """RootlessLocation trait model. + + RootlessLocation trait is a trait that represents a file path that is + without a specific root. To get the absolute path, the root needs to be + resolved by AYON. Rootless path can be used on multiple platforms. + + Example:: + + RootlessLocation( + rootless_path="{root[work]}/project/asset/asset.jpg" + ) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + rootless_path (str): Rootless path. + """ + + name: ClassVar[str] = "RootlessLocation" + description: ClassVar[str] = "RootlessLocation Trait Model" + id: ClassVar[str] = "ayon.content.RootlessLocation.v1" + persistent: ClassVar[bool] = True + rootless_path: str + + +@dataclass +class Compressed(TraitBase): + """Compressed trait model. + + This trait can hold information about compressed content. What type + of compression is used. + + Example:: + + Compressed("gzip") + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + compression_type (str): Compression type. + """ + + name: ClassVar[str] = "Compressed" + description: ClassVar[str] = "Compressed Trait" + id: ClassVar[str] = "ayon.content.Compressed.v1" + persistent: ClassVar[bool] = True + compression_type: str + + +@dataclass +class Bundle(TraitBase): + """Bundle trait model. + + This model list of independent Representation traits + that are bundled together. This is useful for representing + a collection of sub-entities that are part of a single + entity. You can easily reconstruct representations from + the bundle. + + Example:: + + Bundle( + items=[ + [ + MimeType(mime_type="image/jpeg"), + FileLocation(file_path="/path/to/file.jpg") + ], + [ + + MimeType(mime_type="image/png"), + FileLocation(file_path="/path/to/file.png") + ] + ] + ) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + items (list[list[TraitBase]]): List of representations. + """ + + name: ClassVar[str] = "Bundle" + description: ClassVar[str] = "Bundle Trait" + id: ClassVar[str] = "ayon.content.Bundle.v1" + persistent: ClassVar[bool] = True + items: list[list[TraitBase]] + + def to_representations(self) -> Generator[Representation]: + """Convert a bundle to representations. + + Yields: + Representation: Representation of the bundle. + + """ + for idx, item in enumerate(self.items): + yield Representation(name=f"{self.name} {idx}", traits=item) + + +@dataclass +class Fragment(TraitBase): + """Fragment trait model. + + This model represents a fragment trait. A fragment is a part of + a larger entity that is represented by another representation. + + Example:: + + main_representation = Representation(name="parent", + traits=[], + ) + fragment_representation = Representation( + name="fragment", + traits=[ + Fragment(parent=main_representation.id), + ] + ) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + parent (str): Parent representation id. + """ + + name: ClassVar[str] = "Fragment" + description: ClassVar[str] = "Fragment Trait" + id: ClassVar[str] = "ayon.content.Fragment.v1" + persistent: ClassVar[bool] = True + parent: str diff --git a/client/ayon_core/pipeline/traits/cryptography.py b/client/ayon_core/pipeline/traits/cryptography.py new file mode 100644 index 0000000000..7fcbb1b387 --- /dev/null +++ b/client/ayon_core/pipeline/traits/cryptography.py @@ -0,0 +1,42 @@ +"""Cryptography traits.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar, Optional + +from .trait import TraitBase + + +@dataclass +class DigitallySigned(TraitBase): + """Digitally signed trait. + + This type trait means that the data is digitally signed. + + Attributes: + signature (str): Digital signature. + """ + + id: ClassVar[str] = "ayon.cryptography.DigitallySigned.v1" + name: ClassVar[str] = "DigitallySigned" + description: ClassVar[str] = "Digitally signed trait." + persistent: ClassVar[bool] = True + + +@dataclass +class PGPSigned(DigitallySigned): + """PGP signed trait. + + This trait holds PGP (RFC-4880) signed data. + + Attributes: + signed_data (str): Signed data. + clear_text (str): Clear text. + """ + + id: ClassVar[str] = "ayon.cryptography.PGPSigned.v1" + name: ClassVar[str] = "PGPSigned" + description: ClassVar[str] = "PGP signed trait." + persistent: ClassVar[bool] = True + signed_data: str + clear_text: Optional[str] = None diff --git a/client/ayon_core/pipeline/traits/lifecycle.py b/client/ayon_core/pipeline/traits/lifecycle.py new file mode 100644 index 0000000000..4845f04779 --- /dev/null +++ b/client/ayon_core/pipeline/traits/lifecycle.py @@ -0,0 +1,77 @@ +"""Lifecycle traits.""" +from dataclasses import dataclass +from typing import ClassVar + +from .trait import TraitBase, TraitValidationError + + +@dataclass +class Transient(TraitBase): + """Transient trait model. + + Transient trait marks representation as transient. Such representations + are not persisted in the system. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with the version + """ + + name: ClassVar[str] = "Transient" + description: ClassVar[str] = "Transient Trait Model" + id: ClassVar[str] = "ayon.lifecycle.Transient.v1" + persistent: ClassVar[bool] = True # see note in Persistent + + def validate_trait(self, representation) -> None: # noqa: ANN001 + """Validate representation is not Persistent. + + Args: + representation (Representation): Representation model. + + Raises: + TraitValidationError: If representation is marked as both + Persistent and Transient. + + """ + if representation.contains_trait(Persistent): + msg = "Representation is marked as both Persistent and Transient." + raise TraitValidationError(self.name, msg) + + +@dataclass +class Persistent(TraitBase): + """Persistent trait model. + + Persistent trait is opposite to transient trait. It marks representation + as persistent. Such representations are persisted in the system (e.g. in + the database). + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with the version + """ + + name: ClassVar[str] = "Persistent" + description: ClassVar[str] = "Persistent Trait Model" + id: ClassVar[str] = "ayon.lifecycle.Persistent.v1" + # Note that this affects the persistence of the trait itself, not + # the representation. This is a class variable, so it is shared + # among all instances of the class. + persistent: bool = True + + def validate_trait(self, representation) -> None: # noqa: ANN001 + """Validate representation is not Transient. + + Args: + representation (Representation): Representation model. + + Raises: + TraitValidationError: If representation is marked + as both Persistent and Transient. + + """ + if representation.contains_trait(Transient): + msg = "Representation is marked as both Persistent and Transient." + raise TraitValidationError(self.name, msg) diff --git a/client/ayon_core/pipeline/traits/meta.py b/client/ayon_core/pipeline/traits/meta.py new file mode 100644 index 0000000000..26edf3ffb6 --- /dev/null +++ b/client/ayon_core/pipeline/traits/meta.py @@ -0,0 +1,162 @@ +"""Metadata traits.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar, List, Optional + +from .trait import TraitBase + + +@dataclass +class Tagged(TraitBase): + """Tagged trait model. + + This trait can hold a list of tags. + + Example:: + + Tagged(tags=["tag1", "tag2"]) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + tags (List[str]): Tags. + """ + + name: ClassVar[str] = "Tagged" + description: ClassVar[str] = "Tagged Trait Model" + id: ClassVar[str] = "ayon.meta.Tagged.v1" + persistent: ClassVar[bool] = True + tags: List[str] + + +@dataclass +class TemplatePath(TraitBase): + """TemplatePath trait model. + + This model represents a template path with formatting data. + Template path can be an Anatomy template and data is used to format it. + + Example:: + + TemplatePath(template="path/{key}/file", data={"key": "to"}) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + template (str): Template path. + data (dict[str]): Formatting data. + """ + + name: ClassVar[str] = "TemplatePath" + description: ClassVar[str] = "Template Path Trait Model" + id: ClassVar[str] = "ayon.meta.TemplatePath.v1" + persistent: ClassVar[bool] = True + template: str + data: dict + + +@dataclass +class Variant(TraitBase): + """Variant trait model. + + This model represents a variant of the representation. + + Example:: + + Variant(variant="high") + Variant(variant="prores444) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + variant (str): Variant name. + """ + + name: ClassVar[str] = "Variant" + description: ClassVar[str] = "Variant Trait Model" + id: ClassVar[str] = "ayon.meta.Variant.v1" + persistent: ClassVar[bool] = True + variant: str + + +@dataclass +class KeepOriginalLocation(TraitBase): + """Keep files in its original location. + + Note: + This is not a persistent trait. + + """ + name: ClassVar[str] = "KeepOriginalLocation" + description: ClassVar[str] = "Keep Original Location Trait Model" + id: ClassVar[str] = "ayon.meta.KeepOriginalLocation.v1" + persistent: ClassVar[bool] = False + + +@dataclass +class KeepOriginalName(TraitBase): + """Keep files in its original name. + + Note: + This is not a persistent trait. + """ + + name: ClassVar[str] = "KeepOriginalName" + description: ClassVar[str] = "Keep Original Name Trait Model" + id: ClassVar[str] = "ayon.meta.KeepOriginalName.v1" + persistent: ClassVar[bool] = False + + +@dataclass +class SourceApplication(TraitBase): + """Metadata about the source (producing) application. + + This can be useful in cases where this information is + needed, but it cannot be determined from other means - like + .txt files used for various motion tracking applications that + must be interpreted by the loader. + + Note that this is not really connected to any logic in + ayon-applications addon. + + Attributes: + application (str): Application name. + variant (str): Application variant. + version (str): Application version. + platform (str): Platform name (Windows, darwin, etc.). + host_name (str): AYON host name if applicable. + """ + + name: ClassVar[str] = "SourceApplication" + description: ClassVar[str] = "Source Application Trait Model" + id: ClassVar[str] = "ayon.meta.SourceApplication.v1" + persistent: ClassVar[bool] = True + application: str + variant: Optional[str] = None + version: Optional[str] = None + platform: Optional[str] = None + host_name: Optional[str] = None + + +@dataclass +class IntendedUse(TraitBase): + """Intended use of the representation. + + This trait describes the intended use of the representation. It + can be used in cases where the other traits are not enough to + describe the intended use. For example, a txt file with tracking + points can be used as a corner pin in After Effect but not in Nuke. + + Attributes: + use (str): Intended use description. + + """ + name: ClassVar[str] = "IntendedUse" + description: ClassVar[str] = "Intended Use Trait Model" + id: ClassVar[str] = "ayon.meta.IntendedUse.v1" + persistent: ClassVar[bool] = True + use: str diff --git a/client/ayon_core/pipeline/traits/representation.py b/client/ayon_core/pipeline/traits/representation.py new file mode 100644 index 0000000000..f76d5df99f --- /dev/null +++ b/client/ayon_core/pipeline/traits/representation.py @@ -0,0 +1,713 @@ +"""Defines the base trait model and representation.""" +from __future__ import annotations + +import contextlib +import inspect +import re +import sys +import uuid +from functools import lru_cache +from types import GenericAlias +from typing import ( + ClassVar, + Generic, + ItemsView, + Optional, + Type, + TypeVar, + Union, +) + +from .trait import ( + IncompatibleTraitVersionError, + LooseMatchingTraitError, + MissingTraitError, + TraitBase, + TraitValidationError, + UpgradableTraitError, +) + +T = TypeVar("T", bound="TraitBase") + + +def _get_version_from_id(_id: str) -> Optional[int]: + """Get the version from ID. + + Args: + _id (str): ID. + + Returns: + int: Version. + + """ + match = re.search(r"v(\d+)$", _id) + return int(match[1]) if match else None + + +class Representation(Generic[T]): # noqa: PLR0904 + """Representation of products. + + Representation defines a collection of individual properties that describe + the specific "form" of the product. A trait represents a set of + properties therefore, the Representation is a collection of traits. + + It holds methods to add, remove, get, and check for the existence of a + trait in the representation. + + Note: + `PLR0904` is the rule for checking the number of public methods + in a class. + + Arguments: + name (str): Representation name. Must be unique within instance. + representation_id (str): Representation ID. + """ + + _data: dict[str, T] + _module_blacklist: ClassVar[list[str]] = [ + "_", "builtins", "pydantic", + ] + name: str + representation_id: str + + def __hash__(self): + """Return hash of the representation ID.""" + return hash(self.representation_id) + + def __getitem__(self, key: str) -> T: + """Get the trait by ID. + + Args: + key (str): Trait ID. + + Returns: + TraitBase: Trait instance. + + """ + return self.get_trait_by_id(key) + + def __setitem__(self, key: str, value: T) -> None: + """Set the trait by ID. + + Args: + key (str): Trait ID. + value (TraitBase): Trait instance. + + """ + with contextlib.suppress(KeyError): + self._data.pop(key) + + self.add_trait(value) + + def __delitem__(self, key: str) -> None: + """Remove the trait by ID. + + Args: + key (str): Trait ID. + + + """ + self.remove_trait_by_id(key) + + def __contains__(self, key: str) -> bool: + """Check if the trait exists by ID. + + Args: + key (str): Trait ID. + + Returns: + bool: True if the trait exists, False otherwise. + + """ + return self.contains_trait_by_id(key) + + def __iter__(self): + """Return the trait ID iterator.""" + return iter(self._data) + + def __str__(self): + """Return the representation name.""" + return self.name + + def items(self) -> ItemsView[str, T]: + """Return the traits as items.""" + return ItemsView(self._data) + + def add_trait(self, trait: T, *, exists_ok: bool = False) -> None: + """Add a trait to the Representation. + + Args: + trait (TraitBase): Trait to add. + exists_ok (bool, optional): If True, do not raise an error if the + trait already exists. Defaults to False. + + Raises: + ValueError: If the trait ID is not provided, or the trait already + exists. + + """ + if not hasattr(trait, "id"): + error_msg = f"Invalid trait {trait} - ID is required." + raise ValueError(error_msg) + if trait.id in self._data and not exists_ok: + error_msg = f"Trait with ID {trait.id} already exists." + raise ValueError(error_msg) + self._data[trait.id] = trait + + def add_traits( + self, traits: list[T], *, exists_ok: bool = False) -> None: + """Add a list of traits to the Representation. + + Args: + traits (list[TraitBase]): List of traits to add. + exists_ok (bool, optional): If True, do not raise an error if the + trait already exists. Defaults to False. + + """ + for trait in traits: + self.add_trait(trait, exists_ok=exists_ok) + + def remove_trait(self, trait: Type[TraitBase]) -> None: + """Remove a trait from the data. + + Args: + trait (TraitBase, optional): Trait class. + + Raises: + ValueError: If the trait is not found. + + """ + try: + self._data.pop(str(trait.id)) + except KeyError as e: + error_msg = f"Trait with ID {trait.id} not found." + raise ValueError(error_msg) from e + + def remove_trait_by_id(self, trait_id: str) -> None: + """Remove a trait from the data by its ID. + + Args: + trait_id (str): Trait ID. + + Raises: + ValueError: If the trait is not found. + + """ + try: + self._data.pop(trait_id) + except KeyError as e: + error_msg = f"Trait with ID {trait_id} not found." + raise ValueError(error_msg) from e + + def remove_traits(self, traits: list[Type[T]]) -> None: + """Remove a list of traits from the Representation. + + If no trait IDs or traits are provided, all traits will be removed. + + Args: + traits (list[TraitBase]): List of trait classes. + + """ + if not traits: + self._data = {} + return + + for trait in traits: + self.remove_trait(trait) + + def remove_traits_by_id(self, trait_ids: list[str]) -> None: + """Remove a list of traits from the Representation by their ID. + + If no trait IDs or traits are provided, all traits will be removed. + + Args: + trait_ids (list[str], optional): List of trait IDs. + + """ + for trait_id in trait_ids: + self.remove_trait_by_id(trait_id) + + def has_traits(self) -> bool: + """Check if the Representation has any traits. + + Returns: + bool: True if the Representation has any traits, False otherwise. + + """ + return bool(self._data) + + def contains_trait(self, trait: Type[T]) -> bool: + """Check if the trait exists in the Representation. + + Args: + trait (TraitBase): Trait class. + + Returns: + bool: True if the trait exists, False otherwise. + + """ + return bool(self._data.get(str(trait.id))) + + def contains_trait_by_id(self, trait_id: str) -> bool: + """Check if the trait exists using trait id. + + Args: + trait_id (str): Trait ID. + + Returns: + bool: True if the trait exists, False otherwise. + + """ + return bool(self._data.get(trait_id)) + + def contains_traits(self, traits: list[Type[T]]) -> bool: + """Check if the traits exist. + + Args: + traits (list[TraitBase], optional): List of trait classes. + + Returns: + bool: True if all traits exist, False otherwise. + + """ + return all(self.contains_trait(trait=trait) for trait in traits) + + def contains_traits_by_id(self, trait_ids: list[str]) -> bool: + """Check if the traits exist by id. + + If no trait IDs or traits are provided, it will check if the + representation has any traits. + + Args: + trait_ids (list[str]): List of trait IDs. + + Returns: + bool: True if all traits exist, False otherwise. + + """ + return all( + self.contains_trait_by_id(trait_id) for trait_id in trait_ids + ) + + def get_trait(self, trait: Type[T]) -> T: + """Get a trait from the representation. + + Args: + trait (TraitBase, optional): Trait class. + + Returns: + TraitBase: Trait instance. + + Raises: + MissingTraitError: If the trait is not found. + + """ + try: + return self._data[str(trait.id)] + except KeyError as e: + msg = f"Trait with ID {trait.id} not found." + raise MissingTraitError(msg) from e + + def get_trait_by_id(self, trait_id: str) -> T: + # sourcery skip: use-named-expression + """Get a trait from the representation by id. + + Args: + trait_id (str): Trait ID. + + Returns: + TraitBase: Trait instance. + + Raises: + MissingTraitError: If the trait is not found. + + """ + version = _get_version_from_id(trait_id) + if version: + try: + return self._data[trait_id] + except KeyError as e: + msg = f"Trait with ID {trait_id} not found." + raise MissingTraitError(msg) from e + + result = next( + ( + self._data.get(trait_id) + for trait_id in self._data + if trait_id.startswith(trait_id) + ), + None, + ) + if result is None: + msg = f"Trait with ID {trait_id} not found." + raise MissingTraitError(msg) + return result + + def get_traits(self, + traits: Optional[list[Type[T]]] = None + ) -> dict[str, T]: + """Get a list of traits from the representation. + + If no trait IDs or traits are provided, all traits will be returned. + + Args: + traits (list[TraitBase], optional): List of trait classes. + + Returns: + dict: Dictionary of traits. + + """ + result: dict[str, T] = {} + if not traits: + for trait_id in self._data: + result[trait_id] = self.get_trait_by_id(trait_id=trait_id) + return result + + for trait in traits: + result[str(trait.id)] = self.get_trait(trait=trait) + return result + + def get_traits_by_ids(self, trait_ids: list[str]) -> dict[str, T]: + """Get a list of traits from the representation by their id. + + If no trait IDs or traits are provided, all traits will be returned. + + Args: + trait_ids (list[str]): List of trait IDs. + + Returns: + dict: Dictionary of traits. + + """ + return { + trait_id: self.get_trait_by_id(trait_id) + for trait_id in trait_ids + } + + def traits_as_dict(self) -> dict: + """Return the traits from Representation data as a dictionary. + + Returns: + dict: Traits data dictionary. + + """ + return { + trait_id: trait.as_dict() + for trait_id, trait in self._data.items() + if trait and trait_id + } + + def __len__(self): + """Return the length of the data.""" + return len(self._data) + + def __init__( + self, + name: str, + representation_id: Optional[str] = None, + traits: Optional[list[T]] = None): + """Initialize the data. + + Args: + name (str): Representation name. Must be unique within instance. + representation_id (str, optional): Representation ID. + traits (list[TraitBase], optional): List of traits. + + """ + self.name = name + self.representation_id = representation_id or uuid.uuid4().hex + self._data = {} + if traits: + for trait in traits: + self.add_trait(trait) + + @staticmethod + def _get_version_from_id(trait_id: str) -> Union[int, None]: + # sourcery skip: use-named-expression + """Check if the trait has a version specified. + + Args: + trait_id (str): Trait ID. + + Returns: + int: Trait version. + None: If the trait id does not have a version. + + """ + version_regex = r"v(\d+)$" + match = re.search(version_regex, trait_id) + return int(match[1]) if match else None + + def __eq__(self, other: object) -> bool: # noqa: PLR0911 + """Check if the representation is equal to another. + + Args: + other (Representation): Representation to compare. + + Returns: + bool: True if the representations are equal, False otherwise. + + """ + if not isinstance(other, Representation): + return False + + if self.representation_id != other.representation_id: + return False + + if self.name != other.name: + return False + + # number of traits + if len(self) != len(other): + return False + + for trait_id, trait in self._data.items(): + if trait_id not in other._data: + return False + if trait != other._data[trait_id]: + return False + + return True + + @classmethod + @lru_cache(maxsize=64) + def _get_possible_trait_classes_from_modules( + cls, + trait_id: str) -> set[type[T]]: + """Get possible trait classes from modules. + + Args: + trait_id (str): Trait ID. + + Returns: + set[type[T]]: Set of trait classes. + + """ + modules = sys.modules.copy() + filtered_modules = modules.copy() + for module_name in modules: + for bl_module in cls._module_blacklist: + if module_name.startswith(bl_module): + filtered_modules.pop(module_name) + + trait_candidates = set() + for module in filtered_modules.values(): + if not module: + continue + + for attr_name in dir(module): + klass = getattr(module, attr_name) + if not inspect.isclass(klass): + continue + # This needs to be done because of the bug? In + # python ABCMeta, where ``issubclass`` is not working + # if it hits the GenericAlias (that is in fact + # tuple[int, int]). This is added to the scope by + # the ``types`` module. + if type(klass) is GenericAlias: + continue + if issubclass(klass, TraitBase) \ + and str(klass.id).startswith(trait_id): + trait_candidates.add(klass) + # I + return trait_candidates # type: ignore[return-value] + + @classmethod + @lru_cache(maxsize=64) + def _get_trait_class( + cls, trait_id: str) -> Union[Type[T], None]: + """Get the trait class with corresponding to given ID. + + This method will search for the trait class in all the modules except + the blocklisted modules. There is some issue in Pydantic where + ``issubclass`` is not working properly, so we are excluding explicit + modules with offending classes. This list can be updated as needed to + speed up the search. + + Args: + trait_id (str): Trait ID. + + Returns: + Type[TraitBase]: Trait class. + + """ + version = cls._get_version_from_id(trait_id) + + trait_candidates = cls._get_possible_trait_classes_from_modules( + trait_id + ) + if not trait_candidates: + return None + + for trait_class in trait_candidates: + if trait_class.id == trait_id: + # we found a direct match + return trait_class + + # if we didn't find direct match, we will search for the highest + # version of the trait. + if not version: + # sourcery skip: use-named-expression + trait_versions = [ + trait_class for trait_class in trait_candidates + if re.match( + rf"{trait_id}.v(\d+)$", str(trait_class.id)) + ] + if trait_versions: + def _get_version_by_id(trait_klass: Type[T]) -> int: + match = re.search(r"v(\d+)$", str(trait_klass.id)) + return int(match[1]) if match else 0 + + error: LooseMatchingTraitError = LooseMatchingTraitError( + "Found trait that might match.") + error.found_trait = max( + trait_versions, key=_get_version_by_id) + error.expected_id = trait_id + raise error + + return None + + @classmethod + def get_trait_class_by_trait_id(cls, trait_id: str) -> Type[T]: + """Get the trait class for the given trait ID. + + Args: + trait_id (str): Trait ID. + + Returns: + type[TraitBase]: Trait class. + + Raises: + IncompatibleTraitVersionError: If the trait version is incompatible + with the current version of the trait. + + """ + try: + trait_class = cls._get_trait_class(trait_id=trait_id) + except LooseMatchingTraitError as e: + requested_version = _get_version_from_id(trait_id) + found_version = _get_version_from_id(e.found_trait.id) + if found_version is None and not requested_version: + msg = ( + "Trait found with no version and requested version " + "is not specified." + ) + raise IncompatibleTraitVersionError(msg) from e + + if found_version is None: + msg = ( + f"Trait {e.found_trait.id} found with no version, " + "but requested version is specified." + ) + raise IncompatibleTraitVersionError(msg) from e + + if requested_version is None: + trait_class = e.found_trait + requested_version = found_version + + if requested_version > found_version: + error_msg = ( + f"Requested trait version {requested_version} is " + f"higher than the found trait version {found_version}." + ) + raise IncompatibleTraitVersionError(error_msg) from e + + if requested_version < found_version and hasattr( + e.found_trait, "upgrade"): + error_msg = ( + "Requested trait version " + f"{requested_version} is lower " + f"than the found trait version {found_version}." + ) + error: UpgradableTraitError = UpgradableTraitError(error_msg) + error.trait = e.found_trait + raise error from e + return trait_class # type: ignore[return-value] + + @classmethod + def from_dict( + cls: Type[Representation], + name: str, + representation_id: Optional[str] = None, + trait_data: Optional[dict] = None) -> Representation: + """Create a representation from a dictionary. + + Args: + name (str): Representation name. + representation_id (str, optional): Representation ID. + trait_data (dict): Representation data. Dictionary with keys + as trait ids and values as trait data. Example:: + + { + "ayon.2d.PixelBased.v1": { + "display_window_width": 1920, + "display_window_height": 1080 + }, + "ayon.2d.Planar.v1": { + "channels": 3 + } + } + + Returns: + Representation: Representation instance. + + Raises: + ValueError: If the trait model with ID is not found. + TypeError: If the trait data is not a dictionary. + IncompatibleTraitVersionError: If the trait version is incompatible + + """ + if not trait_data: + trait_data = {} + traits = [] + for trait_id, value in trait_data.items(): + if not isinstance(value, dict): + msg = ( + f"Invalid trait data for trait ID {trait_id}. " + "Trait data must be a dictionary." + ) + raise TypeError(msg) + + try: + trait_class = cls.get_trait_class_by_trait_id(trait_id) + except UpgradableTraitError as e: + # we found a newer version of trait, we will upgrade the data + if hasattr(e.trait, "upgrade"): + traits.append(e.trait.upgrade(value)) + else: + msg = ( + f"Newer version of trait {e.trait.id} found " + f"for requested {trait_id} but without " + "upgrade method." + ) + raise IncompatibleTraitVersionError(msg) from e + else: + if not trait_class: + error_msg = f"Trait model with ID {trait_id} not found." + raise ValueError(error_msg) + + traits.append(trait_class(**value)) + + return cls( + name=name, representation_id=representation_id, traits=traits) + + def validate(self) -> None: + """Validate the representation. + + This method will validate all the traits in the representation. + + Raises: + TraitValidationError: If the trait is invalid within representation + + """ + errors = [] + for trait in self._data.values(): + # we do this in the loop to catch all the errors + try: + trait.validate_trait(self) + except TraitValidationError as e: # noqa: PERF203 + errors.append(str(e)) + if errors: + msg = "\n".join(errors) + scope = self.name + raise TraitValidationError(scope, msg) diff --git a/client/ayon_core/pipeline/traits/temporal.py b/client/ayon_core/pipeline/traits/temporal.py new file mode 100644 index 0000000000..9ad5424eee --- /dev/null +++ b/client/ayon_core/pipeline/traits/temporal.py @@ -0,0 +1,457 @@ +"""Temporal (time related) traits.""" +from __future__ import annotations + +import contextlib +import re +from dataclasses import dataclass +from enum import Enum, auto +from re import Pattern +from typing import TYPE_CHECKING, ClassVar, Optional + +import clique + +from .trait import MissingTraitError, TraitBase, TraitValidationError + +if TYPE_CHECKING: + + from .content import FileLocations + from .representation import Representation + + +class GapPolicy(Enum): + """Gap policy enumeration. + + This type defines how to handle gaps in a sequence. + + Attributes: + forbidden (int): Gaps are forbidden. + missing (int): Gaps are interpreted as missing frames. + hold (int): Gaps are interpreted as hold frames (last existing frames). + black (int): Gaps are interpreted as black frames. + """ + + forbidden = auto() + missing = auto() + hold = auto() + black = auto() + + +@dataclass +class FrameRanged(TraitBase): + """Frame ranged trait model. + + Model representing a frame-ranged trait. + + Sync with OpenAssetIO MediaCreation Traits. For compatibility with + OpenAssetIO, we'll need to handle different names of attributes: + + * frame_start -> start_frame + * frame_end -> end_frame + ... + + Note: frames_per_second is a string to allow various precision + formats. FPS is a floating point number, but it can be also + represented as a fraction (e.g. "30000/1001") or as a decimal + or even as an irrational number. We need to support all these + formats. To work with FPS, we'll need some helper function + to convert FPS to Decimal from string. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + frame_start (int): Frame start. + frame_end (int): Frame end. + frame_in (int): Frame in. + frame_out (int): Frame out. + frames_per_second (str): Frames per second. + step (int): Step. + """ + + name: ClassVar[str] = "FrameRanged" + description: ClassVar[str] = "Frame Ranged Trait" + id: ClassVar[str] = "ayon.time.FrameRanged.v1" + persistent: ClassVar[bool] = True + frame_start: int + frame_end: int + frame_in: Optional[int] = None + frame_out: Optional[int] = None + frames_per_second: str = None + step: Optional[int] = None + + +@dataclass +class Handles(TraitBase): + """Handles trait model. + + Handles define the range of frames that are included or excluded + from the sequence. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + inclusive (bool): Handles are inclusive. + frame_start_handle (int): Frame start handle. + frame_end_handle (int): Frame end handle. + """ + + name: ClassVar[str] = "Handles" + description: ClassVar[str] = "Handles Trait" + id: ClassVar[str] = "ayon.time.Handles.v1" + persistent: ClassVar[bool] = True + inclusive: Optional[bool] = False + frame_start_handle: Optional[int] = None + frame_end_handle: Optional[int] = None + + +@dataclass +class Sequence(TraitBase): + """Sequence trait model. + + This model represents a sequence trait. Based on the FrameRanged trait + and Handles, adding support for gaps policy, frame padding and frame + list specification. Regex is used to match frame numbers. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + gaps_policy (GapPolicy): Gaps policy - how to handle gaps in + sequence. + frame_padding (int): Frame padding. + frame_regex (str): Frame regex - regular expression to match + frame numbers. Must include 'index' named group and 'padding' + named group. + frame_spec (str): Frame list specification of frames. This takes + string like "1-10,20-30,40-50" etc. + """ + + name: ClassVar[str] = "Sequence" + description: ClassVar[str] = "Sequence Trait Model" + id: ClassVar[str] = "ayon.time.Sequence.v1" + persistent: ClassVar[bool] = True + frame_padding: int + gaps_policy: Optional[GapPolicy] = GapPolicy.forbidden + frame_regex: Optional[Pattern] = None + frame_spec: Optional[str] = None + + @classmethod + def validate_frame_regex( + cls, v: Optional[Pattern] + ) -> Optional[Pattern]: + """Validate frame regex. + + Frame regex must have index and padding named groups. + + Returns: + Optional[Pattern]: Compiled regex pattern. + + Raises: + ValueError: If frame regex does not include 'index' and 'padding' + + """ + if v is None: + return v + if v and any(s not in v.pattern for s in ["?P", "?P"]): + msg = "Frame regex must include 'index' and `padding named groups" + raise ValueError(msg) + return v + + def validate_trait(self, representation: Representation) -> None: + """Validate the trait.""" + super().validate_trait(representation) + + # if there is a FileLocations trait, run validation + # on it as well + + with contextlib.suppress(MissingTraitError): + self._validate_file_locations(representation) + + def _validate_file_locations(self, representation: Representation) -> None: + """Validate file locations trait. + + If along with the Sequence trait, there is a FileLocations trait, + then we need to validate if the file locations match the frame + list specification. + + Args: + representation (Representation): Representation instance. + + """ + from .content import FileLocations + file_locs: FileLocations = representation.get_trait( + FileLocations) + # Validate if the file locations on representation + # match the frame list (if any). + # We need to extend the expected frames with Handles. + frame_start = None + frame_end = None + handles_frame_start = None + handles_frame_end = None + with contextlib.suppress(MissingTraitError): + handles: Handles = representation.get_trait(Handles) + # if handles are inclusive, they should be already + # accounted for in the FrameRaged frame spec + if not handles.inclusive: + handles_frame_start = handles.frame_start_handle + handles_frame_end = handles.frame_end_handle + with contextlib.suppress(MissingTraitError): + frame_ranged: FrameRanged = representation.get_trait( + FrameRanged) + frame_start = frame_ranged.frame_start + frame_end = frame_ranged.frame_end + if self.frame_spec is not None: + self.validate_frame_list( + file_locs, + frame_start, + frame_end, + handles_frame_start, + handles_frame_end) + + self.validate_frame_padding(file_locs) + + def validate_frame_list( + self, + file_locations: FileLocations, + frame_start: Optional[int] = None, + frame_end: Optional[int] = None, + handles_frame_start: Optional[int] = None, + handles_frame_end: Optional[int] = None) -> None: + """Validate a frame list. + + This will take FileLocations trait and validate if the + file locations match the frame list specification. + + For example, if the frame list is "1-10,20-30,40-50", then + the frame numbers in the file locations should match + these frames. + + It will skip the validation if the frame list is not provided. + + Args: + file_locations (FileLocations): File locations trait. + frame_start (Optional[int]): Frame start. + frame_end (Optional[int]): Frame end. + handles_frame_start (Optional[int]): Frame start handle. + handles_frame_end (Optional[int]): Frame end handle. + + Raises: + TraitValidationError: If the frame list does not match + the expected frames. + + """ + if self.frame_spec is None: + return + + frames: list[int] = [] + if self.frame_regex: + frames = self.get_frame_list( + file_locations, self.frame_regex) + else: + frames = self.get_frame_list( + file_locations) + + expected_frames = self.list_spec_to_frames(self.frame_spec) + if frame_start is None or frame_end is None: + if min(expected_frames) != frame_start: + msg = ( + "Frame start does not match the expected frame start. " + f"Expected: {frame_start}, Found: {min(expected_frames)}" + ) + raise TraitValidationError(self.name, msg) + + if max(expected_frames) != frame_end: + msg = ( + "Frame end does not match the expected frame end. " + f"Expected: {frame_end}, Found: {max(expected_frames)}" + ) + raise TraitValidationError(self.name, msg) + + # we need to extend the expected frames with Handles + if handles_frame_start is not None: + expected_frames.extend( + range( + min(frames) - handles_frame_start, min(frames) + 1)) + + if handles_frame_end is not None: + expected_frames.extend( + range( + max(frames), max(frames) + handles_frame_end + 1)) + + if set(frames) != set(expected_frames): + msg = ( + "Frame list does not match the expected frames. " + f"Expected: {expected_frames}, Found: {frames}" + ) + raise TraitValidationError(self.name, msg) + + def validate_frame_padding( + self, file_locations: FileLocations) -> None: + """Validate frame padding. + + This will take FileLocations trait and validate if the + frame padding matches the expected frame padding. + + Args: + file_locations (FileLocations): File locations trait. + + Raises: + TraitValidationError: If frame padding does not match + the expected frame padding. + + """ + expected_padding = self.get_frame_padding(file_locations) + if self.frame_padding != expected_padding: + msg = ( + "Frame padding does not match the expected frame padding. " + f"Expected: {expected_padding}, Found: {self.frame_padding}" + ) + raise TraitValidationError(self.name, msg) + + @staticmethod + def list_spec_to_frames(list_spec: str) -> list[int]: + """Convert list specification to frames. + + Returns: + list[int]: List of frame numbers. + + Raises: + ValueError: If invalid frame number in the list. + + """ + frames = [] + segments = list_spec.split(",") + for segment in segments: + ranges = segment.split("-") + if len(ranges) == 1: + if not ranges[0].isdigit(): + msg = ( + "Invalid frame number " + f"in the list: {ranges[0]}" + ) + raise ValueError(msg) + frames.append(int(ranges[0])) + continue + start, end = segment.split("-") + frames.extend(range(int(start), int(end) + 1)) + return frames + + @staticmethod + def _get_collection( + file_locations: FileLocations, + regex: Optional[Pattern] = None) -> clique.Collection: + r"""Get the collection from file locations. + + Args: + file_locations (FileLocations): File locations trait. + regex (Optional[Pattern]): Regular expression to match + frame numbers. This is passed to ``clique.assemble()``. + Default clique pattern is:: + + \.(?P(?P0*)\d+)\.\D+\d?$ + + Returns: + clique.Collection: Collection instance. + + Raises: + ValueError: If zero or multiple of collections are found. + + """ + patterns = [regex] if regex else None + files: list[str] = [ + file.file_path.as_posix() + for file in file_locations.file_paths + ] + src_collections, _ = clique.assemble(files, patterns=patterns) + if len(src_collections) != 1: + msg = ( + f"Zero or multiple collections found: {len(src_collections)} " + "expected 1" + ) + raise ValueError(msg) + return src_collections[0] + + @staticmethod + def get_frame_padding(file_locations: FileLocations) -> int: + """Get frame padding. + + Returns: + int: Frame padding. + + """ + src_collection = Sequence._get_collection(file_locations) + padding = src_collection.padding + # sometimes Clique doesn't get the padding right, so + # we need to calculate it manually + if padding == 0: + padding = len(str(max(src_collection.indexes))) + + return padding + + @staticmethod + def get_frame_list( + file_locations: FileLocations, + regex: Optional[Pattern] = None, + ) -> list[int]: + r"""Get the frame list. + + Args: + file_locations (FileLocations): File locations trait. + regex (Optional[Pattern]): Regular expression to match + frame numbers. This is passed to ``clique.assemble()``. + Default clique pattern is:: + + \.(?P(?P0*)\d+)\.\D+\d?$ + + Returns: + list[int]: List of frame numbers. + + """ + src_collection = Sequence._get_collection(file_locations, regex) + return list(src_collection.indexes) + + def get_frame_pattern(self) -> Pattern: + """Get frame regex as a pattern. + + If the regex is a string, it will compile it to the pattern. + + Returns: + Pattern: Compiled regex pattern. + + """ + if self.frame_regex: + if isinstance(self.frame_regex, str): + return re.compile(self.frame_regex) + return self.frame_regex + return re.compile( + r"\.(?P(?P0*)\d+)\.\D+\d?$") + + +# Do we need one for drop and non-drop frame? +@dataclass +class SMPTETimecode(TraitBase): + """SMPTE Timecode trait model. + + Attributes: + timecode (str): SMPTE Timecode HH:MM:SS:FF + """ + + name: ClassVar[str] = "Timecode" + description: ClassVar[str] = "SMPTE Timecode Trait" + id: ClassVar[str] = "ayon.time.SMPTETimecode.v1" + persistent: ClassVar[bool] = True + timecode: str + + +@dataclass +class Static(TraitBase): + """Static time trait. + + Used to define static time (single frame). + """ + + name: ClassVar[str] = "Static" + description: ClassVar[str] = "Static Time Trait" + id: ClassVar[str] = "ayon.time.Static.v1" + persistent: ClassVar[bool] = True diff --git a/client/ayon_core/pipeline/traits/three_dimensional.py b/client/ayon_core/pipeline/traits/three_dimensional.py new file mode 100644 index 0000000000..d68fb99e61 --- /dev/null +++ b/client/ayon_core/pipeline/traits/three_dimensional.py @@ -0,0 +1,93 @@ +"""3D traits.""" +from dataclasses import dataclass +from typing import ClassVar + +from .trait import TraitBase + + +@dataclass +class Spatial(TraitBase): + """Spatial trait model. + + Trait describing spatial information. Up axis valid strings are + "Y", "Z", "X". Handedness valid strings are "left", "right". Meters per + unit is a float value. + + Example:: + + Spatial(up_axis="Y", handedness="right", meters_per_unit=1.0) + + Todo: + * Add value validation for up_axis and handedness. + + Attributes: + up_axis (str): Up axis. + handedness (str): Handedness. + meters_per_unit (float): Meters per unit. + """ + + id: ClassVar[str] = "ayon.3d.Spatial.v1" + name: ClassVar[str] = "Spatial" + description: ClassVar[str] = "Spatial trait model." + persistent: ClassVar[bool] = True + up_axis: str + handedness: str + meters_per_unit: float + + +@dataclass +class Geometry(TraitBase): + """Geometry type trait model. + + Type trait for geometry data. + + Sync with OpenAssetIO MediaCreation Traits. + """ + + id: ClassVar[str] = "ayon.3d.Geometry.v1" + name: ClassVar[str] = "Geometry" + description: ClassVar[str] = "Geometry trait model." + persistent: ClassVar[bool] = True + + +@dataclass +class Shader(TraitBase): + """Shader trait model. + + Type trait for shader data. + + Sync with OpenAssetIO MediaCreation Traits. + """ + + id: ClassVar[str] = "ayon.3d.Shader.v1" + name: ClassVar[str] = "Shader" + description: ClassVar[str] = "Shader trait model." + persistent: ClassVar[bool] = True + + +@dataclass +class Lighting(TraitBase): + """Lighting trait model. + + Type trait for lighting data. + + Sync with OpenAssetIO MediaCreation Traits. + """ + + id: ClassVar[str] = "ayon.3d.Lighting.v1" + name: ClassVar[str] = "Lighting" + description: ClassVar[str] = "Lighting trait model." + persistent: ClassVar[bool] = True + + +@dataclass +class IESProfile(TraitBase): + """IES profile (IES-LM-64) type trait model. + + Sync with OpenAssetIO MediaCreation Traits. + """ + + id: ClassVar[str] = "ayon.3d.IESProfile.v1" + name: ClassVar[str] = "IESProfile" + description: ClassVar[str] = "IES profile trait model." + persistent: ClassVar[bool] = True diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py new file mode 100644 index 0000000000..85f8e07630 --- /dev/null +++ b/client/ayon_core/pipeline/traits/trait.py @@ -0,0 +1,147 @@ +"""Defines the base trait model and representation.""" +from __future__ import annotations + +import re +from abc import ABC, abstractmethod +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING, Generic, Optional, TypeVar + +if TYPE_CHECKING: + from .representation import Representation + + +T = TypeVar("T", bound="TraitBase") + + +@dataclass +class TraitBase(ABC): + """Base trait model. + + This model must be used as a base for all trait models. + ``id``, ``name``, and ``description`` are abstract attributes that must be + implemented in the derived classes. + """ + + @property + @abstractmethod + def id(self) -> str: + """Abstract attribute for ID.""" + ... + + @property + @abstractmethod + def name(self) -> str: + """Abstract attribute for name.""" + ... + + @property + @abstractmethod + def description(self) -> str: + """Abstract attribute for description.""" + ... + + def validate_trait(self, representation: Representation) -> None: # noqa: PLR6301 + """Validate the trait. + + This method should be implemented in the derived classes to validate + the trait data. It can be used by traits to validate against other + traits in the representation. + + Args: + representation (Representation): Representation instance. + + """ + return + + @classmethod + def get_version(cls) -> Optional[int]: + # sourcery skip: use-named-expression + """Get a trait version from ID. + + This assumes Trait ID ends with `.v{version}`. If not, it will + return None. + + Returns: + Optional[int]: Trait version + + """ + version_regex = r"v(\d+)$" + match = re.search(version_regex, str(cls.id)) + return int(match[1]) if match else None + + @classmethod + def get_versionless_id(cls) -> str: + """Get a trait ID without a version. + + Returns: + str: Trait ID without a version. + + """ + return re.sub(r"\.v\d+$", "", str(cls.id)) + + def as_dict(self) -> dict: + """Return a trait as a dictionary. + + Returns: + dict: Trait as dictionary. + + """ + return asdict(self) + + +class IncompatibleTraitVersionError(Exception): + """Incompatible trait version exception. + + This exception is raised when the trait version is incompatible with the + current version of the trait. + """ + + +class UpgradableTraitError(Exception, Generic[T]): + """Upgradable trait version exception. + + This exception is raised when the trait can upgrade existing data + meant for older versions of the trait. It must implement an `upgrade` + method that will take old trait data as an argument to handle the upgrade. + """ + + trait: T + old_data: dict + + +class LooseMatchingTraitError(Exception, Generic[T]): + """Loose matching trait exception. + + This exception is raised when the trait is found with a loose matching + criteria. + """ + + found_trait: T + expected_id: str + + +class TraitValidationError(Exception): + """Trait validation error exception. + + This exception is raised when the trait validation fails. + """ + + def __init__(self, scope: str, message: str): + """Initialize the exception. + + We could determine the scope from the stack in the future, + provided the scope is always Trait name. + + Args: + scope (str): Scope of the error. + message (str): Error message. + + """ + super().__init__(f"{scope}: {message}") + + +class MissingTraitError(TypeError): + """Missing trait error exception. + + This exception is raised when the trait is missing. + """ diff --git a/client/ayon_core/pipeline/traits/two_dimensional.py b/client/ayon_core/pipeline/traits/two_dimensional.py new file mode 100644 index 0000000000..d94294bf74 --- /dev/null +++ b/client/ayon_core/pipeline/traits/two_dimensional.py @@ -0,0 +1,208 @@ +"""Two-dimensional image traits.""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar, Optional + +from .trait import TraitBase + +if TYPE_CHECKING: + from .content import FileLocation, FileLocations + + +@dataclass +class Image(TraitBase): + """Image trait model. + + Type trait model for image. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + """ + + name: ClassVar[str] = "Image" + description: ClassVar[str] = "Image Trait" + id: ClassVar[str] = "ayon.2d.Image.v1" + persistent: ClassVar[bool] = True + + +@dataclass +class PixelBased(TraitBase): + """PixelBased trait model. + + The pixel-related trait for image data. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + display_window_width (int): Width of the image display window. + display_window_height (int): Height of the image display window. + pixel_aspect_ratio (float): Pixel aspect ratio. + """ + + name: ClassVar[str] = "PixelBased" + description: ClassVar[str] = "PixelBased Trait Model" + id: ClassVar[str] = "ayon.2d.PixelBased.v1" + persistent: ClassVar[bool] = True + display_window_width: int + display_window_height: int + pixel_aspect_ratio: float + + +@dataclass +class Planar(TraitBase): + """Planar trait model. + + This model represents an Image with planar configuration. + + Todo: + * (antirotor): Is this really a planar configuration? As with + bit planes and everything? If it serves as differentiator for + Deep images, should it be named differently? Like Raster? + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + planar_configuration (str): Planar configuration. + """ + + name: ClassVar[str] = "Planar" + description: ClassVar[str] = "Planar Trait Model" + id: ClassVar[str] = "ayon.2d.Planar.v1" + persistent: ClassVar[bool] = True + planar_configuration: str + + +@dataclass +class Deep(TraitBase): + """Deep trait model. + + Type trait model for deep EXR images. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + """ + + name: ClassVar[str] = "Deep" + description: ClassVar[str] = "Deep Trait Model" + id: ClassVar[str] = "ayon.2d.Deep.v1" + persistent: ClassVar[bool] = True + + +@dataclass +class Overscan(TraitBase): + """Overscan trait model. + + This model represents an overscan (or underscan) trait. Defines the + extra pixels around the image. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + left (int): Left overscan/underscan. + right (int): Right overscan/underscan. + top (int): Top overscan/underscan. + bottom (int): Bottom overscan/underscan. + """ + + name: ClassVar[str] = "Overscan" + description: ClassVar[str] = "Overscan Trait" + id: ClassVar[str] = "ayon.2d.Overscan.v1" + persistent: ClassVar[bool] = True + left: int + right: int + top: int + bottom: int + + +@dataclass +class UDIM(TraitBase): + """UDIM trait model. + + This model represents a UDIM trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + udim (int): UDIM value. + udim_regex (str): UDIM regex. + """ + + name: ClassVar[str] = "UDIM" + description: ClassVar[str] = "UDIM Trait" + id: ClassVar[str] = "ayon.2d.UDIM.v1" + persistent: ClassVar[bool] = True + udim: list[int] + udim_regex: Optional[str] = r"(?:\.|_)(?P\d+)\.\D+\d?$" + + # Field validator for udim_regex - this works in the pydantic model v2 + # but not with the pure data classes. + @classmethod + def validate_frame_regex(cls, v: Optional[str]) -> Optional[str]: + """Validate udim regex. + + Returns: + Optional[str]: UDIM regex. + + Raises: + ValueError: UDIM regex must include 'udim' named group. + + """ + if v is not None and "?P" not in v: + msg = "UDIM regex must include 'udim' named group" + raise ValueError(msg) + return v + + def get_file_location_for_udim( + self, + file_locations: FileLocations, + udim: int, + ) -> Optional[FileLocation]: + """Get file location for UDIM. + + Args: + file_locations (FileLocations): File locations. + udim (int): UDIM value. + + Returns: + Optional[FileLocation]: File location. + + """ + if not self.udim_regex: + return None + pattern = re.compile(self.udim_regex) + for location in file_locations.file_paths: + result = re.search(pattern, location.file_path.name) + if result: + udim_index = int(result.group("udim")) + if udim_index == udim: + return location + return None + + def get_udim_from_file_location( + self, file_location: FileLocation) -> Optional[int]: + """Get UDIM from the file location. + + Args: + file_location (FileLocation): File location. + + Returns: + Optional[int]: UDIM value. + + """ + if not self.udim_regex: + return None + pattern = re.compile(self.udim_regex) + result = re.search(pattern, file_location.file_path.name) + if result: + return int(result.group("udim")) + return None diff --git a/client/ayon_core/pipeline/traits/utils.py b/client/ayon_core/pipeline/traits/utils.py new file mode 100644 index 0000000000..4cb9a643fa --- /dev/null +++ b/client/ayon_core/pipeline/traits/utils.py @@ -0,0 +1,90 @@ +"""Utility functions for traits.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from clique import assemble + +from ayon_core.addon import AddonsManager, ITraits +from ayon_core.pipeline.traits.temporal import FrameRanged + +if TYPE_CHECKING: + from pathlib import Path + from ayon_core.pipeline.traits.trait import TraitBase + + +def get_sequence_from_files(paths: list[Path]) -> FrameRanged: + """Get the original frame range from files. + + Note that this cannot guess frame rate, so it's set to 25. + This will also fail on paths that cannot be assembled into + one collection without any reminders. + + Args: + paths (list[Path]): List of file paths. + + Returns: + FrameRanged: FrameRanged trait. + + Raises: + ValueError: If paths cannot be assembled into one collection + + """ + cols, rems = assemble([path.as_posix() for path in paths]) + if rems: + msg = "Cannot assemble paths into one collection" + raise ValueError(msg) + if len(cols) != 1: + msg = "More than one collection found" + raise ValueError(msg) + col = cols[0] + + sorted_frames = sorted(col.indexes) + # First frame used for end value + first_frame = sorted_frames[0] + # Get last frame for padding + last_frame = sorted_frames[-1] + # Use padding from a collection of the last frame lengths as string + # padding = max(col.padding, len(str(last_frame))) + + return FrameRanged( + frame_start=first_frame, frame_end=last_frame, + frames_per_second="25.0" + ) + + +def get_available_traits( + addons_manager: Optional[AddonsManager] = None +) -> Optional[list[TraitBase]]: + """Get available traits from active addons. + + Args: + addons_manager (Optional[AddonsManager]): Addons manager instance. + If not provided, a new one will be created. Within pyblish + plugins, you can use an already collected instance of + AddonsManager from context `context.data["ayonAddonsManager"]`. + + Returns: + list[TraitBase]: List of available traits. + + """ + if addons_manager is None: + # Create a new instance of AddonsManager + addons_manager = AddonsManager() + + # Get active addons + enabled_addons = addons_manager.get_enabled_addons() + traits = [] + for addon in enabled_addons: + if not issubclass(type(addon), ITraits): + # Skip addons not providing traits + continue + # Get traits from addon + addon_traits = addon.get_addon_traits() + if addon_traits: + # Add traits to a list + for trait in addon_traits: + if trait not in traits: + traits.append(trait) + + return traits diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 27da278c5e..8cea7de86b 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -8,7 +8,7 @@ targeted by task types and names. Placeholders are created using placeholder plugins which should care about logic and data of placeholder items. 'PlaceholderItem' is used to keep track -about it's progress. +about its progress. """ import os @@ -17,6 +17,7 @@ import collections import copy from abc import ABC, abstractmethod +import ayon_api from ayon_api import ( get_folders, get_folder_by_path, @@ -60,6 +61,32 @@ from ayon_core.pipeline.create import ( _NOT_SET = object() +class EntityResolutionError(Exception): + """Exception raised when entity URI resolution fails.""" + + +def resolve_entity_uri(entity_uri: str) -> str: + """Resolve AYON entity URI to a filesystem path for local system.""" + response = ayon_api.post( + "resolve", + resolveRoots=True, + uris=[entity_uri] + ) + if response.status_code != 200: + raise RuntimeError( + f"Unable to resolve AYON entity URI filepath for " + f"'{entity_uri}': {response.text}" + ) + + entities = response.data[0]["entities"] + if len(entities) != 1: + raise EntityResolutionError( + f"Unable to resolve AYON entity URI '{entity_uri}' to a " + f"single filepath. Received data: {response.data}" + ) + return entities[0]["filePath"] + + class TemplateNotFound(Exception): """Exception raised when template does not exist.""" pass @@ -823,7 +850,6 @@ class AbstractTemplateBuilder(ABC): """ host_name = self.host_name - project_name = self.project_name task_name = self.current_task_name task_type = self.current_task_type @@ -835,7 +861,6 @@ class AbstractTemplateBuilder(ABC): "task_names": task_name } ) - if not profile: raise TemplateProfileNotFound(( "No matching profile found for task '{}' of type '{}' " @@ -843,6 +868,22 @@ class AbstractTemplateBuilder(ABC): ).format(task_name, task_type, host_name)) path = profile["path"] + if not path: + raise TemplateLoadFailed(( + "Template path is not set.\n" + "Path need to be set in {}\\Template Workfile Build " + "Settings\\Profiles" + ).format(host_name.title())) + + resolved_path = self.resolve_template_path(path) + if not resolved_path or not os.path.exists(resolved_path): + raise TemplateNotFound( + "Template file found in AYON settings for task '{}' with host " + "'{}' does not exists. (Not found : {})".format( + task_name, host_name, resolved_path) + ) + + self.log.info(f"Found template at: '{resolved_path}'") # switch to remove placeholders after they are used keep_placeholder = profile.get("keep_placeholder") @@ -852,44 +893,86 @@ class AbstractTemplateBuilder(ABC): if keep_placeholder is None: keep_placeholder = True - if not path: - raise TemplateLoadFailed(( - "Template path is not set.\n" - "Path need to be set in {}\\Template Workfile Build " - "Settings\\Profiles" - ).format(host_name.title())) - - # Try to fill path with environments and anatomy roots - anatomy = Anatomy(project_name) - fill_data = { - key: value - for key, value in os.environ.items() + return { + "path": resolved_path, + "keep_placeholder": keep_placeholder, + "create_first_version": create_first_version } - fill_data["root"] = anatomy.roots - fill_data["project"] = { - "name": project_name, - "code": anatomy.project_code, - } + def resolve_template_path(self, path, fill_data=None) -> str: + """Resolve the template path. - path = self.resolve_template_path(path, fill_data) + By default, this: + - Resolves AYON entity URI to a filesystem path + - Returns path directly if it exists on disk. + - Resolves template keys through anatomy and environment variables. + This can be overridden in host integrations to perform additional + resolving over the template. Like, `hou.text.expandString` in Houdini. + It's recommended to still call the super().resolve_template_path() + to ensure the basic resolving is done across all integrations. + + Arguments: + path (str): The input path. + fill_data (dict[str, str]): Deprecated. This is computed inside + the method using the current environment and project settings. + Used to be the data to use for template formatting. + + Returns: + str: The resolved path. + + """ + + # If the path is an AYON entity URI, then resolve the filepath + # through the backend + if path.startswith("ayon+entity://") or path.startswith("ayon://"): + # This is a special case where the path is an AYON entity URI + # We need to resolve it to a filesystem path + resolved_path = resolve_entity_uri(path) + return resolved_path + + # If the path is set and it's found on disk, return it directly if path and os.path.exists(path): - self.log.info("Found template at: '{}'".format(path)) - return { - "path": path, - "keep_placeholder": keep_placeholder, - "create_first_version": create_first_version + return path + + # We may have path for another platform, like C:/path/to/file + # or a path with template keys, like {project[code]} or both. + # Try to fill path with environments and anatomy roots + project_name = self.project_name + anatomy = Anatomy(project_name) + + # Simple check whether the path contains any template keys + if "{" in path: + fill_data = { + key: value + for key, value in os.environ.items() + } + fill_data["root"] = anatomy.roots + fill_data["project"] = { + "name": project_name, + "code": anatomy.project_code, } - solved_path = None + # Format the template using local fill data + result = StringTemplate.format_template(path, fill_data) + if not result.solved: + return path + + path = result.normalized() + if os.path.exists(path): + return path + + # If the path were set in settings using a Windows path and we + # are now on a Linux system, we try to convert the solved path to + # the current platform. while True: try: solved_path = anatomy.path_remapper(path) except KeyError as missing_key: raise KeyError( - "Could not solve key '{}' in template path '{}'".format( - missing_key, path)) + f"Could not solve key '{missing_key}'" + f" in template path '{path}'" + ) if solved_path is None: solved_path = path @@ -898,40 +981,7 @@ class AbstractTemplateBuilder(ABC): path = solved_path solved_path = os.path.normpath(solved_path) - if not os.path.exists(solved_path): - raise TemplateNotFound( - "Template found in AYON settings for task '{}' with host " - "'{}' does not exists. (Not found : {})".format( - task_name, host_name, solved_path)) - - self.log.info("Found template at: '{}'".format(solved_path)) - - return { - "path": solved_path, - "keep_placeholder": keep_placeholder, - "create_first_version": create_first_version - } - - def resolve_template_path(self, path, fill_data) -> str: - """Resolve the template path. - - By default, this does nothing except returning the path directly. - - This can be overridden in host integrations to perform additional - resolving over the template. Like, `hou.text.expandString` in Houdini. - - Arguments: - path (str): The input path. - fill_data (dict[str, str]): Data to use for template formatting. - - Returns: - str: The resolved path. - - """ - result = StringTemplate.format_template(path, fill_data) - if result.solved: - path = result.normalized() - return path + return solved_path def emit_event(self, topic, data=None, source=None) -> Event: return self._event_system.emit(topic, data, source) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 3ac04ab1c7..7a96db76ad 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -59,7 +59,7 @@ class ExtractOIIOTranscode(publish.Extractor): optional = True # Supported extensions - supported_exts = ["exr", "jpg", "jpeg", "png", "dpx"] + supported_exts = {"exr", "jpg", "jpeg", "png", "dpx"} # Configurable by Settings profiles = None diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 3fc2185d1a..89bc56c670 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -135,11 +135,11 @@ class ExtractReview(pyblish.api.InstancePlugin): ] # Supported extensions - image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"] - video_exts = ["mov", "mp4"] - supported_exts = image_exts + video_exts + image_exts = {"exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"} + video_exts = {"mov", "mp4"} + supported_exts = image_exts | video_exts - alpha_exts = ["exr", "png", "dpx"] + alpha_exts = {"exr", "png", "dpx"} # Preset attributes profiles = [] diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py new file mode 100644 index 0000000000..38c9ecdeb4 --- /dev/null +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -0,0 +1,1208 @@ +"""Integrate representations with traits.""" +from __future__ import annotations + +import contextlib +import copy +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pyblish.api +from ayon_api import ( + get_attributes_for_type, + get_product_by_name, + # get_representations, + get_version_by_name, +) +from ayon_api.operations import ( + OperationsSession, + new_product_entity, + new_representation_entity, + new_version_entity, +) +from ayon_api.utils import create_entity_id +from ayon_core.lib import source_hash +from ayon_core.lib.file_transaction import ( + FileTransaction, +) +from ayon_core.pipeline.publish import ( + PublishError, + get_publish_template_name, + has_trait_representations, + get_trait_representations, + set_trait_representations, +) +from ayon_core.pipeline.traits import ( + UDIM, + Bundle, + ColorManaged, + FileLocation, + FileLocations, + FrameRanged, + MissingTraitError, + Persistent, + PixelBased, + Representation, + Sequence, + TemplatePath, + TraitValidationError, + Transient, + Variant, +) + +if TYPE_CHECKING: + import logging + + from ayon_core.pipeline import Anatomy + from ayon_core.pipeline.anatomy.templates import ( + AnatomyStringTemplate, + ) + from ayon_core.pipeline.anatomy.templates import ( + TemplateItem as AnatomyTemplateItem, + ) + + +@dataclass(frozen=True) +class TransferItem: + """Represents a single transfer item. + + Source file path, destination file path, template that was used to + construct the destination path, template data that was used in the + template, size of the file, checksum of the file. + + Attributes: + source (Path): Source file path. + destination (Path): Destination file path. + size (int): Size of the file. + checksum (str): Checksum of the file. + template (str): Template path. + template_data (dict[str, Any]): Template data. + representation (Representation): Reference to representation + + """ + source: Path + destination: Path + size: int + checksum: str + template: str + template_data: dict[str, Any] + representation: Representation + related_trait: FileLocation + + @staticmethod + def get_size(file_path: Path) -> int: + """Get the size of the file. + + Args: + file_path (Path): File path. + + Returns: + int: Size of the file. + + """ + return file_path.stat().st_size + + @staticmethod + def get_checksum(file_path: Path) -> str: + """Get checksum of the file. + + Args: + file_path (Path): File path. + + Returns: + str: Checksum of the file. + + """ + return hashlib.sha256( + file_path.read_bytes() + ).hexdigest() + + +@dataclass +class TemplateItem: + """Represents single template item. + + Template path, template data that was used in the template. + + Attributes: + anatomy (Anatomy): Anatomy object. + template (str): Template path. + template_data (dict[str, Any]): Template data. + template_object (AnatomyTemplateItem): Template object + """ + anatomy: Anatomy + template: str + template_data: dict[str, Any] + template_object: AnatomyTemplateItem + + +@dataclass +class RepresentationEntity: + """Representation entity data.""" + id: str + versionId: str # noqa: N815 + name: str + files: dict[str, Any] + attrib: dict[str, Any] + data: str + tags: list[str] + status: str + + +def get_instance_families(instance: pyblish.api.Instance) -> list[str]: + """Get all families of the instance. + + Todo: + Move to the library. + + Args: + instance (pyblish.api.Instance): Instance to get families from. + + Returns: + list[str]: List of families. + + """ + family = instance.data.get("family") + families = [] + if family: + families.append(family) + + for _family in (instance.data.get("families") or []): + if _family not in families: + families.append(_family) + + return families + + +def get_changed_attributes( + old_entity: dict, new_entity: dict) -> (dict[str, Any]): + """Prepare changes for entity update. + + Todo: + Move to the library. + + Args: + old_entity (dict[str, Any]): Existing entity. + new_entity (dict[str, Any]): New entity. + + Returns: + dict[str, Any]: Changes that have new entity. + + """ + changes = {} + for key in set(new_entity.keys()): + if key == "attrib": + continue + + if key in new_entity and new_entity[key] != old_entity.get(key): + changes[key] = new_entity[key] + continue + + attrib_changes = {} + if "attrib" in new_entity: + attrib_changes = { + key: value + for key, value in new_entity["attrib"].items() + if value != old_entity["attrib"].get(key) + } + if attrib_changes: + changes["attrib"] = attrib_changes + return changes + + +def prepare_for_json(data: dict[str, Any]) -> dict[str, Any]: + """Prepare data for JSON serialization. + + If there are values that json cannot serialize, this function will + convert them to strings. + + Args: + data (dict[str, Any]): Data to prepare. + + Returns: + dict[str, Any]: Prepared data. + + Raises: + TypeError: If the data cannot be converted to JSON. + + """ + prepared = {} + for key, value in data.items(): + if isinstance(value, dict): + value = prepare_for_json(value) + try: + json.dumps(value) + except TypeError: + value = value.as_posix() if issubclass( + value.__class__, Path) else str(value) + prepared[key] = value + return prepared + + +class IntegrateTraits(pyblish.api.InstancePlugin): + """Integrate representations with traits.""" + + label = "Integrate Traits of an Asset" + order = pyblish.api.IntegratorOrder + log: logging.Logger + + def process(self, instance: pyblish.api.Instance) -> None: + """Integrate representations with traits. + + Todo: + Refactor this method to be more readable and maintainable. + + Args: + instance (pyblish.api.Instance): Instance to process. + + """ + # 1) skip farm and integrate == False + + if instance.data.get("integrate", True) is False: + self.log.debug("Instance is marked to skip integrating. Skipping") + return + + if instance.data.get("farm"): + self.log.debug( + "Instance is marked to be processed on farm. Skipping") + return + + # TODO (antirotor): Find better name for the key + if not has_trait_representations(instance): + self.log.debug( + "Instance has no representations with traits. Skipping") + return + + # 2) filter representations based on LifeCycle traits + set_trait_representations( + instance, + self.filter_lifecycle(get_trait_representations(instance)) + ) + + representations: list[Representation] = get_trait_representations( + instance + ) + if not representations: + self.log.debug( + "Instance has no persistent representations. Skipping") + return + + op_session = OperationsSession() + + product_entity = self.prepare_product(instance, op_session) + + version_entity = self.prepare_version( + instance, op_session, product_entity + ) + instance.data["versionEntity"] = version_entity + + transfers = self.get_transfers_from_representations( + instance, representations) + + # 8) Transfer files + file_transactions = FileTransaction( + log=self.log, + # Enforce unique transfers + allow_queue_replacements=False) + for transfer in transfers: + self.log.debug( + "Transferring file: %s -> %s", + transfer.source, + transfer.destination + ) + file_transactions.add( + transfer.source.as_posix(), + transfer.destination.as_posix(), + mode=FileTransaction.MODE_COPY, + ) + file_transactions.process() + self.log.debug( + "Transferred files %s", [file_transactions.transferred]) + + # replace original paths with the destination in traits. + for transfer in transfers: + transfer.related_trait.file_path = transfer.destination + + # 9) Create representation entities + for representation in representations: + representation_entity = new_representation_entity( + representation.name, + version_entity["id"], + files=self._get_legacy_files_for_representation( + transfers, + representation, + anatomy=instance.context.data["anatomy"]), + attribs={}, + data="", + tags=[], + status="", + ) + # add traits to representation entity + representation_entity["traits"] = representation.traits_as_dict() + op_session.create_entity( + project_name=instance.context.data["projectName"], + entity_type="representation", + data=prepare_for_json(representation_entity), + ) + + # 10) Commit the session to AYON + self.log.debug("{}".format(op_session.to_data())) + op_session.commit() + + def get_transfers_from_representations( + self, + instance: pyblish.api.Instance, + representations: list[Representation]) -> list[TransferItem]: + """Get transfers from representations. + + This method will go through all representations and prepare transfers + based on the traits they contain. First it will validate the + representation, and then it will prepare template data for the + representation. It specifically handles FileLocations, FileLocation, + Bundle, Sequence and UDIM traits. + + Args: + instance (pyblish.api.Instance): Instance to process. + representations (list[Representation]): List of representations. + + Returns: + list[TransferItem]: List of transfers. + + Raises: + PublishError: If representation is invalid. + + """ + template: str = self.get_publish_template(instance) + instance_template_data: dict[str, str] = {} + transfers: list[TransferItem] = [] + # prepare template and data to format it + for representation in representations: + + # validate representation first, this will go through all traits + # and check if they are valid + try: + representation.validate() + except TraitValidationError as e: + msg = f"Representation '{representation.name}' is invalid: {e}" + raise PublishError(msg) from e + + template_data = self.get_template_data_from_representation( + representation, instance) + # add instance based template data + + template_data.update(instance_template_data) + + # treat Variant as `output` in template data + with contextlib.suppress(MissingTraitError): + template_data["output"] = ( + representation.get_trait(Variant).variant + ) + + template_item = TemplateItem( + anatomy=instance.context.data["anatomy"], + template=template, + template_data=copy.deepcopy(template_data), + template_object=self.get_publish_template_object(instance), + ) + + if representation.contains_trait(FileLocations): + # If representation has FileLocations trait (list of files) + # it can be either Sequence or UDIM tile set. + # We do not allow unrelated files in the single representation. + # Note: we do not support yet frame sequence of multiple UDIM + # tiles in the same representation + self.get_transfers_from_file_locations( + representation, template_item, transfers + ) + elif representation.contains_trait(FileLocation): + # This is just a single file representation + self.get_transfers_from_file_location( + representation, template_item, transfers + ) + + elif representation.contains_trait(Bundle): + # Bundle groups multiple "sub-representations" together. + # It has a list of lists with traits, some might be + # FileLocations,but some might be "file-less" representations + # or even other bundles. + self.get_transfers_from_bundle( + representation, template_item, transfers + ) + return transfers + + def _get_relative_to_root_original_dirname( + self, instance: pyblish.api.Instance) -> str: + """Get path stripped of root of the original directory name. + + If `originalDirname` or `stagingDir` is set in instance data, + this will return it as rootless path. The path must reside + within the project directory. + + Returns: + str: Relative path to the root of the project directory. + + Raises: + PublishError: If the path is not within the project directory. + + """ + original_directory = ( + instance.data.get("originalDirname") or + instance.data.get("stagingDir")) + anatomy = instance.context.data["anatomy"] + + rootless = self.get_rootless_path(anatomy, original_directory) + # this check works because _rootless will be the same as + # original_directory if the original_directory cannot be transformed + # to the rootless path. + if rootless == original_directory: + msg = ( + f"Destination path '{original_directory}' must " + "be in project directory.") + raise PublishError(msg) + # the root is at the beginning - {root[work]}/rest/of/the/path + relative_path_start = rootless.rfind("}") + 2 + return rootless[relative_path_start:] + + # 8) Transfer files + # 9) Commit the session to AYON + # 10) Finalize represetations - add integrated path Trait etc. + + @staticmethod + def filter_lifecycle( + representations: list[Representation]) -> list[Representation]: + """Filter representations based on LifeCycle traits. + + Args: + representations (list): List of representations. + + Returns: + list: Filtered representations. + + """ + return [ + representation + for representation in representations + if representation.contains_trait(Persistent) + ] + + def get_template_name(self, instance: pyblish.api.Instance) -> str: + """Return anatomy template name to use for integration. + + Args: + instance (pyblish.api.Instance): Instance to process. + + Returns: + str: Anatomy template name + + """ + # Anatomy data is pre-filled by Collectors + context = instance.context + project_name = context.data["projectName"] + + # Task can be optional in anatomy data + host_name = context.data["hostName"] + anatomy_data = instance.data["anatomyData"] + product_type = instance.data["productType"] + task_info = anatomy_data.get("task") or {} + + return get_publish_template_name( + project_name, + host_name, + product_type, + task_name=task_info.get("name"), + task_type=task_info.get("type"), + project_settings=context.data["project_settings"], + logger=self.log + ) + + def get_publish_template(self, instance: pyblish.api.Instance) -> str: + """Return anatomy template name to use for integration. + + Args: + instance (pyblish.api.Instance): Instance to process. + + Returns: + str: Anatomy template name + + """ + # Anatomy data is pre-filled by Collectors + template_name = self.get_template_name(instance) + anatomy = instance.context.data["anatomy"] + publish_template = anatomy.get_template_item("publish", template_name) + path_template_obj = publish_template["path"] + return path_template_obj.template.replace("\\", "/") + + def get_publish_template_object( + self, instance: pyblish.api.Instance) -> AnatomyTemplateItem: + """Return anatomy template object to use for integration. + + Note: What is the actual type of the object? + + Args: + instance (pyblish.api.Instance): Instance to process. + + Returns: + AnatomyTemplateItem: Anatomy template object + + """ + # Anatomy data is pre-filled by Collectors + template_name = self.get_template_name(instance) + anatomy = instance.context.data["anatomy"] + return anatomy.get_template_item("publish", template_name) + + def prepare_product( + self, + instance: pyblish.api.Instance, + op_session: OperationsSession) -> dict: + """Prepare product for integration. + + Args: + instance (pyblish.api.Instance): Instance to process. + op_session (OperationsSession): Operations session. + + Returns: + dict: Product entity. + + """ + project_name = instance.context.data["projectName"] + folder_entity = instance.data["folderEntity"] + product_name = instance.data["productName"] + product_type = instance.data["productType"] + self.log.debug("Product: %s", product_name) + + # Get existing product if it exists + existing_product_entity = get_product_by_name( + project_name, product_name, folder_entity["id"] + ) + + # Define product data + data = { + "families": get_instance_families(instance) + } + attributes = {} + + product_group = instance.data.get("productGroup") + if product_group: + attributes["productGroup"] = product_group + elif existing_product_entity: + # Preserve previous product group if new version does not set it + product_group = existing_product_entity.get("attrib", {}).get( + "productGroup" + ) + if product_group is not None: + attributes["productGroup"] = product_group + + product_id = existing_product_entity["id"] if existing_product_entity else None # noqa: E501 + product_entity = new_product_entity( + product_name, + product_type, + folder_entity["id"], + data=data, + attribs=attributes, + entity_id=product_id + ) + + if existing_product_entity is None: + # Create a new product + self.log.info( + "Product '%s' not found, creating ...", + product_name + ) + op_session.create_entity( + project_name, "product", product_entity + ) + + else: + # Update existing product data with new data and set in database. + # We also change the found product in-place so we don't need to + # re-query the product afterward + update_data = get_changed_attributes( + existing_product_entity, product_entity + ) + op_session.update_entity( + project_name, + "product", + product_entity["id"], + update_data + ) + + self.log.debug("Prepared product: %s", product_name) + return product_entity + + def prepare_version( + self, + instance: pyblish.api.Instance, + op_session: OperationsSession, + product_entity: dict) -> dict: + """Prepare version for integration. + + Args: + instance (pyblish.api.Instance): Instance to process. + op_session (OperationsSession): Operations session. + product_entity (dict): Product entity. + + Returns: + dict: Version entity. + + """ + project_name = instance.context.data["projectName"] + version_number = instance.data["version"] + task_entity = instance.data.get("taskEntity") + task_id = task_entity["id"] if task_entity else None + existing_version = get_version_by_name( + project_name, + version_number, + product_entity["id"] + ) + version_id = existing_version["id"] if existing_version else None + all_version_data = self.get_version_data_from_instance(instance) + version_data = {} + version_attributes = {} + attr_defs = self.get_attributes_for_type(instance.context, "version") + for key, value in all_version_data.items(): + if key in attr_defs: + version_attributes[key] = value + else: + version_data[key] = value + + version_entity = new_version_entity( + version_number, + product_entity["id"], + task_id=task_id, + status=instance.data.get("status"), + data=version_data, + attribs=version_attributes, + entity_id=version_id, + ) + + if existing_version: + self.log.debug("Updating existing version ...") + update_data = get_changed_attributes( + existing_version, version_entity) + op_session.update_entity( + project_name, + "version", + version_entity["id"], + update_data + ) + else: + self.log.debug("Creating new version ...") + op_session.create_entity( + project_name, "version", version_entity + ) + + self.log.debug( + "Prepared version: v%s", + "{:03d}".format(version_entity["version"]) + ) + + return version_entity + + def get_version_data_from_instance( + self, instance: pyblish.api.Instance) -> dict: + """Get version data from the Instance. + + Args: + instance (pyblish.api.Instance): the current instance + being published. + + Returns: + dict: the required information for ``version["data"]`` + + """ + context = instance.context + + # create relative source path for DB + if "source" in instance.data: + source = instance.data["source"] + else: + source = context.data["currentFile"] + anatomy = instance.context.data["anatomy"] + source = self.get_rootless_path(anatomy, source) + self.log.debug("Source: %s", source) + + version_data = { + "families": get_instance_families(instance), + "time": context.data["time"], + "author": context.data["user"], + "source": source, + "comment": instance.data["comment"], + "machine": context.data.get("machine"), + "fps": instance.data.get("fps", context.data.get("fps")) + } + + intent_value = context.data.get("intent") + if intent_value and isinstance(intent_value, dict): + intent_value = intent_value.get("value") + + if intent_value: + version_data["intent"] = intent_value + + # Include optional data if present in + optionals = [ + "frameStart", "frameEnd", "step", + "handleEnd", "handleStart", "sourceHashes" + ] + for key in optionals: + if key in instance.data: + version_data[key] = instance.data[key] + + # Include instance.data[versionData] directly + version_data_instance = instance.data.get("versionData") + if version_data_instance: + version_data.update(version_data_instance) + + return version_data + + def get_rootless_path(self, anatomy: Anatomy, path: str) -> str: + r"""Get rootless variant of the path. + + Returns, if possible, a path without an absolute portion from the root + (e.g. 'c:\' or '/opt/..'). This is basically a wrapper for the + meth:`Anatomy.find_root_template_from_path` method that displays + a warning if the root path is not found. + + This information is platform-dependent and shouldn't be captured. + For example:: + + 'c:/projects/MyProject1/Assets/publish...' + will be transformed to: + '{root}/MyProject1/Assets...' + + Args: + anatomy (Anatomy): Project anatomy. + path (str): Absolute path. + + Returns: + str: Path where root path is replaced by formatting string. + + """ + success, rootless_path = anatomy.find_root_template_from_path(path) + if success: + path = rootless_path + else: + self.log.warning(( + 'Could not find root path for remapping "%s".' + " This may cause issues on farm." + ), path) + return path + + def get_attributes_for_type( + self, + context: pyblish.api.Context, + entity_type: str) -> dict: + """Get AYON attributes for the given entity type. + + Args: + context (pyblish.api.Context): Context to get attributes from. + entity_type (str): Entity type to get attributes for. + + Returns: + dict: AYON attributes for the given entity type. + + """ + return self.get_attributes_by_type(context)[entity_type] + + @staticmethod + def get_attributes_by_type( + context: pyblish.api.Context) -> dict: + """Gets AYON attributes from the given context. + + Args: + context (pyblish.api.Context): Context to get attributes from. + + Returns: + dict: AYON attributes. + + """ + attributes = context.data.get("ayonAttributes") + if attributes is None: + attributes = { + key: get_attributes_for_type(key) + for key in ( + "project", + "folder", + "product", + "version", + "representation", + ) + } + context.data["ayonAttributes"] = attributes + return attributes + + def get_template_data_from_representation( + self, + representation: Representation, + instance: pyblish.api.Instance) -> dict: + """Get template data from representation. + + Using representation traits and data on instance + prepare data for formatting template. + + Args: + representation (Representation): Representation to process. + instance (pyblish.api.Instance): Instance to process. + + Returns: + dict: Template data. + + """ + template_data = copy.deepcopy(instance.data["anatomyData"]) + template_data["representation"] = representation.name + template_data["version"] = instance.data["version"] + # template_data["hierarchy"] = instance.data["hierarchy"] + + # add colorspace data to template data + if representation.contains_trait(ColorManaged): + colorspace_data: ColorManaged = representation.get_trait( + ColorManaged) + + template_data["colorspace"] = { + "colorspace": colorspace_data.color_space, + "config": colorspace_data.config + } + + # add explicit list of traits properties to template data + # there must be some better way to handle this + try: + # resolution from PixelBased trait + template_data["resolution_width"] = representation.get_trait( + PixelBased).display_window_width + template_data["resolution_height"] = representation.get_trait( + PixelBased).display_window_height + # get fps from representation traits + template_data["fps"] = representation.get_trait( + FrameRanged).frames_per_second + + # Note: handle "output" and "originalBasename" + + except MissingTraitError as e: + self.log.debug("Missing traits: %s", e) + + return template_data + + @staticmethod + def get_transfers_from_file_locations( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem]) -> None: + """Get transfers from FileLocations trait. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + Raises: + PublishError: If representation is invalid. + + """ + if representation.contains_trait(Sequence): + IntegrateTraits.get_transfers_from_sequence( + representation, template_item, transfers + ) + + elif representation.contains_trait(UDIM) and \ + not representation.contains_trait(Sequence): + # handle UDIM not in sequence + IntegrateTraits.get_transfers_from_udim( + representation, template_item, transfers + ) + + else: + # This should never happen because the representation + # validation should catch this. + msg = ( + "Representation contains FileLocations trait, but " + "is not a Sequence or UDIM." + ) + raise PublishError(msg) + + @staticmethod + def get_transfers_from_sequence( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem] + ) -> None: + """Get transfers from Sequence trait. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + """ + sequence: Sequence = representation.get_trait(Sequence) + path_template_object = template_item.template_object["path"] + + # get the padding from the sequence if the padding on the + # template is higher, us the one from the template + dst_padding = representation.get_trait( + Sequence).frame_padding + frames: list[int] = sequence.get_frame_list( + representation.get_trait(FileLocations), + regex=sequence.frame_regex) + template_padding = template_item.anatomy.templates_obj.frame_padding + dst_padding = max(template_padding, dst_padding) + + # Go through all frames in the sequence and + # find their corresponding file locations, then + # format their template and add them to transfers. + for frame in frames: + file_loc: FileLocation = representation.get_trait( + FileLocations).get_file_location_for_frame( + frame, sequence) + + template_item.template_data["frame"] = frame + template_item.template_data["ext"] = ( + file_loc.file_path.suffix.lstrip(".")) + template_filled = path_template_object.format_strict( + template_item.template_data + ) + + # add used values to the template data + used_values: dict = template_filled.used_values + template_item.template_data.update(used_values) + + transfers.append( + TransferItem( + source=file_loc.file_path, + destination=Path(template_filled), + size=file_loc.file_size or TransferItem.get_size( + file_loc.file_path), + checksum=file_loc.file_hash or TransferItem.get_checksum( + file_loc.file_path), + template=template_item.template, + template_data=template_item.template_data, + representation=representation, + related_trait=file_loc + ) + ) + + # add template path and the data to resolve it + if not representation.contains_trait(TemplatePath): + representation.add_trait(TemplatePath( + template=template_item.template, + data=template_item.template_data + )) + + @staticmethod + def get_transfers_from_udim( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem] + ) -> None: + """Get transfers from UDIM trait. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + """ + udim: UDIM = representation.get_trait(UDIM) + path_template_object: AnatomyStringTemplate = ( + template_item.template_object["path"] + ) + for file_loc in representation.get_trait( + FileLocations).file_paths: + template_item.template_data["udim"] = ( + udim.get_udim_from_file_location(file_loc) + ) + + template_filled = path_template_object.format_strict( + template_item.template_data + ) + + # add used values to the template data + used_values: dict = template_filled.used_values + template_item.template_data.update(used_values) + + transfers.append( + TransferItem( + source=file_loc.file_path, + destination=Path(template_filled), + size=file_loc.file_size or TransferItem.get_size( + file_loc.file_path), + checksum=file_loc.file_hash or TransferItem.get_checksum( + file_loc.file_path), + template=template_item.template, + template_data=template_item.template_data, + representation=representation, + related_trait=file_loc + ) + ) + # add template path and the data to resolve it + representation.add_trait(TemplatePath( + template=template_item.template, + data=template_item.template_data + )) + + @staticmethod + def get_transfers_from_file_location( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem] + ) -> None: + """Get transfers from FileLocation trait. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + """ + path_template_object: AnatomyStringTemplate = ( + template_item.template_object["path"] + ) + template_item.template_data["ext"] = ( + representation.get_trait(FileLocation).file_path.suffix.lstrip(".") + ) + template_item.template_data.pop("frame", None) + with contextlib.suppress(MissingTraitError): + udim = representation.get_trait(UDIM) + template_item.template_data["udim"] = udim.udim[0] + + template_filled = path_template_object.format_strict( + template_item.template_data + ) + + # add used values to the template data + used_values: dict = template_filled.used_values + template_item.template_data.update(used_values) + + file_loc: FileLocation = representation.get_trait(FileLocation) + transfers.append( + TransferItem( + source=file_loc.file_path, + destination=Path(template_filled), + size=file_loc.file_size or TransferItem.get_size( + file_loc.file_path), + checksum=file_loc.file_hash or TransferItem.get_checksum( + file_loc.file_path), + template=template_item.template, + template_data=template_item.template_data, + representation=representation, + related_trait=file_loc + ) + ) + # add template path and the data to resolve it + representation.add_trait(TemplatePath( + template=template_item.template, + data=template_item.template_data + )) + + @staticmethod + def get_transfers_from_bundle( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem] + ) -> None: + """Get transfers from Bundle trait. + + This will be called recursively for each sub-representation in the + bundle that is a Bundle itself. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + """ + bundle: Bundle = representation.get_trait(Bundle) + for idx, sub_representation_traits in enumerate(bundle.items): + sub_representation = Representation( + name=f"{representation.name}_{idx}", + traits=sub_representation_traits) + # sub presentation transient: + sub_representation.add_trait(Transient()) + if sub_representation.contains_trait(FileLocations): + IntegrateTraits.get_transfers_from_file_locations( + sub_representation, template_item, transfers + ) + elif sub_representation.contains_trait(FileLocation): + IntegrateTraits.get_transfers_from_file_location( + sub_representation, template_item, transfers + ) + elif sub_representation.contains_trait(Bundle): + IntegrateTraits.get_transfers_from_bundle( + sub_representation, template_item, transfers + ) + + def _prepare_file_info( + self, path: Path, anatomy: Anatomy) -> dict[str, Any]: + """Prepare information for one file (asset or resource). + + Arguments: + path (Path): Destination url of published file. + anatomy (Anatomy): Project anatomy part from instance. + + Raises: + PublishError: If file does not exist. + + Returns: + dict[str, Any]: Representation file info dictionary. + + """ + if not path.exists(): + msg = f"File '{path}' does not exist." + raise PublishError(msg) + + return { + "id": create_entity_id(), + "name": path.name, + "path": self.get_rootless_path(anatomy, path.as_posix()), + "size": path.stat().st_size, + "hash": source_hash(path.as_posix()), + "hash_type": "op3", + } + + def _get_legacy_files_for_representation( + self, + transfer_items: list[TransferItem], + representation: Representation, + anatomy: Anatomy, + ) -> list[dict[str, str]]: + """Get legacy files for a given representation. + + This expects the file to exist - it must run after the transfer + is done. + + Returns: + list: List of legacy files. + + """ + selected: list[TransferItem] = [] + selected.extend( + item + for item in transfer_items + if item.representation == representation + ) + files: list[dict[str, str]] = [] + files.extend( + self._prepare_file_info(item.destination, anatomy) + for item in selected + ) + return files diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index 7ec941e6bd..f2599c9c9b 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -1,6 +1,8 @@ +from __future__ import annotations import contextlib from abc import ABC, abstractmethod from typing import Dict, Any +from dataclasses import dataclass import ayon_api @@ -140,6 +142,7 @@ class TaskTypeItem: ) +@dataclass class ProjectItem: """Item representing folder entity on a server. @@ -150,21 +153,14 @@ class ProjectItem: active (Union[str, None]): Parent folder id. If 'None' then project is parent. """ - - def __init__(self, name, active, is_library, icon=None): - self.name = name - self.active = active - self.is_library = is_library - if icon is None: - icon = { - "type": "awesome-font", - "name": "fa.book" if is_library else "fa.map", - "color": get_default_entity_icon_color(), - } - self.icon = icon + name: str + active: bool + is_library: bool + icon: dict[str, Any] + is_pinned: bool = False @classmethod - def from_entity(cls, project_entity): + def from_entity(cls, project_entity: dict[str, Any]) -> "ProjectItem": """Creates folder item from entity. Args: @@ -174,10 +170,16 @@ class ProjectItem: ProjectItem: Project item. """ + icon = { + "type": "awesome-font", + "name": "fa.book" if project_entity["library"] else "fa.map", + "color": get_default_entity_icon_color(), + } return cls( project_entity["name"], project_entity["active"], project_entity["library"], + icon ) def to_data(self): @@ -208,16 +210,18 @@ class ProjectItem: return cls(**data) -def _get_project_items_from_entitiy(projects): +def _get_project_items_from_entitiy( + projects: list[dict[str, Any]] +) -> list[ProjectItem]: """ Args: projects (list[dict[str, Any]]): List of projects. Returns: - ProjectItem: Project item. - """ + list[ProjectItem]: Project item. + """ return [ ProjectItem.from_entity(project) for project in projects @@ -428,9 +432,20 @@ class ProjectsModel(object): self._projects_cache.update_data(project_items) return self._projects_cache.get_data() - def _query_projects(self): + def _query_projects(self) -> list[ProjectItem]: projects = ayon_api.get_projects(fields=["name", "active", "library"]) - return _get_project_items_from_entitiy(projects) + user = ayon_api.get_user() + pinned_projects = ( + user + .get("data", {}) + .get("frontendPreferences", {}) + .get("pinnedProjects") + ) or [] + pinned_projects = set(pinned_projects) + project_items = _get_project_items_from_entitiy(list(projects)) + for project in project_items: + project.is_pinned = project.name in pinned_projects + return project_items def _status_items_getter(self, project_entity): if not project_entity: diff --git a/client/ayon_core/tools/launcher/control.py b/client/ayon_core/tools/launcher/control.py index fb9f950bd1..58d22453be 100644 --- a/client/ayon_core/tools/launcher/control.py +++ b/client/ayon_core/tools/launcher/control.py @@ -1,6 +1,6 @@ from ayon_core.lib import Logger, get_ayon_username from ayon_core.lib.events import QueuedEventSystem -from ayon_core.settings import get_project_settings +from ayon_core.settings import get_project_settings, get_studio_settings from ayon_core.tools.common_models import ProjectsModel, HierarchyModel from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend @@ -85,7 +85,10 @@ class BaseLauncherController( def get_project_settings(self, project_name): if project_name in self._project_settings: return self._project_settings[project_name] - settings = get_project_settings(project_name) + if project_name: + settings = get_project_settings(project_name) + else: + settings = get_studio_settings() self._project_settings[project_name] = settings return settings diff --git a/client/ayon_core/tools/launcher/ui/projects_widget.py b/client/ayon_core/tools/launcher/ui/projects_widget.py deleted file mode 100644 index e2af54b55d..0000000000 --- a/client/ayon_core/tools/launcher/ui/projects_widget.py +++ /dev/null @@ -1,154 +0,0 @@ -from qtpy import QtWidgets, QtCore - -from ayon_core.tools.flickcharm import FlickCharm -from ayon_core.tools.utils import ( - PlaceholderLineEdit, - RefreshButton, - ProjectsQtModel, - ProjectSortFilterProxy, -) -from ayon_core.tools.common_models import PROJECTS_MODEL_SENDER - - -class ProjectIconView(QtWidgets.QListView): - """Styled ListView that allows to toggle between icon and list mode. - - Toggling between the two modes is done by Right Mouse Click. - """ - - IconMode = 0 - ListMode = 1 - - def __init__(self, parent=None, mode=ListMode): - super(ProjectIconView, self).__init__(parent=parent) - - # Workaround for scrolling being super slow or fast when - # toggling between the two visual modes - self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) - self.setObjectName("IconView") - - self._mode = None - self.set_mode(mode) - - def set_mode(self, mode): - if mode == self._mode: - return - - self._mode = mode - - if mode == self.IconMode: - self.setViewMode(QtWidgets.QListView.IconMode) - self.setResizeMode(QtWidgets.QListView.Adjust) - self.setWrapping(True) - self.setWordWrap(True) - self.setGridSize(QtCore.QSize(151, 90)) - self.setIconSize(QtCore.QSize(50, 50)) - self.setSpacing(0) - self.setAlternatingRowColors(False) - - self.setProperty("mode", "icon") - self.style().polish(self) - - self.verticalScrollBar().setSingleStep(30) - - elif self.ListMode: - self.setProperty("mode", "list") - self.style().polish(self) - - self.setViewMode(QtWidgets.QListView.ListMode) - self.setResizeMode(QtWidgets.QListView.Adjust) - self.setWrapping(False) - self.setWordWrap(False) - self.setIconSize(QtCore.QSize(20, 20)) - self.setGridSize(QtCore.QSize(100, 25)) - self.setSpacing(0) - self.setAlternatingRowColors(False) - - self.verticalScrollBar().setSingleStep(34) - - def mousePressEvent(self, event): - if event.button() == QtCore.Qt.RightButton: - self.set_mode(int(not self._mode)) - return super(ProjectIconView, self).mousePressEvent(event) - - -class ProjectsWidget(QtWidgets.QWidget): - """Projects Page""" - - refreshed = QtCore.Signal() - - def __init__(self, controller, parent=None): - super(ProjectsWidget, self).__init__(parent=parent) - - header_widget = QtWidgets.QWidget(self) - - projects_filter_text = PlaceholderLineEdit(header_widget) - projects_filter_text.setPlaceholderText("Filter projects...") - - refresh_btn = RefreshButton(header_widget) - - header_layout = QtWidgets.QHBoxLayout(header_widget) - header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.addWidget(projects_filter_text, 1) - header_layout.addWidget(refresh_btn, 0) - - projects_view = ProjectIconView(parent=self) - projects_view.setSelectionMode(QtWidgets.QListView.NoSelection) - flick = FlickCharm(parent=self) - flick.activateOn(projects_view) - projects_model = ProjectsQtModel(controller) - projects_proxy_model = ProjectSortFilterProxy() - projects_proxy_model.setSourceModel(projects_model) - - projects_view.setModel(projects_proxy_model) - - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.addWidget(header_widget, 0) - main_layout.addWidget(projects_view, 1) - - projects_view.clicked.connect(self._on_view_clicked) - projects_model.refreshed.connect(self.refreshed) - projects_filter_text.textChanged.connect( - self._on_project_filter_change) - refresh_btn.clicked.connect(self._on_refresh_clicked) - - controller.register_event_callback( - "projects.refresh.finished", - self._on_projects_refresh_finished - ) - - self._controller = controller - - self._projects_view = projects_view - self._projects_model = projects_model - self._projects_proxy_model = projects_proxy_model - - def has_content(self): - """Model has at least one project. - - Returns: - bool: True if there is any content in the model. - """ - - return self._projects_model.has_content() - - def _on_view_clicked(self, index): - if not index.isValid(): - return - model = index.model() - flags = model.flags(index) - if not flags & QtCore.Qt.ItemIsEnabled: - return - project_name = index.data(QtCore.Qt.DisplayRole) - self._controller.set_selected_project(project_name) - - def _on_project_filter_change(self, text): - self._projects_proxy_model.setFilterFixedString(text) - - def _on_refresh_clicked(self): - self._controller.refresh() - - def _on_projects_refresh_finished(self, event): - if event["sender"] != PROJECTS_MODEL_SENDER: - self._projects_model.refresh() diff --git a/client/ayon_core/tools/launcher/ui/window.py b/client/ayon_core/tools/launcher/ui/window.py index 7fde8518b0..819e141d59 100644 --- a/client/ayon_core/tools/launcher/ui/window.py +++ b/client/ayon_core/tools/launcher/ui/window.py @@ -3,9 +3,13 @@ from qtpy import QtWidgets, QtCore, QtGui from ayon_core import style, resources from ayon_core.tools.launcher.control import BaseLauncherController -from ayon_core.tools.utils import MessageOverlayObject +from ayon_core.tools.utils import ( + MessageOverlayObject, + PlaceholderLineEdit, + RefreshButton, + ProjectsWidget, +) -from .projects_widget import ProjectsWidget from .hierarchy_page import HierarchyPage from .actions_widget import ActionsWidget @@ -50,7 +54,25 @@ class LauncherWindow(QtWidgets.QWidget): pages_widget = QtWidgets.QWidget(content_body) # - First page - Projects - projects_page = ProjectsWidget(controller, pages_widget) + projects_page = QtWidgets.QWidget(pages_widget) + projects_header_widget = QtWidgets.QWidget(projects_page) + + projects_filter_text = PlaceholderLineEdit(projects_header_widget) + projects_filter_text.setPlaceholderText("Filter projects...") + + refresh_btn = RefreshButton(projects_header_widget) + + projects_header_layout = QtWidgets.QHBoxLayout(projects_header_widget) + projects_header_layout.setContentsMargins(0, 0, 0, 0) + projects_header_layout.addWidget(projects_filter_text, 1) + projects_header_layout.addWidget(refresh_btn, 0) + + projects_widget = ProjectsWidget(controller, pages_widget) + + projects_layout = QtWidgets.QVBoxLayout(projects_page) + projects_layout.setContentsMargins(0, 0, 0, 0) + projects_layout.addWidget(projects_header_widget, 0) + projects_layout.addWidget(projects_widget, 1) # - Second page - Hierarchy (folders & tasks) hierarchy_page = HierarchyPage(controller, pages_widget) @@ -102,12 +124,16 @@ class LauncherWindow(QtWidgets.QWidget): page_slide_anim.setEndValue(1.0) page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad) - projects_page.refreshed.connect(self._on_projects_refresh) + refresh_btn.clicked.connect(self._on_refresh_request) + projects_widget.refreshed.connect(self._on_projects_refresh) + actions_refresh_timer.timeout.connect( self._on_actions_refresh_timeout) page_slide_anim.valueChanged.connect( self._on_page_slide_value_changed) page_slide_anim.finished.connect(self._on_page_slide_finished) + projects_filter_text.textChanged.connect( + self._on_project_filter_change) controller.register_event_callback( "selection.project.changed", @@ -142,6 +168,7 @@ class LauncherWindow(QtWidgets.QWidget): self._pages_widget = pages_widget self._pages_layout = pages_layout self._projects_page = projects_page + self._projects_widget = projects_widget self._hierarchy_page = hierarchy_page self._actions_widget = actions_widget # self._action_history = action_history @@ -194,6 +221,12 @@ class LauncherWindow(QtWidgets.QWidget): elif self._is_on_projects_page: self._go_to_hierarchy_page(project_name) + def _on_project_filter_change(self, text): + self._projects_widget.set_name_filter(text) + + def _on_refresh_request(self): + self._controller.refresh() + def _on_projects_refresh(self): # Refresh only actions on projects page if self._is_on_projects_page: @@ -201,7 +234,7 @@ class LauncherWindow(QtWidgets.QWidget): return # No projects were found -> go back to projects page - if not self._projects_page.has_content(): + if not self._projects_widget.has_content(): self._go_to_projects_page() return @@ -280,6 +313,9 @@ class LauncherWindow(QtWidgets.QWidget): def _go_to_projects_page(self): if self._is_on_projects_page: return + + # Deselect project in projects widget + self._projects_widget.set_selected_project(None) self._is_on_projects_page = True self._hierarchy_page.set_page_visible(False) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index cfe91cadab..40331d73a4 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -322,7 +322,6 @@ class LoaderActionsModel: available_loaders = self._filter_loaders_by_tool_name( project_name, discover_loader_plugins(project_name) ) - repre_loaders = [] product_loaders = [] loaders_by_identifier = {} @@ -340,6 +339,7 @@ class LoaderActionsModel: loaders_by_identifier_c.update_data(loaders_by_identifier) product_loaders_c.update_data(product_loaders) repre_loaders_c.update_data(repre_loaders) + return product_loaders, repre_loaders def _get_loader_by_identifier(self, project_name, identifier): @@ -719,7 +719,12 @@ class LoaderActionsModel: loader, repre_contexts, options ) - def _load_representations_by_loader(self, loader, repre_contexts, options): + def _load_representations_by_loader( + self, + loader, + repre_contexts, + options + ): """Loops through list of repre_contexts and loads them with one loader Args: @@ -770,7 +775,12 @@ class LoaderActionsModel: )) return error_info - def _load_products_by_loader(self, loader, version_contexts, options): + def _load_products_by_loader( + self, + loader, + version_contexts, + options + ): """Triggers load with ProductLoader type of loaders. Warning: @@ -796,7 +806,6 @@ class LoaderActionsModel: version_contexts, options=options ) - except Exception as exc: formatted_traceback = None if not isinstance(exc, LoadError): diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py index 8688430c71..111b7c614b 100644 --- a/client/ayon_core/tools/utils/__init__.py +++ b/client/ayon_core/tools/utils/__init__.py @@ -29,6 +29,7 @@ from .widgets import ( from .views import ( DeselectableTreeView, TreeView, + ListView, ) from .error_dialog import ErrorMessageBox from .lib import ( @@ -61,6 +62,7 @@ from .dialogs import ( ) from .projects_widget import ( ProjectsCombobox, + ProjectsWidget, ProjectsQtModel, ProjectSortFilterProxy, PROJECT_NAME_ROLE, @@ -114,6 +116,7 @@ __all__ = ( "DeselectableTreeView", "TreeView", + "ListView", "ErrorMessageBox", @@ -145,6 +148,7 @@ __all__ = ( "PopupUpdateKeys", "ProjectsCombobox", + "ProjectsWidget", "ProjectsQtModel", "ProjectSortFilterProxy", "PROJECT_NAME_ROLE", diff --git a/client/ayon_core/tools/utils/projects_widget.py b/client/ayon_core/tools/utils/projects_widget.py index c340be2f83..1c87d79a58 100644 --- a/client/ayon_core/tools/utils/projects_widget.py +++ b/client/ayon_core/tools/utils/projects_widget.py @@ -1,21 +1,69 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from collections.abc import Callable +import typing +from typing import Optional + from qtpy import QtWidgets, QtCore, QtGui -from ayon_core.tools.common_models import PROJECTS_MODEL_SENDER +from ayon_core.tools.common_models import ( + ProjectItem, + PROJECTS_MODEL_SENDER, +) +from .views import ListView from .lib import RefreshThread, get_qt_icon +if typing.TYPE_CHECKING: + from typing import TypedDict + + class ExpectedProjectSelectionData(TypedDict): + name: Optional[str] + current: Optional[str] + selected: Optional[str] + + class ExpectedSelectionData(TypedDict): + project: ExpectedProjectSelectionData + + PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2 PROJECT_IS_LIBRARY_ROLE = QtCore.Qt.UserRole + 3 PROJECT_IS_CURRENT_ROLE = QtCore.Qt.UserRole + 4 -LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 5 +PROJECT_IS_PINNED_ROLE = QtCore.Qt.UserRole + 5 +LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 6 + + +class AbstractProjectController(ABC): + @abstractmethod + def register_event_callback(self, topic: str, callback: Callable): + pass + + @abstractmethod + def get_project_items( + self, sender: Optional[str] = None + ) -> list[str]: + pass + + @abstractmethod + def set_selected_project(self, project_name: str): + pass + + # These are required only if widget should handle expected selection + @abstractmethod + def expected_project_selected(self, project_name: str): + pass + + @abstractmethod + def get_expected_selection_data(self) -> "ExpectedSelectionData": + pass class ProjectsQtModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() - def __init__(self, controller): - super(ProjectsQtModel, self).__init__() + def __init__(self, controller: AbstractProjectController): + super().__init__() self._controller = controller self._project_items = {} @@ -213,7 +261,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel): else: self.refreshed.emit() - def _fill_items(self, project_items): + def _fill_items(self, project_items: list[ProjectItem]): new_project_names = { project_item.name for project_item in project_items @@ -252,6 +300,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel): item.setData(project_name, PROJECT_NAME_ROLE) item.setData(project_item.active, PROJECT_IS_ACTIVE_ROLE) item.setData(project_item.is_library, PROJECT_IS_LIBRARY_ROLE) + item.setData(project_item.is_pinned, PROJECT_IS_PINNED_ROLE) is_current = project_name == self._current_context_project item.setData(is_current, PROJECT_IS_CURRENT_ROLE) self._project_items[project_name] = item @@ -279,7 +328,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel): class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): def __init__(self, *args, **kwargs): - super(ProjectSortFilterProxy, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._filter_inactive = True self._filter_standard = False self._filter_library = False @@ -323,26 +372,51 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): return False # Library separator should be before library projects - result = self._type_sort(left_index, right_index) - if result is not None: - return result + l_is_library = left_index.data(PROJECT_IS_LIBRARY_ROLE) + r_is_library = right_index.data(PROJECT_IS_LIBRARY_ROLE) + l_is_sep = left_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE) + r_is_sep = right_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE) + if l_is_sep: + return bool(r_is_library) - if left_index.data(PROJECT_NAME_ROLE) is None: + if r_is_sep: + return not l_is_library + + # Non project items should be on top + l_project_name = left_index.data(PROJECT_NAME_ROLE) + r_project_name = right_index.data(PROJECT_NAME_ROLE) + if l_project_name is None: return True - - if right_index.data(PROJECT_NAME_ROLE) is None: + if r_project_name is None: return False left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE) right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE) - if right_is_active == left_is_active: - return super(ProjectSortFilterProxy, self).lessThan( - left_index, right_index - ) + if right_is_active != left_is_active: + return left_is_active - if left_is_active: + l_is_pinned = left_index.data(PROJECT_IS_PINNED_ROLE) + r_is_pinned = right_index.data(PROJECT_IS_PINNED_ROLE) + if l_is_pinned is True and not r_is_pinned: return True - return False + + if r_is_pinned is True and not l_is_pinned: + return False + + # Move inactive projects to the end + left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE) + right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE) + if right_is_active != left_is_active: + return left_is_active + + # Move library projects after standard projects + if ( + l_is_library is not None + and r_is_library is not None + and l_is_library != r_is_library + ): + return r_is_library + return super().lessThan(left_index, right_index) def filterAcceptsRow(self, source_row, source_parent): index = self.sourceModel().index(source_row, 0, source_parent) @@ -415,15 +489,153 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): self.invalidate() +class ProjectsDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._pin_icon = None + + def paint(self, painter, option, index): + is_pinned = index.data(PROJECT_IS_PINNED_ROLE) + if not is_pinned: + super().paint(painter, option, index) + return + opt = QtWidgets.QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + widget = option.widget + if widget is None: + style = QtWidgets.QApplication.style() + else: + style = widget.style() + # CE_ItemViewItem + proxy = style.proxy() + painter.save() + painter.setClipRect(option.rect) + decor_rect = proxy.subElementRect( + QtWidgets.QStyle.SE_ItemViewItemDecoration, opt, widget + ) + text_rect = proxy.subElementRect( + QtWidgets.QStyle.SE_ItemViewItemText, opt, widget + ) + proxy.drawPrimitive( + QtWidgets.QStyle.PE_PanelItemViewItem, opt, painter, widget + ) + mode = QtGui.QIcon.Normal + if not opt.state & QtWidgets.QStyle.State_Enabled: + mode = QtGui.QIcon.Disabled + elif opt.state & QtWidgets.QStyle.State_Selected: + mode = QtGui.QIcon.Selected + state = QtGui.QIcon.Off + if opt.state & QtWidgets.QStyle.State_Open: + state = QtGui.QIcon.On + + # Draw project icon + opt.icon.paint( + painter, decor_rect, opt.decorationAlignment, mode, state + ) + + # Draw pin icon + if index.data(PROJECT_IS_PINNED_ROLE): + pin_icon = self._get_pin_icon() + pin_rect = QtCore.QRect(decor_rect) + diff = option.rect.width() - pin_rect.width() + pin_rect.moveLeft(diff) + pin_icon.paint( + painter, pin_rect, opt.decorationAlignment, mode, state + ) + + # Draw text + if opt.text: + if not opt.state & QtWidgets.QStyle.State_Enabled: + cg = QtGui.QPalette.Disabled + elif not (opt.state & QtWidgets.QStyle.State_Active): + cg = QtGui.QPalette.Inactive + else: + cg = QtGui.QPalette.Normal + + if opt.state & QtWidgets.QStyle.State_Selected: + painter.setPen( + opt.palette.color(cg, QtGui.QPalette.HighlightedText) + ) + else: + painter.setPen(opt.palette.color(cg, QtGui.QPalette.Text)) + + if opt.state & QtWidgets.QStyle.State_Editing: + painter.setPen(opt.palette.color(cg, QtGui.QPalette.Text)) + painter.drawRect(text_rect.adjusted(0, 0, -1, -1)) + + margin = proxy.pixelMetric( + QtWidgets.QStyle.PM_FocusFrameHMargin, None, widget + ) + 1 + text_rect.adjust(margin, 0, -margin, 0) + # NOTE skipping some steps e.g. word wrapping and elided + # text (adding '...' when too long). + painter.drawText( + text_rect, + opt.displayAlignment, + opt.text + ) + + # Draw focus rect + if opt.state & QtWidgets.QStyle.State_HasFocus: + focus_opt = QtWidgets.QStyleOptionFocusRect() + focus_opt.state = option.state + focus_opt.direction = option.direction + focus_opt.rect = option.rect + focus_opt.fontMetrics = option.fontMetrics + focus_opt.palette = option.palette + + focus_opt.rect = style.subElementRect( + QtWidgets.QCommonStyle.SE_ItemViewItemFocusRect, + option, + option.widget + ) + focus_opt.state |= ( + QtWidgets.QStyle.State_KeyboardFocusChange + | QtWidgets.QStyle.State_Item + ) + focus_opt.backgroundColor = option.palette.color( + ( + QtGui.QPalette.Normal + if option.state & QtWidgets.QStyle.State_Enabled + else QtGui.QPalette.Disabled + ), + ( + QtGui.QPalette.Highlight + if option.state & QtWidgets.QStyle.State_Selected + else QtGui.QPalette.Window + ) + ) + style.drawPrimitive( + QtWidgets.QCommonStyle.PE_FrameFocusRect, + focus_opt, + painter, + option.widget + ) + painter.restore() + + def _get_pin_icon(self): + if self._pin_icon is None: + self._pin_icon = get_qt_icon({ + "type": "material-symbols", + "name": "keep", + }) + return self._pin_icon + + class ProjectsCombobox(QtWidgets.QWidget): refreshed = QtCore.Signal() - selection_changed = QtCore.Signal() + selection_changed = QtCore.Signal(str) - def __init__(self, controller, parent, handle_expected_selection=False): - super(ProjectsCombobox, self).__init__(parent) + def __init__( + self, + controller: AbstractProjectController, + parent: QtWidgets.QWidget, + handle_expected_selection: bool = False, + ): + super().__init__(parent) projects_combobox = QtWidgets.QComboBox(self) - combobox_delegate = QtWidgets.QStyledItemDelegate(projects_combobox) + combobox_delegate = ProjectsDelegate(projects_combobox) projects_combobox.setItemDelegate(combobox_delegate) projects_model = ProjectsQtModel(controller) projects_proxy_model = ProjectSortFilterProxy() @@ -468,7 +680,7 @@ class ProjectsCombobox(QtWidgets.QWidget): def refresh(self): self._projects_model.refresh() - def set_selection(self, project_name): + def set_selection(self, project_name: str): """Set selection to a given project. Selection change is ignored if project is not found. @@ -480,8 +692,8 @@ class ProjectsCombobox(QtWidgets.QWidget): bool: True if selection was changed, False otherwise. NOTE: Selection may not be changed if project is not found, or if project is already selected. - """ + """ idx = self._projects_combobox.findData( project_name, PROJECT_NAME_ROLE) if idx < 0: @@ -491,7 +703,7 @@ class ProjectsCombobox(QtWidgets.QWidget): return True return False - def set_listen_to_selection_change(self, listen): + def set_listen_to_selection_change(self, listen: bool): """Disable listening to changes of the selection. Because combobox is triggering selection change when it's model @@ -517,11 +729,11 @@ class ProjectsCombobox(QtWidgets.QWidget): return None return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE) - def set_current_context_project(self, project_name): + def set_current_context_project(self, project_name: str): self._projects_model.set_current_context_project(project_name) self._projects_proxy_model.invalidateFilter() - def set_select_item_visible(self, visible): + def set_select_item_visible(self, visible: bool): self._select_item_visible = visible self._projects_model.set_select_item_visible(visible) self._update_select_item_visiblity() @@ -559,7 +771,7 @@ class ProjectsCombobox(QtWidgets.QWidget): idx, PROJECT_NAME_ROLE) self._update_select_item_visiblity(project_name=project_name) self._controller.set_selected_project(project_name) - self.selection_changed.emit() + self.selection_changed.emit(project_name or "") def _on_model_refresh(self): self._projects_proxy_model.sort(0) @@ -614,5 +826,119 @@ class ProjectsCombobox(QtWidgets.QWidget): class ProjectsWidget(QtWidgets.QWidget): - # TODO implement - pass + """Projects widget showing projects in list. + + Warnings: + This widget does not support expected selection handling. + + """ + refreshed = QtCore.Signal() + selection_changed = QtCore.Signal(str) + double_clicked = QtCore.Signal() + + def __init__( + self, + controller: AbstractProjectController, + parent: Optional[QtWidgets.QWidget] = None + ): + super().__init__(parent=parent) + + projects_view = ListView(parent=self) + projects_view.setResizeMode(QtWidgets.QListView.Adjust) + projects_view.setVerticalScrollMode( + QtWidgets.QAbstractItemView.ScrollPerPixel + ) + projects_view.setAlternatingRowColors(False) + projects_view.setWrapping(False) + projects_view.setWordWrap(False) + projects_view.setSpacing(0) + projects_delegate = ProjectsDelegate(projects_view) + projects_view.setItemDelegate(projects_delegate) + projects_view.activate_flick_charm() + projects_view.set_deselectable(True) + + projects_model = ProjectsQtModel(controller) + projects_proxy_model = ProjectSortFilterProxy() + projects_proxy_model.setSourceModel(projects_model) + projects_view.setModel(projects_proxy_model) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(projects_view, 1) + + projects_view.selectionModel().selectionChanged.connect( + self._on_selection_change + ) + projects_view.double_clicked.connect(self.double_clicked) + projects_model.refreshed.connect(self._on_model_refresh) + + controller.register_event_callback( + "projects.refresh.finished", + self._on_projects_refresh_finished + ) + + self._controller = controller + + self._projects_view = projects_view + self._projects_model = projects_model + self._projects_proxy_model = projects_proxy_model + self._projects_delegate = projects_delegate + + def refresh(self): + self._projects_model.refresh() + + def has_content(self) -> bool: + """Model has at least one project. + + Returns: + bool: True if there is any content in the model. + + """ + return self._projects_model.has_content() + + def set_name_filter(self, text: str): + self._projects_proxy_model.setFilterFixedString(text) + + def get_selected_project(self) -> Optional[str]: + selection_model = self._projects_view.selectionModel() + for index in selection_model.selectedIndexes(): + project_name = index.data(PROJECT_NAME_ROLE) + if project_name: + return project_name + return None + + def set_selected_project(self, project_name: Optional[str]): + if project_name is None: + self._projects_view.clearSelection() + self._projects_view.setCurrentIndex(QtCore.QModelIndex()) + return + + index = self._projects_model.get_index_by_project_name(project_name) + if not index.isValid(): + return + proxy_index = self._projects_proxy_model.mapFromSource(index) + if proxy_index.isValid(): + selection_model = self._projects_view.selectionModel() + selection_model.select( + proxy_index, + QtCore.QItemSelectionModel.ClearAndSelect + ) + + def _on_model_refresh(self): + self._projects_proxy_model.sort(0) + self._projects_proxy_model.invalidateFilter() + self.refreshed.emit() + + def _on_selection_change(self, new_selection, _old_selection): + project_name = None + for index in new_selection.indexes(): + name = index.data(PROJECT_NAME_ROLE) + if name: + project_name = name + break + self.selection_changed.emit(project_name or "") + self._controller.set_selected_project(project_name) + + def _on_projects_refresh_finished(self, event): + if event["sender"] != PROJECTS_MODEL_SENDER: + self._projects_model.refresh() diff --git a/client/ayon_core/tools/utils/views.py b/client/ayon_core/tools/utils/views.py index d69be9b6a9..2ad1d6c7b5 100644 --- a/client/ayon_core/tools/utils/views.py +++ b/client/ayon_core/tools/utils/views.py @@ -37,7 +37,7 @@ class TreeView(QtWidgets.QTreeView): double_clicked = QtCore.Signal(QtGui.QMouseEvent) def __init__(self, *args, **kwargs): - super(TreeView, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._deselectable = False self._flick_charm_activated = False @@ -60,12 +60,64 @@ class TreeView(QtWidgets.QTreeView): self.clearSelection() # clear the current index self.setCurrentIndex(QtCore.QModelIndex()) - super(TreeView, self).mousePressEvent(event) + super().mousePressEvent(event) def mouseDoubleClickEvent(self, event): self.double_clicked.emit(event) - return super(TreeView, self).mouseDoubleClickEvent(event) + return super().mouseDoubleClickEvent(event) + + def activate_flick_charm(self): + if self._flick_charm_activated: + return + self._flick_charm_activated = True + self._before_flick_scroll_mode = self.verticalScrollMode() + self._flick_charm.activateOn(self) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + + def deactivate_flick_charm(self): + if not self._flick_charm_activated: + return + self._flick_charm_activated = False + self._flick_charm.deactivateFrom(self) + if self._before_flick_scroll_mode is not None: + self.setVerticalScrollMode(self._before_flick_scroll_mode) + + +class ListView(QtWidgets.QListView): + """A tree view that deselects on clicking on an empty area in the view""" + double_clicked = QtCore.Signal(QtGui.QMouseEvent) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._deselectable = False + + self._flick_charm_activated = False + self._flick_charm = FlickCharm(parent=self) + self._before_flick_scroll_mode = None + + def is_deselectable(self): + return self._deselectable + + def set_deselectable(self, deselectable): + self._deselectable = deselectable + + deselectable = property(is_deselectable, set_deselectable) + + def mousePressEvent(self, event): + if self._deselectable: + index = self.indexAt(event.pos()) + if not index.isValid(): + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + super().mousePressEvent(event) + + def mouseDoubleClickEvent(self, event): + self.double_clicked.emit(event) + + return super().mouseDoubleClickEvent(event) def activate_flick_charm(self): if self._flick_charm_activated: diff --git a/pyproject.toml b/pyproject.toml index 246781b12f..c932917224 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,15 +20,12 @@ pytest = "^8.0" pytest-print = "^1.0" ayon-python-api = "^1.0" # linting dependencies -ruff = "0.11.7" -pre-commit = "^3.6.2" +ruff = "^0.11.7" +pre-commit = "^4" codespell = "^2.2.6" semver = "^3.0.2" +mypy = "^1.14.0" mock = "^5.0.0" -attrs = "^25.0.0" -pyblish-base = "^1.8.7" -clique = "^2.0.0" -opentimelineio = "^0.17.0" tomlkit = "^0.13.2" requests = "^2.32.3" mkdocs-material = "^9.6.7" @@ -41,6 +38,16 @@ pymdown-extensions = "^10.14.3" mike = "^2.1.3" mkdocstrings-shell = "^1.0.2" +[tool.poetry.group.test.dependencies] +attrs = "^25.0.0" +pyblish-base = "^1.8.7" +clique = "^2.0.0" +opentimelineio = "^0.17.0" +speedcopy = "^2.1" +qtpy="^2.4.3" +pyside6 = "^6.5.2" +pytest-ayon = { git = "https://github.com/ynput/pytest-ayon.git", branch = "chore/align-dependencies" } + [tool.codespell] # Ignore words that are not in the dictionary. ignore-words-list = "ayon,ynput,parms,parm,hda,developpement" @@ -53,11 +60,13 @@ skip = "./.*,./package/*,*/client/ayon_core/vendor/*" count = true quiet-level = 3 +[tool.mypy] +mypy_path = "$MYPY_CONFIG_FILE_DIR/client" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" - [tool.pytest.ini_options] log_cli = true log_cli_level = "INFO" @@ -65,3 +74,11 @@ addopts = "-ra -q" testpaths = [ "client/ayon_core/tests" ] +markers = [ + "unit: Unit tests", + "integration: Integration tests", + "api: API tests", + "cli: CLI tests", + "slow: Slow tests", + "server: Tests that require a running AYON server", +] diff --git a/ruff.toml b/ruff.toml index f9b073e818..c0a501a5dc 100644 --- a/ruff.toml +++ b/ruff.toml @@ -57,6 +57,7 @@ exclude = [ [lint.per-file-ignores] "client/ayon_core/lib/__init__.py" = ["E402"] +"tests/*.py" = ["S101", "PLR2004"] # allow asserts and magical values [format] # Like Black, use double quotes for strings. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..d420712d8b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests.""" diff --git a/tests/client/ayon_core/pipeline/traits/__init__.py b/tests/client/ayon_core/pipeline/traits/__init__.py new file mode 100644 index 0000000000..ead0593ced --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/__init__.py @@ -0,0 +1 @@ +"""Tests for the representation traits.""" diff --git a/tests/client/ayon_core/pipeline/traits/lib/__init__.py b/tests/client/ayon_core/pipeline/traits/lib/__init__.py new file mode 100644 index 0000000000..d7ea7ae0ad --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/lib/__init__.py @@ -0,0 +1,25 @@ +"""Metadata traits.""" +from typing import ClassVar + +from ayon_core.pipeline.traits import TraitBase + + +class NewTestTrait(TraitBase): + """New Test trait model. + + This model represents a tagged trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + """ + + name: ClassVar[str] = "New Test Trait" + description: ClassVar[str] = ( + "This test trait is used for testing updating." + ) + id: ClassVar[str] = "ayon.test.NewTestTrait.v999" + + +__all__ = ["NewTestTrait"] diff --git a/tests/client/ayon_core/pipeline/traits/test_content_traits.py b/tests/client/ayon_core/pipeline/traits/test_content_traits.py new file mode 100644 index 0000000000..3fcbd04ac0 --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/test_content_traits.py @@ -0,0 +1,184 @@ +"""Tests for the content traits.""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from ayon_core.pipeline.traits import ( + Bundle, + FileLocation, + FileLocations, + FrameRanged, + Image, + MimeType, + PixelBased, + Planar, + Representation, + Sequence, +) +from ayon_core.pipeline.traits.trait import TraitValidationError + + +def test_bundles() -> None: + """Test bundle trait.""" + diffuse_texture = [ + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + FileLocation( + file_path=Path("/path/to/diffuse.jpg"), + file_size=1024, + file_hash=None), + MimeType(mime_type="image/jpeg"), + ] + bump_texture = [ + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + FileLocation( + file_path=Path("/path/to/bump.tif"), + file_size=1024, + file_hash=None), + MimeType(mime_type="image/tiff"), + ] + bundle = Bundle(items=[diffuse_texture, bump_texture]) + representation = Representation(name="test_bundle", traits=[bundle]) + + if representation.contains_trait(trait=Bundle): + assert representation.get_trait(trait=Bundle).items == [ + diffuse_texture, bump_texture + ] + + for item in representation.get_trait(trait=Bundle).items: + sub_representation = Representation(name="test", traits=item) + assert sub_representation.contains_trait(trait=Image) + sub: MimeType = sub_representation.get_trait(trait=MimeType) + assert sub.mime_type in { + "image/jpeg", "image/tiff" + } + + +def test_file_locations_validation() -> None: + """Test FileLocations trait validation.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1051) + ] + + representation = Representation(name="test", traits=[ + FileLocations(file_paths=file_locations_list), + Sequence(frame_padding=4), + ]) + + file_locations_trait: FileLocations = FileLocations( + file_paths=file_locations_list) + + # this should be valid trait + file_locations_trait.validate_trait(representation) + + # add valid FrameRanged trait + frameranged_trait = FrameRanged( + frame_start=1001, + frame_end=1050, + frames_per_second="25" + ) + representation.add_trait(frameranged_trait) + + # it should still validate fine + file_locations_trait.validate_trait(representation) + + # create empty file locations trait + empty_file_locations_trait = FileLocations(file_paths=[]) + representation = Representation(name="test", traits=[ + empty_file_locations_trait + ]) + with pytest.raises(TraitValidationError): + empty_file_locations_trait.validate_trait(representation) + + # create valid file locations trait but with not matching + # frame range trait + representation = Representation(name="test", traits=[ + FileLocations(file_paths=file_locations_list), + Sequence(frame_padding=4), + ]) + invalid_sequence_trait = FrameRanged( + frame_start=1001, + frame_end=1051, + frames_per_second="25" + ) + + representation.add_trait(invalid_sequence_trait) + with pytest.raises(TraitValidationError): + file_locations_trait.validate_trait(representation) + + # invalid representation with multiple file locations but + # unrelated to either Sequence or Bundle traits + representation = Representation(name="test", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path("/path/to/file_foo.exr"), + file_size=1024, + file_hash=None, + ), + FileLocation( + file_path=Path("/path/to/anotherfile.obj"), + file_size=1234, + file_hash=None, + ) + ]) + ]) + + with pytest.raises(TraitValidationError): + representation.validate() + + +def test_get_file_location_from_frame() -> None: + """Test get_file_location_from_frame method.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1051) + ] + + file_locations_trait: FileLocations = FileLocations( + file_paths=file_locations_list) + + assert file_locations_trait.get_file_location_for_frame(frame=1001) == \ + file_locations_list[0] + assert file_locations_trait.get_file_location_for_frame(frame=1050) == \ + file_locations_list[-1] + assert file_locations_trait.get_file_location_for_frame(frame=1100) is None + + # test with custom regex + sequence = Sequence( + frame_padding=4, + frame_regex=re.compile(r"boo_(?P(?P0*)\d+)\.exr")) + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/boo_{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1051) + ] + + file_locations_trait = FileLocations( + file_paths=file_locations_list) + + assert file_locations_trait.get_file_location_for_frame( + frame=1001, sequence_trait=sequence) == \ + file_locations_list[0] diff --git a/tests/client/ayon_core/pipeline/traits/test_time_traits.py b/tests/client/ayon_core/pipeline/traits/test_time_traits.py new file mode 100644 index 0000000000..28ace89910 --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/test_time_traits.py @@ -0,0 +1,248 @@ +"""Tests for the time related traits.""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from ayon_core.pipeline.traits import ( + FileLocation, + FileLocations, + FrameRanged, + Handles, + Representation, + Sequence, +) +from ayon_core.pipeline.traits.trait import TraitValidationError + + +def test_sequence_validations() -> None: + """Test Sequence trait validation.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1010 + 1) # because range is zero based + ] + + file_locations_list += [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1015, 1020 + 1) + ] + + file_locations_list += [ + FileLocation + ( + file_path=Path("/path/to/file.1100.exr"), + file_size=1024, + file_hash=None, + ) + ] + + representation = Representation(name="test_1", traits=[ + FileLocations(file_paths=file_locations_list), + FrameRanged( + frame_start=1001, + frame_end=1100, frames_per_second="25"), + Sequence( + frame_padding=4, + frame_spec="1001-1010,1015-1020,1100") + ]) + + representation.get_trait(Sequence).validate_trait(representation) + + # here we set handles and set them as inclusive, so this should pass + representation = Representation(name="test_2", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1100 + 1) # because range is zero based + ]), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=True + ), + FrameRanged( + frame_start=1001, + frame_end=1100, frames_per_second="25"), + Sequence(frame_padding=4) + ]) + + representation.validate() + + # do the same but set handles as exclusive + representation = Representation(name="test_3", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(996, 1105 + 1) # because range is zero based + ]), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ), + FrameRanged( + frame_start=1001, + frame_end=1100, frames_per_second="25"), + Sequence(frame_padding=4) + ]) + + representation.validate() + + # invalid representation with file range not extended for handles + representation = Representation(name="test_4", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1050 + 1) # because range is zero based + ]), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ), + FrameRanged( + frame_start=1001, + frame_end=1050, frames_per_second="25"), + Sequence(frame_padding=4) + ]) + + with pytest.raises(TraitValidationError): + representation.validate() + + # invalid representation with frame spec not matching the files + del representation + representation = Representation(name="test_5", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1050 + 1) # because range is zero based + ]), + FrameRanged( + frame_start=1001, + frame_end=1050, frames_per_second="25"), + Sequence(frame_padding=4, frame_spec="1001-1010,1012-2000") + ]) + with pytest.raises(TraitValidationError): + representation.validate() + + representation = Representation(name="test_6", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1050 + 1) # because range is zero based + ]), + Sequence(frame_padding=4, frame_spec="1-1010,1012-1050"), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ) + ]) + with pytest.raises(TraitValidationError): + representation.validate() + + representation = Representation(name="test_6", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(996, 1050 + 1) # because range is zero based + ]), + Sequence(frame_padding=4, frame_spec="1001-1010,1012-2000"), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ) + ]) + with pytest.raises(TraitValidationError): + representation.validate() + + representation = Representation(name="test_7", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(996, 1050 + 1) # because range is zero based + ]), + Sequence( + frame_padding=4, + frame_regex=re.compile( + r"img\.(?P(?P0*)\d{4})\.png$")), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ) + ]) + representation.validate() + + +def test_list_spec_to_frames() -> None: + """Test converting list specification to frames.""" + assert Sequence.list_spec_to_frames("1-10,20-30,55") == [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 55 + ] + assert Sequence.list_spec_to_frames("1,2,3,4,5") == [ + 1, 2, 3, 4, 5 + ] + assert Sequence.list_spec_to_frames("1-10") == [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 + ] + test_list = list(range(1001, 1011)) + test_list += list(range(1012, 2001)) + assert Sequence.list_spec_to_frames("1001-1010,1012-2000") == test_list + + assert Sequence.list_spec_to_frames("1") == [1] + with pytest.raises( + ValueError, + match=r"Invalid frame number in the list: .*"): + Sequence.list_spec_to_frames("a") + + +def test_sequence_get_frame_padding() -> None: + """Test getting frame padding from FileLocations trait.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1051) + ] + + representation = Representation(name="test", traits=[ + FileLocations(file_paths=file_locations_list) + ]) + + assert Sequence.get_frame_padding( + file_locations=representation.get_trait(FileLocations)) == 4 diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py new file mode 100644 index 0000000000..e4aef1ba18 --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -0,0 +1,405 @@ +"""Tests for the representation traits.""" +from __future__ import annotations + +from pathlib import Path + +import pytest +from ayon_core.pipeline.traits import ( + Bundle, + FileLocation, + Image, + MimeType, + Overscan, + PixelBased, + Planar, + Representation, + TraitBase, +) + +REPRESENTATION_DATA: dict = { + FileLocation.id: { + "file_path": Path("/path/to/file"), + "file_size": 1024, + "file_hash": None, + # "persistent": True, + }, + Image.id: {}, + PixelBased.id: { + "display_window_width": 1920, + "display_window_height": 1080, + "pixel_aspect_ratio": 1.0, + # "persistent": True, + }, + Planar.id: { + "planar_configuration": "RGB", + # "persistent": True, + }, + } + + +class UpgradedImage(Image): + """Upgraded image class.""" + id = "ayon.2d.Image.v2" + + @classmethod + def upgrade(cls, data: dict) -> UpgradedImage: # noqa: ARG003 + """Upgrade the trait. + + Returns: + UpgradedImage: Upgraded image instance. + + """ + return cls() + + +class InvalidTrait: + """Invalid trait class.""" + foo = "bar" + + +@pytest.fixture +def representation() -> Representation: + """Return a traits data instance.""" + return Representation(name="test", traits=[ + FileLocation(**REPRESENTATION_DATA[FileLocation.id]), + Image(), + PixelBased(**REPRESENTATION_DATA[PixelBased.id]), + Planar(**REPRESENTATION_DATA[Planar.id]), + ]) + + +def test_representation_errors(representation: Representation) -> None: + """Test errors in representation.""" + with pytest.raises(ValueError, + match=r"Invalid trait .* - ID is required."): + representation.add_trait(InvalidTrait()) + + with pytest.raises(ValueError, + match=f"Trait with ID {Image.id} already exists."): + representation.add_trait(Image()) + + with pytest.raises(ValueError, + match=r"Trait with ID .* not found."): + representation.remove_trait_by_id("foo") + + +def test_representation_traits(representation: Representation) -> None: + """Test setting and getting traits.""" + assert representation.get_trait_by_id( + "ayon.2d.PixelBased").get_version() == 1 + + assert len(representation) == len(REPRESENTATION_DATA) + assert representation.get_trait_by_id(FileLocation.id) + assert representation.get_trait_by_id(Image.id) + assert representation.get_trait_by_id(trait_id="ayon.2d.Image.v1") + assert representation.get_trait_by_id(PixelBased.id) + assert representation.get_trait_by_id(trait_id="ayon.2d.PixelBased.v1") + assert representation.get_trait_by_id(Planar.id) + assert representation.get_trait_by_id(trait_id="ayon.2d.Planar.v1") + + assert representation.get_trait(FileLocation) + assert representation.get_trait(Image) + assert representation.get_trait(PixelBased) + assert representation.get_trait(Planar) + + assert issubclass( + type(representation.get_trait(FileLocation)), TraitBase) + + assert representation.get_trait(FileLocation) == \ + representation.get_trait_by_id(FileLocation.id) + assert representation.get_trait(Image) == \ + representation.get_trait_by_id(Image.id) + assert representation.get_trait(PixelBased) == \ + representation.get_trait_by_id(PixelBased.id) + assert representation.get_trait(Planar) == \ + representation.get_trait_by_id(Planar.id) + + assert representation.get_trait_by_id( + "ayon.2d.PixelBased.v1").display_window_width == \ + REPRESENTATION_DATA[PixelBased.id]["display_window_width"] + assert representation.get_trait( + trait=PixelBased).display_window_height == \ + REPRESENTATION_DATA[PixelBased.id]["display_window_height"] + + repre_dict = { + FileLocation.id: FileLocation(**REPRESENTATION_DATA[FileLocation.id]), + Image.id: Image(), + PixelBased.id: PixelBased(**REPRESENTATION_DATA[PixelBased.id]), + Planar.id: Planar(**REPRESENTATION_DATA[Planar.id]), + } + assert representation.get_traits() == repre_dict + + assert representation.get_traits_by_ids( + trait_ids=[FileLocation.id, Image.id, PixelBased.id, Planar.id]) == \ + repre_dict + assert representation.get_traits( + [FileLocation, Image, PixelBased, Planar]) == \ + repre_dict + + assert representation.has_traits() is True + empty_representation: Representation = Representation( + name="test", traits=[]) + assert empty_representation.has_traits() is False + + assert representation.contains_trait(trait=FileLocation) is True + assert representation.contains_traits([Image, FileLocation]) is True + assert representation.contains_trait_by_id(FileLocation.id) is True + assert representation.contains_traits_by_id( + trait_ids=[FileLocation.id, Image.id]) is True + + assert representation.contains_trait(trait=Bundle) is False + assert representation.contains_traits([Image, Bundle]) is False + assert representation.contains_trait_by_id(Bundle.id) is False + assert representation.contains_traits_by_id( + trait_ids=[FileLocation.id, Bundle.id]) is False + + +def test_trait_removing(representation: Representation) -> None: + """Test removing traits.""" + assert representation.contains_trait_by_id("nonexistent") is False + with pytest.raises( + ValueError, match=r"Trait with ID nonexistent not found."): + representation.remove_trait_by_id("nonexistent") + + assert representation.contains_trait(trait=FileLocation) is True + representation.remove_trait(trait=FileLocation) + assert representation.contains_trait(trait=FileLocation) is False + + assert representation.contains_trait_by_id(Image.id) is True + representation.remove_trait_by_id(Image.id) + assert representation.contains_trait_by_id(Image.id) is False + + assert representation.contains_traits([PixelBased, Planar]) is True + representation.remove_traits([Planar, PixelBased]) + assert representation.contains_traits([PixelBased, Planar]) is False + + assert representation.has_traits() is False + + with pytest.raises( + ValueError, match=f"Trait with ID {Image.id} not found."): + representation.remove_trait(Image) + + +def test_representation_dict_properties( + representation: Representation) -> None: + """Test representation as dictionary.""" + representation = Representation(name="test") + representation[Image.id] = Image() + assert Image.id in representation + image = representation[Image.id] + assert image == Image() + for trait_id, trait in representation.items(): + assert trait_id == Image.id + assert trait == Image() + + +def test_getting_traits_data(representation: Representation) -> None: + """Test getting a batch of traits.""" + result = representation.get_traits_by_ids( + trait_ids=[FileLocation.id, Image.id, PixelBased.id, Planar.id]) + assert result == { + "ayon.2d.Image.v1": Image(), + "ayon.2d.PixelBased.v1": PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + "ayon.2d.Planar.v1": Planar(planar_configuration="RGB"), + "ayon.content.FileLocation.v1": FileLocation( + file_path=Path("/path/to/file"), + file_size=1024, + file_hash=None) + } + + +def test_traits_data_to_dict(representation: Representation) -> None: + """Test converting traits data to dictionary.""" + result = representation.traits_as_dict() + assert result == REPRESENTATION_DATA + + +def test_get_version_from_id() -> None: + """Test getting version from trait ID.""" + assert Image().get_version() == 1 + + class TestOverscan(Overscan): + id = "ayon.2d.Overscan.v2" + + assert TestOverscan( + left=0, + right=0, + top=0, + bottom=0 + ).get_version() == 2 + + class TestMimeType(MimeType): + id = "ayon.content.MimeType" + + assert TestMimeType(mime_type="foo/bar").get_version() is None + + +def test_get_versionless_id() -> None: + """Test getting versionless trait ID.""" + assert Image().get_versionless_id() == "ayon.2d.Image" + + class TestOverscan(Overscan): + id = "ayon.2d.Overscan.v2" + + assert TestOverscan( + left=0, + right=0, + top=0, + bottom=0 + ).get_versionless_id() == "ayon.2d.Overscan" + + class TestMimeType(MimeType): + id = "ayon.content.MimeType" + + assert TestMimeType(mime_type="foo/bar").get_versionless_id() == \ + "ayon.content.MimeType" + + +def test_from_dict() -> None: + """Test creating representation from dictionary.""" + traits_data = { + "ayon.content.FileLocation.v1": { + "file_path": "/path/to/file", + "file_size": 1024, + "file_hash": None, + }, + "ayon.2d.Image.v1": {}, + } + + representation = Representation.from_dict( + "test", trait_data=traits_data) + + assert len(representation) == 2 + assert representation.get_trait_by_id("ayon.content.FileLocation.v1") + assert representation.get_trait_by_id("ayon.2d.Image.v1") + + traits_data = { + "ayon.content.FileLocation.v999": { + "file_path": "/path/to/file", + "file_size": 1024, + "file_hash": None, + }, + } + + with pytest.raises(ValueError, match=r"Trait model with ID .* not found."): + representation = Representation.from_dict( + "test", trait_data=traits_data) + + traits_data = { + "ayon.content.FileLocation": { + "file_path": "/path/to/file", + "file_size": 1024, + "file_hash": None, + }, + } + + representation = Representation.from_dict( + "test", trait_data=traits_data) + + assert len(representation) == 1 + assert representation.get_trait_by_id("ayon.content.FileLocation.v1") + + # this won't work right now because we would need to somewhat mock + # the import + """ + from .lib import NewTestTrait + + traits_data = { + "ayon.test.NewTestTrait.v1": {}, + } + + representation = Representation.from_dict( + "test", trait_data=traits_data) + """ + + +def test_representation_equality() -> None: + """Test representation equality.""" + # rep_a and rep_b are equal + rep_a = Representation(name="test", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + ]) + rep_b = Representation(name="test", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + ]) + + # rep_c has different value for planar_configuration then rep_a and rep_b + rep_c = Representation(name="test", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGBA"), + ]) + + rep_d = Representation(name="test", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + ]) + rep_e = Representation(name="foo", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + ]) + rep_f = Representation(name="foo", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Planar(planar_configuration="RGBA"), + ]) + + # let's assume ids are the same (because ids are randomly generated) + rep_b.representation_id = rep_d.representation_id = rep_a.representation_id + rep_c.representation_id = rep_e.representation_id = rep_a.representation_id + rep_f.representation_id = rep_a.representation_id + assert rep_a == rep_b + + # because of the trait value difference + assert rep_a != rep_c + # because of the type difference + assert rep_a != "foo" + # because of the trait count difference + assert rep_a != rep_d + # because of the name difference + assert rep_d != rep_e + # because of the trait difference + assert rep_d != rep_f + + +def test_get_repre_by_name(): + """Test getting representation by name.""" + rep_a = Representation(name="test_a", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + ]) + rep_b = Representation(name="test_b", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + ]) + + representations = [rep_a, rep_b] + _ = next(rep for rep in representations if rep.name == "test_a") diff --git a/tests/client/ayon_core/pipeline/traits/test_two_dimensional_traits.py b/tests/client/ayon_core/pipeline/traits/test_two_dimensional_traits.py new file mode 100644 index 0000000000..f09d2b0864 --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/test_two_dimensional_traits.py @@ -0,0 +1,63 @@ +"""Tests for the 2d related traits.""" +from __future__ import annotations + +from pathlib import Path + +from ayon_core.pipeline.traits import ( + UDIM, + FileLocation, + FileLocations, + Representation, +) + + +def test_get_file_location_for_udim() -> None: + """Test get_file_location_for_udim.""" + file_locations_list = [ + FileLocation( + file_path=Path("/path/to/file.1001.exr"), + file_size=1024, + file_hash=None, + ), + FileLocation( + file_path=Path("/path/to/file.1002.exr"), + file_size=1024, + file_hash=None, + ), + FileLocation( + file_path=Path("/path/to/file.1003.exr"), + file_size=1024, + file_hash=None, + ), + ] + + representation = Representation(name="test_1", traits=[ + FileLocations(file_paths=file_locations_list), + UDIM(udim=[1001, 1002, 1003]), + ]) + + udim_trait = representation.get_trait(UDIM) + assert udim_trait.get_file_location_for_udim( + file_locations=representation.get_trait(FileLocations), + udim=1001 + ) == file_locations_list[0] + + +def test_get_udim_from_file_location() -> None: + """Test get_udim_from_file_location.""" + file_location_1 = FileLocation( + file_path=Path("/path/to/file.1001.exr"), + file_size=1024, + file_hash=None, + ) + + file_location_2 = FileLocation( + file_path=Path("/path/to/file.xxxxx.exr"), + file_size=1024, + file_hash=None, + ) + assert UDIM(udim=[1001]).get_udim_from_file_location( + file_location_1) == 1001 + + assert UDIM(udim=[1001]).get_udim_from_file_location( + file_location_2) is None diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py new file mode 100644 index 0000000000..abb605a121 --- /dev/null +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -0,0 +1,451 @@ +"""Tests for the representation traits.""" +from __future__ import annotations + +import base64 +import re +import time +from pathlib import Path +from typing import TYPE_CHECKING + +import pyblish.api +import pytest + +from ayon_core.lib.file_transaction import ( + FileTransaction, +) + +from ayon_core.pipeline.anatomy import Anatomy +from ayon_core.pipeline.traits import ( + Bundle, + FileLocation, + FileLocations, + FrameRanged, + Image, + MimeType, + Persistent, + PixelBased, + Representation, + Sequence, + Transient, +) +from ayon_core.pipeline.version_start import get_versioning_start + +# Tagged, +# TemplatePath, +from ayon_core.plugins.publish.integrate_traits import ( + IntegrateTraits, + TransferItem, +) + +from ayon_core.settings import get_project_settings + +from ayon_api.operations import ( + OperationsSession, +) + +if TYPE_CHECKING: + import pytest_ayon + +PNG_FILE_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVR4AWNgAAAAAgABc3UBGAAAAABJRU5ErkJggg==" # noqa: E501 +SEQUENCE_LENGTH = 10 +CURRENT_TIME = time.time() + + +@pytest.fixture(scope="session") +def single_file(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Return a temporary image file.""" + filename = tmp_path_factory.mktemp("single") / "img.png" + filename.write_bytes(base64.b64decode(PNG_FILE_B64)) + return filename + + +@pytest.fixture(scope="session") +def sequence_files(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: + """Return a sequence of temporary image files.""" + files = [] + dir_name = tmp_path_factory.mktemp("sequence") + for i in range(SEQUENCE_LENGTH): + frame = i + 1 + filename = dir_name / f"img.{frame:04d}.png" + filename.write_bytes(base64.b64decode(PNG_FILE_B64)) + files.append(filename) + return files + + +@pytest.fixture +def mock_context( + project: pytest_ayon.ProjectInfo, + single_file: Path, + sequence_files: list[Path]) -> pyblish.api.Context: + """Return a mock instance. + + This is mocking pyblish context for testing. It is using real AYON project + thanks to the ``project`` fixture. + + Args: + project (object): The project info. It is `ProjectInfo` object + returned by pytest fixture. + single_file (Path): The path to a single image file. + sequence_files (list[Path]): The paths to a sequence of image files. + + """ + anatomy = Anatomy(project.project_name) + context = pyblish.api.Context() + context.data["projectName"] = project.project_name + context.data["hostName"] = "test_host" + context.data["project_settings"] = get_project_settings( + project.project_name) + context.data["anatomy"] = anatomy + context.data["time"] = CURRENT_TIME + context.data["user"] = "test_user" + context.data["machine"] = "test_machine" + context.data["fps"] = 25 + + instance = context.create_instance("mock_instance") + instance.data["source"] = "test_source" + instance.data["families"] = ["render"] + + parents = project.folder_entity["path"].lstrip("/").split("/") + hierarchy = "/".join(parents) if parents else "" + + instance.data["anatomyData"] = { + "project": { + "name": project.project_name, + "code": project.project_code + }, + "task": { + "name": project.task.name, + "type": "test" # pytest-ayon doesn't return the task type yet + }, + "folder": { + "name": project.folder.name, + "type": "test" # pytest-ayon doesn't return the folder type yet + }, + "product": { + "name": project.product.name, + "type": "test" # pytest-ayon doesn't return the product type yet + }, + "hierarchy": hierarchy, + + } + instance.data["folderEntity"] = project.folder_entity + instance.data["productType"] = "test_product" + instance.data["productName"] = project.product.name + instance.data["anatomy"] = anatomy + instance.data["comment"] = "test_comment" + + instance.data["integrate"] = True + instance.data["farm"] = False + + parents = project.folder_entity["path"].lstrip("/").split("/") + + hierarchy = "/".join(parents) if parents else "" + instance.data["hierarchy"] = hierarchy + + version_number = get_versioning_start( + context.data["projectName"], + instance.context.data["hostName"], + task_name=project.task.name, + task_type="test", + product_type=instance.data["productType"], + product_name=instance.data["productName"] + ) + + instance.data["version"] = version_number + + file_size = len(base64.b64decode(PNG_FILE_B64)) + file_locations = [ + FileLocation( + file_path=f, + file_size=file_size) + for f in sequence_files] + + instance.data["representations_with_traits"] = [ + Representation(name="test_single", traits=[ + Persistent(), + FileLocation( + file_path=single_file, + file_size=len(base64.b64decode(PNG_FILE_B64))), + Image(), + MimeType(mime_type="image/png"), + ]), + Representation(name="test_sequence", traits=[ + Persistent(), + FrameRanged( + frame_start=1, + frame_end=SEQUENCE_LENGTH, + frame_in=0, + frame_out=SEQUENCE_LENGTH - 1, + frames_per_second="25", + ), + Sequence( + frame_padding=4, + frame_regex=re.compile( + r"img\.(?P(?P0*)\d{4})\.png$"), + ), + FileLocations( + file_paths=file_locations, + ), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + MimeType(mime_type="image/png"), + ]), + Representation(name="test_bundle", traits=[ + Persistent(), + Bundle( + items=[ + [ + FileLocation( + file_path=single_file, + file_size=len(base64.b64decode(PNG_FILE_B64))), + Image(), + MimeType(mime_type="image/png"), + ], + [ + Persistent(), + FrameRanged( + frame_start=1, + frame_end=SEQUENCE_LENGTH, + frame_in=0, + frame_out=SEQUENCE_LENGTH - 1, + frames_per_second="25", + ), + Sequence( + frame_padding=4, + frame_regex=re.compile( + r"img\.(?P(?P0*)\d{4})\.png$"), + ), + FileLocations( + file_paths=file_locations, + ), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + MimeType(mime_type="image/png"), + ], + ], + ), + ]), + ] + + return context + + +@pytest.mark.server +def test_get_template_name(mock_context: pyblish.api.Context) -> None: + """Test get_template_name. + + TODO (antirotor): this will always return "default" probably, if + there are no studio overrides. To test this properly, we need + to set up the studio overrides in the test environment. + + """ + integrator = IntegrateTraits() + template_name = integrator.get_template_name( + mock_context[0]) + + assert template_name == "default" + + +class TestGetSize: + @staticmethod + def get_size(file_path: Path) -> int: + """Get size of the file. + + Args: + file_path (Path): File path. + + Returns: + int: Size of the file. + + """ + return file_path.stat().st_size + + @pytest.mark.parametrize( + "file_path, expected_size", + [ + (Path("./test_file_1.txt"), 10), # id: happy_path_small_file + (Path("./test_file_2.txt"), 1024), # id: happy_path_medium_file + (Path("./test_file_3.txt"), 10485760) # id: happy_path_large_file + ], + ids=["happy_path_small_file", + "happy_path_medium_file", + "happy_path_large_file"] + ) + def test_get_size_happy_path( + self, file_path: Path, expected_size: int, tmp_path: Path): + # Arrange + file_path = tmp_path / file_path + file_path.write_bytes(b"\0" * expected_size) + + # Act + size = self.get_size(file_path) + + # Assert + assert size == expected_size + + @pytest.mark.parametrize( + "file_path, expected_size", + [ + (Path("./test_file_empty.txt"), 0) # id: edge_case_empty_file + ], + ids=["edge_case_empty_file"] + ) + def test_get_size_edge_cases( + self, file_path: Path, expected_size: int, tmp_path: Path): + # Arrange + file_path = tmp_path / file_path + file_path.touch() # Create an empty file + + # Act + size = self.get_size(file_path) + + # Assert + assert size == expected_size + + @pytest.mark.parametrize( + "file_path, expected_exception", + [ + ( + Path("./non_existent_file.txt"), + FileNotFoundError + ), # id: error_file_not_found + (123, TypeError) # id: error_invalid_input_type + ], + ids=["error_file_not_found", "error_invalid_input_type"] + ) + def test_get_size_error_cases( + self, file_path, expected_exception, tmp_path): + + # Act & Assert + with pytest.raises(expected_exception): + file_path = tmp_path / file_path + self.get_size(file_path) + + +def test_filter_lifecycle() -> None: + """Test filter_lifecycle.""" + integrator = IntegrateTraits() + persistent_representation = Representation( + name="test", + traits=[ + Persistent(), + FileLocation( + file_path=Path("test"), + file_size=1234), + Image(), + MimeType(mime_type="image/png"), + ]) + transient_representation = Representation( + name="test", + traits=[ + Transient(), + Image(), + MimeType(mime_type="image/png"), + ]) + filtered = integrator.filter_lifecycle( + [persistent_representation, transient_representation]) + + assert len(filtered) == 1 + assert filtered[0] == persistent_representation + + +@pytest.mark.server +def test_prepare_product( + project: pytest_ayon.ProjectInfo, + mock_context: pyblish.api.Context) -> None: + """Test prepare_product.""" + integrator = IntegrateTraits() + op_session = OperationsSession() + product = integrator.prepare_product(mock_context[0], op_session) + + assert product == { + "attrib": {}, + "data": { + "families": ["default", "render"], + }, + "folderId": project.folder_entity["id"], + "name": "renderMain", + "productType": "test_product", + "id": project.product_entity["id"], + } + + +@pytest.mark.server +def test_prepare_version( + project: pytest_ayon.ProjectInfo, + mock_context: pyblish.api.Context) -> None: + """Test prepare_version.""" + integrator = IntegrateTraits() + op_session = OperationsSession() + product = integrator.prepare_product(mock_context[0], op_session) + version = integrator.prepare_version( + mock_context[0], op_session, product) + + assert version == { + "attrib": { + "comment": "test_comment", + "families": ["default", "render"], + "fps": 25, + "machine": "test_machine", + "source": "test_source", + }, + "data": { + "author": "test_user", + "time": CURRENT_TIME, + }, + "id": project.version_entity["id"], + "productId": project.product_entity["id"], + "version": 1, + } + + +@pytest.mark.server +def test_get_transfers_from_representation( + mock_context: pyblish.api.Context) -> None: + """Test get_transfers_from_representation. + + This tests getting actual transfers from the representations and + also the legacy files. + + Todo: This test will benefit massively from a proper mocking of the + context. We need to parametrize the test with different + representations and test the output of the function. + + """ + integrator = IntegrateTraits() + + instance = mock_context[0] + representations: list[Representation] = instance.data[ + "representations_with_traits"] + transfers = integrator.get_transfers_from_representations( + instance, representations) + + assert len(representations) == 3 + assert len(transfers) == 22 + + for transfer in transfers: + assert transfer.checksum == TransferItem.get_checksum( + transfer.source) + + file_transactions = FileTransaction( + # Enforce unique transfers + allow_queue_replacements=False) + + for transfer in transfers: + file_transactions.add( + transfer.source.as_posix(), + transfer.destination.as_posix(), + mode=FileTransaction.MODE_COPY, + ) + + file_transactions.process() + + for representation in representations: + _ = integrator._get_legacy_files_for_representation( # noqa: SLF001 + transfers, representation, anatomy=instance.data["anatomy"]) diff --git a/tests/conftest.py b/tests/conftest.py index a3c46a9dd7..33c29d13f0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +"""conftest.py: pytest configuration file.""" import sys from pathlib import Path @@ -5,5 +6,3 @@ client_path = Path(__file__).resolve().parent.parent / "client" # add client path to sys.path sys.path.append(str(client_path)) - -print(f"Added {client_path} to sys.path") diff --git a/tools/manage.ps1 b/tools/manage.ps1 index 8324277713..306a61e30d 100755 --- a/tools/manage.ps1 +++ b/tools/manage.ps1 @@ -242,7 +242,7 @@ function Run-From-Code { function Run-Tests { $Poetry = "$RepoRoot\.poetry\bin\poetry.exe" - $RunArgs = @( "run", "pytest", "$($RepoRoot)/tests") + $RunArgs = @( "run", "pytest", "$($RepoRoot)/tests", "-m", "not server") & $Poetry $RunArgs @arguments } diff --git a/tools/manage.sh b/tools/manage.sh index 86ae7155c5..5362374045 100755 --- a/tools/manage.sh +++ b/tools/manage.sh @@ -186,7 +186,7 @@ run_command () { run_tests () { echo -e "${BIGreen}>>>${RST} Running tests..." shift; # will remove first arg ("run-tests") from the "$@" - "$POETRY_HOME/bin/poetry" run pytest ./tests + "$POETRY_HOME/bin/poetry" run pytest ./tests -m "not server" } main () {