mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 12:54:40 +01:00
Merge branch 'develop' into enhancement/1294-product-base-types-support-in-loading
This commit is contained in:
commit
6443d46c61
42 changed files with 6583 additions and 404 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -82,6 +82,7 @@ poetry.lock
|
|||
.editorconfig
|
||||
.pre-commit-config.yaml
|
||||
mypy.ini
|
||||
poetry.lock
|
||||
|
||||
.github_changelog_generator
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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, [])
|
||||
|
|
|
|||
453
client/ayon_core/pipeline/traits/README.md
Normal file
453
client/ayon_core/pipeline/traits/README.md
Normal file
|
|
@ -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.
|
||||
|
||||
112
client/ayon_core/pipeline/traits/__init__.py
Normal file
112
client/ayon_core/pipeline/traits/__init__.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
30
client/ayon_core/pipeline/traits/color.py
Normal file
30
client/ayon_core/pipeline/traits/color.py
Normal file
|
|
@ -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
|
||||
485
client/ayon_core/pipeline/traits/content.py
Normal file
485
client/ayon_core/pipeline/traits/content.py
Normal file
|
|
@ -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<index>(?P<padding>0*)\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
|
||||
42
client/ayon_core/pipeline/traits/cryptography.py
Normal file
42
client/ayon_core/pipeline/traits/cryptography.py
Normal file
|
|
@ -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
|
||||
77
client/ayon_core/pipeline/traits/lifecycle.py
Normal file
77
client/ayon_core/pipeline/traits/lifecycle.py
Normal file
|
|
@ -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)
|
||||
162
client/ayon_core/pipeline/traits/meta.py
Normal file
162
client/ayon_core/pipeline/traits/meta.py
Normal file
|
|
@ -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
|
||||
713
client/ayon_core/pipeline/traits/representation.py
Normal file
713
client/ayon_core/pipeline/traits/representation.py
Normal file
|
|
@ -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)
|
||||
457
client/ayon_core/pipeline/traits/temporal.py
Normal file
457
client/ayon_core/pipeline/traits/temporal.py
Normal file
|
|
@ -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<index>", "?P<padding>"]):
|
||||
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<index>(?P<padding>0*)\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<index>(?P<padding>0*)\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<index>(?P<padding>0*)\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
|
||||
93
client/ayon_core/pipeline/traits/three_dimensional.py
Normal file
93
client/ayon_core/pipeline/traits/three_dimensional.py
Normal file
|
|
@ -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
|
||||
147
client/ayon_core/pipeline/traits/trait.py
Normal file
147
client/ayon_core/pipeline/traits/trait.py
Normal file
|
|
@ -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.
|
||||
"""
|
||||
208
client/ayon_core/pipeline/traits/two_dimensional.py
Normal file
208
client/ayon_core/pipeline/traits/two_dimensional.py
Normal file
|
|
@ -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<udim>\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<udim>" 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
|
||||
90
client/ayon_core/pipeline/traits/utils.py
Normal file
90
client/ayon_core/pipeline/traits/utils.py
Normal file
|
|
@ -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
|
||||
|
|
@ -8,7 +8,7 @@ targeted by task types and names.
|
|||
|
||||
Placeholders are created using placeholder plugins which should care about
|
||||
logic and data of placeholder items. 'PlaceholderItem' is used to keep track
|
||||
about it's progress.
|
||||
about its progress.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -17,6 +17,7 @@ import collections
|
|||
import copy
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import ayon_api
|
||||
from ayon_api import (
|
||||
get_folders,
|
||||
get_folder_by_path,
|
||||
|
|
@ -60,6 +61,32 @@ from ayon_core.pipeline.create import (
|
|||
_NOT_SET = object()
|
||||
|
||||
|
||||
class EntityResolutionError(Exception):
|
||||
"""Exception raised when entity URI resolution fails."""
|
||||
|
||||
|
||||
def resolve_entity_uri(entity_uri: str) -> str:
|
||||
"""Resolve AYON entity URI to a filesystem path for local system."""
|
||||
response = ayon_api.post(
|
||||
"resolve",
|
||||
resolveRoots=True,
|
||||
uris=[entity_uri]
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"Unable to resolve AYON entity URI filepath for "
|
||||
f"'{entity_uri}': {response.text}"
|
||||
)
|
||||
|
||||
entities = response.data[0]["entities"]
|
||||
if len(entities) != 1:
|
||||
raise EntityResolutionError(
|
||||
f"Unable to resolve AYON entity URI '{entity_uri}' to a "
|
||||
f"single filepath. Received data: {response.data}"
|
||||
)
|
||||
return entities[0]["filePath"]
|
||||
|
||||
|
||||
class TemplateNotFound(Exception):
|
||||
"""Exception raised when template does not exist."""
|
||||
pass
|
||||
|
|
@ -823,7 +850,6 @@ class AbstractTemplateBuilder(ABC):
|
|||
"""
|
||||
|
||||
host_name = self.host_name
|
||||
project_name = self.project_name
|
||||
task_name = self.current_task_name
|
||||
task_type = self.current_task_type
|
||||
|
||||
|
|
@ -835,7 +861,6 @@ class AbstractTemplateBuilder(ABC):
|
|||
"task_names": task_name
|
||||
}
|
||||
)
|
||||
|
||||
if not profile:
|
||||
raise TemplateProfileNotFound((
|
||||
"No matching profile found for task '{}' of type '{}' "
|
||||
|
|
@ -843,6 +868,22 @@ class AbstractTemplateBuilder(ABC):
|
|||
).format(task_name, task_type, host_name))
|
||||
|
||||
path = profile["path"]
|
||||
if not path:
|
||||
raise TemplateLoadFailed((
|
||||
"Template path is not set.\n"
|
||||
"Path need to be set in {}\\Template Workfile Build "
|
||||
"Settings\\Profiles"
|
||||
).format(host_name.title()))
|
||||
|
||||
resolved_path = self.resolve_template_path(path)
|
||||
if not resolved_path or not os.path.exists(resolved_path):
|
||||
raise TemplateNotFound(
|
||||
"Template file found in AYON settings for task '{}' with host "
|
||||
"'{}' does not exists. (Not found : {})".format(
|
||||
task_name, host_name, resolved_path)
|
||||
)
|
||||
|
||||
self.log.info(f"Found template at: '{resolved_path}'")
|
||||
|
||||
# switch to remove placeholders after they are used
|
||||
keep_placeholder = profile.get("keep_placeholder")
|
||||
|
|
@ -852,44 +893,86 @@ class AbstractTemplateBuilder(ABC):
|
|||
if keep_placeholder is None:
|
||||
keep_placeholder = True
|
||||
|
||||
if not path:
|
||||
raise TemplateLoadFailed((
|
||||
"Template path is not set.\n"
|
||||
"Path need to be set in {}\\Template Workfile Build "
|
||||
"Settings\\Profiles"
|
||||
).format(host_name.title()))
|
||||
|
||||
# Try to fill path with environments and anatomy roots
|
||||
anatomy = Anatomy(project_name)
|
||||
fill_data = {
|
||||
key: value
|
||||
for key, value in os.environ.items()
|
||||
return {
|
||||
"path": resolved_path,
|
||||
"keep_placeholder": keep_placeholder,
|
||||
"create_first_version": create_first_version
|
||||
}
|
||||
|
||||
fill_data["root"] = anatomy.roots
|
||||
fill_data["project"] = {
|
||||
"name": project_name,
|
||||
"code": anatomy.project_code,
|
||||
}
|
||||
def resolve_template_path(self, path, fill_data=None) -> str:
|
||||
"""Resolve the template path.
|
||||
|
||||
path = self.resolve_template_path(path, fill_data)
|
||||
By default, this:
|
||||
- Resolves AYON entity URI to a filesystem path
|
||||
- Returns path directly if it exists on disk.
|
||||
- Resolves template keys through anatomy and environment variables.
|
||||
|
||||
This can be overridden in host integrations to perform additional
|
||||
resolving over the template. Like, `hou.text.expandString` in Houdini.
|
||||
It's recommended to still call the super().resolve_template_path()
|
||||
to ensure the basic resolving is done across all integrations.
|
||||
|
||||
Arguments:
|
||||
path (str): The input path.
|
||||
fill_data (dict[str, str]): Deprecated. This is computed inside
|
||||
the method using the current environment and project settings.
|
||||
Used to be the data to use for template formatting.
|
||||
|
||||
Returns:
|
||||
str: The resolved path.
|
||||
|
||||
"""
|
||||
|
||||
# If the path is an AYON entity URI, then resolve the filepath
|
||||
# through the backend
|
||||
if path.startswith("ayon+entity://") or path.startswith("ayon://"):
|
||||
# This is a special case where the path is an AYON entity URI
|
||||
# We need to resolve it to a filesystem path
|
||||
resolved_path = resolve_entity_uri(path)
|
||||
return resolved_path
|
||||
|
||||
# If the path is set and it's found on disk, return it directly
|
||||
if path and os.path.exists(path):
|
||||
self.log.info("Found template at: '{}'".format(path))
|
||||
return {
|
||||
"path": path,
|
||||
"keep_placeholder": keep_placeholder,
|
||||
"create_first_version": create_first_version
|
||||
return path
|
||||
|
||||
# We may have path for another platform, like C:/path/to/file
|
||||
# or a path with template keys, like {project[code]} or both.
|
||||
# Try to fill path with environments and anatomy roots
|
||||
project_name = self.project_name
|
||||
anatomy = Anatomy(project_name)
|
||||
|
||||
# Simple check whether the path contains any template keys
|
||||
if "{" in path:
|
||||
fill_data = {
|
||||
key: value
|
||||
for key, value in os.environ.items()
|
||||
}
|
||||
fill_data["root"] = anatomy.roots
|
||||
fill_data["project"] = {
|
||||
"name": project_name,
|
||||
"code": anatomy.project_code,
|
||||
}
|
||||
|
||||
solved_path = None
|
||||
# Format the template using local fill data
|
||||
result = StringTemplate.format_template(path, fill_data)
|
||||
if not result.solved:
|
||||
return path
|
||||
|
||||
path = result.normalized()
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
|
||||
# If the path were set in settings using a Windows path and we
|
||||
# are now on a Linux system, we try to convert the solved path to
|
||||
# the current platform.
|
||||
while True:
|
||||
try:
|
||||
solved_path = anatomy.path_remapper(path)
|
||||
except KeyError as missing_key:
|
||||
raise KeyError(
|
||||
"Could not solve key '{}' in template path '{}'".format(
|
||||
missing_key, path))
|
||||
f"Could not solve key '{missing_key}'"
|
||||
f" in template path '{path}'"
|
||||
)
|
||||
|
||||
if solved_path is None:
|
||||
solved_path = path
|
||||
|
|
@ -898,40 +981,7 @@ class AbstractTemplateBuilder(ABC):
|
|||
path = solved_path
|
||||
|
||||
solved_path = os.path.normpath(solved_path)
|
||||
if not os.path.exists(solved_path):
|
||||
raise TemplateNotFound(
|
||||
"Template found in AYON settings for task '{}' with host "
|
||||
"'{}' does not exists. (Not found : {})".format(
|
||||
task_name, host_name, solved_path))
|
||||
|
||||
self.log.info("Found template at: '{}'".format(solved_path))
|
||||
|
||||
return {
|
||||
"path": solved_path,
|
||||
"keep_placeholder": keep_placeholder,
|
||||
"create_first_version": create_first_version
|
||||
}
|
||||
|
||||
def resolve_template_path(self, path, fill_data) -> str:
|
||||
"""Resolve the template path.
|
||||
|
||||
By default, this does nothing except returning the path directly.
|
||||
|
||||
This can be overridden in host integrations to perform additional
|
||||
resolving over the template. Like, `hou.text.expandString` in Houdini.
|
||||
|
||||
Arguments:
|
||||
path (str): The input path.
|
||||
fill_data (dict[str, str]): Data to use for template formatting.
|
||||
|
||||
Returns:
|
||||
str: The resolved path.
|
||||
|
||||
"""
|
||||
result = StringTemplate.format_template(path, fill_data)
|
||||
if result.solved:
|
||||
path = result.normalized()
|
||||
return path
|
||||
return solved_path
|
||||
|
||||
def emit_event(self, topic, data=None, source=None) -> Event:
|
||||
return self._event_system.emit(topic, data, source)
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ class ExtractOIIOTranscode(publish.Extractor):
|
|||
optional = True
|
||||
|
||||
# Supported extensions
|
||||
supported_exts = ["exr", "jpg", "jpeg", "png", "dpx"]
|
||||
supported_exts = {"exr", "jpg", "jpeg", "png", "dpx"}
|
||||
|
||||
# Configurable by Settings
|
||||
profiles = None
|
||||
|
|
|
|||
|
|
@ -135,11 +135,11 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
]
|
||||
|
||||
# Supported extensions
|
||||
image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"]
|
||||
video_exts = ["mov", "mp4"]
|
||||
supported_exts = image_exts + video_exts
|
||||
image_exts = {"exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"}
|
||||
video_exts = {"mov", "mp4"}
|
||||
supported_exts = image_exts | video_exts
|
||||
|
||||
alpha_exts = ["exr", "png", "dpx"]
|
||||
alpha_exts = {"exr", "png", "dpx"}
|
||||
|
||||
# Preset attributes
|
||||
profiles = []
|
||||
|
|
|
|||
1208
client/ayon_core/plugins/publish/integrate_traits.py
Normal file
1208
client/ayon_core/plugins/publish/integrate_traits.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,8 @@
|
|||
from __future__ import annotations
|
||||
import contextlib
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
import ayon_api
|
||||
|
||||
|
|
@ -140,6 +142,7 @@ class TaskTypeItem:
|
|||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectItem:
|
||||
"""Item representing folder entity on a server.
|
||||
|
||||
|
|
@ -150,21 +153,14 @@ class ProjectItem:
|
|||
active (Union[str, None]): Parent folder id. If 'None' then project
|
||||
is parent.
|
||||
"""
|
||||
|
||||
def __init__(self, name, active, is_library, icon=None):
|
||||
self.name = name
|
||||
self.active = active
|
||||
self.is_library = is_library
|
||||
if icon is None:
|
||||
icon = {
|
||||
"type": "awesome-font",
|
||||
"name": "fa.book" if is_library else "fa.map",
|
||||
"color": get_default_entity_icon_color(),
|
||||
}
|
||||
self.icon = icon
|
||||
name: str
|
||||
active: bool
|
||||
is_library: bool
|
||||
icon: dict[str, Any]
|
||||
is_pinned: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_entity(cls, project_entity):
|
||||
def from_entity(cls, project_entity: dict[str, Any]) -> "ProjectItem":
|
||||
"""Creates folder item from entity.
|
||||
|
||||
Args:
|
||||
|
|
@ -174,10 +170,16 @@ class ProjectItem:
|
|||
ProjectItem: Project item.
|
||||
|
||||
"""
|
||||
icon = {
|
||||
"type": "awesome-font",
|
||||
"name": "fa.book" if project_entity["library"] else "fa.map",
|
||||
"color": get_default_entity_icon_color(),
|
||||
}
|
||||
return cls(
|
||||
project_entity["name"],
|
||||
project_entity["active"],
|
||||
project_entity["library"],
|
||||
icon
|
||||
)
|
||||
|
||||
def to_data(self):
|
||||
|
|
@ -208,16 +210,18 @@ class ProjectItem:
|
|||
return cls(**data)
|
||||
|
||||
|
||||
def _get_project_items_from_entitiy(projects):
|
||||
def _get_project_items_from_entitiy(
|
||||
projects: list[dict[str, Any]]
|
||||
) -> list[ProjectItem]:
|
||||
"""
|
||||
|
||||
Args:
|
||||
projects (list[dict[str, Any]]): List of projects.
|
||||
|
||||
Returns:
|
||||
ProjectItem: Project item.
|
||||
"""
|
||||
list[ProjectItem]: Project item.
|
||||
|
||||
"""
|
||||
return [
|
||||
ProjectItem.from_entity(project)
|
||||
for project in projects
|
||||
|
|
@ -428,9 +432,20 @@ class ProjectsModel(object):
|
|||
self._projects_cache.update_data(project_items)
|
||||
return self._projects_cache.get_data()
|
||||
|
||||
def _query_projects(self):
|
||||
def _query_projects(self) -> list[ProjectItem]:
|
||||
projects = ayon_api.get_projects(fields=["name", "active", "library"])
|
||||
return _get_project_items_from_entitiy(projects)
|
||||
user = ayon_api.get_user()
|
||||
pinned_projects = (
|
||||
user
|
||||
.get("data", {})
|
||||
.get("frontendPreferences", {})
|
||||
.get("pinnedProjects")
|
||||
) or []
|
||||
pinned_projects = set(pinned_projects)
|
||||
project_items = _get_project_items_from_entitiy(list(projects))
|
||||
for project in project_items:
|
||||
project.is_pinned = project.name in pinned_projects
|
||||
return project_items
|
||||
|
||||
def _status_items_getter(self, project_entity):
|
||||
if not project_entity:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from ayon_core.lib import Logger, get_ayon_username
|
||||
from ayon_core.lib.events import QueuedEventSystem
|
||||
from ayon_core.settings import get_project_settings
|
||||
from ayon_core.settings import get_project_settings, get_studio_settings
|
||||
from ayon_core.tools.common_models import ProjectsModel, HierarchyModel
|
||||
|
||||
from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend
|
||||
|
|
@ -85,7 +85,10 @@ class BaseLauncherController(
|
|||
def get_project_settings(self, project_name):
|
||||
if project_name in self._project_settings:
|
||||
return self._project_settings[project_name]
|
||||
settings = get_project_settings(project_name)
|
||||
if project_name:
|
||||
settings = get_project_settings(project_name)
|
||||
else:
|
||||
settings = get_studio_settings()
|
||||
self._project_settings[project_name] = settings
|
||||
return settings
|
||||
|
||||
|
|
|
|||
|
|
@ -1,154 +0,0 @@
|
|||
from qtpy import QtWidgets, QtCore
|
||||
|
||||
from ayon_core.tools.flickcharm import FlickCharm
|
||||
from ayon_core.tools.utils import (
|
||||
PlaceholderLineEdit,
|
||||
RefreshButton,
|
||||
ProjectsQtModel,
|
||||
ProjectSortFilterProxy,
|
||||
)
|
||||
from ayon_core.tools.common_models import PROJECTS_MODEL_SENDER
|
||||
|
||||
|
||||
class ProjectIconView(QtWidgets.QListView):
|
||||
"""Styled ListView that allows to toggle between icon and list mode.
|
||||
|
||||
Toggling between the two modes is done by Right Mouse Click.
|
||||
"""
|
||||
|
||||
IconMode = 0
|
||||
ListMode = 1
|
||||
|
||||
def __init__(self, parent=None, mode=ListMode):
|
||||
super(ProjectIconView, self).__init__(parent=parent)
|
||||
|
||||
# Workaround for scrolling being super slow or fast when
|
||||
# toggling between the two visual modes
|
||||
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
|
||||
self.setObjectName("IconView")
|
||||
|
||||
self._mode = None
|
||||
self.set_mode(mode)
|
||||
|
||||
def set_mode(self, mode):
|
||||
if mode == self._mode:
|
||||
return
|
||||
|
||||
self._mode = mode
|
||||
|
||||
if mode == self.IconMode:
|
||||
self.setViewMode(QtWidgets.QListView.IconMode)
|
||||
self.setResizeMode(QtWidgets.QListView.Adjust)
|
||||
self.setWrapping(True)
|
||||
self.setWordWrap(True)
|
||||
self.setGridSize(QtCore.QSize(151, 90))
|
||||
self.setIconSize(QtCore.QSize(50, 50))
|
||||
self.setSpacing(0)
|
||||
self.setAlternatingRowColors(False)
|
||||
|
||||
self.setProperty("mode", "icon")
|
||||
self.style().polish(self)
|
||||
|
||||
self.verticalScrollBar().setSingleStep(30)
|
||||
|
||||
elif self.ListMode:
|
||||
self.setProperty("mode", "list")
|
||||
self.style().polish(self)
|
||||
|
||||
self.setViewMode(QtWidgets.QListView.ListMode)
|
||||
self.setResizeMode(QtWidgets.QListView.Adjust)
|
||||
self.setWrapping(False)
|
||||
self.setWordWrap(False)
|
||||
self.setIconSize(QtCore.QSize(20, 20))
|
||||
self.setGridSize(QtCore.QSize(100, 25))
|
||||
self.setSpacing(0)
|
||||
self.setAlternatingRowColors(False)
|
||||
|
||||
self.verticalScrollBar().setSingleStep(34)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == QtCore.Qt.RightButton:
|
||||
self.set_mode(int(not self._mode))
|
||||
return super(ProjectIconView, self).mousePressEvent(event)
|
||||
|
||||
|
||||
class ProjectsWidget(QtWidgets.QWidget):
|
||||
"""Projects Page"""
|
||||
|
||||
refreshed = QtCore.Signal()
|
||||
|
||||
def __init__(self, controller, parent=None):
|
||||
super(ProjectsWidget, self).__init__(parent=parent)
|
||||
|
||||
header_widget = QtWidgets.QWidget(self)
|
||||
|
||||
projects_filter_text = PlaceholderLineEdit(header_widget)
|
||||
projects_filter_text.setPlaceholderText("Filter projects...")
|
||||
|
||||
refresh_btn = RefreshButton(header_widget)
|
||||
|
||||
header_layout = QtWidgets.QHBoxLayout(header_widget)
|
||||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||||
header_layout.addWidget(projects_filter_text, 1)
|
||||
header_layout.addWidget(refresh_btn, 0)
|
||||
|
||||
projects_view = ProjectIconView(parent=self)
|
||||
projects_view.setSelectionMode(QtWidgets.QListView.NoSelection)
|
||||
flick = FlickCharm(parent=self)
|
||||
flick.activateOn(projects_view)
|
||||
projects_model = ProjectsQtModel(controller)
|
||||
projects_proxy_model = ProjectSortFilterProxy()
|
||||
projects_proxy_model.setSourceModel(projects_model)
|
||||
|
||||
projects_view.setModel(projects_proxy_model)
|
||||
|
||||
main_layout = QtWidgets.QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(header_widget, 0)
|
||||
main_layout.addWidget(projects_view, 1)
|
||||
|
||||
projects_view.clicked.connect(self._on_view_clicked)
|
||||
projects_model.refreshed.connect(self.refreshed)
|
||||
projects_filter_text.textChanged.connect(
|
||||
self._on_project_filter_change)
|
||||
refresh_btn.clicked.connect(self._on_refresh_clicked)
|
||||
|
||||
controller.register_event_callback(
|
||||
"projects.refresh.finished",
|
||||
self._on_projects_refresh_finished
|
||||
)
|
||||
|
||||
self._controller = controller
|
||||
|
||||
self._projects_view = projects_view
|
||||
self._projects_model = projects_model
|
||||
self._projects_proxy_model = projects_proxy_model
|
||||
|
||||
def has_content(self):
|
||||
"""Model has at least one project.
|
||||
|
||||
Returns:
|
||||
bool: True if there is any content in the model.
|
||||
"""
|
||||
|
||||
return self._projects_model.has_content()
|
||||
|
||||
def _on_view_clicked(self, index):
|
||||
if not index.isValid():
|
||||
return
|
||||
model = index.model()
|
||||
flags = model.flags(index)
|
||||
if not flags & QtCore.Qt.ItemIsEnabled:
|
||||
return
|
||||
project_name = index.data(QtCore.Qt.DisplayRole)
|
||||
self._controller.set_selected_project(project_name)
|
||||
|
||||
def _on_project_filter_change(self, text):
|
||||
self._projects_proxy_model.setFilterFixedString(text)
|
||||
|
||||
def _on_refresh_clicked(self):
|
||||
self._controller.refresh()
|
||||
|
||||
def _on_projects_refresh_finished(self, event):
|
||||
if event["sender"] != PROJECTS_MODEL_SENDER:
|
||||
self._projects_model.refresh()
|
||||
|
|
@ -3,9 +3,13 @@ from qtpy import QtWidgets, QtCore, QtGui
|
|||
from ayon_core import style, resources
|
||||
|
||||
from ayon_core.tools.launcher.control import BaseLauncherController
|
||||
from ayon_core.tools.utils import MessageOverlayObject
|
||||
from ayon_core.tools.utils import (
|
||||
MessageOverlayObject,
|
||||
PlaceholderLineEdit,
|
||||
RefreshButton,
|
||||
ProjectsWidget,
|
||||
)
|
||||
|
||||
from .projects_widget import ProjectsWidget
|
||||
from .hierarchy_page import HierarchyPage
|
||||
from .actions_widget import ActionsWidget
|
||||
|
||||
|
|
@ -50,7 +54,25 @@ class LauncherWindow(QtWidgets.QWidget):
|
|||
pages_widget = QtWidgets.QWidget(content_body)
|
||||
|
||||
# - First page - Projects
|
||||
projects_page = ProjectsWidget(controller, pages_widget)
|
||||
projects_page = QtWidgets.QWidget(pages_widget)
|
||||
projects_header_widget = QtWidgets.QWidget(projects_page)
|
||||
|
||||
projects_filter_text = PlaceholderLineEdit(projects_header_widget)
|
||||
projects_filter_text.setPlaceholderText("Filter projects...")
|
||||
|
||||
refresh_btn = RefreshButton(projects_header_widget)
|
||||
|
||||
projects_header_layout = QtWidgets.QHBoxLayout(projects_header_widget)
|
||||
projects_header_layout.setContentsMargins(0, 0, 0, 0)
|
||||
projects_header_layout.addWidget(projects_filter_text, 1)
|
||||
projects_header_layout.addWidget(refresh_btn, 0)
|
||||
|
||||
projects_widget = ProjectsWidget(controller, pages_widget)
|
||||
|
||||
projects_layout = QtWidgets.QVBoxLayout(projects_page)
|
||||
projects_layout.setContentsMargins(0, 0, 0, 0)
|
||||
projects_layout.addWidget(projects_header_widget, 0)
|
||||
projects_layout.addWidget(projects_widget, 1)
|
||||
|
||||
# - Second page - Hierarchy (folders & tasks)
|
||||
hierarchy_page = HierarchyPage(controller, pages_widget)
|
||||
|
|
@ -102,12 +124,16 @@ class LauncherWindow(QtWidgets.QWidget):
|
|||
page_slide_anim.setEndValue(1.0)
|
||||
page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad)
|
||||
|
||||
projects_page.refreshed.connect(self._on_projects_refresh)
|
||||
refresh_btn.clicked.connect(self._on_refresh_request)
|
||||
projects_widget.refreshed.connect(self._on_projects_refresh)
|
||||
|
||||
actions_refresh_timer.timeout.connect(
|
||||
self._on_actions_refresh_timeout)
|
||||
page_slide_anim.valueChanged.connect(
|
||||
self._on_page_slide_value_changed)
|
||||
page_slide_anim.finished.connect(self._on_page_slide_finished)
|
||||
projects_filter_text.textChanged.connect(
|
||||
self._on_project_filter_change)
|
||||
|
||||
controller.register_event_callback(
|
||||
"selection.project.changed",
|
||||
|
|
@ -142,6 +168,7 @@ class LauncherWindow(QtWidgets.QWidget):
|
|||
self._pages_widget = pages_widget
|
||||
self._pages_layout = pages_layout
|
||||
self._projects_page = projects_page
|
||||
self._projects_widget = projects_widget
|
||||
self._hierarchy_page = hierarchy_page
|
||||
self._actions_widget = actions_widget
|
||||
# self._action_history = action_history
|
||||
|
|
@ -194,6 +221,12 @@ class LauncherWindow(QtWidgets.QWidget):
|
|||
elif self._is_on_projects_page:
|
||||
self._go_to_hierarchy_page(project_name)
|
||||
|
||||
def _on_project_filter_change(self, text):
|
||||
self._projects_widget.set_name_filter(text)
|
||||
|
||||
def _on_refresh_request(self):
|
||||
self._controller.refresh()
|
||||
|
||||
def _on_projects_refresh(self):
|
||||
# Refresh only actions on projects page
|
||||
if self._is_on_projects_page:
|
||||
|
|
@ -201,7 +234,7 @@ class LauncherWindow(QtWidgets.QWidget):
|
|||
return
|
||||
|
||||
# No projects were found -> go back to projects page
|
||||
if not self._projects_page.has_content():
|
||||
if not self._projects_widget.has_content():
|
||||
self._go_to_projects_page()
|
||||
return
|
||||
|
||||
|
|
@ -280,6 +313,9 @@ class LauncherWindow(QtWidgets.QWidget):
|
|||
def _go_to_projects_page(self):
|
||||
if self._is_on_projects_page:
|
||||
return
|
||||
|
||||
# Deselect project in projects widget
|
||||
self._projects_widget.set_selected_project(None)
|
||||
self._is_on_projects_page = True
|
||||
self._hierarchy_page.set_page_visible(False)
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from .widgets import (
|
|||
from .views import (
|
||||
DeselectableTreeView,
|
||||
TreeView,
|
||||
ListView,
|
||||
)
|
||||
from .error_dialog import ErrorMessageBox
|
||||
from .lib import (
|
||||
|
|
@ -61,6 +62,7 @@ from .dialogs import (
|
|||
)
|
||||
from .projects_widget import (
|
||||
ProjectsCombobox,
|
||||
ProjectsWidget,
|
||||
ProjectsQtModel,
|
||||
ProjectSortFilterProxy,
|
||||
PROJECT_NAME_ROLE,
|
||||
|
|
@ -114,6 +116,7 @@ __all__ = (
|
|||
|
||||
"DeselectableTreeView",
|
||||
"TreeView",
|
||||
"ListView",
|
||||
|
||||
"ErrorMessageBox",
|
||||
|
||||
|
|
@ -145,6 +148,7 @@ __all__ = (
|
|||
"PopupUpdateKeys",
|
||||
|
||||
"ProjectsCombobox",
|
||||
"ProjectsWidget",
|
||||
"ProjectsQtModel",
|
||||
"ProjectSortFilterProxy",
|
||||
"PROJECT_NAME_ROLE",
|
||||
|
|
|
|||
|
|
@ -1,21 +1,69 @@
|
|||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
import typing
|
||||
from typing import Optional
|
||||
|
||||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
|
||||
from ayon_core.tools.common_models import PROJECTS_MODEL_SENDER
|
||||
from ayon_core.tools.common_models import (
|
||||
ProjectItem,
|
||||
PROJECTS_MODEL_SENDER,
|
||||
)
|
||||
|
||||
from .views import ListView
|
||||
from .lib import RefreshThread, get_qt_icon
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from typing import TypedDict
|
||||
|
||||
class ExpectedProjectSelectionData(TypedDict):
|
||||
name: Optional[str]
|
||||
current: Optional[str]
|
||||
selected: Optional[str]
|
||||
|
||||
class ExpectedSelectionData(TypedDict):
|
||||
project: ExpectedProjectSelectionData
|
||||
|
||||
|
||||
PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1
|
||||
PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2
|
||||
PROJECT_IS_LIBRARY_ROLE = QtCore.Qt.UserRole + 3
|
||||
PROJECT_IS_CURRENT_ROLE = QtCore.Qt.UserRole + 4
|
||||
LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 5
|
||||
PROJECT_IS_PINNED_ROLE = QtCore.Qt.UserRole + 5
|
||||
LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 6
|
||||
|
||||
|
||||
class AbstractProjectController(ABC):
|
||||
@abstractmethod
|
||||
def register_event_callback(self, topic: str, callback: Callable):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_project_items(
|
||||
self, sender: Optional[str] = None
|
||||
) -> list[str]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_selected_project(self, project_name: str):
|
||||
pass
|
||||
|
||||
# These are required only if widget should handle expected selection
|
||||
@abstractmethod
|
||||
def expected_project_selected(self, project_name: str):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_expected_selection_data(self) -> "ExpectedSelectionData":
|
||||
pass
|
||||
|
||||
|
||||
class ProjectsQtModel(QtGui.QStandardItemModel):
|
||||
refreshed = QtCore.Signal()
|
||||
|
||||
def __init__(self, controller):
|
||||
super(ProjectsQtModel, self).__init__()
|
||||
def __init__(self, controller: AbstractProjectController):
|
||||
super().__init__()
|
||||
self._controller = controller
|
||||
|
||||
self._project_items = {}
|
||||
|
|
@ -213,7 +261,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel):
|
|||
else:
|
||||
self.refreshed.emit()
|
||||
|
||||
def _fill_items(self, project_items):
|
||||
def _fill_items(self, project_items: list[ProjectItem]):
|
||||
new_project_names = {
|
||||
project_item.name
|
||||
for project_item in project_items
|
||||
|
|
@ -252,6 +300,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel):
|
|||
item.setData(project_name, PROJECT_NAME_ROLE)
|
||||
item.setData(project_item.active, PROJECT_IS_ACTIVE_ROLE)
|
||||
item.setData(project_item.is_library, PROJECT_IS_LIBRARY_ROLE)
|
||||
item.setData(project_item.is_pinned, PROJECT_IS_PINNED_ROLE)
|
||||
is_current = project_name == self._current_context_project
|
||||
item.setData(is_current, PROJECT_IS_CURRENT_ROLE)
|
||||
self._project_items[project_name] = item
|
||||
|
|
@ -279,7 +328,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel):
|
|||
|
||||
class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ProjectSortFilterProxy, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
self._filter_inactive = True
|
||||
self._filter_standard = False
|
||||
self._filter_library = False
|
||||
|
|
@ -323,26 +372,51 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
|
|||
return False
|
||||
|
||||
# Library separator should be before library projects
|
||||
result = self._type_sort(left_index, right_index)
|
||||
if result is not None:
|
||||
return result
|
||||
l_is_library = left_index.data(PROJECT_IS_LIBRARY_ROLE)
|
||||
r_is_library = right_index.data(PROJECT_IS_LIBRARY_ROLE)
|
||||
l_is_sep = left_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE)
|
||||
r_is_sep = right_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE)
|
||||
if l_is_sep:
|
||||
return bool(r_is_library)
|
||||
|
||||
if left_index.data(PROJECT_NAME_ROLE) is None:
|
||||
if r_is_sep:
|
||||
return not l_is_library
|
||||
|
||||
# Non project items should be on top
|
||||
l_project_name = left_index.data(PROJECT_NAME_ROLE)
|
||||
r_project_name = right_index.data(PROJECT_NAME_ROLE)
|
||||
if l_project_name is None:
|
||||
return True
|
||||
|
||||
if right_index.data(PROJECT_NAME_ROLE) is None:
|
||||
if r_project_name is None:
|
||||
return False
|
||||
|
||||
left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE)
|
||||
right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE)
|
||||
if right_is_active == left_is_active:
|
||||
return super(ProjectSortFilterProxy, self).lessThan(
|
||||
left_index, right_index
|
||||
)
|
||||
if right_is_active != left_is_active:
|
||||
return left_is_active
|
||||
|
||||
if left_is_active:
|
||||
l_is_pinned = left_index.data(PROJECT_IS_PINNED_ROLE)
|
||||
r_is_pinned = right_index.data(PROJECT_IS_PINNED_ROLE)
|
||||
if l_is_pinned is True and not r_is_pinned:
|
||||
return True
|
||||
return False
|
||||
|
||||
if r_is_pinned is True and not l_is_pinned:
|
||||
return False
|
||||
|
||||
# Move inactive projects to the end
|
||||
left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE)
|
||||
right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE)
|
||||
if right_is_active != left_is_active:
|
||||
return left_is_active
|
||||
|
||||
# Move library projects after standard projects
|
||||
if (
|
||||
l_is_library is not None
|
||||
and r_is_library is not None
|
||||
and l_is_library != r_is_library
|
||||
):
|
||||
return r_is_library
|
||||
return super().lessThan(left_index, right_index)
|
||||
|
||||
def filterAcceptsRow(self, source_row, source_parent):
|
||||
index = self.sourceModel().index(source_row, 0, source_parent)
|
||||
|
|
@ -415,15 +489,153 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
|
|||
self.invalidate()
|
||||
|
||||
|
||||
class ProjectsDelegate(QtWidgets.QStyledItemDelegate):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._pin_icon = None
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
is_pinned = index.data(PROJECT_IS_PINNED_ROLE)
|
||||
if not is_pinned:
|
||||
super().paint(painter, option, index)
|
||||
return
|
||||
opt = QtWidgets.QStyleOptionViewItem(option)
|
||||
self.initStyleOption(opt, index)
|
||||
widget = option.widget
|
||||
if widget is None:
|
||||
style = QtWidgets.QApplication.style()
|
||||
else:
|
||||
style = widget.style()
|
||||
# CE_ItemViewItem
|
||||
proxy = style.proxy()
|
||||
painter.save()
|
||||
painter.setClipRect(option.rect)
|
||||
decor_rect = proxy.subElementRect(
|
||||
QtWidgets.QStyle.SE_ItemViewItemDecoration, opt, widget
|
||||
)
|
||||
text_rect = proxy.subElementRect(
|
||||
QtWidgets.QStyle.SE_ItemViewItemText, opt, widget
|
||||
)
|
||||
proxy.drawPrimitive(
|
||||
QtWidgets.QStyle.PE_PanelItemViewItem, opt, painter, widget
|
||||
)
|
||||
mode = QtGui.QIcon.Normal
|
||||
if not opt.state & QtWidgets.QStyle.State_Enabled:
|
||||
mode = QtGui.QIcon.Disabled
|
||||
elif opt.state & QtWidgets.QStyle.State_Selected:
|
||||
mode = QtGui.QIcon.Selected
|
||||
state = QtGui.QIcon.Off
|
||||
if opt.state & QtWidgets.QStyle.State_Open:
|
||||
state = QtGui.QIcon.On
|
||||
|
||||
# Draw project icon
|
||||
opt.icon.paint(
|
||||
painter, decor_rect, opt.decorationAlignment, mode, state
|
||||
)
|
||||
|
||||
# Draw pin icon
|
||||
if index.data(PROJECT_IS_PINNED_ROLE):
|
||||
pin_icon = self._get_pin_icon()
|
||||
pin_rect = QtCore.QRect(decor_rect)
|
||||
diff = option.rect.width() - pin_rect.width()
|
||||
pin_rect.moveLeft(diff)
|
||||
pin_icon.paint(
|
||||
painter, pin_rect, opt.decorationAlignment, mode, state
|
||||
)
|
||||
|
||||
# Draw text
|
||||
if opt.text:
|
||||
if not opt.state & QtWidgets.QStyle.State_Enabled:
|
||||
cg = QtGui.QPalette.Disabled
|
||||
elif not (opt.state & QtWidgets.QStyle.State_Active):
|
||||
cg = QtGui.QPalette.Inactive
|
||||
else:
|
||||
cg = QtGui.QPalette.Normal
|
||||
|
||||
if opt.state & QtWidgets.QStyle.State_Selected:
|
||||
painter.setPen(
|
||||
opt.palette.color(cg, QtGui.QPalette.HighlightedText)
|
||||
)
|
||||
else:
|
||||
painter.setPen(opt.palette.color(cg, QtGui.QPalette.Text))
|
||||
|
||||
if opt.state & QtWidgets.QStyle.State_Editing:
|
||||
painter.setPen(opt.palette.color(cg, QtGui.QPalette.Text))
|
||||
painter.drawRect(text_rect.adjusted(0, 0, -1, -1))
|
||||
|
||||
margin = proxy.pixelMetric(
|
||||
QtWidgets.QStyle.PM_FocusFrameHMargin, None, widget
|
||||
) + 1
|
||||
text_rect.adjust(margin, 0, -margin, 0)
|
||||
# NOTE skipping some steps e.g. word wrapping and elided
|
||||
# text (adding '...' when too long).
|
||||
painter.drawText(
|
||||
text_rect,
|
||||
opt.displayAlignment,
|
||||
opt.text
|
||||
)
|
||||
|
||||
# Draw focus rect
|
||||
if opt.state & QtWidgets.QStyle.State_HasFocus:
|
||||
focus_opt = QtWidgets.QStyleOptionFocusRect()
|
||||
focus_opt.state = option.state
|
||||
focus_opt.direction = option.direction
|
||||
focus_opt.rect = option.rect
|
||||
focus_opt.fontMetrics = option.fontMetrics
|
||||
focus_opt.palette = option.palette
|
||||
|
||||
focus_opt.rect = style.subElementRect(
|
||||
QtWidgets.QCommonStyle.SE_ItemViewItemFocusRect,
|
||||
option,
|
||||
option.widget
|
||||
)
|
||||
focus_opt.state |= (
|
||||
QtWidgets.QStyle.State_KeyboardFocusChange
|
||||
| QtWidgets.QStyle.State_Item
|
||||
)
|
||||
focus_opt.backgroundColor = option.palette.color(
|
||||
(
|
||||
QtGui.QPalette.Normal
|
||||
if option.state & QtWidgets.QStyle.State_Enabled
|
||||
else QtGui.QPalette.Disabled
|
||||
),
|
||||
(
|
||||
QtGui.QPalette.Highlight
|
||||
if option.state & QtWidgets.QStyle.State_Selected
|
||||
else QtGui.QPalette.Window
|
||||
)
|
||||
)
|
||||
style.drawPrimitive(
|
||||
QtWidgets.QCommonStyle.PE_FrameFocusRect,
|
||||
focus_opt,
|
||||
painter,
|
||||
option.widget
|
||||
)
|
||||
painter.restore()
|
||||
|
||||
def _get_pin_icon(self):
|
||||
if self._pin_icon is None:
|
||||
self._pin_icon = get_qt_icon({
|
||||
"type": "material-symbols",
|
||||
"name": "keep",
|
||||
})
|
||||
return self._pin_icon
|
||||
|
||||
|
||||
class ProjectsCombobox(QtWidgets.QWidget):
|
||||
refreshed = QtCore.Signal()
|
||||
selection_changed = QtCore.Signal()
|
||||
selection_changed = QtCore.Signal(str)
|
||||
|
||||
def __init__(self, controller, parent, handle_expected_selection=False):
|
||||
super(ProjectsCombobox, self).__init__(parent)
|
||||
def __init__(
|
||||
self,
|
||||
controller: AbstractProjectController,
|
||||
parent: QtWidgets.QWidget,
|
||||
handle_expected_selection: bool = False,
|
||||
):
|
||||
super().__init__(parent)
|
||||
|
||||
projects_combobox = QtWidgets.QComboBox(self)
|
||||
combobox_delegate = QtWidgets.QStyledItemDelegate(projects_combobox)
|
||||
combobox_delegate = ProjectsDelegate(projects_combobox)
|
||||
projects_combobox.setItemDelegate(combobox_delegate)
|
||||
projects_model = ProjectsQtModel(controller)
|
||||
projects_proxy_model = ProjectSortFilterProxy()
|
||||
|
|
@ -468,7 +680,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
|
|||
def refresh(self):
|
||||
self._projects_model.refresh()
|
||||
|
||||
def set_selection(self, project_name):
|
||||
def set_selection(self, project_name: str):
|
||||
"""Set selection to a given project.
|
||||
|
||||
Selection change is ignored if project is not found.
|
||||
|
|
@ -480,8 +692,8 @@ class ProjectsCombobox(QtWidgets.QWidget):
|
|||
bool: True if selection was changed, False otherwise. NOTE:
|
||||
Selection may not be changed if project is not found, or if
|
||||
project is already selected.
|
||||
"""
|
||||
|
||||
"""
|
||||
idx = self._projects_combobox.findData(
|
||||
project_name, PROJECT_NAME_ROLE)
|
||||
if idx < 0:
|
||||
|
|
@ -491,7 +703,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
|
|||
return True
|
||||
return False
|
||||
|
||||
def set_listen_to_selection_change(self, listen):
|
||||
def set_listen_to_selection_change(self, listen: bool):
|
||||
"""Disable listening to changes of the selection.
|
||||
|
||||
Because combobox is triggering selection change when it's model
|
||||
|
|
@ -517,11 +729,11 @@ class ProjectsCombobox(QtWidgets.QWidget):
|
|||
return None
|
||||
return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE)
|
||||
|
||||
def set_current_context_project(self, project_name):
|
||||
def set_current_context_project(self, project_name: str):
|
||||
self._projects_model.set_current_context_project(project_name)
|
||||
self._projects_proxy_model.invalidateFilter()
|
||||
|
||||
def set_select_item_visible(self, visible):
|
||||
def set_select_item_visible(self, visible: bool):
|
||||
self._select_item_visible = visible
|
||||
self._projects_model.set_select_item_visible(visible)
|
||||
self._update_select_item_visiblity()
|
||||
|
|
@ -559,7 +771,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
|
|||
idx, PROJECT_NAME_ROLE)
|
||||
self._update_select_item_visiblity(project_name=project_name)
|
||||
self._controller.set_selected_project(project_name)
|
||||
self.selection_changed.emit()
|
||||
self.selection_changed.emit(project_name or "")
|
||||
|
||||
def _on_model_refresh(self):
|
||||
self._projects_proxy_model.sort(0)
|
||||
|
|
@ -614,5 +826,119 @@ class ProjectsCombobox(QtWidgets.QWidget):
|
|||
|
||||
|
||||
class ProjectsWidget(QtWidgets.QWidget):
|
||||
# TODO implement
|
||||
pass
|
||||
"""Projects widget showing projects in list.
|
||||
|
||||
Warnings:
|
||||
This widget does not support expected selection handling.
|
||||
|
||||
"""
|
||||
refreshed = QtCore.Signal()
|
||||
selection_changed = QtCore.Signal(str)
|
||||
double_clicked = QtCore.Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller: AbstractProjectController,
|
||||
parent: Optional[QtWidgets.QWidget] = None
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
projects_view = ListView(parent=self)
|
||||
projects_view.setResizeMode(QtWidgets.QListView.Adjust)
|
||||
projects_view.setVerticalScrollMode(
|
||||
QtWidgets.QAbstractItemView.ScrollPerPixel
|
||||
)
|
||||
projects_view.setAlternatingRowColors(False)
|
||||
projects_view.setWrapping(False)
|
||||
projects_view.setWordWrap(False)
|
||||
projects_view.setSpacing(0)
|
||||
projects_delegate = ProjectsDelegate(projects_view)
|
||||
projects_view.setItemDelegate(projects_delegate)
|
||||
projects_view.activate_flick_charm()
|
||||
projects_view.set_deselectable(True)
|
||||
|
||||
projects_model = ProjectsQtModel(controller)
|
||||
projects_proxy_model = ProjectSortFilterProxy()
|
||||
projects_proxy_model.setSourceModel(projects_model)
|
||||
projects_view.setModel(projects_proxy_model)
|
||||
|
||||
main_layout = QtWidgets.QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(projects_view, 1)
|
||||
|
||||
projects_view.selectionModel().selectionChanged.connect(
|
||||
self._on_selection_change
|
||||
)
|
||||
projects_view.double_clicked.connect(self.double_clicked)
|
||||
projects_model.refreshed.connect(self._on_model_refresh)
|
||||
|
||||
controller.register_event_callback(
|
||||
"projects.refresh.finished",
|
||||
self._on_projects_refresh_finished
|
||||
)
|
||||
|
||||
self._controller = controller
|
||||
|
||||
self._projects_view = projects_view
|
||||
self._projects_model = projects_model
|
||||
self._projects_proxy_model = projects_proxy_model
|
||||
self._projects_delegate = projects_delegate
|
||||
|
||||
def refresh(self):
|
||||
self._projects_model.refresh()
|
||||
|
||||
def has_content(self) -> bool:
|
||||
"""Model has at least one project.
|
||||
|
||||
Returns:
|
||||
bool: True if there is any content in the model.
|
||||
|
||||
"""
|
||||
return self._projects_model.has_content()
|
||||
|
||||
def set_name_filter(self, text: str):
|
||||
self._projects_proxy_model.setFilterFixedString(text)
|
||||
|
||||
def get_selected_project(self) -> Optional[str]:
|
||||
selection_model = self._projects_view.selectionModel()
|
||||
for index in selection_model.selectedIndexes():
|
||||
project_name = index.data(PROJECT_NAME_ROLE)
|
||||
if project_name:
|
||||
return project_name
|
||||
return None
|
||||
|
||||
def set_selected_project(self, project_name: Optional[str]):
|
||||
if project_name is None:
|
||||
self._projects_view.clearSelection()
|
||||
self._projects_view.setCurrentIndex(QtCore.QModelIndex())
|
||||
return
|
||||
|
||||
index = self._projects_model.get_index_by_project_name(project_name)
|
||||
if not index.isValid():
|
||||
return
|
||||
proxy_index = self._projects_proxy_model.mapFromSource(index)
|
||||
if proxy_index.isValid():
|
||||
selection_model = self._projects_view.selectionModel()
|
||||
selection_model.select(
|
||||
proxy_index,
|
||||
QtCore.QItemSelectionModel.ClearAndSelect
|
||||
)
|
||||
|
||||
def _on_model_refresh(self):
|
||||
self._projects_proxy_model.sort(0)
|
||||
self._projects_proxy_model.invalidateFilter()
|
||||
self.refreshed.emit()
|
||||
|
||||
def _on_selection_change(self, new_selection, _old_selection):
|
||||
project_name = None
|
||||
for index in new_selection.indexes():
|
||||
name = index.data(PROJECT_NAME_ROLE)
|
||||
if name:
|
||||
project_name = name
|
||||
break
|
||||
self.selection_changed.emit(project_name or "")
|
||||
self._controller.set_selected_project(project_name)
|
||||
|
||||
def _on_projects_refresh_finished(self, event):
|
||||
if event["sender"] != PROJECTS_MODEL_SENDER:
|
||||
self._projects_model.refresh()
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class TreeView(QtWidgets.QTreeView):
|
|||
double_clicked = QtCore.Signal(QtGui.QMouseEvent)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(TreeView, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
self._deselectable = False
|
||||
|
||||
self._flick_charm_activated = False
|
||||
|
|
@ -60,12 +60,64 @@ class TreeView(QtWidgets.QTreeView):
|
|||
self.clearSelection()
|
||||
# clear the current index
|
||||
self.setCurrentIndex(QtCore.QModelIndex())
|
||||
super(TreeView, self).mousePressEvent(event)
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
self.double_clicked.emit(event)
|
||||
|
||||
return super(TreeView, self).mouseDoubleClickEvent(event)
|
||||
return super().mouseDoubleClickEvent(event)
|
||||
|
||||
def activate_flick_charm(self):
|
||||
if self._flick_charm_activated:
|
||||
return
|
||||
self._flick_charm_activated = True
|
||||
self._before_flick_scroll_mode = self.verticalScrollMode()
|
||||
self._flick_charm.activateOn(self)
|
||||
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
|
||||
|
||||
def deactivate_flick_charm(self):
|
||||
if not self._flick_charm_activated:
|
||||
return
|
||||
self._flick_charm_activated = False
|
||||
self._flick_charm.deactivateFrom(self)
|
||||
if self._before_flick_scroll_mode is not None:
|
||||
self.setVerticalScrollMode(self._before_flick_scroll_mode)
|
||||
|
||||
|
||||
class ListView(QtWidgets.QListView):
|
||||
"""A tree view that deselects on clicking on an empty area in the view"""
|
||||
double_clicked = QtCore.Signal(QtGui.QMouseEvent)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._deselectable = False
|
||||
|
||||
self._flick_charm_activated = False
|
||||
self._flick_charm = FlickCharm(parent=self)
|
||||
self._before_flick_scroll_mode = None
|
||||
|
||||
def is_deselectable(self):
|
||||
return self._deselectable
|
||||
|
||||
def set_deselectable(self, deselectable):
|
||||
self._deselectable = deselectable
|
||||
|
||||
deselectable = property(is_deselectable, set_deselectable)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if self._deselectable:
|
||||
index = self.indexAt(event.pos())
|
||||
if not index.isValid():
|
||||
# clear the selection
|
||||
self.clearSelection()
|
||||
# clear the current index
|
||||
self.setCurrentIndex(QtCore.QModelIndex())
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
self.double_clicked.emit(event)
|
||||
|
||||
return super().mouseDoubleClickEvent(event)
|
||||
|
||||
def activate_flick_charm(self):
|
||||
if self._flick_charm_activated:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Tests."""
|
||||
1
tests/client/ayon_core/pipeline/traits/__init__.py
Normal file
1
tests/client/ayon_core/pipeline/traits/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the representation traits."""
|
||||
25
tests/client/ayon_core/pipeline/traits/lib/__init__.py
Normal file
25
tests/client/ayon_core/pipeline/traits/lib/__init__.py
Normal file
|
|
@ -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"]
|
||||
184
tests/client/ayon_core/pipeline/traits/test_content_traits.py
Normal file
184
tests/client/ayon_core/pipeline/traits/test_content_traits.py
Normal file
|
|
@ -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<index>(?P<padding>0*)\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]
|
||||
248
tests/client/ayon_core/pipeline/traits/test_time_traits.py
Normal file
248
tests/client/ayon_core/pipeline/traits/test_time_traits.py
Normal file
|
|
@ -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<index>(?P<padding>0*)\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
|
||||
405
tests/client/ayon_core/pipeline/traits/test_traits.py
Normal file
405
tests/client/ayon_core/pipeline/traits/test_traits.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
451
tests/client/ayon_core/plugins/publish/test_integrate_traits.py
Normal file
451
tests/client/ayon_core/plugins/publish/test_integrate_traits.py
Normal file
|
|
@ -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<index>(?P<padding>0*)\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<index>(?P<padding>0*)\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"])
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue