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/lib/__init__.py b/client/ayon_core/lib/__init__.py index 8d8cc6af49..477eb29c28 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -62,6 +62,7 @@ from .execute import ( run_subprocess, run_detached_process, run_ayon_launcher_process, + run_detached_ayon_launcher_process, path_to_subprocess_arg, CREATE_NO_WINDOW ) @@ -131,6 +132,7 @@ from .ayon_info import ( is_staging_enabled, is_dev_mode_enabled, is_in_tests, + get_settings_variant, ) terminal = Terminal @@ -160,6 +162,7 @@ __all__ = [ "run_subprocess", "run_detached_process", "run_ayon_launcher_process", + "run_detached_ayon_launcher_process", "path_to_subprocess_arg", "CREATE_NO_WINDOW", @@ -240,4 +243,5 @@ __all__ = [ "is_staging_enabled", "is_dev_mode_enabled", "is_in_tests", + "get_settings_variant", ] diff --git a/client/ayon_core/lib/ayon_info.py b/client/ayon_core/lib/ayon_info.py index 7e194a824e..1a7e4cca76 100644 --- a/client/ayon_core/lib/ayon_info.py +++ b/client/ayon_core/lib/ayon_info.py @@ -78,15 +78,15 @@ def is_using_ayon_console(): return "ayon_console" in executable_filename -def is_headless_mode_enabled(): +def is_headless_mode_enabled() -> bool: return os.getenv("AYON_HEADLESS_MODE") == "1" -def is_staging_enabled(): +def is_staging_enabled() -> bool: return os.getenv("AYON_USE_STAGING") == "1" -def is_in_tests(): +def is_in_tests() -> bool: """Process is running in automatic tests mode. Returns: @@ -96,7 +96,7 @@ def is_in_tests(): return os.environ.get("AYON_IN_TESTS") == "1" -def is_dev_mode_enabled(): +def is_dev_mode_enabled() -> bool: """Dev mode is enabled in AYON. Returns: @@ -106,6 +106,22 @@ def is_dev_mode_enabled(): return os.getenv("AYON_USE_DEV") == "1" +def get_settings_variant() -> str: + """Get AYON settings variant. + + Returns: + str: Settings variant. + + """ + if is_dev_mode_enabled(): + return os.environ["AYON_BUNDLE_NAME"] + + if is_staging_enabled(): + return "staging" + + return "production" + + def get_ayon_info(): executable_args = get_ayon_launcher_args() if is_running_from_build(): diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index 516ea958f5..7c6efde35c 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import sys import subprocess @@ -201,29 +202,9 @@ def clean_envs_for_ayon_process(env=None): return env -def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs): - """Execute AYON process with passed arguments and wait. - - Wrapper for 'run_process' which prepends AYON executable arguments - before passed arguments and define environments if are not passed. - - Values from 'os.environ' are used for environments if are not passed. - They are cleaned using 'clean_envs_for_ayon_process' function. - - Example: - ``` - run_ayon_process("run", "") - ``` - - Args: - *args (str): ayon-launcher cli arguments. - **kwargs (Any): Keyword arguments for subprocess.Popen. - - Returns: - str: Full output of subprocess concatenated stdout and stderr. - - """ - args = get_ayon_launcher_args(*args) +def _prepare_ayon_launcher_env( + add_sys_paths: bool, kwargs: dict +) -> dict[str, str]: env = kwargs.pop("env", None) # Keep env untouched if are passed and not empty if not env: @@ -239,8 +220,7 @@ def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs): new_pythonpath.append(path) lookup_set.add(path) env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) - - return run_subprocess(args, env=env, **kwargs) + return env def run_detached_process(args, **kwargs): @@ -314,6 +294,67 @@ def run_detached_process(args, **kwargs): return process +def run_ayon_launcher_process( + *args, add_sys_paths: bool = False, **kwargs +) -> str: + """Execute AYON process with passed arguments and wait. + + Wrapper for 'run_process' which prepends AYON executable arguments + before passed arguments and define environments if are not passed. + + Values from 'os.environ' are used for environments if are not passed. + They are cleaned using 'clean_envs_for_ayon_process' function. + + Example: + ``` + run_ayon_launcher_process("run", "") + ``` + + Args: + *args (str): ayon-launcher cli arguments. + add_sys_paths (bool): Add system paths to PYTHONPATH. + **kwargs (Any): Keyword arguments for subprocess.Popen. + + Returns: + str: Full output of subprocess concatenated stdout and stderr. + + """ + args = get_ayon_launcher_args(*args) + env = _prepare_ayon_launcher_env(add_sys_paths, kwargs) + return run_subprocess(args, env=env, **kwargs) + + +def run_detached_ayon_launcher_process( + *args, add_sys_paths: bool = False, **kwargs +) -> subprocess.Popen: + """Execute AYON process with passed arguments and wait. + + Wrapper for 'run_process' which prepends AYON executable arguments + before passed arguments and define environments if are not passed. + + Values from 'os.environ' are used for environments if are not passed. + They are cleaned using 'clean_envs_for_ayon_process' function. + + Example: + ``` + run_detached_ayon_launcher_process("run", "") + ``` + + Args: + *args (str): ayon-launcher cli arguments. + add_sys_paths (bool): Add system paths to PYTHONPATH. + **kwargs (Any): Keyword arguments for subprocess.Popen. + + Returns: + subprocess.Popen: Pointer to launched process but it is possible that + launched process is already killed (on linux). + + """ + args = get_ayon_launcher_args(*args) + env = _prepare_ayon_launcher_env(add_sys_paths, kwargs) + return run_detached_process(args, env=env, **kwargs) + + def path_to_subprocess_arg(path): """Prepare path for subprocess arguments. diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 41bcd0dbd1..137736c302 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -100,6 +100,10 @@ from .context_tools import ( get_current_task_name ) +from .compatibility import ( + is_product_base_type_supported, +) + from .workfile import ( discover_workfile_build_plugins, register_workfile_build_plugin, @@ -223,4 +227,7 @@ __all__ = ( # Backwards compatible function names "install", "uninstall", + + # Feature detection + "is_product_base_type_supported", ) diff --git a/client/ayon_core/pipeline/compatibility.py b/client/ayon_core/pipeline/compatibility.py new file mode 100644 index 0000000000..f7d48526b7 --- /dev/null +++ b/client/ayon_core/pipeline/compatibility.py @@ -0,0 +1,16 @@ +"""Package to handle compatibility checks for pipeline components.""" + + +def is_product_base_type_supported() -> bool: + """Check support for product base types. + + This function checks if the current pipeline supports product base types. + Once this feature is implemented, it will return True. This should be used + in places where some kind of backward compatibility is needed to avoid + breaking existing functionality that relies on the current behavior. + + Returns: + bool: True if product base types are supported, False otherwise. + + """ + return False 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/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 87208f5574..3fc2185d1a 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import re import copy @@ -5,7 +6,8 @@ import json import shutil import subprocess from abc import ABC, abstractmethod -from typing import Dict, Any, Optional +from typing import Any, Optional +from dataclasses import dataclass, field import tempfile import clique @@ -35,6 +37,39 @@ from ayon_core.pipeline.publish import ( from ayon_core.pipeline.publish.lib import add_repre_files_for_cleanup +@dataclass +class TempData: + """Temporary data used across extractor's process.""" + fps: float + frame_start: int + frame_end: int + handle_start: int + handle_end: int + frame_start_handle: int + frame_end_handle: int + output_frame_start: int + output_frame_end: int + pixel_aspect: float + resolution_width: int + resolution_height: int + origin_repre: dict[str, Any] + input_is_sequence: bool + first_sequence_frame: int + input_allow_bg: bool + with_audio: bool + without_handles: bool + handles_are_set: bool + input_ext: str + explicit_input_paths: list[str] + paths_to_remove: list[str] + + # Set later + full_output_path: str = "" + filled_files: dict[int, str] = field(default_factory=dict) + output_ext_is_image: bool = True + output_is_sequence: bool = True + + def frame_to_timecode(frame: int, fps: float) -> str: """Convert a frame number and FPS to editorial timecode (HH:MM:SS:FF). @@ -405,10 +440,10 @@ class ExtractReview(pyblish.api.InstancePlugin): temp_data = self.prepare_temp_data(instance, repre, output_def) new_frame_files = {} - if temp_data["input_is_sequence"]: + if temp_data.input_is_sequence: self.log.debug("Checking sequence to fill gaps in sequence..") - files = temp_data["origin_repre"]["files"] + files = temp_data.origin_repre["files"] collections = clique.assemble( files, )[0] @@ -423,18 +458,18 @@ class ExtractReview(pyblish.api.InstancePlugin): new_frame_files = self.fill_sequence_gaps_from_existing( collection=collection, staging_dir=new_repre["stagingDir"], - start_frame=temp_data["frame_start"], - end_frame=temp_data["frame_end"], + start_frame=temp_data.frame_start, + end_frame=temp_data.frame_end, ) elif fill_missing_frames == "blank": new_frame_files = self.fill_sequence_gaps_with_blanks( collection=collection, staging_dir=new_repre["stagingDir"], - start_frame=temp_data["frame_start"], - end_frame=temp_data["frame_end"], - resolution_width=temp_data["resolution_width"], - resolution_height=temp_data["resolution_height"], - extension=temp_data["input_ext"], + start_frame=temp_data.frame_start, + end_frame=temp_data.frame_end, + resolution_width=temp_data.resolution_width, + resolution_height=temp_data.resolution_height, + extension=temp_data.input_ext, temp_data=temp_data ) elif fill_missing_frames == "previous_version": @@ -443,8 +478,8 @@ class ExtractReview(pyblish.api.InstancePlugin): staging_dir=new_repre["stagingDir"], instance=instance, current_repre_name=repre["name"], - start_frame=temp_data["frame_start"], - end_frame=temp_data["frame_end"], + start_frame=temp_data.frame_start, + end_frame=temp_data.frame_end, ) # fallback to original workflow if new_frame_files is None: @@ -452,11 +487,11 @@ class ExtractReview(pyblish.api.InstancePlugin): self.fill_sequence_gaps_from_existing( collection=collection, staging_dir=new_repre["stagingDir"], - start_frame=temp_data["frame_start"], - end_frame=temp_data["frame_end"], + start_frame=temp_data.frame_start, + end_frame=temp_data.frame_end, )) elif fill_missing_frames == "only_rendered": - temp_data["explicit_input_paths"] = [ + temp_data.explicit_input_paths = [ os.path.join( new_repre["stagingDir"], file ).replace("\\", "/") @@ -467,10 +502,10 @@ class ExtractReview(pyblish.api.InstancePlugin): # modify range for burnins instance.data["frameStart"] = frame_start instance.data["frameEnd"] = frame_end - temp_data["frame_start"] = frame_start - temp_data["frame_end"] = frame_end + temp_data.frame_start = frame_start + temp_data.frame_end = frame_end - temp_data["filled_files"] = new_frame_files + temp_data.filled_files = new_frame_files # create or update outputName output_name = new_repre.get("outputName", "") @@ -478,7 +513,7 @@ class ExtractReview(pyblish.api.InstancePlugin): if output_name: output_name += "_" output_name += output_def["filename_suffix"] - if temp_data["without_handles"]: + if temp_data.without_handles: output_name += "_noHandles" # add outputName to anatomy format fill_data @@ -491,7 +526,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # like Resolve or Premiere can detect the start frame for e.g. # review output files "timecode": frame_to_timecode( - frame=temp_data["frame_start_handle"], + frame=temp_data.frame_start_handle, fps=float(instance.data["fps"]) ) }) @@ -508,7 +543,7 @@ class ExtractReview(pyblish.api.InstancePlugin): except ZeroDivisionError: # TODO recalculate width and height using OIIO before # conversion - if 'exr' in temp_data["origin_repre"]["ext"]: + if 'exr' in temp_data.origin_repre["ext"]: self.log.warning( ( "Unsupported compression on input files." @@ -531,16 +566,16 @@ class ExtractReview(pyblish.api.InstancePlugin): for filepath in new_frame_files.values(): os.unlink(filepath) - for filepath in temp_data["paths_to_remove"]: + for filepath in temp_data.paths_to_remove: os.unlink(filepath) new_repre.update({ - "fps": temp_data["fps"], + "fps": temp_data.fps, "name": "{}_{}".format(output_name, output_ext), "outputName": output_name, "outputDef": output_def, - "frameStartFtrack": temp_data["output_frame_start"], - "frameEndFtrack": temp_data["output_frame_end"], + "frameStartFtrack": temp_data.output_frame_start, + "frameEndFtrack": temp_data.output_frame_end, "ffmpeg_cmd": subprcs_cmd }) @@ -566,7 +601,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # - there can be more than one collection return isinstance(repre["files"], (list, tuple)) - def prepare_temp_data(self, instance, repre, output_def): + def prepare_temp_data(self, instance, repre, output_def) -> TempData: """Prepare dictionary with values used across extractor's process. All data are collected from instance, context, origin representation @@ -582,7 +617,7 @@ class ExtractReview(pyblish.api.InstancePlugin): output_def (dict): Definition of output of this plugin. Returns: - dict: All data which are used across methods during process. + TempData: All data which are used across methods during process. Their values should not change during process but new keys with values may be added. """ @@ -647,30 +682,30 @@ class ExtractReview(pyblish.api.InstancePlugin): else: ext = os.path.splitext(repre["files"])[1].replace(".", "") - return { - "fps": float(instance.data["fps"]), - "frame_start": frame_start, - "frame_end": frame_end, - "handle_start": handle_start, - "handle_end": handle_end, - "frame_start_handle": frame_start_handle, - "frame_end_handle": frame_end_handle, - "output_frame_start": int(output_frame_start), - "output_frame_end": int(output_frame_end), - "pixel_aspect": instance.data.get("pixelAspect", 1), - "resolution_width": instance.data.get("resolutionWidth"), - "resolution_height": instance.data.get("resolutionHeight"), - "origin_repre": repre, - "input_is_sequence": input_is_sequence, - "first_sequence_frame": first_sequence_frame, - "input_allow_bg": input_allow_bg, - "with_audio": with_audio, - "without_handles": without_handles, - "handles_are_set": handles_are_set, - "input_ext": ext, - "explicit_input_paths": [], # absolute paths to rendered files - "paths_to_remove": [] - } + return TempData( + fps=float(instance.data["fps"]), + frame_start=frame_start, + frame_end=frame_end, + handle_start=handle_start, + handle_end=handle_end, + frame_start_handle=frame_start_handle, + frame_end_handle=frame_end_handle, + output_frame_start=int(output_frame_start), + output_frame_end=int(output_frame_end), + pixel_aspect=instance.data.get("pixelAspect", 1), + resolution_width=instance.data.get("resolutionWidth"), + resolution_height=instance.data.get("resolutionHeight"), + origin_repre=repre, + input_is_sequence=input_is_sequence, + first_sequence_frame=first_sequence_frame, + input_allow_bg=input_allow_bg, + with_audio=with_audio, + without_handles=without_handles, + handles_are_set=handles_are_set, + input_ext=ext, + explicit_input_paths=[], # absolute paths to rendered files + paths_to_remove=[] + ) def _ffmpeg_arguments( self, @@ -691,7 +726,7 @@ class ExtractReview(pyblish.api.InstancePlugin): instance (Instance): Currently processed instance. new_repre (dict): Representation representing output of this process. - temp_data (dict): Base data for successful process. + temp_data (TempData): Base data for successful process. """ # Get FFmpeg arguments from profile presets @@ -733,32 +768,32 @@ class ExtractReview(pyblish.api.InstancePlugin): # Set output frames len to 1 when output is single image if ( - temp_data["output_ext_is_image"] - and not temp_data["output_is_sequence"] + temp_data.output_ext_is_image + and not temp_data.output_is_sequence ): output_frames_len = 1 else: output_frames_len = ( - temp_data["output_frame_end"] - - temp_data["output_frame_start"] + temp_data.output_frame_end + - temp_data.output_frame_start + 1 ) - duration_seconds = float(output_frames_len / temp_data["fps"]) + duration_seconds = float(output_frames_len / temp_data.fps) # Define which layer should be used if layer_name: ffmpeg_input_args.extend(["-layer", layer_name]) - explicit_input_paths = temp_data["explicit_input_paths"] - if temp_data["input_is_sequence"] and not explicit_input_paths: + explicit_input_paths = temp_data.explicit_input_paths + if temp_data.input_is_sequence and not explicit_input_paths: # Set start frame of input sequence (just frame in filename) # - definition of input filepath # - add handle start if output should be without handles - start_number = temp_data["first_sequence_frame"] - if temp_data["without_handles"] and temp_data["handles_are_set"]: - start_number += temp_data["handle_start"] + start_number = temp_data.first_sequence_frame + if temp_data.without_handles and temp_data.handles_are_set: + start_number += temp_data.handle_start ffmpeg_input_args.extend([ "-start_number", str(start_number) ]) @@ -771,32 +806,32 @@ class ExtractReview(pyblish.api.InstancePlugin): # } # Add framerate to input when input is sequence ffmpeg_input_args.extend([ - "-framerate", str(temp_data["fps"]) + "-framerate", str(temp_data.fps) ]) # Add duration of an input sequence if output is video - if not temp_data["output_is_sequence"]: + if not temp_data.output_is_sequence: ffmpeg_input_args.extend([ "-to", "{:0.10f}".format(duration_seconds) ]) - if temp_data["output_is_sequence"] and not explicit_input_paths: + if temp_data.output_is_sequence and not explicit_input_paths: # Set start frame of output sequence (just frame in filename) # - this is definition of an output ffmpeg_output_args.extend([ - "-start_number", str(temp_data["output_frame_start"]) + "-start_number", str(temp_data.output_frame_start) ]) # Change output's duration and start point if should not contain # handles - if temp_data["without_handles"] and temp_data["handles_are_set"]: + if temp_data.without_handles and temp_data.handles_are_set: # Set output duration in seconds ffmpeg_output_args.extend([ "-t", "{:0.10}".format(duration_seconds) ]) # Add -ss (start offset in seconds) if input is not sequence - if not temp_data["input_is_sequence"]: - start_sec = float(temp_data["handle_start"]) / temp_data["fps"] + if not temp_data.input_is_sequence: + start_sec = float(temp_data.handle_start) / temp_data.fps # Set start time without handles # - Skip if start sec is 0.0 if start_sec > 0.0: @@ -805,7 +840,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ]) # Set frame range of output when input or output is sequence - elif temp_data["output_is_sequence"]: + elif temp_data.output_is_sequence: ffmpeg_output_args.extend([ "-frames:v", str(output_frames_len) ]) @@ -813,10 +848,10 @@ class ExtractReview(pyblish.api.InstancePlugin): if not explicit_input_paths: # Add video/image input path ffmpeg_input_args.extend([ - "-i", path_to_subprocess_arg(temp_data["full_input_path"]) + "-i", path_to_subprocess_arg(temp_data.full_input_path) ]) else: - frame_duration = 1 / temp_data["fps"] + frame_duration = 1 / temp_data.fps explicit_frames_meta = tempfile.NamedTemporaryFile( mode="w", prefix="explicit_frames", suffix=".txt", delete=False @@ -826,21 +861,21 @@ class ExtractReview(pyblish.api.InstancePlugin): with open(explicit_frames_path, "w") as fp: lines = [ f"file '{path}'{os.linesep}duration {frame_duration}" - for path in temp_data["explicit_input_paths"] + for path in temp_data.explicit_input_paths ] fp.write("\n".join(lines)) - temp_data["paths_to_remove"].append(explicit_frames_path) + temp_data.paths_to_remove.append(explicit_frames_path) # let ffmpeg use only rendered files, might have gaps ffmpeg_input_args.extend([ "-f", "concat", "-safe", "0", "-i", path_to_subprocess_arg(explicit_frames_path), - "-r", str(temp_data["fps"]) + "-r", str(temp_data.fps) ]) # Add audio arguments if there are any. Skipped when output are images. - if not temp_data["output_ext_is_image"] and temp_data["with_audio"]: + if not temp_data.output_ext_is_image and temp_data.with_audio: audio_in_args, audio_filters, audio_out_args = self.audio_args( instance, temp_data, duration_seconds ) @@ -862,7 +897,7 @@ class ExtractReview(pyblish.api.InstancePlugin): bg_red, bg_green, bg_blue, bg_alpha = bg_color if bg_alpha > 0.0: - if not temp_data["input_allow_bg"]: + if not temp_data.input_allow_bg: self.log.info(( "Output definition has defined BG color input was" " resolved as does not support adding BG." @@ -893,7 +928,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # NOTE This must be latest added item to output arguments. ffmpeg_output_args.append( - path_to_subprocess_arg(temp_data["full_output_path"]) + path_to_subprocess_arg(temp_data.full_output_path) ) return self.ffmpeg_full_args( @@ -985,7 +1020,7 @@ class ExtractReview(pyblish.api.InstancePlugin): current_repre_name: str, start_frame: int, end_frame: int - ) -> Optional[Dict[int, str]]: + ) -> Optional[dict[int, str]]: """Tries to replace missing frames from ones from last version""" repre_file_paths = self._get_last_version_files( instance, current_repre_name) @@ -1072,8 +1107,8 @@ class ExtractReview(pyblish.api.InstancePlugin): resolution_width: int, resolution_height: int, extension: str, - temp_data: Dict[str, Any] - ) -> Optional[Dict[int, str]]: + temp_data: TempData + ) -> Optional[dict[int, str]]: """Fills missing files by blank frame.""" blank_frame_path = None @@ -1089,7 +1124,7 @@ class ExtractReview(pyblish.api.InstancePlugin): blank_frame_path = self._create_blank_frame( staging_dir, extension, resolution_width, resolution_height ) - temp_data["paths_to_remove"].append(blank_frame_path) + temp_data.paths_to_remove.append(blank_frame_path) speedcopy.copyfile(blank_frame_path, hole_fpath) added_files[frame] = hole_fpath @@ -1129,7 +1164,7 @@ class ExtractReview(pyblish.api.InstancePlugin): staging_dir: str, start_frame: int, end_frame: int - ) -> Dict[int, str]: + ) -> dict[int, str]: """Fill missing files in sequence by duplicating existing ones. This will take nearest frame file and copy it with so as to fill @@ -1176,7 +1211,7 @@ class ExtractReview(pyblish.api.InstancePlugin): return added_files - def input_output_paths(self, new_repre, output_def, temp_data): + def input_output_paths(self, new_repre, output_def, temp_data: TempData): """Deduce input nad output file paths based on entered data. Input may be sequence of images, video file or single image file and @@ -1189,11 +1224,11 @@ class ExtractReview(pyblish.api.InstancePlugin): "sequence_file" (if output is sequence) keys to new representation. """ - repre = temp_data["origin_repre"] + repre = temp_data.origin_repre src_staging_dir = repre["stagingDir"] dst_staging_dir = new_repre["stagingDir"] - if temp_data["input_is_sequence"]: + if temp_data.input_is_sequence: collections = clique.assemble(repre["files"])[0] full_input_path = os.path.join( src_staging_dir, @@ -1218,13 +1253,13 @@ class ExtractReview(pyblish.api.InstancePlugin): # Make sure to have full path to one input file full_input_path_single_file = full_input_path - filled_files = temp_data["filled_files"] + filled_files = temp_data.filled_files if filled_files: first_frame, first_file = next(iter(filled_files.items())) if first_file < full_input_path_single_file: self.log.warning(f"Using filled frame: '{first_file}'") full_input_path_single_file = first_file - temp_data["first_sequence_frame"] = first_frame + temp_data.first_sequence_frame = first_frame filename_suffix = output_def["filename_suffix"] @@ -1252,8 +1287,8 @@ class ExtractReview(pyblish.api.InstancePlugin): ) if output_is_sequence: new_repre_files = [] - frame_start = temp_data["output_frame_start"] - frame_end = temp_data["output_frame_end"] + frame_start = temp_data.output_frame_start + frame_end = temp_data.output_frame_end filename_base = "{}_{}".format(filename, filename_suffix) # Temporary template for frame filling. Example output: @@ -1290,18 +1325,18 @@ class ExtractReview(pyblish.api.InstancePlugin): new_repre["stagingDir"] = dst_staging_dir # Store paths to temp data - temp_data["full_input_path"] = full_input_path - temp_data["full_input_path_single_file"] = full_input_path_single_file - temp_data["full_output_path"] = full_output_path + temp_data.full_input_path = full_input_path + temp_data.full_input_path_single_file = full_input_path_single_file + temp_data.full_output_path = full_output_path # Store information about output - temp_data["output_ext_is_image"] = output_ext_is_image - temp_data["output_is_sequence"] = output_is_sequence + temp_data.output_ext_is_image = output_ext_is_image + temp_data.output_is_sequence = output_is_sequence self.log.debug("Input path {}".format(full_input_path)) self.log.debug("Output path {}".format(full_output_path)) - def audio_args(self, instance, temp_data, duration_seconds): + def audio_args(self, instance, temp_data: TempData, duration_seconds): """Prepares FFMpeg arguments for audio inputs.""" audio_in_args = [] audio_filters = [] @@ -1318,7 +1353,7 @@ class ExtractReview(pyblish.api.InstancePlugin): frame_start_ftrack = instance.data.get("frameStartFtrack") if frame_start_ftrack is not None: offset_frames = frame_start_ftrack - audio["offset"] - offset_seconds = offset_frames / temp_data["fps"] + offset_seconds = offset_frames / temp_data.fps if offset_seconds > 0: audio_in_args.append( @@ -1502,7 +1537,7 @@ class ExtractReview(pyblish.api.InstancePlugin): return output - def rescaling_filters(self, temp_data, output_def, new_repre): + def rescaling_filters(self, temp_data: TempData, output_def, new_repre): """Prepare vieo filters based on tags in new representation. It is possible to add letterboxes to output video or rescale to @@ -1522,7 +1557,7 @@ class ExtractReview(pyblish.api.InstancePlugin): self.log.debug("reformat_in_baking: `{}`".format(reformat_in_baking)) # NOTE Skipped using instance's resolution - full_input_path_single_file = temp_data["full_input_path_single_file"] + full_input_path_single_file = temp_data.full_input_path_single_file try: streams = get_ffprobe_streams( full_input_path_single_file, self.log @@ -1547,7 +1582,7 @@ class ExtractReview(pyblish.api.InstancePlugin): break # Get instance data - pixel_aspect = temp_data["pixel_aspect"] + pixel_aspect = temp_data.pixel_aspect if reformat_in_baking: self.log.debug(( "Using resolution from input. It is already " @@ -1642,8 +1677,8 @@ class ExtractReview(pyblish.api.InstancePlugin): # - use instance resolution only if there were not scale changes # that may massivelly affect output 'use_input_res' if not use_input_res and output_width is None or output_height is None: - output_width = temp_data["resolution_width"] - output_height = temp_data["resolution_height"] + output_width = temp_data.resolution_width + output_height = temp_data.resolution_height # Use source's input resolution instance does not have set it. if output_width is None or output_height is None: 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/settings/lib.py b/client/ayon_core/settings/lib.py index aa56fa8326..72af07799f 100644 --- a/client/ayon_core/settings/lib.py +++ b/client/ayon_core/settings/lib.py @@ -56,14 +56,9 @@ class _AyonSettingsCache: @classmethod def _get_variant(cls): if _AyonSettingsCache.variant is None: - from ayon_core.lib import is_staging_enabled, is_dev_mode_enabled - - variant = "production" - if is_dev_mode_enabled(): - variant = cls._get_bundle_name() - elif is_staging_enabled(): - variant = "staging" + from ayon_core.lib import get_settings_variant + variant = get_settings_variant() # Cache variant _AyonSettingsCache.variant = variant diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 0e19702d53..4ef903540e 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -829,6 +829,37 @@ HintedLineEditButton { } /* Launcher specific stylesheets */ +ActionsView[mode="icon"] { + /* font size can't be set on items */ + font-size: 9pt; + border: 0px; + padding: 0px; + margin: 0px; +} + +ActionsView[mode="icon"]::item { + padding-top: 8px; + padding-bottom: 4px; + border: 0px; + border-radius: 0.3em; +} + +ActionsView[mode="icon"]::item:hover { + color: {color:font-hover}; + background: #424A57; +} + +ActionsView[mode="icon"]::icon {} + +ActionMenuPopup #Wrapper { + border-radius: 0.3em; + background: #353B46; +} +ActionMenuPopup ActionsView[mode="icon"] { + background: transparent; + border: none; +} + #IconView[mode="icon"] { /* font size can't be set on items */ font-size: 9pt; diff --git a/client/ayon_core/tools/attribute_defs/dialog.py b/client/ayon_core/tools/attribute_defs/dialog.py index ef717d576a..7423d58475 100644 --- a/client/ayon_core/tools/attribute_defs/dialog.py +++ b/client/ayon_core/tools/attribute_defs/dialog.py @@ -1,22 +1,58 @@ -from qtpy import QtWidgets +from __future__ import annotations + +from typing import Optional + +from qtpy import QtWidgets, QtGui + +from ayon_core.style import load_stylesheet +from ayon_core.resources import get_ayon_icon_filepath +from ayon_core.lib import AbstractAttrDef from .widgets import AttributeDefinitionsWidget class AttributeDefinitionsDialog(QtWidgets.QDialog): - def __init__(self, attr_defs, parent=None): - super(AttributeDefinitionsDialog, self).__init__(parent) + def __init__( + self, + attr_defs: list[AbstractAttrDef], + title: Optional[str] = None, + submit_label: Optional[str] = None, + cancel_label: Optional[str] = None, + submit_icon: Optional[QtGui.QIcon] = None, + cancel_icon: Optional[QtGui.QIcon] = None, + parent: Optional[QtWidgets.QWidget] = None, + ): + super().__init__(parent) + + if title: + self.setWindowTitle(title) + + icon = QtGui.QIcon(get_ayon_icon_filepath()) + self.setWindowIcon(icon) + self.setStyleSheet(load_stylesheet()) attrs_widget = AttributeDefinitionsWidget(attr_defs, self) + if submit_label is None: + submit_label = "OK" + + if cancel_label is None: + cancel_label = "Cancel" + btns_widget = QtWidgets.QWidget(self) - ok_btn = QtWidgets.QPushButton("OK", btns_widget) - cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget) + cancel_btn = QtWidgets.QPushButton(cancel_label, btns_widget) + submit_btn = QtWidgets.QPushButton(submit_label, btns_widget) + + if submit_icon is not None: + submit_btn.setIcon(submit_icon) + + if cancel_icon is not None: + cancel_btn.setIcon(cancel_icon) btns_layout = QtWidgets.QHBoxLayout(btns_widget) btns_layout.setContentsMargins(0, 0, 0, 0) btns_layout.addStretch(1) - btns_layout.addWidget(ok_btn, 0) + btns_layout.addWidget(submit_btn, 0) btns_layout.addWidget(cancel_btn, 0) main_layout = QtWidgets.QVBoxLayout(self) @@ -24,10 +60,33 @@ class AttributeDefinitionsDialog(QtWidgets.QDialog): main_layout.addStretch(1) main_layout.addWidget(btns_widget, 0) - ok_btn.clicked.connect(self.accept) + submit_btn.clicked.connect(self.accept) cancel_btn.clicked.connect(self.reject) self._attrs_widget = attrs_widget + self._submit_btn = submit_btn + self._cancel_btn = cancel_btn def get_values(self): return self._attrs_widget.current_value() + + def set_values(self, values): + self._attrs_widget.set_value(values) + + def set_submit_label(self, text: str): + self._submit_btn.setText(text) + + def set_submit_icon(self, icon: QtGui.QIcon): + self._submit_btn.setIcon(icon) + + def set_submit_visible(self, visible: bool): + self._submit_btn.setVisible(visible) + + def set_cancel_label(self, text: str): + self._cancel_btn.setText(text) + + def set_cancel_icon(self, icon: QtGui.QIcon): + self._cancel_btn.setIcon(icon) + + def set_cancel_visible(self, visible: bool): + self._cancel_btn.setVisible(visible) diff --git a/client/ayon_core/tools/attribute_defs/widgets.py b/client/ayon_core/tools/attribute_defs/widgets.py index dbd65fd215..1e948b2d28 100644 --- a/client/ayon_core/tools/attribute_defs/widgets.py +++ b/client/ayon_core/tools/attribute_defs/widgets.py @@ -22,6 +22,7 @@ from ayon_core.tools.utils import ( FocusSpinBox, FocusDoubleSpinBox, MultiSelectionComboBox, + MarkdownLabel, PlaceholderLineEdit, PlaceholderPlainTextEdit, set_style_property, @@ -247,12 +248,10 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): def set_value(self, value): new_value = copy.deepcopy(value) - unused_keys = set(new_value.keys()) for widget in self._widgets_by_id.values(): attr_def = widget.attr_def if attr_def.key not in new_value: continue - unused_keys.remove(attr_def.key) widget_value = new_value[attr_def.key] if widget_value is None: @@ -350,7 +349,7 @@ class SeparatorAttrWidget(_BaseAttrDefWidget): class LabelAttrWidget(_BaseAttrDefWidget): def _ui_init(self): - input_widget = QtWidgets.QLabel(self) + input_widget = MarkdownLabel(self) label = self.attr_def.label if label: input_widget.setText(str(label)) diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index ea0842f24d..1d7dafd62f 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -1,4 +1,59 @@ +from __future__ import annotations + from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Optional, Any + +from ayon_core.tools.common_models import ( + ProjectItem, + FolderItem, + FolderTypeItem, + TaskItem, + TaskTypeItem, +) + + +@dataclass +class WebactionContext: + """Context used for methods related to webactions.""" + identifier: str + project_name: str + folder_id: str + task_id: str + addon_name: str + addon_version: str + + +@dataclass +class ActionItem: + """Item representing single action to trigger. + + Attributes: + action_type (Literal["webaction", "local"]): Type of action. + identifier (str): Unique identifier of action item. + order (int): Action ordering. + label (str): Action label. + variant_label (Union[str, None]): Variant label, full label is + concatenated with space. Actions are grouped under single + action if it has same 'label' and have set 'variant_label'. + full_label (str): Full label, if not set it is generated + from 'label' and 'variant_label'. + icon (dict[str, str]): Icon definition. + addon_name (Optional[str]): Addon name. + addon_version (Optional[str]): Addon version. + config_fields (list[dict]): Config fields for webaction. + + """ + action_type: str + identifier: str + order: int + label: str + variant_label: Optional[str] + full_label: str + icon: Optional[dict[str, str]] + config_fields: list[dict] + addon_name: Optional[str] = None + addon_version: Optional[str] = None class AbstractLauncherCommon(ABC): @@ -88,7 +143,9 @@ class AbstractLauncherBackend(AbstractLauncherCommon): class AbstractLauncherFrontEnd(AbstractLauncherCommon): # Entity items for UI @abstractmethod - def get_project_items(self, sender=None): + def get_project_items( + self, sender: Optional[str] = None + ) -> list[ProjectItem]: """Project items for all projects. This function may trigger events 'projects.refresh.started' and @@ -106,7 +163,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_folder_type_items(self, project_name, sender=None): + def get_folder_type_items( + self, project_name: str, sender: Optional[str] = None + ) -> list[FolderTypeItem]: """Folder type items for a project. This function may trigger events with topics @@ -126,7 +185,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_task_type_items(self, project_name, sender=None): + def get_task_type_items( + self, project_name: str, sender: Optional[str] = None + ) -> list[TaskTypeItem]: """Task type items for a project. This function may trigger events with topics @@ -146,7 +207,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_folder_items(self, project_name, sender=None): + def get_folder_items( + self, project_name: str, sender: Optional[str] = None + ) -> list[FolderItem]: """Folder items to visualize project hierarchy. This function may trigger events 'folders.refresh.started' and @@ -165,7 +228,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_task_items(self, project_name, folder_id, sender=None): + def get_task_items( + self, project_name: str, folder_id: str, sender: Optional[str] = None + ) -> list[TaskItem]: """Task items. This function may trigger events 'tasks.refresh.started' and @@ -185,7 +250,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_selected_project_name(self): + def get_selected_project_name(self) -> Optional[str]: """Selected project name. Returns: @@ -195,7 +260,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_selected_folder_id(self): + def get_selected_folder_id(self) -> Optional[str]: """Selected folder id. Returns: @@ -205,7 +270,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_selected_task_id(self): + def get_selected_task_id(self) -> Optional[str]: """Selected task id. Returns: @@ -215,7 +280,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_selected_task_name(self): + def get_selected_task_name(self) -> Optional[str]: """Selected task name. Returns: @@ -225,7 +290,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_selected_context(self): + def get_selected_context(self) -> dict[str, Optional[str]]: """Get whole selected context. Example: @@ -243,7 +308,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def set_selected_project(self, project_name): + def set_selected_project(self, project_name: Optional[str]): """Change selected folder. Args: @@ -254,7 +319,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def set_selected_folder(self, folder_id): + def set_selected_folder(self, folder_id: Optional[str]): """Change selected folder. Args: @@ -265,7 +330,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def set_selected_task(self, task_id, task_name): + def set_selected_task( + self, task_id: Optional[str], task_name: Optional[str] + ): """Change selected task. Args: @@ -279,7 +346,12 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): # Actions @abstractmethod - def get_action_items(self, project_name, folder_id, task_id): + def get_action_items( + self, + project_name: Optional[str], + folder_id: Optional[str], + task_id: Optional[str], + ) -> list[ActionItem]: """Get action items for given context. Args: @@ -295,30 +367,67 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def trigger_action(self, project_name, folder_id, task_id, action_id): + def trigger_action( + self, + action_id: str, + project_name: Optional[str], + folder_id: Optional[str], + task_id: Optional[str], + ): """Trigger action on given context. Args: + action_id (str): Action identifier. project_name (Union[str, None]): Project name. folder_id (Union[str, None]): Folder id. task_id (Union[str, None]): Task id. - action_id (str): Action identifier. """ pass @abstractmethod - def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_ids, enabled + def trigger_webaction( + self, + context: WebactionContext, + action_label: str, + form_data: Optional[dict[str, Any]] = None, ): - """This is application action related to force not open last workfile. + """Trigger action on the given context. Args: - project_name (Union[str, None]): Project name. - folder_id (Union[str, None]): Folder id. - task_id (Union[str, None]): Task id. - action_ids (Iterable[str]): Action identifiers. - enabled (bool): New value of force not open workfile. + context (WebactionContext): Webaction context. + action_label (str): Action label. + form_data (Optional[dict[str, Any]]): Form values of action. + + """ + pass + + @abstractmethod + def get_action_config_values( + self, context: WebactionContext + ) -> dict[str, Any]: + """Get action config values. + + Args: + context (WebactionContext): Webaction context. + + Returns: + dict[str, Any]: Action config values. + + """ + pass + + @abstractmethod + def set_action_config_values( + self, + context: WebactionContext, + values: dict[str, Any], + ): + """Set action config values. + + Args: + context (WebactionContext): Webaction context. + values (dict[str, Any]): Action config values. """ pass @@ -343,14 +452,16 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_my_tasks_entity_ids(self, project_name: str): + def get_my_tasks_entity_ids( + self, project_name: str + ) -> dict[str, list[str]]: """Get entity ids for my tasks. Args: project_name (str): Project name. Returns: - dict[str, Union[list[str]]]: Folder and task ids. + dict[str, list[str]]: Folder and task ids. """ pass diff --git a/client/ayon_core/tools/launcher/control.py b/client/ayon_core/tools/launcher/control.py index 45cb2b7945..fb9f950bd1 100644 --- a/client/ayon_core/tools/launcher/control.py +++ b/client/ayon_core/tools/launcher/control.py @@ -32,7 +32,7 @@ class BaseLauncherController( @property def event_system(self): - """Inner event system for workfiles tool controller. + """Inner event system for launcher tool controller. Is used for communication with UI. Event system is created on demand. @@ -135,16 +135,30 @@ class BaseLauncherController( return self._actions_model.get_action_items( project_name, folder_id, task_id) - def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_ids, enabled + def trigger_action( + self, + identifier, + project_name, + folder_id, + task_id, ): - self._actions_model.set_application_force_not_open_workfile( - project_name, folder_id, task_id, action_ids, enabled + self._actions_model.trigger_action( + identifier, + project_name, + folder_id, + task_id, ) - def trigger_action(self, project_name, folder_id, task_id, identifier): - self._actions_model.trigger_action( - project_name, folder_id, task_id, identifier) + def trigger_webaction(self, context, action_label, form_data=None): + self._actions_model.trigger_webaction( + context, action_label, form_data + ) + + def get_action_config_values(self, context): + return self._actions_model.get_action_config_values(context) + + def set_action_config_values(self, context, values): + return self._actions_model.set_action_config_values(context, values) # General methods def refresh(self): diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index e1612e2b9f..0ed4bdad3a 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -1,219 +1,47 @@ import os +import uuid +from dataclasses import dataclass, asdict +from urllib.parse import urlencode, urlparse +from typing import Any, Optional +import webbrowser + +import ayon_api from ayon_core import resources -from ayon_core.lib import Logger, AYONSettingsRegistry +from ayon_core.lib import ( + Logger, + NestedCacheItem, + CacheItem, + get_settings_variant, + run_detached_ayon_launcher_process, +) from ayon_core.addon import AddonsManager from ayon_core.pipeline.actions import ( discover_launcher_actions, - LauncherAction, LauncherActionSelection, register_launcher_action_path, ) -from ayon_core.pipeline.workfile import should_use_last_workfile_on_launch - -try: - # Available since applications addon 0.2.4 - from ayon_applications.action import ApplicationAction -except ImportError: - # Backwards compatibility from 0.3.3 (24/06/10) - # TODO: Remove in future releases - class ApplicationAction(LauncherAction): - """Action to launch an application. - - Application action based on 'ApplicationManager' system. - - Handling of applications in launcher is not ideal and should be - completely redone from scratch. This is just a temporary solution - to keep backwards compatibility with AYON launcher. - - Todos: - Move handling of errors to frontend. - """ - - # Application object - application = None - # Action attributes - name = None - label = None - label_variant = None - group = None - icon = None - color = None - order = 0 - data = {} - project_settings = {} - project_entities = {} - - _log = None - - @property - def log(self): - if self._log is None: - self._log = Logger.get_logger(self.__class__.__name__) - return self._log - - def is_compatible(self, selection): - if not selection.is_task_selected: - return False - - project_entity = self.project_entities[selection.project_name] - apps = project_entity["attrib"].get("applications") - if not apps or self.application.full_name not in apps: - return False - - project_settings = self.project_settings[selection.project_name] - only_available = project_settings["applications"]["only_available"] - if only_available and not self.application.find_executable(): - return False - return True - - def _show_message_box(self, title, message, details=None): - from qtpy import QtWidgets, QtGui - from ayon_core import style - - dialog = QtWidgets.QMessageBox() - icon = QtGui.QIcon(resources.get_ayon_icon_filepath()) - dialog.setWindowIcon(icon) - dialog.setStyleSheet(style.load_stylesheet()) - dialog.setWindowTitle(title) - dialog.setText(message) - if details: - dialog.setDetailedText(details) - dialog.exec_() - - def process(self, selection, **kwargs): - """Process the full Application action""" - - from ayon_applications import ( - ApplicationExecutableNotFound, - ApplicationLaunchFailed, - ) - - try: - self.application.launch( - project_name=selection.project_name, - folder_path=selection.folder_path, - task_name=selection.task_name, - **self.data - ) - - except ApplicationExecutableNotFound as exc: - details = exc.details - msg = exc.msg - log_msg = str(msg) - if details: - log_msg += "\n" + details - self.log.warning(log_msg) - self._show_message_box( - "Application executable not found", msg, details - ) - - except ApplicationLaunchFailed as exc: - msg = str(exc) - self.log.warning(msg, exc_info=True) - self._show_message_box("Application launch failed", msg) +from ayon_core.tools.launcher.abstract import ActionItem, WebactionContext -# class Action: -# def __init__(self, label, icon=None, identifier=None): -# self._label = label -# self._icon = icon -# self._callbacks = [] -# self._identifier = identifier or uuid.uuid4().hex -# self._checked = True -# self._checkable = False -# -# def set_checked(self, checked): -# self._checked = checked -# -# def set_checkable(self, checkable): -# self._checkable = checkable -# -# def set_label(self, label): -# self._label = label -# -# def add_callback(self, callback): -# self._callbacks = callback -# -# -# class Menu: -# def __init__(self, label, icon=None): -# self.label = label -# self.icon = icon -# self._actions = [] -# -# def add_action(self, action): -# self._actions.append(action) +@dataclass +class WebactionForm: + fields: list[dict[str, Any]] + title: str + submit_label: str + submit_icon: str + cancel_label: str + cancel_icon: str -class ActionItem: - """Item representing single action to trigger. - - Todos: - Get rid of application specific logic. - - Args: - identifier (str): Unique identifier of action item. - label (str): Action label. - variant_label (Union[str, None]): Variant label, full label is - concatenated with space. Actions are grouped under single - action if it has same 'label' and have set 'variant_label'. - icon (dict[str, str]): Icon definition. - order (int): Action ordering. - is_application (bool): Is action application action. - force_not_open_workfile (bool): Force not open workfile. Application - related. - full_label (Optional[str]): Full label, if not set it is generated - from 'label' and 'variant_label'. - """ - - def __init__( - self, - identifier, - label, - variant_label, - icon, - order, - is_application, - force_not_open_workfile, - full_label=None - ): - self.identifier = identifier - self.label = label - self.variant_label = variant_label - self.icon = icon - self.order = order - self.is_application = is_application - self.force_not_open_workfile = force_not_open_workfile - self._full_label = full_label - - def copy(self): - return self.from_data(self.to_data()) - - @property - def full_label(self): - if self._full_label is None: - if self.variant_label: - self._full_label = " ".join([self.label, self.variant_label]) - else: - self._full_label = self.label - return self._full_label - - def to_data(self): - return { - "identifier": self.identifier, - "label": self.label, - "variant_label": self.variant_label, - "icon": self.icon, - "order": self.order, - "is_application": self.is_application, - "force_not_open_workfile": self.force_not_open_workfile, - "full_label": self._full_label, - } - - @classmethod - def from_data(cls, data): - return cls(**data) +@dataclass +class WebactionResponse: + response_type: str + success: bool + message: Optional[str] = None + clipboard_text: Optional[str] = None + form: Optional[WebactionForm] = None + error_message: Optional[str] = None def get_action_icon(action): @@ -264,8 +92,6 @@ class ActionsModel: controller (AbstractLauncherBackend): Controller instance. """ - _not_open_workfile_reg_key = "force_not_open_workfile" - def __init__(self, controller): self._controller = controller @@ -274,11 +100,21 @@ class ActionsModel: self._discovered_actions = None self._actions = None self._action_items = {} - - self._launcher_tool_reg = AYONSettingsRegistry("launcher_tool") + self._webaction_items = NestedCacheItem( + levels=2, default_factory=list, lifetime=20, + ) self._addons_manager = None + self._variant = get_settings_variant() + + @staticmethod + def calculate_full_label(label: str, variant_label: Optional[str]) -> str: + """Calculate full label from label and variant_label.""" + if variant_label: + return " ".join([label, variant_label]) + return label + @property def log(self): if self._log is None: @@ -289,39 +125,12 @@ class ActionsModel: self._discovered_actions = None self._actions = None self._action_items = {} + self._webaction_items.reset() self._controller.emit_event("actions.refresh.started") self._get_action_objects() self._controller.emit_event("actions.refresh.finished") - def _should_start_last_workfile( - self, - project_name, - task_id, - identifier, - host_name, - not_open_workfile_actions - ): - if identifier in not_open_workfile_actions: - return not not_open_workfile_actions[identifier] - - task_name = None - task_type = None - if task_id is not None: - task_entity = self._controller.get_task_entity( - project_name, task_id - ) - task_name = task_entity["name"] - task_type = task_entity["taskType"] - - output = should_use_last_workfile_on_launch( - project_name, - host_name, - task_name, - task_type - ) - return output - def get_action_items(self, project_name, folder_id, task_id): """Get actions for project. @@ -332,53 +141,31 @@ class ActionsModel: Returns: list[ActionItem]: List of actions. + """ - not_open_workfile_actions = self._get_no_last_workfile_for_context( - project_name, folder_id, task_id) selection = self._prepare_selection(project_name, folder_id, task_id) output = [] action_items = self._get_action_items(project_name) for identifier, action in self._get_action_objects().items(): - if not action.is_compatible(selection): - continue + if action.is_compatible(selection): + output.append(action_items[identifier]) + output.extend(self._get_webactions(selection)) - action_item = action_items[identifier] - # Handling of 'force_not_open_workfile' for applications - if action_item.is_application: - action_item = action_item.copy() - start_last_workfile = self._should_start_last_workfile( - project_name, - task_id, - identifier, - action.application.host_name, - not_open_workfile_actions - ) - action_item.force_not_open_workfile = ( - not start_last_workfile - ) - - output.append(action_item) return output - def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_ids, enabled + def trigger_action( + self, + identifier, + project_name, + folder_id, + task_id, ): - no_workfile_reg_data = self._get_no_last_workfile_reg_data() - project_data = no_workfile_reg_data.setdefault(project_name, {}) - folder_data = project_data.setdefault(folder_id, {}) - task_data = folder_data.setdefault(task_id, {}) - for action_id in action_ids: - task_data[action_id] = enabled - self._launcher_tool_reg.set_item( - self._not_open_workfile_reg_key, no_workfile_reg_data - ) - - def trigger_action(self, project_name, folder_id, task_id, identifier): selection = self._prepare_selection(project_name, folder_id, task_id) failed = False error_message = None action_label = identifier action_items = self._get_action_items(project_name) + trigger_id = uuid.uuid4().hex try: action = self._actions[identifier] action_item = action_items[identifier] @@ -386,22 +173,11 @@ class ActionsModel: self._controller.emit_event( "action.trigger.started", { + "trigger_id": trigger_id, "identifier": identifier, "full_label": action_label, } ) - if isinstance(action, ApplicationAction): - per_action = self._get_no_last_workfile_for_context( - project_name, folder_id, task_id - ) - start_last_workfile = self._should_start_last_workfile( - project_name, - task_id, - identifier, - action.application.host_name, - per_action - ) - action.data["start_last_workfile"] = start_last_workfile action.process(selection) except Exception as exc: @@ -412,6 +188,7 @@ class ActionsModel: self._controller.emit_event( "action.trigger.finished", { + "trigger_id": trigger_id, "identifier": identifier, "failed": failed, "error_message": error_message, @@ -419,32 +196,148 @@ class ActionsModel: } ) + def trigger_webaction(self, context, action_label, form_data): + entity_type = None + entity_ids = [] + identifier = context.identifier + folder_id = context.folder_id + task_id = context.task_id + project_name = context.project_name + addon_name = context.addon_name + addon_version = context.addon_version + + if task_id: + entity_type = "task" + entity_ids.append(task_id) + elif folder_id: + entity_type = "folder" + entity_ids.append(folder_id) + + query = { + "addonName": addon_name, + "addonVersion": addon_version, + "identifier": identifier, + "variant": self._variant, + } + url = f"actions/execute?{urlencode(query)}" + request_data = { + "projectName": project_name, + "entityType": entity_type, + "entityIds": entity_ids, + } + if form_data is not None: + request_data["formData"] = form_data + + trigger_id = uuid.uuid4().hex + failed = False + try: + self._controller.emit_event( + "webaction.trigger.started", + { + "trigger_id": trigger_id, + "identifier": identifier, + "full_label": action_label, + } + ) + + conn = ayon_api.get_server_api_connection() + # Add 'referer' header to the request + # - ayon-api 1.1.1 adds the value to the header automatically + headers = conn.get_headers() + if "referer" in headers: + headers = None + else: + headers["referer"] = conn.get_base_url() + response = ayon_api.raw_post( + url, headers=headers, json=request_data + ) + response.raise_for_status() + handle_response = self._handle_webaction_response(response.data) + + except Exception: + failed = True + self.log.warning("Action trigger failed.", exc_info=True) + handle_response = WebactionResponse( + "unknown", + False, + error_message="Failed to trigger webaction.", + ) + + data = asdict(handle_response) + data.update({ + "trigger_failed": failed, + "trigger_id": trigger_id, + "identifier": identifier, + "full_label": action_label, + "project_name": project_name, + "folder_id": folder_id, + "task_id": task_id, + "addon_name": addon_name, + "addon_version": addon_version, + }) + self._controller.emit_event( + "webaction.trigger.finished", + data, + ) + + def get_action_config_values(self, context: WebactionContext): + selection = self._prepare_selection( + context.project_name, context.folder_id, context.task_id + ) + if not selection.is_project_selected: + return {} + + request_data = self._get_webaction_request_data(selection) + + query = { + "addonName": context.addon_name, + "addonVersion": context.addon_version, + "identifier": context.identifier, + "variant": self._variant, + } + url = f"actions/config?{urlencode(query)}" + try: + response = ayon_api.post(url, **request_data) + response.raise_for_status() + except Exception: + self.log.warning( + "Failed to collect webaction config values.", + exc_info=True + ) + return {} + return response.data + + def set_action_config_values(self, context, values): + selection = self._prepare_selection( + context.project_name, context.folder_id, context.task_id + ) + if not selection.is_project_selected: + return {} + + request_data = self._get_webaction_request_data(selection) + request_data["value"] = values + + query = { + "addonName": context.addon_name, + "addonVersion": context.addon_version, + "identifier": context.identifier, + "variant": self._variant, + } + url = f"actions/config?{urlencode(query)}" + try: + response = ayon_api.post(url, **request_data) + response.raise_for_status() + except Exception: + self.log.warning( + "Failed to store webaction config values.", + exc_info=True + ) + def _get_addons_manager(self): if self._addons_manager is None: self._addons_manager = AddonsManager() return self._addons_manager - def _get_no_last_workfile_reg_data(self): - try: - no_workfile_reg_data = self._launcher_tool_reg.get_item( - self._not_open_workfile_reg_key) - except ValueError: - no_workfile_reg_data = {} - self._launcher_tool_reg.set_item( - self._not_open_workfile_reg_key, no_workfile_reg_data) - return no_workfile_reg_data - - def _get_no_last_workfile_for_context( - self, project_name, folder_id, task_id - ): - not_open_workfile_reg_data = self._get_no_last_workfile_reg_data() - return ( - not_open_workfile_reg_data - .get(project_name, {}) - .get(folder_id, {}) - .get(task_id, {}) - ) - def _prepare_selection(self, project_name, folder_id, task_id): project_entity = None if project_name: @@ -458,6 +351,179 @@ class ActionsModel: project_settings=project_settings, ) + def _get_webaction_request_data(self, selection: LauncherActionSelection): + if not selection.is_project_selected: + return None + + entity_type = None + entity_id = None + entity_subtypes = [] + if selection.is_task_selected: + entity_type = "task" + entity_id = selection.task_entity["id"] + entity_subtypes = [selection.task_entity["taskType"]] + + elif selection.is_folder_selected: + entity_type = "folder" + entity_id = selection.folder_entity["id"] + entity_subtypes = [selection.folder_entity["folderType"]] + + entity_ids = [] + if entity_id: + entity_ids.append(entity_id) + + project_name = selection.project_name + return { + "projectName": project_name, + "entityType": entity_type, + "entitySubtypes": entity_subtypes, + "entityIds": entity_ids, + } + + def _get_webactions(self, selection: LauncherActionSelection): + if not selection.is_project_selected: + return [] + + request_data = self._get_webaction_request_data(selection) + project_name = selection.project_name + entity_id = None + if request_data["entityIds"]: + entity_id = request_data["entityIds"][0] + + cache: CacheItem = self._webaction_items[project_name][entity_id] + if cache.is_valid: + return cache.get_data() + + try: + response = ayon_api.post("actions/list", **request_data) + response.raise_for_status() + except Exception: + self.log.warning("Failed to collect webactions.", exc_info=True) + return [] + + action_items = [] + for action in response.data["actions"]: + # NOTE Settings variant may be important for triggering? + # - action["variant"] + icon = action.get("icon") + if icon and icon["type"] == "url": + if not urlparse(icon["url"]).scheme: + icon["type"] = "ayon_url" + + config_fields = action.get("configFields") or [] + variant_label = action["label"] + group_label = action.get("groupLabel") + if not group_label: + group_label = variant_label + variant_label = None + + full_label = self.calculate_full_label( + group_label, variant_label + ) + action_items.append(ActionItem( + action_type="webaction", + identifier=action["identifier"], + order=action["order"], + label=group_label, + variant_label=variant_label, + full_label=full_label, + icon=icon, + addon_name=action["addonName"], + addon_version=action["addonVersion"], + config_fields=config_fields, + # category=action["category"], + )) + + cache.update_data(action_items) + return cache.get_data() + + def _handle_webaction_response(self, data) -> WebactionResponse: + response_type = data["type"] + # Backwards compatibility -> 'server' type is not available since + # AYON backend 1.8.3 + if response_type == "server": + return WebactionResponse( + response_type, + False, + error_message="Please use AYON web UI to run the action.", + ) + + payload = data.get("payload") or {} + + download_uri = payload.get("extra_download") + if download_uri is not None: + # Find out if is relative or absolute URL + if not urlparse(download_uri).scheme: + ayon_url = ayon_api.get_base_url().rstrip("/") + path = download_uri.lstrip("/") + download_uri = f"{ayon_url}/{path}" + + # Use webbrowser to open file + webbrowser.open_new_tab(download_uri) + + response = WebactionResponse( + response_type, + data["success"], + data.get("message"), + payload.get("extra_clipboard"), + ) + if response_type == "simple": + pass + + elif response_type == "redirect": + # NOTE unused 'newTab' key because we always have to + # open new tab from desktop app. + if not webbrowser.open_new_tab(payload["uri"]): + payload.error_message = "Failed to open web browser." + + elif response_type == "form": + submit_icon = payload["submit_icon"] or None + cancel_icon = payload["cancel_icon"] or None + if submit_icon: + submit_icon = { + "type": "material-symbols", + "name": submit_icon, + } + + if cancel_icon: + cancel_icon = { + "type": "material-symbols", + "name": cancel_icon, + } + + response.form = WebactionForm( + fields=payload["fields"], + title=payload["title"], + submit_label=payload["submit_label"], + cancel_label=payload["cancel_label"], + submit_icon=submit_icon, + cancel_icon=cancel_icon, + ) + + elif response_type == "launcher": + # Run AYON launcher process with uri in arguments + # NOTE This does pass environment variables of current process + # to the subprocess. + # NOTE We could 'take action' directly and use the arguments here + if payload is not None: + uri = payload["uri"] + else: + uri = data["uri"] + run_detached_ayon_launcher_process(uri) + + elif response_type in ("query", "navigate"): + response.error_message = ( + "Please use AYON web UI to run the action." + ) + + else: + self.log.warning( + f"Unknown webaction response type '{response_type}'" + ) + response.error_message = "Unknown webaction response type." + + return response + def _get_discovered_action_classes(self): if self._discovered_actions is None: # NOTE We don't need to register the paths, but that would @@ -470,7 +536,6 @@ class ActionsModel: register_launcher_action_path(path) self._discovered_actions = ( discover_launcher_actions() - + self._get_applications_action_classes() ) return self._discovered_actions @@ -498,62 +563,29 @@ class ActionsModel: action_items = {} for identifier, action in self._get_action_objects().items(): - is_application = isinstance(action, ApplicationAction) # Backwards compatibility from 0.3.3 (24/06/10) # TODO: Remove in future releases - if is_application and hasattr(action, "project_settings"): + if hasattr(action, "project_settings"): action.project_entities[project_name] = project_entity action.project_settings[project_name] = project_settings label = action.label or identifier variant_label = getattr(action, "label_variant", None) + full_label = self.calculate_full_label( + label, variant_label + ) icon = get_action_icon(action) item = ActionItem( - identifier, - label, - variant_label, - icon, - action.order, - is_application, - False + action_type="local", + identifier=identifier, + order=action.order, + label=label, + variant_label=variant_label, + full_label=full_label, + icon=icon, + config_fields=[], ) action_items[identifier] = item self._action_items[project_name] = action_items return action_items - - def _get_applications_action_classes(self): - addons_manager = self._get_addons_manager() - applications_addon = addons_manager.get_enabled_addon("applications") - if hasattr(applications_addon, "get_applications_action_classes"): - return applications_addon.get_applications_action_classes() - - # Backwards compatibility from 0.3.3 (24/06/10) - # TODO: Remove in future releases - actions = [] - if applications_addon is None: - return actions - - manager = applications_addon.get_applications_manager() - for full_name, application in manager.applications.items(): - if not application.enabled: - continue - - action = type( - "app_{}".format(full_name), - (ApplicationAction,), - { - "identifier": "application.{}".format(full_name), - "application": application, - "name": application.name, - "label": application.group.label, - "label_variant": application.label, - "group": None, - "icon": application.icon, - "color": getattr(application, "color", None), - "order": getattr(application, "order", None) or 0, - "data": {} - } - ) - actions.append(action) - return actions diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index c64d718172..0459999958 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -1,22 +1,38 @@ import time +import uuid import collections from qtpy import QtWidgets, QtCore, QtGui +from ayon_core.lib import Logger +from ayon_core.lib.attribute_definitions import ( + UILabelDef, + EnumDef, + TextDef, + BoolDef, + NumberDef, + HiddenDef, +) from ayon_core.tools.flickcharm import FlickCharm -from ayon_core.tools.utils import get_qt_icon - -from .resources import get_options_image_path +from ayon_core.tools.utils import ( + get_qt_icon, + PixmapLabel, +) +from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog +from ayon_core.tools.launcher.abstract import WebactionContext ANIMATION_LEN = 7 ACTION_ID_ROLE = QtCore.Qt.UserRole + 1 -ACTION_IS_APPLICATION_ROLE = QtCore.Qt.UserRole + 2 +ACTION_TYPE_ROLE = QtCore.Qt.UserRole + 2 ACTION_IS_GROUP_ROLE = QtCore.Qt.UserRole + 3 -ACTION_SORT_ROLE = QtCore.Qt.UserRole + 4 -ANIMATION_START_ROLE = QtCore.Qt.UserRole + 5 -ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 6 -FORCE_NOT_OPEN_WORKFILE_ROLE = QtCore.Qt.UserRole + 7 +ACTION_HAS_CONFIGS_ROLE = QtCore.Qt.UserRole + 4 +ACTION_SORT_ROLE = QtCore.Qt.UserRole + 5 +ACTION_ADDON_NAME_ROLE = QtCore.Qt.UserRole + 6 +ACTION_ADDON_VERSION_ROLE = QtCore.Qt.UserRole + 7 +PLACEHOLDER_ITEM_ROLE = QtCore.Qt.UserRole + 8 +ANIMATION_START_ROLE = QtCore.Qt.UserRole + 9 +ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 10 def _variant_label_sort_getter(action_item): @@ -34,6 +50,43 @@ def _variant_label_sort_getter(action_item): return action_item.variant_label or "" +# --- Replacement for QAction for action variants --- +class LauncherSettingsLabel(PixmapLabel): + _settings_icon = None + + def __init__(self, parent): + icon = self._get_settings_icon() + super().__init__(icon.pixmap(64, 64), parent) + + @classmethod + def _get_settings_icon(cls): + if cls._settings_icon is None: + cls._settings_icon = get_qt_icon({ + "type": "material-symbols", + "name": "settings", + }) + return cls._settings_icon + + +class ActionOverlayWidget(QtWidgets.QFrame): + config_requested = QtCore.Signal(str) + + def __init__(self, item_id, parent): + super().__init__(parent) + self._item_id = item_id + + settings_icon = LauncherSettingsLabel(self) + settings_icon.setToolTip("Right click for options") + + main_layout = QtWidgets.QGridLayout(self) + main_layout.setContentsMargins(5, 5, 0, 0) + main_layout.addWidget(settings_icon, 0, 0) + main_layout.setColumnStretch(1, 1) + main_layout.setRowStretch(1, 1) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + class ActionsQtModel(QtGui.QStandardItemModel): """Qt model for actions. @@ -44,7 +97,8 @@ class ActionsQtModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() def __init__(self, controller): - super(ActionsQtModel, self).__init__() + self._log = Logger.get_logger(self.__class__.__name__) + super().__init__() controller.register_event_callback( "selection.project.changed", @@ -84,6 +138,17 @@ class ActionsQtModel(QtGui.QStandardItemModel): def get_item_by_id(self, action_id): return self._items_by_id.get(action_id) + def get_group_item_by_action_id(self, action_id): + item = self._items_by_id.get(action_id) + if item is not None: + return item + + for group_id, items in self._groups_by_id.items(): + for item in items: + if item.identifier == action_id: + return self._items_by_id[group_id] + return None + def get_action_item_by_id(self, action_id): return self._action_items_by_id.get(action_id) @@ -108,8 +173,10 @@ class ActionsQtModel(QtGui.QStandardItemModel): root_item = self.invisibleRootItem() all_action_items_info = [] + action_items_by_id = {} items_by_label = collections.defaultdict(list) for item in items: + action_items_by_id[item.identifier] = item if not item.variant_label: all_action_items_info.append((item, False)) else: @@ -122,16 +189,30 @@ class ActionsQtModel(QtGui.QStandardItemModel): all_action_items_info.append((first_item, len(action_items) > 1)) groups_by_id[first_item.identifier] = action_items + transparent_icon = {"type": "transparent", "size": 256} new_items = [] items_by_id = {} - action_items_by_id = {} for action_item_info in all_action_items_info: action_item, is_group = action_item_info - icon = get_qt_icon(action_item.icon) + icon_def = action_item.icon + if not icon_def: + icon_def = transparent_icon.copy() + + try: + icon = get_qt_icon(icon_def) + except Exception: + self._log.warning( + "Failed to parse icon definition", exc_info=True + ) + # Use empty icon if failed to parse definition + icon = get_qt_icon(transparent_icon.copy()) + if is_group: + has_configs = False label = action_item.label else: label = action_item.full_label + has_configs = bool(action_item.config_fields) item = self._items_by_id.get(action_item.identifier) if item is None: @@ -141,16 +222,15 @@ class ActionsQtModel(QtGui.QStandardItemModel): item.setFlags(QtCore.Qt.ItemIsEnabled) item.setData(label, QtCore.Qt.DisplayRole) + # item.setData(label, QtCore.Qt.ToolTipRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(is_group, ACTION_IS_GROUP_ROLE) + item.setData(has_configs, ACTION_HAS_CONFIGS_ROLE) + item.setData(action_item.action_type, ACTION_TYPE_ROLE) + item.setData(action_item.addon_name, ACTION_ADDON_NAME_ROLE) + item.setData(action_item.addon_version, ACTION_ADDON_VERSION_ROLE) item.setData(action_item.order, ACTION_SORT_ROLE) - item.setData( - action_item.is_application, ACTION_IS_APPLICATION_ROLE) - item.setData( - action_item.force_not_open_workfile, - FORCE_NOT_OPEN_WORKFILE_ROLE) items_by_id[action_item.identifier] = item - action_items_by_id[action_item.identifier] = action_item if new_items: root_item.appendRows(new_items) @@ -166,6 +246,12 @@ class ActionsQtModel(QtGui.QStandardItemModel): self._action_items_by_id = action_items_by_id self.refreshed.emit() + def get_action_config_fields(self, action_id: str): + action_item = self._action_items_by_id.get(action_id) + if action_item is not None: + return action_item.config_fields + return None + def _on_selection_project_changed(self, event): self._selected_project_name = event["project_name"] self._selected_folder_id = None @@ -185,14 +271,341 @@ class ActionsQtModel(QtGui.QStandardItemModel): self.refresh() +class ActionMenuPopupModel(QtGui.QStandardItemModel): + def set_action_items(self, action_items): + """Set action items for the popup.""" + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) + + transparent_icon = {"type": "transparent", "size": 256} + new_items = [] + for action_item in action_items: + icon_def = action_item.icon + if not icon_def: + icon_def = transparent_icon.copy() + + try: + icon = get_qt_icon(icon_def) + except Exception: + self._log.warning( + "Failed to parse icon definition", exc_info=True + ) + # Use empty icon if failed to parse definition + icon = get_qt_icon(transparent_icon.copy()) + + item = QtGui.QStandardItem() + item.setFlags(QtCore.Qt.ItemIsEnabled) + # item.setData(action_item.full_label, QtCore.Qt.ToolTipRole) + item.setData(action_item.full_label, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(action_item.identifier, ACTION_ID_ROLE) + item.setData( + bool(action_item.config_fields), + ACTION_HAS_CONFIGS_ROLE + ) + item.setData(action_item.order, ACTION_SORT_ROLE) + + new_items.append(item) + + if new_items: + root_item.appendRows(new_items) + + def fill_to_count(self, count: int): + """Fill up items to specifi counter. + + This is needed to visually organize structure or the viewed items. If + items are shown right to left then mouse would not hover over + last item i there are multiple rows that are uneven. This will + fill the "first items" with invisible items so visually it looks + correct. + + Visually it will cause this: + [ ] [ ] [ ] [A] + [A] [A] [A] [A] + + Instead of: + [A] [A] [A] [A] + [A] [ ] [ ] [ ] + + """ + remainders = count - self.rowCount() + if not remainders: + return + + items = [] + for _ in range(remainders): + item = QtGui.QStandardItem() + item.setFlags(QtCore.Qt.NoItemFlags) + item.setData(True, PLACEHOLDER_ITEM_ROLE) + items.append(item) + + root_item = self.invisibleRootItem() + root_item.appendRows(items) + + +class ActionMenuPopup(QtWidgets.QWidget): + action_triggered = QtCore.Signal(str) + config_requested = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + + self.setWindowFlags(QtCore.Qt.Tool | QtCore.Qt.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WA_ShowWithoutActivating, True) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + # Close widget if is not updated by event + close_timer = QtCore.QTimer() + close_timer.setSingleShot(True) + close_timer.setInterval(100) + + expand_anim = QtCore.QVariantAnimation() + expand_anim.setDuration(60) + expand_anim.setEasingCurve(QtCore.QEasingCurve.InOutQuad) + + # View with actions + view = ActionsView(self) + view.setGridSize(QtCore.QSize(75, 80)) + view.setIconSize(QtCore.QSize(32, 32)) + view.move(QtCore.QPoint(3, 3)) + + # Background draw + wrapper = QtWidgets.QFrame(self) + wrapper.setObjectName("Wrapper") + wrapper.stackUnder(view) + + model = ActionMenuPopupModel() + proxy_model = ActionsProxyModel() + proxy_model.setSourceModel(model) + + view.setModel(proxy_model) + view.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + view.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + + close_timer.timeout.connect(self.close) + expand_anim.valueChanged.connect(self._on_expand_anim) + expand_anim.finished.connect(self._on_expand_finish) + + view.clicked.connect(self._on_clicked) + view.config_requested.connect(self.config_requested) + + self._view = view + self._wrapper = wrapper + self._model = model + self._proxy_model = proxy_model + + self._close_timer = close_timer + self._expand_anim = expand_anim + + self._showed = False + self._current_id = None + self._right_to_left = False + + def showEvent(self, event): + self._showed = True + super().showEvent(event) + + def closeEvent(self, event): + self._showed = False + super().closeEvent(event) + + def enterEvent(self, event): + super().leaveEvent(event) + self._close_timer.stop() + + def leaveEvent(self, event): + super().leaveEvent(event) + self._close_timer.start() + + def show_items(self, action_id, action_items, pos): + if not action_items: + if self._showed: + self._close_timer.start() + self._current_id = None + return + + self._close_timer.stop() + + update_position = False + if action_id != self._current_id: + update_position = True + self._current_id = action_id + self._update_items(action_items) + + # Make sure is visible + if not self._showed: + update_position = True + self.show() + + if not update_position: + self.raise_() + return + + # Set geometry to position + # - first make sure widget changes from '_update_items' + # are recalculated + app = QtWidgets.QApplication.instance() + app.processEvents() + items_count, size, target_size = self._get_size_hint() + self._model.fill_to_count(items_count) + + window = self.screen() + window_geo = window.geometry() + right_to_left = ( + pos.x() + target_size.width() > window_geo.right() + or pos.y() + target_size.height() > window_geo.bottom() + ) + + pos_x = pos.x() - 5 + pos_y = pos.y() - 4 + + wrap_x = wrap_y = 0 + sort_order = QtCore.Qt.DescendingOrder + if right_to_left: + sort_order = QtCore.Qt.AscendingOrder + size_diff = target_size - size + pos_x -= size_diff.width() + pos_y -= size_diff.height() + wrap_x = size_diff.width() + wrap_y = size_diff.height() + + wrap_geo = QtCore.QRect( + wrap_x, wrap_y, size.width(), size.height() + ) + if self._expand_anim.state() == QtCore.QAbstractAnimation.Running: + self._expand_anim.stop() + self._first_anim_frame = True + self._right_to_left = right_to_left + + self._proxy_model.sort(0, sort_order) + self.setUpdatesEnabled(False) + self._view.setMask(wrap_geo) + self._view.setMinimumWidth(target_size.width()) + self._view.setMaximumWidth(target_size.width()) + self._wrapper.setGeometry(wrap_geo) + self.setGeometry( + pos_x, pos_y, + target_size.width(), target_size.height() + ) + self.setUpdatesEnabled(True) + self._expand_anim.updateCurrentTime(0) + self._expand_anim.setStartValue(size) + self._expand_anim.setEndValue(target_size) + self._expand_anim.start() + + self.raise_() + + def _on_clicked(self, index): + if not index or not index.isValid(): + return + + if not index.data(ACTION_HAS_CONFIGS_ROLE): + return + + action_id = index.data(ACTION_ID_ROLE) + self.action_triggered.emit(action_id) + + def _on_expand_anim(self, value): + if not self._showed: + if self._expand_anim.state() == QtCore.QAbstractAnimation.Running: + self._expand_anim.stop() + return + + wrapper_geo = self._wrapper.geometry() + wrapper_geo.setWidth(value.width()) + wrapper_geo.setHeight(value.height()) + + if self._right_to_left: + geo = self.geometry() + pos = QtCore.QPoint( + geo.width() - value.width(), + geo.height() - value.height(), + ) + wrapper_geo.setTopLeft(pos) + + self._view.setMask(wrapper_geo) + self._wrapper.setGeometry(wrapper_geo) + + def _on_expand_finish(self): + # Make sure that size is recalculated if src and targe size is same + _, _, size = self._get_size_hint() + self._on_expand_anim(size) + + def _get_size_hint(self): + grid_size = self._view.gridSize() + row_count = self._proxy_model.rowCount() + cols = 4 + rows = 1 + while True: + rows = row_count // cols + if row_count % cols: + rows += 1 + if rows <= cols: + break + cols += 1 + + if rows == 1: + cols = row_count + + m_l, m_t, m_r, m_b = (3, 3, 1, 1) + # QUESTION how to get the margins from Qt? + border = 2 * 1 + single_width = ( + grid_size.width() + + self._view.horizontalOffset() + border + m_l + m_r + 1 + ) + single_height = ( + grid_size.height() + + self._view.verticalOffset() + border + m_b + m_t + 1 + ) + total_width = single_width + total_height = single_height + if cols > 1: + total_width += ( + (cols - 1) * (self._view.spacing() + grid_size.width()) + ) + + if rows > 1: + total_height += ( + (rows - 1) * (grid_size.height() + self._view.spacing()) + ) + return ( + cols * rows, + QtCore.QSize(single_width, single_height), + QtCore.QSize(total_width, total_height) + ) + + def _update_items(self, action_items): + """Update items in the tooltip.""" + # This method can be used to update the content of the tooltip + # with new icon, text and settings button visibility. + self._model.set_action_items(action_items) + self._view.update_on_refresh() + + def _on_trigger(self, action_id): + self.action_triggered.emit(action_id) + self.close() + + def _on_configs_trigger(self, action_id): + self.config_requested.emit(action_id) + self.close() + + class ActionDelegate(QtWidgets.QStyledItemDelegate): _cached_extender = {} + _cached_extender_base_pix = None def __init__(self, *args, **kwargs): - super(ActionDelegate, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._anim_start_color = QtGui.QColor(178, 255, 246) self._anim_end_color = QtGui.QColor(5, 44, 50) + def sizeHint(self, option, index): + return option.widget.gridSize() + + def updateEditorGeometry(self, editor, option, index): + editor.setGeometry(option.rect) + def _draw_animation(self, painter, option, index): grid_size = option.widget.gridSize() x_offset = int( @@ -244,7 +657,17 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): pix = cls._cached_extender.get(size) if pix is not None: return pix - pix = QtGui.QPixmap(get_options_image_path()).scaled( + + base_pix = cls._cached_extender_base_pix + if base_pix is None: + icon = get_qt_icon({ + "type": "material-symbols", + "name": "more_horiz", + }) + base_pix = icon.pixmap(64, 64) + cls._cached_extender_base_pix = base_pix + + pix = base_pix.scaled( size, size, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation @@ -260,15 +683,8 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): if index.data(ANIMATION_STATE_ROLE): self._draw_animation(painter, option, index) - - super(ActionDelegate, self).paint(painter, option, index) - - if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): - rect = QtCore.QRectF( - option.rect.x(), option.rect.y() + option.rect.height(), 5, 5) - painter.setPen(QtCore.Qt.NoPen) - painter.setBrush(QtGui.QColor(200, 0, 0)) - painter.drawEllipse(rect) + option.displayAlignment = QtCore.Qt.AlignHCenter | QtCore.Qt.AlignTop + super().paint(painter, option, index) if not index.data(ACTION_IS_GROUP_ROLE): return @@ -297,7 +713,11 @@ class ActionsProxyModel(QtCore.QSortFilterProxyModel): self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) def lessThan(self, left, right): - # Sort by action order and then by label + if left.data(PLACEHOLDER_ITEM_ROLE): + return True + if right.data(PLACEHOLDER_ITEM_ROLE): + return False + left_value = left.data(ACTION_SORT_ROLE) right_value = right.data(ACTION_SORT_ROLE) @@ -318,29 +738,129 @@ class ActionsProxyModel(QtCore.QSortFilterProxyModel): return True +class ActionsView(QtWidgets.QListView): + action_triggered = QtCore.Signal(str) + config_requested = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + self.setProperty("mode", "icon") + self.setViewMode(QtWidgets.QListView.IconMode) + self.setResizeMode(QtWidgets.QListView.Adjust) + self.setSelectionMode(QtWidgets.QListView.NoSelection) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.setContentsMargins(0, 0, 0, 0) + self.setViewportMargins(0, 0, 0, 0) + self.setWrapping(True) + self.setSpacing(0) + self.setWordWrap(True) + self.setMouseTracking(True) + + vertical_scroll = self.verticalScrollBar() + vertical_scroll.setSingleStep(8) + + delegate = ActionDelegate(self) + self.setItemDelegate(delegate) + + # Make view flickable + flick = FlickCharm(parent=self) + flick.activateOn(self) + + self.customContextMenuRequested.connect(self._on_context_menu) + + self._overlay_widgets = [] + self._flick = flick + self._delegate = delegate + self._popup_widget = None + + def mouseMoveEvent(self, event): + """Handle mouse move event.""" + super().mouseMoveEvent(event) + # Update hover state for the item under mouse + index = self.indexAt(event.pos()) + if index.isValid() and index.data(ACTION_IS_GROUP_ROLE): + self._show_group_popup(index) + + elif self._popup_widget is not None: + self._popup_widget.close() + + def _on_context_menu(self, point): + """Creates menu to force skip opening last workfile.""" + index = self.indexAt(point) + if not index.isValid(): + return + action_id = index.data(ACTION_ID_ROLE) + self.config_requested.emit(action_id) + + def _get_popup_widget(self): + if self._popup_widget is None: + popup_widget = ActionMenuPopup(self) + + popup_widget.action_triggered.connect(self.action_triggered) + popup_widget.config_requested.connect(self.config_requested) + self._popup_widget = popup_widget + return self._popup_widget + + def _show_group_popup(self, index): + action_id = index.data(ACTION_ID_ROLE) + model = self.model() + while hasattr(model, "sourceModel"): + model = model.sourceModel() + + if not hasattr(model, "get_group_items"): + return + + action_items = model.get_group_items(action_id) + rect = self.visualRect(index) + pos = self.mapToGlobal(rect.topLeft()) + + popup_widget = self._get_popup_widget() + popup_widget.show_items( + action_id, action_items, pos + ) + + def update_on_refresh(self): + viewport = self.viewport() + viewport.update() + self._add_overlay_widgets() + + def _add_overlay_widgets(self): + overlay_widgets = [] + viewport = self.viewport() + model = self.model() + for row in range(model.rowCount()): + index = model.index(row, 0) + has_configs = index.data(ACTION_HAS_CONFIGS_ROLE) + widget = None + if has_configs: + item_id = index.data(ACTION_ID_ROLE) + widget = ActionOverlayWidget(item_id, viewport) + widget.config_requested.connect( + self.config_requested + ) + overlay_widgets.append(widget) + self.setIndexWidget(index, widget) + + while self._overlay_widgets: + widget = self._overlay_widgets.pop(0) + widget.setVisible(False) + widget.setParent(None) + widget.deleteLater() + + self._overlay_widgets = overlay_widgets + + class ActionsWidget(QtWidgets.QWidget): def __init__(self, controller, parent): - super(ActionsWidget, self).__init__(parent) + super().__init__(parent) self._controller = controller - view = QtWidgets.QListView(self) - view.setProperty("mode", "icon") - view.setObjectName("IconView") - view.setViewMode(QtWidgets.QListView.IconMode) - view.setResizeMode(QtWidgets.QListView.Adjust) - view.setSelectionMode(QtWidgets.QListView.NoSelection) - view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) - view.setWrapping(True) + view = ActionsView(self) view.setGridSize(QtCore.QSize(70, 75)) view.setIconSize(QtCore.QSize(30, 30)) - view.setSpacing(0) - view.setWordWrap(True) - - # Make view flickable - flick = FlickCharm(parent=view) - flick.activateOn(view) model = ActionsQtModel(controller) @@ -348,9 +868,6 @@ class ActionsWidget(QtWidgets.QWidget): proxy_model.setSourceModel(model) view.setModel(proxy_model) - delegate = ActionDelegate(self) - view.setItemDelegate(delegate) - layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(view) @@ -360,15 +877,13 @@ class ActionsWidget(QtWidgets.QWidget): animation_timer.timeout.connect(self._on_animation) view.clicked.connect(self._on_clicked) - view.customContextMenuRequested.connect(self._on_context_menu) + view.action_triggered.connect(self._trigger_action) + view.config_requested.connect(self._on_config_request) model.refreshed.connect(self._on_model_refresh) self._animated_items = set() self._animation_timer = animation_timer - self._context_menu = None - - self._flick = flick self._view = view self._model = model self._proxy_model = proxy_model @@ -378,14 +893,52 @@ class ActionsWidget(QtWidgets.QWidget): def refresh(self): self._model.refresh() + def handle_webaction_form_event(self, event): + # NOTE The 'ActionsWidget' should be responsible for handling this + # but because we're showing messages to user it is handled by window + identifier = event["identifier"] + form = event["form"] + submit_icon = form["submit_icon"] + if submit_icon: + submit_icon = get_qt_icon(submit_icon) + + cancel_icon = form["cancel_icon"] + if cancel_icon: + cancel_icon = get_qt_icon(cancel_icon) + + dialog = self._create_attrs_dialog( + form["fields"], + form["title"], + form["submit_label"], + form["cancel_label"], + submit_icon, + cancel_icon, + ) + dialog.setMinimumSize(380, 180) + result = dialog.exec_() + if result != QtWidgets.QDialog.Accepted: + return + form_data = dialog.get_values() + self._controller.trigger_webaction( + WebactionContext( + identifier, + event["project_name"], + event["folder_id"], + event["task_id"], + event["addon_name"], + event["addon_version"], + ), + event["action_label"], + form_data, + ) + def _set_row_height(self, rows): self.setMinimumHeight(rows * 75) def _on_model_refresh(self): self._proxy_model.sort(0) # Force repaint all items - viewport = self._view.viewport() - viewport.update() + self._view.update_on_refresh() def _on_animation(self): time_now = time.time() @@ -416,89 +969,193 @@ class ActionsWidget(QtWidgets.QWidget): self._animated_items.add(action_id) self._animation_timer.start() - def _on_context_menu(self, point): - """Creates menu to force skip opening last workfile.""" - index = self._view.indexAt(point) - if not index.isValid(): - return - - if not index.data(ACTION_IS_APPLICATION_ROLE): - return - - menu = QtWidgets.QMenu(self._view) - checkbox = QtWidgets.QCheckBox( - "Skip opening last workfile.", menu) - if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): - checkbox.setChecked(True) - - action_id = index.data(ACTION_ID_ROLE) - is_group = index.data(ACTION_IS_GROUP_ROLE) - if is_group: - action_items = self._model.get_group_items(action_id) - else: - action_items = [self._model.get_action_item_by_id(action_id)] - action_ids = {action_item.identifier for action_item in action_items} - checkbox.stateChanged.connect( - lambda: self._on_checkbox_changed( - action_ids, checkbox.isChecked() - ) - ) - action = QtWidgets.QWidgetAction(menu) - action.setDefaultWidget(checkbox) - - menu.addAction(action) - - self._context_menu = menu - global_point = self.mapToGlobal(point) - menu.exec_(global_point) - self._context_menu = None - - def _on_checkbox_changed(self, action_ids, is_checked): - if self._context_menu is not None: - self._context_menu.close() - - project_name = self._model.get_selected_project_name() - folder_id = self._model.get_selected_folder_id() - task_id = self._model.get_selected_task_id() - self._controller.set_application_force_not_open_workfile( - project_name, folder_id, task_id, action_ids, is_checked) - self._model.refresh() - def _on_clicked(self, index): if not index or not index.isValid(): return is_group = index.data(ACTION_IS_GROUP_ROLE) + if is_group: + return action_id = index.data(ACTION_ID_ROLE) + self._trigger_action(action_id, index) + + def _trigger_action(self, action_id, index=None): + project_name = self._model.get_selected_project_name() + folder_id = self._model.get_selected_folder_id() + task_id = self._model.get_selected_task_id() + action_item = self._model.get_action_item_by_id(action_id) + + if action_item.action_type == "webaction": + action_item = self._model.get_action_item_by_id(action_id) + context = WebactionContext( + action_id, + project_name, + folder_id, + task_id, + action_item.addon_name, + action_item.addon_version + ) + self._controller.trigger_webaction( + context, action_item.full_label + ) + else: + self._controller.trigger_action( + action_id, project_name, folder_id, task_id + ) + + if index is None: + item = self._model.get_group_item_by_action_id(action_id) + if item is not None: + index = self._proxy_model.mapFromSource(item.index()) + + if index is not None: + self._start_animation(index) + + def _on_config_request(self, action_id): + self._show_config_dialog(action_id) + + def _show_config_dialog(self, action_id): + action_item = self._model.get_action_item_by_id(action_id) + config_fields = self._model.get_action_config_fields(action_id) + if not config_fields: + return project_name = self._model.get_selected_project_name() folder_id = self._model.get_selected_folder_id() task_id = self._model.get_selected_task_id() - - if not is_group: - self._controller.trigger_action( - project_name, folder_id, task_id, action_id - ) - self._start_animation(index) - return - - action_items = self._model.get_group_items(action_id) - - menu = QtWidgets.QMenu(self) - actions_mapping = {} - - for action_item in action_items: - menu_action = QtWidgets.QAction(action_item.full_label) - menu.addAction(menu_action) - actions_mapping[menu_action] = action_item - - result = menu.exec_(QtGui.QCursor.pos()) - if not result: - return - - action_item = actions_mapping[result] - - self._controller.trigger_action( - project_name, folder_id, task_id, action_item.identifier + context = WebactionContext( + action_id, + project_name=project_name, + folder_id=folder_id, + task_id=task_id, + addon_name=action_item.addon_name, + addon_version=action_item.addon_version, ) - self._start_animation(index) + values = self._controller.get_action_config_values(context) + + dialog = self._create_attrs_dialog( + config_fields, + "Action Config", + "Save", + "Cancel", + ) + dialog.set_values(values) + result = dialog.exec_() + if result == QtWidgets.QDialog.Accepted: + new_values = dialog.get_values() + self._controller.set_action_config_values(context, new_values) + + def _create_attrs_dialog( + self, + config_fields, + title, + submit_label, + cancel_label, + submit_icon=None, + cancel_icon=None, + ): + """Creates attribute definitions dialog. + + Types: + label - 'text' + text - 'label', 'value', 'placeholder', 'regex', + 'multiline', 'syntax' + boolean - 'label', 'value' + select - 'label', 'value', 'options' + multiselect - 'label', 'value', 'options' + hidden - 'value' + integer - 'label', 'value', 'placeholder', 'min', 'max' + float - 'label', 'value', 'placeholder', 'min', 'max' + + """ + attr_defs = [] + for config_field in config_fields: + field_type = config_field["type"] + attr_def = None + if field_type == "label": + label = config_field.get("value") + if label is None: + label = config_field.get("text") + attr_def = UILabelDef( + label, key=uuid.uuid4().hex + ) + elif field_type == "boolean": + value = config_field["value"] + if isinstance(value, str): + value = value.lower() == "true" + + attr_def = BoolDef( + config_field["name"], + default=value, + label=config_field.get("label"), + ) + elif field_type == "text": + attr_def = TextDef( + config_field["name"], + default=config_field.get("value"), + label=config_field.get("label"), + placeholder=config_field.get("placeholder"), + multiline=config_field.get("multiline", False), + regex=config_field.get("regex"), + # syntax=config_field["syntax"], + ) + elif field_type in ("integer", "float"): + value = config_field.get("value") + if isinstance(value, str): + if field_type == "integer": + value = int(value) + else: + value = float(value) + attr_def = NumberDef( + config_field["name"], + default=value, + label=config_field.get("label"), + decimals=0 if field_type == "integer" else 5, + # placeholder=config_field.get("placeholder"), + minimum=config_field.get("min"), + maximum=config_field.get("max"), + ) + elif field_type in ("select", "multiselect"): + attr_def = EnumDef( + config_field["name"], + items=config_field["options"], + default=config_field.get("value"), + label=config_field.get("label"), + multiselection=field_type == "multiselect", + ) + elif field_type == "hidden": + attr_def = HiddenDef( + config_field["name"], + default=config_field.get("value"), + ) + + if attr_def is None: + print(f"Unknown config field type: {field_type}") + attr_def = UILabelDef( + f"Unknown field type '{field_type}", + key=uuid.uuid4().hex + ) + attr_defs.append(attr_def) + + dialog = AttributeDefinitionsDialog( + attr_defs, + title=title, + parent=self, + ) + if submit_label: + dialog.set_submit_label(submit_label) + else: + dialog.set_submit_visible(False) + + if submit_icon: + dialog.set_submit_icon(submit_icon) + + if cancel_label: + dialog.set_cancel_label(cancel_label) + else: + dialog.set_cancel_visible(False) + + if cancel_icon: + dialog.set_cancel_icon(cancel_icon) + + return dialog diff --git a/client/ayon_core/tools/launcher/ui/resources/__init__.py b/client/ayon_core/tools/launcher/ui/resources/__init__.py deleted file mode 100644 index 27c59af2ba..0000000000 --- a/client/ayon_core/tools/launcher/ui/resources/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -import os - -RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__)) - - -def get_options_image_path(): - return os.path.join(RESOURCES_DIR, "options.png") diff --git a/client/ayon_core/tools/launcher/ui/resources/options.png b/client/ayon_core/tools/launcher/ui/resources/options.png deleted file mode 100644 index a9617d0d19..0000000000 Binary files a/client/ayon_core/tools/launcher/ui/resources/options.png and /dev/null differ diff --git a/client/ayon_core/tools/launcher/ui/window.py b/client/ayon_core/tools/launcher/ui/window.py index aa336108ed..7fde8518b0 100644 --- a/client/ayon_core/tools/launcher/ui/window.py +++ b/client/ayon_core/tools/launcher/ui/window.py @@ -1,9 +1,9 @@ from qtpy import QtWidgets, QtCore, QtGui -from ayon_core import style -from ayon_core import resources +from ayon_core import style, resources from ayon_core.tools.launcher.control import BaseLauncherController +from ayon_core.tools.utils import MessageOverlayObject from .projects_widget import ProjectsWidget from .hierarchy_page import HierarchyPage @@ -41,6 +41,8 @@ class LauncherWindow(QtWidgets.QWidget): self._controller = controller + overlay_object = MessageOverlayObject(self) + # Main content - Pages & Actions content_body = QtWidgets.QSplitter(self) @@ -78,26 +80,18 @@ class LauncherWindow(QtWidgets.QWidget): content_body.setSizes([580, 160]) # Footer - footer_widget = QtWidgets.QWidget(self) - - # - Message label - message_label = QtWidgets.QLabel(footer_widget) - + # footer_widget = QtWidgets.QWidget(self) + # # action_history = ActionHistory(footer_widget) # action_history.setStatusTip("Show Action History") - - footer_layout = QtWidgets.QHBoxLayout(footer_widget) - footer_layout.setContentsMargins(0, 0, 0, 0) - footer_layout.addWidget(message_label, 1) + # + # footer_layout = QtWidgets.QHBoxLayout(footer_widget) + # footer_layout.setContentsMargins(0, 0, 0, 0) # footer_layout.addWidget(action_history, 0) layout = QtWidgets.QVBoxLayout(self) layout.addWidget(content_body, 1) - layout.addWidget(footer_widget, 0) - - message_timer = QtCore.QTimer() - message_timer.setInterval(self.message_interval) - message_timer.setSingleShot(True) + # layout.addWidget(footer_widget, 0) actions_refresh_timer = QtCore.QTimer() actions_refresh_timer.setInterval(self.refresh_interval) @@ -109,7 +103,6 @@ class LauncherWindow(QtWidgets.QWidget): page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad) projects_page.refreshed.connect(self._on_projects_refresh) - message_timer.timeout.connect(self._on_message_timeout) actions_refresh_timer.timeout.connect( self._on_actions_refresh_timeout) page_slide_anim.valueChanged.connect( @@ -128,6 +121,16 @@ class LauncherWindow(QtWidgets.QWidget): "action.trigger.finished", self._on_action_trigger_finished, ) + controller.register_event_callback( + "webaction.trigger.started", + self._on_webaction_trigger_started, + ) + controller.register_event_callback( + "webaction.trigger.finished", + self._on_webaction_trigger_finished, + ) + + self._overlay_object = overlay_object self._controller = controller @@ -141,11 +144,8 @@ class LauncherWindow(QtWidgets.QWidget): self._projects_page = projects_page self._hierarchy_page = hierarchy_page self._actions_widget = actions_widget - - self._message_label = message_label # self._action_history = action_history - self._message_timer = message_timer self._actions_refresh_timer = actions_refresh_timer self._page_slide_anim = page_slide_anim @@ -185,13 +185,6 @@ class LauncherWindow(QtWidgets.QWidget): else: self._refresh_on_activate = True - def _echo(self, message): - self._message_label.setText(str(message)) - self._message_timer.start() - - def _on_message_timeout(self): - self._message_label.setText("") - def _on_project_selection_change(self, event): project_name = event["project_name"] self._selected_project_name = project_name @@ -215,13 +208,69 @@ class LauncherWindow(QtWidgets.QWidget): self._hierarchy_page.refresh() self._actions_widget.refresh() + def _show_toast_message(self, message, success=True, message_id=None): + message_type = None + if not success: + message_type = "error" + + self._overlay_object.add_message( + message, message_type, message_id=message_id + ) + def _on_action_trigger_started(self, event): - self._echo("Running action: {}".format(event["full_label"])) + self._show_toast_message( + "Running: {}".format(event["full_label"]), + message_id=event["trigger_id"], + ) def _on_action_trigger_finished(self, event): - if not event["failed"]: + action_label = event["full_label"] + if event["failed"]: + message = f"Failed to run: {action_label}" + else: + message = f"Finished: {action_label}" + self._show_toast_message( + message, + not event["failed"], + message_id=event["trigger_id"], + ) + + def _on_webaction_trigger_started(self, event): + self._show_toast_message( + "Running: {}".format(event["full_label"]), + message_id=event["trigger_id"], + ) + + def _on_webaction_trigger_finished(self, event): + clipboard_text = event["clipboard_text"] + if clipboard_text: + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(clipboard_text) + + action_label = event["full_label"] + # Avoid to show exception message + if event["trigger_failed"]: + self._show_toast_message( + f"Failed to run: {action_label}", + message_id=event["trigger_id"] + ) return - self._echo("Failed: {}".format(event["error_message"])) + + # Failed to run webaction, e.g. because of missing webaction handling + # - not reported by server + if event["error_message"]: + self._show_toast_message( + event["error_message"], + success=False, + message_id=event["trigger_id"] + ) + return + + if event["message"]: + self._show_toast_message(event["message"], event["success"]) + + if event["form"]: + self._actions_widget.handle_webaction_form_event(event) def _is_page_slide_anim_running(self): return ( diff --git a/client/ayon_core/tools/loader/ui/actions_utils.py b/client/ayon_core/tools/loader/ui/actions_utils.py index 5a988ef4c2..b601cd95bd 100644 --- a/client/ayon_core/tools/loader/ui/actions_utils.py +++ b/client/ayon_core/tools/loader/ui/actions_utils.py @@ -84,15 +84,17 @@ def _get_options(action, action_item, parent): if not getattr(action, "optioned", False) or not options: return {} + dialog_title = action.label + " Options" if isinstance(options[0], AbstractAttrDef): qargparse_options = False - dialog = AttributeDefinitionsDialog(options, parent) + dialog = AttributeDefinitionsDialog( + options, title=dialog_title, parent=parent + ) else: qargparse_options = True dialog = OptionDialog(parent) dialog.create(options) - - dialog.setWindowTitle(action.label + " Options") + dialog.setWindowTitle(dialog_title) if not dialog.exec_(): return None diff --git a/client/ayon_core/tools/sceneinventory/view.py b/client/ayon_core/tools/sceneinventory/view.py index bb95e37d4e..fdd1bdbe75 100644 --- a/client/ayon_core/tools/sceneinventory/view.py +++ b/client/ayon_core/tools/sceneinventory/view.py @@ -959,11 +959,13 @@ class SceneInventoryView(QtWidgets.QTreeView): remove_container(container) self.data_changed.emit() - def _show_version_error_dialog(self, version, item_ids): + def _show_version_error_dialog(self, version, item_ids, exception): """Shows QMessageBox when version switch doesn't work Args: version: str or int or None + item_ids (Iterable[str]): List of item ids to run the + exception (Exception): Exception that occurred """ if version == -1: version_str = "latest" @@ -988,10 +990,11 @@ class SceneInventoryView(QtWidgets.QTreeView): dialog.addButton(QtWidgets.QMessageBox.Cancel) msg = ( - "Version update to '{}' failed as representation doesn't exist." + "Version update to '{}' failed with the following error:\n" + "{}." "\n\nPlease update to version with a valid representation" " OR \n use 'Switch Folder' button to change folder." - ).format(version_str) + ).format(version_str, exception) dialog.setText(msg) dialog.exec_() @@ -1105,10 +1108,10 @@ class SceneInventoryView(QtWidgets.QTreeView): container = containers_by_id[item_id] try: update_container(container, item_version) - except AssertionError: + except Exception as exc: log.warning("Update failed", exc_info=True) self._show_version_error_dialog( - item_version, [item_id] + item_version, [item_id], exc ) finally: # Always update the scene inventory view, even if errors occurred diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py index 9206af9beb..8688430c71 100644 --- a/client/ayon_core/tools/utils/__init__.py +++ b/client/ayon_core/tools/utils/__init__.py @@ -6,6 +6,7 @@ from .widgets import ( CustomTextComboBox, PlaceholderLineEdit, PlaceholderPlainTextEdit, + MarkdownLabel, ElideLabel, HintedLineEdit, ExpandingTextEdit, @@ -91,6 +92,7 @@ __all__ = ( "CustomTextComboBox", "PlaceholderLineEdit", "PlaceholderPlainTextEdit", + "MarkdownLabel", "ElideLabel", "HintedLineEdit", "ExpandingTextEdit", diff --git a/client/ayon_core/tools/utils/constants.py b/client/ayon_core/tools/utils/constants.py index 0c92e3ccc8..b590d1d778 100644 --- a/client/ayon_core/tools/utils/constants.py +++ b/client/ayon_core/tools/utils/constants.py @@ -14,3 +14,4 @@ except AttributeError: DEFAULT_PROJECT_LABEL = "< Default >" PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 101 PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 102 +DEFAULT_WEB_ICON_COLOR = "#f4f5f5" diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index 4b303c0143..f7919a3317 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -1,11 +1,14 @@ import os import sys +import io import contextlib import collections import traceback +import urllib.request from functools import partial from typing import Union, Any +import ayon_api from qtpy import QtWidgets, QtCore, QtGui import qtawesome import qtmaterialsymbols @@ -17,7 +20,12 @@ from ayon_core.style import ( from ayon_core.resources import get_image_path from ayon_core.lib import Logger -from .constants import CHECKED_INT, UNCHECKED_INT, PARTIALLY_CHECKED_INT +from .constants import ( + CHECKED_INT, + UNCHECKED_INT, + PARTIALLY_CHECKED_INT, + DEFAULT_WEB_ICON_COLOR, +) log = Logger.get_logger(__name__) @@ -480,11 +488,27 @@ class _IconsCache: if icon_type == "path": parts = [icon_type, icon_def["path"]] - elif icon_type in {"awesome-font", "material-symbols"}: - color = icon_def["color"] or "" + elif icon_type == "awesome-font": + color = icon_def.get("color") or "" if isinstance(color, QtGui.QColor): color = color.name() parts = [icon_type, icon_def["name"] or "", color] + + elif icon_type == "material-symbols": + color = icon_def.get("color") or DEFAULT_WEB_ICON_COLOR + if isinstance(color, QtGui.QColor): + color = color.name() + parts = [icon_type, icon_def["name"] or "", color] + + elif icon_type in {"url", "ayon_url"}: + parts = [icon_type, icon_def["url"]] + + elif icon_type == "transparent": + size = icon_def.get("size") + if size is None: + size = 256 + parts = [icon_type, str(size)] + return "|".join(parts) @classmethod @@ -505,7 +529,7 @@ class _IconsCache: elif icon_type == "awesome-font": icon_name = icon_def["name"] - icon_color = icon_def["color"] + icon_color = icon_def.get("color") icon = cls.get_qta_icon_by_name_and_color(icon_name, icon_color) if icon is None: icon = cls.get_qta_icon_by_name_and_color( @@ -513,10 +537,40 @@ class _IconsCache: elif icon_type == "material-symbols": icon_name = icon_def["name"] - icon_color = icon_def["color"] + icon_color = icon_def.get("color") or DEFAULT_WEB_ICON_COLOR if qtmaterialsymbols.get_icon_name_char(icon_name) is not None: icon = qtmaterialsymbols.get_icon(icon_name, icon_color) + elif icon_type == "url": + url = icon_def["url"] + try: + content = urllib.request.urlopen(url).read() + pix = QtGui.QPixmap() + pix.loadFromData(content) + icon = QtGui.QIcon(pix) + except Exception: + log.warning( + "Failed to download image '%s'", url, exc_info=True + ) + icon = None + + elif icon_type == "ayon_url": + url = icon_def["url"].lstrip("/") + url = f"{ayon_api.get_base_url()}/{url}" + stream = io.BytesIO() + ayon_api.download_file_to_stream(url, stream) + pix = QtGui.QPixmap() + pix.loadFromData(stream.getvalue()) + icon = QtGui.QIcon(pix) + + elif icon_type == "transparent": + size = icon_def.get("size") + if size is None: + size = 256 + pix = QtGui.QPixmap(size, size) + pix.fill(QtCore.Qt.transparent) + icon = QtGui.QIcon(pix) + if icon is None: icon = cls.get_default() cls._cache[cache_key] = icon diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 0cd6d68ab3..af0745af1f 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -6,6 +6,11 @@ from qtpy import QtWidgets, QtCore, QtGui import qargparse import qtawesome +try: + import markdown +except Exception: + markdown = None + from ayon_core.style import ( get_objected_colors, get_style_image_path, @@ -131,6 +136,37 @@ class PlaceholderPlainTextEdit(QtWidgets.QPlainTextEdit): viewport.setPalette(filter_palette) +class MarkdownLabel(QtWidgets.QLabel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Enable word wrap by default + self.setWordWrap(True) + + text_format_available = hasattr(QtCore.Qt, "MarkdownText") + if text_format_available: + self.setTextFormat(QtCore.Qt.MarkdownText) + + self._text_format_available = text_format_available + + self.setText(self.text()) + + def setText(self, text): + if not self._text_format_available: + text = self._md_to_html(text) + super().setText(text) + + @staticmethod + def _md_to_html(text): + if markdown is None: + # This does add style definition to the markdown which does not + # feel natural in the UI (but still better than raw MD). + doc = QtGui.QTextDocument() + doc.setMarkdown(text) + return doc.toHtml() + return markdown.markdown(text) + + class ElideLabel(QtWidgets.QLabel): """Label which elide text. @@ -459,15 +495,15 @@ class ClickableLabel(QtWidgets.QLabel): """Label that catch left mouse click and can trigger 'clicked' signal.""" clicked = QtCore.Signal() - def __init__(self, parent): - super(ClickableLabel, self).__init__(parent) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self._mouse_pressed = False def mousePressEvent(self, event): if event.button() == QtCore.Qt.LeftButton: self._mouse_pressed = True - super(ClickableLabel, self).mousePressEvent(event) + super().mousePressEvent(event) def mouseReleaseEvent(self, event): if self._mouse_pressed: @@ -475,7 +511,7 @@ class ClickableLabel(QtWidgets.QLabel): if self.rect().contains(event.pos()): self.clicked.emit() - super(ClickableLabel, self).mouseReleaseEvent(event) + super().mouseReleaseEvent(event) class ExpandBtnLabel(QtWidgets.QLabel): @@ -704,7 +740,7 @@ class PixmapLabel(QtWidgets.QLabel): def resizeEvent(self, event): self._set_resized_pix() - super(PixmapLabel, self).resizeEvent(event) + super().resizeEvent(event) class PixmapButtonPainter(QtWidgets.QWidget): diff --git a/client/pyproject.toml b/client/pyproject.toml index edf7f57317..6416d9b8e1 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -4,6 +4,7 @@ description="AYON core addon." [tool.poetry.dependencies] python = ">=3.9.1,<3.10" +markdown = "^3.4.1" clique = "1.6.*" jsonschema = "^2.6.0" pyblish-base = "^1.8.11" diff --git a/package.py b/package.py index f564d371d2..908d34ffa8 100644 --- a/package.py +++ b/package.py @@ -6,11 +6,12 @@ client_dir = "ayon_core" plugin_for = ["ayon_server"] -ayon_server_version = ">=1.7.6,<2.0.0" +ayon_server_version = ">=1.8.4,<2.0.0" ayon_launcher_version = ">=1.0.2" ayon_required_addons = {} ayon_compatible_addons = { "ayon_ocio": ">=1.2.1", + "applications": ">=1.1.2", "harmony": ">0.4.0", "fusion": ">=0.3.3", "openrv": ">=1.0.2", 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 () {