Merge branch 'feature/AY-2218_Plugin-hooks-Loader-and-Scene-Inventory' of https://github.com/ynput/ayon-core into feature/AY-2218_Plugin-hooks-Loader-and-Scene-Inventory

This commit is contained in:
Roy Nieterau 2025-06-17 23:26:47 +02:00
commit 4c07e03fc6
57 changed files with 7844 additions and 838 deletions

1
.gitignore vendored
View file

@ -82,6 +82,7 @@ poetry.lock
.editorconfig
.pre-commit-config.yaml
mypy.ini
poetry.lock
.github_changelog_generator

View file

@ -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",
)

View file

@ -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.
"""

View file

@ -62,6 +62,7 @@ from .execute import (
run_subprocess,
run_detached_process,
run_ayon_launcher_process,
run_detached_ayon_launcher_process,
path_to_subprocess_arg,
CREATE_NO_WINDOW
)
@ -131,6 +132,7 @@ from .ayon_info import (
is_staging_enabled,
is_dev_mode_enabled,
is_in_tests,
get_settings_variant,
)
terminal = Terminal
@ -160,6 +162,7 @@ __all__ = [
"run_subprocess",
"run_detached_process",
"run_ayon_launcher_process",
"run_detached_ayon_launcher_process",
"path_to_subprocess_arg",
"CREATE_NO_WINDOW",
@ -240,4 +243,5 @@ __all__ = [
"is_staging_enabled",
"is_dev_mode_enabled",
"is_in_tests",
"get_settings_variant",
]

View file

@ -78,15 +78,15 @@ def is_using_ayon_console():
return "ayon_console" in executable_filename
def is_headless_mode_enabled():
def is_headless_mode_enabled() -> bool:
return os.getenv("AYON_HEADLESS_MODE") == "1"
def is_staging_enabled():
def is_staging_enabled() -> bool:
return os.getenv("AYON_USE_STAGING") == "1"
def is_in_tests():
def is_in_tests() -> bool:
"""Process is running in automatic tests mode.
Returns:
@ -96,7 +96,7 @@ def is_in_tests():
return os.environ.get("AYON_IN_TESTS") == "1"
def is_dev_mode_enabled():
def is_dev_mode_enabled() -> bool:
"""Dev mode is enabled in AYON.
Returns:
@ -106,6 +106,22 @@ def is_dev_mode_enabled():
return os.getenv("AYON_USE_DEV") == "1"
def get_settings_variant() -> str:
"""Get AYON settings variant.
Returns:
str: Settings variant.
"""
if is_dev_mode_enabled():
return os.environ["AYON_BUNDLE_NAME"]
if is_staging_enabled():
return "staging"
return "production"
def get_ayon_info():
executable_args = get_ayon_launcher_args()
if is_running_from_build():

View file

@ -1,3 +1,4 @@
from __future__ import annotations
import os
import sys
import subprocess
@ -201,29 +202,9 @@ def clean_envs_for_ayon_process(env=None):
return env
def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs):
"""Execute AYON process with passed arguments and wait.
Wrapper for 'run_process' which prepends AYON executable arguments
before passed arguments and define environments if are not passed.
Values from 'os.environ' are used for environments if are not passed.
They are cleaned using 'clean_envs_for_ayon_process' function.
Example:
```
run_ayon_process("run", "<path to .py script>")
```
Args:
*args (str): ayon-launcher cli arguments.
**kwargs (Any): Keyword arguments for subprocess.Popen.
Returns:
str: Full output of subprocess concatenated stdout and stderr.
"""
args = get_ayon_launcher_args(*args)
def _prepare_ayon_launcher_env(
add_sys_paths: bool, kwargs: dict
) -> dict[str, str]:
env = kwargs.pop("env", None)
# Keep env untouched if are passed and not empty
if not env:
@ -239,8 +220,7 @@ def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs):
new_pythonpath.append(path)
lookup_set.add(path)
env["PYTHONPATH"] = os.pathsep.join(new_pythonpath)
return run_subprocess(args, env=env, **kwargs)
return env
def run_detached_process(args, **kwargs):
@ -314,6 +294,67 @@ def run_detached_process(args, **kwargs):
return process
def run_ayon_launcher_process(
*args, add_sys_paths: bool = False, **kwargs
) -> str:
"""Execute AYON process with passed arguments and wait.
Wrapper for 'run_process' which prepends AYON executable arguments
before passed arguments and define environments if are not passed.
Values from 'os.environ' are used for environments if are not passed.
They are cleaned using 'clean_envs_for_ayon_process' function.
Example:
```
run_ayon_launcher_process("run", "<path to .py script>")
```
Args:
*args (str): ayon-launcher cli arguments.
add_sys_paths (bool): Add system paths to PYTHONPATH.
**kwargs (Any): Keyword arguments for subprocess.Popen.
Returns:
str: Full output of subprocess concatenated stdout and stderr.
"""
args = get_ayon_launcher_args(*args)
env = _prepare_ayon_launcher_env(add_sys_paths, kwargs)
return run_subprocess(args, env=env, **kwargs)
def run_detached_ayon_launcher_process(
*args, add_sys_paths: bool = False, **kwargs
) -> subprocess.Popen:
"""Execute AYON process with passed arguments and wait.
Wrapper for 'run_process' which prepends AYON executable arguments
before passed arguments and define environments if are not passed.
Values from 'os.environ' are used for environments if are not passed.
They are cleaned using 'clean_envs_for_ayon_process' function.
Example:
```
run_detached_ayon_launcher_process("run", "<path to .py script>")
```
Args:
*args (str): ayon-launcher cli arguments.
add_sys_paths (bool): Add system paths to PYTHONPATH.
**kwargs (Any): Keyword arguments for subprocess.Popen.
Returns:
subprocess.Popen: Pointer to launched process but it is possible that
launched process is already killed (on linux).
"""
args = get_ayon_launcher_args(*args)
env = _prepare_ayon_launcher_env(add_sys_paths, kwargs)
return run_detached_process(args, env=env, **kwargs)
def path_to_subprocess_arg(path):
"""Prepare path for subprocess arguments.

View file

@ -100,6 +100,10 @@ from .context_tools import (
get_current_task_name
)
from .compatibility import (
is_product_base_type_supported,
)
from .workfile import (
discover_workfile_build_plugins,
register_workfile_build_plugin,
@ -223,4 +227,7 @@ __all__ = (
# Backwards compatible function names
"install",
"uninstall",
# Feature detection
"is_product_base_type_supported",
)

View file

@ -0,0 +1,16 @@
"""Package to handle compatibility checks for pipeline components."""
def is_product_base_type_supported() -> bool:
"""Check support for product base types.
This function checks if the current pipeline supports product base types.
Once this feature is implemented, it will return True. This should be used
in places where some kind of backward compatibility is needed to avoid
breaking existing functionality that relies on the current behavior.
Returns:
bool: True if product base types are supported, False otherwise.
"""
return False

View file

@ -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",
)

View file

@ -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, [])

View 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.

View 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",
]

View 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

View 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

View 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

View 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)

View 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

View 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)

View 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

View 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

View 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.
"""

View 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

View 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

View file

@ -1,3 +1,4 @@
from __future__ import annotations
import os
import re
import copy
@ -5,7 +6,8 @@ import json
import shutil
import subprocess
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
from typing import Any, Optional
from dataclasses import dataclass, field
import tempfile
import clique
@ -35,6 +37,39 @@ from ayon_core.pipeline.publish import (
from ayon_core.pipeline.publish.lib import add_repre_files_for_cleanup
@dataclass
class TempData:
"""Temporary data used across extractor's process."""
fps: float
frame_start: int
frame_end: int
handle_start: int
handle_end: int
frame_start_handle: int
frame_end_handle: int
output_frame_start: int
output_frame_end: int
pixel_aspect: float
resolution_width: int
resolution_height: int
origin_repre: dict[str, Any]
input_is_sequence: bool
first_sequence_frame: int
input_allow_bg: bool
with_audio: bool
without_handles: bool
handles_are_set: bool
input_ext: str
explicit_input_paths: list[str]
paths_to_remove: list[str]
# Set later
full_output_path: str = ""
filled_files: dict[int, str] = field(default_factory=dict)
output_ext_is_image: bool = True
output_is_sequence: bool = True
def frame_to_timecode(frame: int, fps: float) -> str:
"""Convert a frame number and FPS to editorial timecode (HH:MM:SS:FF).
@ -405,10 +440,10 @@ class ExtractReview(pyblish.api.InstancePlugin):
temp_data = self.prepare_temp_data(instance, repre, output_def)
new_frame_files = {}
if temp_data["input_is_sequence"]:
if temp_data.input_is_sequence:
self.log.debug("Checking sequence to fill gaps in sequence..")
files = temp_data["origin_repre"]["files"]
files = temp_data.origin_repre["files"]
collections = clique.assemble(
files,
)[0]
@ -423,18 +458,18 @@ class ExtractReview(pyblish.api.InstancePlugin):
new_frame_files = self.fill_sequence_gaps_from_existing(
collection=collection,
staging_dir=new_repre["stagingDir"],
start_frame=temp_data["frame_start"],
end_frame=temp_data["frame_end"],
start_frame=temp_data.frame_start,
end_frame=temp_data.frame_end,
)
elif fill_missing_frames == "blank":
new_frame_files = self.fill_sequence_gaps_with_blanks(
collection=collection,
staging_dir=new_repre["stagingDir"],
start_frame=temp_data["frame_start"],
end_frame=temp_data["frame_end"],
resolution_width=temp_data["resolution_width"],
resolution_height=temp_data["resolution_height"],
extension=temp_data["input_ext"],
start_frame=temp_data.frame_start,
end_frame=temp_data.frame_end,
resolution_width=temp_data.resolution_width,
resolution_height=temp_data.resolution_height,
extension=temp_data.input_ext,
temp_data=temp_data
)
elif fill_missing_frames == "previous_version":
@ -443,8 +478,8 @@ class ExtractReview(pyblish.api.InstancePlugin):
staging_dir=new_repre["stagingDir"],
instance=instance,
current_repre_name=repre["name"],
start_frame=temp_data["frame_start"],
end_frame=temp_data["frame_end"],
start_frame=temp_data.frame_start,
end_frame=temp_data.frame_end,
)
# fallback to original workflow
if new_frame_files is None:
@ -452,11 +487,11 @@ class ExtractReview(pyblish.api.InstancePlugin):
self.fill_sequence_gaps_from_existing(
collection=collection,
staging_dir=new_repre["stagingDir"],
start_frame=temp_data["frame_start"],
end_frame=temp_data["frame_end"],
start_frame=temp_data.frame_start,
end_frame=temp_data.frame_end,
))
elif fill_missing_frames == "only_rendered":
temp_data["explicit_input_paths"] = [
temp_data.explicit_input_paths = [
os.path.join(
new_repre["stagingDir"], file
).replace("\\", "/")
@ -467,10 +502,10 @@ class ExtractReview(pyblish.api.InstancePlugin):
# modify range for burnins
instance.data["frameStart"] = frame_start
instance.data["frameEnd"] = frame_end
temp_data["frame_start"] = frame_start
temp_data["frame_end"] = frame_end
temp_data.frame_start = frame_start
temp_data.frame_end = frame_end
temp_data["filled_files"] = new_frame_files
temp_data.filled_files = new_frame_files
# create or update outputName
output_name = new_repre.get("outputName", "")
@ -478,7 +513,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
if output_name:
output_name += "_"
output_name += output_def["filename_suffix"]
if temp_data["without_handles"]:
if temp_data.without_handles:
output_name += "_noHandles"
# add outputName to anatomy format fill_data
@ -491,7 +526,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
# like Resolve or Premiere can detect the start frame for e.g.
# review output files
"timecode": frame_to_timecode(
frame=temp_data["frame_start_handle"],
frame=temp_data.frame_start_handle,
fps=float(instance.data["fps"])
)
})
@ -508,7 +543,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
except ZeroDivisionError:
# TODO recalculate width and height using OIIO before
# conversion
if 'exr' in temp_data["origin_repre"]["ext"]:
if 'exr' in temp_data.origin_repre["ext"]:
self.log.warning(
(
"Unsupported compression on input files."
@ -531,16 +566,16 @@ class ExtractReview(pyblish.api.InstancePlugin):
for filepath in new_frame_files.values():
os.unlink(filepath)
for filepath in temp_data["paths_to_remove"]:
for filepath in temp_data.paths_to_remove:
os.unlink(filepath)
new_repre.update({
"fps": temp_data["fps"],
"fps": temp_data.fps,
"name": "{}_{}".format(output_name, output_ext),
"outputName": output_name,
"outputDef": output_def,
"frameStartFtrack": temp_data["output_frame_start"],
"frameEndFtrack": temp_data["output_frame_end"],
"frameStartFtrack": temp_data.output_frame_start,
"frameEndFtrack": temp_data.output_frame_end,
"ffmpeg_cmd": subprcs_cmd
})
@ -566,7 +601,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
# - there can be more than one collection
return isinstance(repre["files"], (list, tuple))
def prepare_temp_data(self, instance, repre, output_def):
def prepare_temp_data(self, instance, repre, output_def) -> TempData:
"""Prepare dictionary with values used across extractor's process.
All data are collected from instance, context, origin representation
@ -582,7 +617,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
output_def (dict): Definition of output of this plugin.
Returns:
dict: All data which are used across methods during process.
TempData: All data which are used across methods during process.
Their values should not change during process but new keys
with values may be added.
"""
@ -647,30 +682,30 @@ class ExtractReview(pyblish.api.InstancePlugin):
else:
ext = os.path.splitext(repre["files"])[1].replace(".", "")
return {
"fps": float(instance.data["fps"]),
"frame_start": frame_start,
"frame_end": frame_end,
"handle_start": handle_start,
"handle_end": handle_end,
"frame_start_handle": frame_start_handle,
"frame_end_handle": frame_end_handle,
"output_frame_start": int(output_frame_start),
"output_frame_end": int(output_frame_end),
"pixel_aspect": instance.data.get("pixelAspect", 1),
"resolution_width": instance.data.get("resolutionWidth"),
"resolution_height": instance.data.get("resolutionHeight"),
"origin_repre": repre,
"input_is_sequence": input_is_sequence,
"first_sequence_frame": first_sequence_frame,
"input_allow_bg": input_allow_bg,
"with_audio": with_audio,
"without_handles": without_handles,
"handles_are_set": handles_are_set,
"input_ext": ext,
"explicit_input_paths": [], # absolute paths to rendered files
"paths_to_remove": []
}
return TempData(
fps=float(instance.data["fps"]),
frame_start=frame_start,
frame_end=frame_end,
handle_start=handle_start,
handle_end=handle_end,
frame_start_handle=frame_start_handle,
frame_end_handle=frame_end_handle,
output_frame_start=int(output_frame_start),
output_frame_end=int(output_frame_end),
pixel_aspect=instance.data.get("pixelAspect", 1),
resolution_width=instance.data.get("resolutionWidth"),
resolution_height=instance.data.get("resolutionHeight"),
origin_repre=repre,
input_is_sequence=input_is_sequence,
first_sequence_frame=first_sequence_frame,
input_allow_bg=input_allow_bg,
with_audio=with_audio,
without_handles=without_handles,
handles_are_set=handles_are_set,
input_ext=ext,
explicit_input_paths=[], # absolute paths to rendered files
paths_to_remove=[]
)
def _ffmpeg_arguments(
self,
@ -691,7 +726,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
instance (Instance): Currently processed instance.
new_repre (dict): Representation representing output of this
process.
temp_data (dict): Base data for successful process.
temp_data (TempData): Base data for successful process.
"""
# Get FFmpeg arguments from profile presets
@ -733,32 +768,32 @@ class ExtractReview(pyblish.api.InstancePlugin):
# Set output frames len to 1 when output is single image
if (
temp_data["output_ext_is_image"]
and not temp_data["output_is_sequence"]
temp_data.output_ext_is_image
and not temp_data.output_is_sequence
):
output_frames_len = 1
else:
output_frames_len = (
temp_data["output_frame_end"]
- temp_data["output_frame_start"]
temp_data.output_frame_end
- temp_data.output_frame_start
+ 1
)
duration_seconds = float(output_frames_len / temp_data["fps"])
duration_seconds = float(output_frames_len / temp_data.fps)
# Define which layer should be used
if layer_name:
ffmpeg_input_args.extend(["-layer", layer_name])
explicit_input_paths = temp_data["explicit_input_paths"]
if temp_data["input_is_sequence"] and not explicit_input_paths:
explicit_input_paths = temp_data.explicit_input_paths
if temp_data.input_is_sequence and not explicit_input_paths:
# Set start frame of input sequence (just frame in filename)
# - definition of input filepath
# - add handle start if output should be without handles
start_number = temp_data["first_sequence_frame"]
if temp_data["without_handles"] and temp_data["handles_are_set"]:
start_number += temp_data["handle_start"]
start_number = temp_data.first_sequence_frame
if temp_data.without_handles and temp_data.handles_are_set:
start_number += temp_data.handle_start
ffmpeg_input_args.extend([
"-start_number", str(start_number)
])
@ -771,32 +806,32 @@ class ExtractReview(pyblish.api.InstancePlugin):
# }
# Add framerate to input when input is sequence
ffmpeg_input_args.extend([
"-framerate", str(temp_data["fps"])
"-framerate", str(temp_data.fps)
])
# Add duration of an input sequence if output is video
if not temp_data["output_is_sequence"]:
if not temp_data.output_is_sequence:
ffmpeg_input_args.extend([
"-to", "{:0.10f}".format(duration_seconds)
])
if temp_data["output_is_sequence"] and not explicit_input_paths:
if temp_data.output_is_sequence and not explicit_input_paths:
# Set start frame of output sequence (just frame in filename)
# - this is definition of an output
ffmpeg_output_args.extend([
"-start_number", str(temp_data["output_frame_start"])
"-start_number", str(temp_data.output_frame_start)
])
# Change output's duration and start point if should not contain
# handles
if temp_data["without_handles"] and temp_data["handles_are_set"]:
if temp_data.without_handles and temp_data.handles_are_set:
# Set output duration in seconds
ffmpeg_output_args.extend([
"-t", "{:0.10}".format(duration_seconds)
])
# Add -ss (start offset in seconds) if input is not sequence
if not temp_data["input_is_sequence"]:
start_sec = float(temp_data["handle_start"]) / temp_data["fps"]
if not temp_data.input_is_sequence:
start_sec = float(temp_data.handle_start) / temp_data.fps
# Set start time without handles
# - Skip if start sec is 0.0
if start_sec > 0.0:
@ -805,7 +840,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
])
# Set frame range of output when input or output is sequence
elif temp_data["output_is_sequence"]:
elif temp_data.output_is_sequence:
ffmpeg_output_args.extend([
"-frames:v", str(output_frames_len)
])
@ -813,10 +848,10 @@ class ExtractReview(pyblish.api.InstancePlugin):
if not explicit_input_paths:
# Add video/image input path
ffmpeg_input_args.extend([
"-i", path_to_subprocess_arg(temp_data["full_input_path"])
"-i", path_to_subprocess_arg(temp_data.full_input_path)
])
else:
frame_duration = 1 / temp_data["fps"]
frame_duration = 1 / temp_data.fps
explicit_frames_meta = tempfile.NamedTemporaryFile(
mode="w", prefix="explicit_frames", suffix=".txt", delete=False
@ -826,21 +861,21 @@ class ExtractReview(pyblish.api.InstancePlugin):
with open(explicit_frames_path, "w") as fp:
lines = [
f"file '{path}'{os.linesep}duration {frame_duration}"
for path in temp_data["explicit_input_paths"]
for path in temp_data.explicit_input_paths
]
fp.write("\n".join(lines))
temp_data["paths_to_remove"].append(explicit_frames_path)
temp_data.paths_to_remove.append(explicit_frames_path)
# let ffmpeg use only rendered files, might have gaps
ffmpeg_input_args.extend([
"-f", "concat",
"-safe", "0",
"-i", path_to_subprocess_arg(explicit_frames_path),
"-r", str(temp_data["fps"])
"-r", str(temp_data.fps)
])
# Add audio arguments if there are any. Skipped when output are images.
if not temp_data["output_ext_is_image"] and temp_data["with_audio"]:
if not temp_data.output_ext_is_image and temp_data.with_audio:
audio_in_args, audio_filters, audio_out_args = self.audio_args(
instance, temp_data, duration_seconds
)
@ -862,7 +897,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
bg_red, bg_green, bg_blue, bg_alpha = bg_color
if bg_alpha > 0.0:
if not temp_data["input_allow_bg"]:
if not temp_data.input_allow_bg:
self.log.info((
"Output definition has defined BG color input was"
" resolved as does not support adding BG."
@ -893,7 +928,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
# NOTE This must be latest added item to output arguments.
ffmpeg_output_args.append(
path_to_subprocess_arg(temp_data["full_output_path"])
path_to_subprocess_arg(temp_data.full_output_path)
)
return self.ffmpeg_full_args(
@ -985,7 +1020,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
current_repre_name: str,
start_frame: int,
end_frame: int
) -> Optional[Dict[int, str]]:
) -> Optional[dict[int, str]]:
"""Tries to replace missing frames from ones from last version"""
repre_file_paths = self._get_last_version_files(
instance, current_repre_name)
@ -1072,8 +1107,8 @@ class ExtractReview(pyblish.api.InstancePlugin):
resolution_width: int,
resolution_height: int,
extension: str,
temp_data: Dict[str, Any]
) -> Optional[Dict[int, str]]:
temp_data: TempData
) -> Optional[dict[int, str]]:
"""Fills missing files by blank frame."""
blank_frame_path = None
@ -1089,7 +1124,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
blank_frame_path = self._create_blank_frame(
staging_dir, extension, resolution_width, resolution_height
)
temp_data["paths_to_remove"].append(blank_frame_path)
temp_data.paths_to_remove.append(blank_frame_path)
speedcopy.copyfile(blank_frame_path, hole_fpath)
added_files[frame] = hole_fpath
@ -1129,7 +1164,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
staging_dir: str,
start_frame: int,
end_frame: int
) -> Dict[int, str]:
) -> dict[int, str]:
"""Fill missing files in sequence by duplicating existing ones.
This will take nearest frame file and copy it with so as to fill
@ -1176,7 +1211,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
return added_files
def input_output_paths(self, new_repre, output_def, temp_data):
def input_output_paths(self, new_repre, output_def, temp_data: TempData):
"""Deduce input nad output file paths based on entered data.
Input may be sequence of images, video file or single image file and
@ -1189,11 +1224,11 @@ class ExtractReview(pyblish.api.InstancePlugin):
"sequence_file" (if output is sequence) keys to new representation.
"""
repre = temp_data["origin_repre"]
repre = temp_data.origin_repre
src_staging_dir = repre["stagingDir"]
dst_staging_dir = new_repre["stagingDir"]
if temp_data["input_is_sequence"]:
if temp_data.input_is_sequence:
collections = clique.assemble(repre["files"])[0]
full_input_path = os.path.join(
src_staging_dir,
@ -1218,13 +1253,13 @@ class ExtractReview(pyblish.api.InstancePlugin):
# Make sure to have full path to one input file
full_input_path_single_file = full_input_path
filled_files = temp_data["filled_files"]
filled_files = temp_data.filled_files
if filled_files:
first_frame, first_file = next(iter(filled_files.items()))
if first_file < full_input_path_single_file:
self.log.warning(f"Using filled frame: '{first_file}'")
full_input_path_single_file = first_file
temp_data["first_sequence_frame"] = first_frame
temp_data.first_sequence_frame = first_frame
filename_suffix = output_def["filename_suffix"]
@ -1252,8 +1287,8 @@ class ExtractReview(pyblish.api.InstancePlugin):
)
if output_is_sequence:
new_repre_files = []
frame_start = temp_data["output_frame_start"]
frame_end = temp_data["output_frame_end"]
frame_start = temp_data.output_frame_start
frame_end = temp_data.output_frame_end
filename_base = "{}_{}".format(filename, filename_suffix)
# Temporary template for frame filling. Example output:
@ -1290,18 +1325,18 @@ class ExtractReview(pyblish.api.InstancePlugin):
new_repre["stagingDir"] = dst_staging_dir
# Store paths to temp data
temp_data["full_input_path"] = full_input_path
temp_data["full_input_path_single_file"] = full_input_path_single_file
temp_data["full_output_path"] = full_output_path
temp_data.full_input_path = full_input_path
temp_data.full_input_path_single_file = full_input_path_single_file
temp_data.full_output_path = full_output_path
# Store information about output
temp_data["output_ext_is_image"] = output_ext_is_image
temp_data["output_is_sequence"] = output_is_sequence
temp_data.output_ext_is_image = output_ext_is_image
temp_data.output_is_sequence = output_is_sequence
self.log.debug("Input path {}".format(full_input_path))
self.log.debug("Output path {}".format(full_output_path))
def audio_args(self, instance, temp_data, duration_seconds):
def audio_args(self, instance, temp_data: TempData, duration_seconds):
"""Prepares FFMpeg arguments for audio inputs."""
audio_in_args = []
audio_filters = []
@ -1318,7 +1353,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
frame_start_ftrack = instance.data.get("frameStartFtrack")
if frame_start_ftrack is not None:
offset_frames = frame_start_ftrack - audio["offset"]
offset_seconds = offset_frames / temp_data["fps"]
offset_seconds = offset_frames / temp_data.fps
if offset_seconds > 0:
audio_in_args.append(
@ -1502,7 +1537,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
return output
def rescaling_filters(self, temp_data, output_def, new_repre):
def rescaling_filters(self, temp_data: TempData, output_def, new_repre):
"""Prepare vieo filters based on tags in new representation.
It is possible to add letterboxes to output video or rescale to
@ -1522,7 +1557,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
self.log.debug("reformat_in_baking: `{}`".format(reformat_in_baking))
# NOTE Skipped using instance's resolution
full_input_path_single_file = temp_data["full_input_path_single_file"]
full_input_path_single_file = temp_data.full_input_path_single_file
try:
streams = get_ffprobe_streams(
full_input_path_single_file, self.log
@ -1547,7 +1582,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
break
# Get instance data
pixel_aspect = temp_data["pixel_aspect"]
pixel_aspect = temp_data.pixel_aspect
if reformat_in_baking:
self.log.debug((
"Using resolution from input. It is already "
@ -1642,8 +1677,8 @@ class ExtractReview(pyblish.api.InstancePlugin):
# - use instance resolution only if there were not scale changes
# that may massivelly affect output 'use_input_res'
if not use_input_res and output_width is None or output_height is None:
output_width = temp_data["resolution_width"]
output_height = temp_data["resolution_height"]
output_width = temp_data.resolution_width
output_height = temp_data.resolution_height
# Use source's input resolution instance does not have set it.
if output_width is None or output_height is None:

File diff suppressed because it is too large Load diff

View file

@ -56,14 +56,9 @@ class _AyonSettingsCache:
@classmethod
def _get_variant(cls):
if _AyonSettingsCache.variant is None:
from ayon_core.lib import is_staging_enabled, is_dev_mode_enabled
variant = "production"
if is_dev_mode_enabled():
variant = cls._get_bundle_name()
elif is_staging_enabled():
variant = "staging"
from ayon_core.lib import get_settings_variant
variant = get_settings_variant()
# Cache variant
_AyonSettingsCache.variant = variant

View file

@ -829,6 +829,37 @@ HintedLineEditButton {
}
/* Launcher specific stylesheets */
ActionsView[mode="icon"] {
/* font size can't be set on items */
font-size: 9pt;
border: 0px;
padding: 0px;
margin: 0px;
}
ActionsView[mode="icon"]::item {
padding-top: 8px;
padding-bottom: 4px;
border: 0px;
border-radius: 0.3em;
}
ActionsView[mode="icon"]::item:hover {
color: {color:font-hover};
background: #424A57;
}
ActionsView[mode="icon"]::icon {}
ActionMenuPopup #Wrapper {
border-radius: 0.3em;
background: #353B46;
}
ActionMenuPopup ActionsView[mode="icon"] {
background: transparent;
border: none;
}
#IconView[mode="icon"] {
/* font size can't be set on items */
font-size: 9pt;

View file

@ -1,22 +1,58 @@
from qtpy import QtWidgets
from __future__ import annotations
from typing import Optional
from qtpy import QtWidgets, QtGui
from ayon_core.style import load_stylesheet
from ayon_core.resources import get_ayon_icon_filepath
from ayon_core.lib import AbstractAttrDef
from .widgets import AttributeDefinitionsWidget
class AttributeDefinitionsDialog(QtWidgets.QDialog):
def __init__(self, attr_defs, parent=None):
super(AttributeDefinitionsDialog, self).__init__(parent)
def __init__(
self,
attr_defs: list[AbstractAttrDef],
title: Optional[str] = None,
submit_label: Optional[str] = None,
cancel_label: Optional[str] = None,
submit_icon: Optional[QtGui.QIcon] = None,
cancel_icon: Optional[QtGui.QIcon] = None,
parent: Optional[QtWidgets.QWidget] = None,
):
super().__init__(parent)
if title:
self.setWindowTitle(title)
icon = QtGui.QIcon(get_ayon_icon_filepath())
self.setWindowIcon(icon)
self.setStyleSheet(load_stylesheet())
attrs_widget = AttributeDefinitionsWidget(attr_defs, self)
if submit_label is None:
submit_label = "OK"
if cancel_label is None:
cancel_label = "Cancel"
btns_widget = QtWidgets.QWidget(self)
ok_btn = QtWidgets.QPushButton("OK", btns_widget)
cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget)
cancel_btn = QtWidgets.QPushButton(cancel_label, btns_widget)
submit_btn = QtWidgets.QPushButton(submit_label, btns_widget)
if submit_icon is not None:
submit_btn.setIcon(submit_icon)
if cancel_icon is not None:
cancel_btn.setIcon(cancel_icon)
btns_layout = QtWidgets.QHBoxLayout(btns_widget)
btns_layout.setContentsMargins(0, 0, 0, 0)
btns_layout.addStretch(1)
btns_layout.addWidget(ok_btn, 0)
btns_layout.addWidget(submit_btn, 0)
btns_layout.addWidget(cancel_btn, 0)
main_layout = QtWidgets.QVBoxLayout(self)
@ -24,10 +60,33 @@ class AttributeDefinitionsDialog(QtWidgets.QDialog):
main_layout.addStretch(1)
main_layout.addWidget(btns_widget, 0)
ok_btn.clicked.connect(self.accept)
submit_btn.clicked.connect(self.accept)
cancel_btn.clicked.connect(self.reject)
self._attrs_widget = attrs_widget
self._submit_btn = submit_btn
self._cancel_btn = cancel_btn
def get_values(self):
return self._attrs_widget.current_value()
def set_values(self, values):
self._attrs_widget.set_value(values)
def set_submit_label(self, text: str):
self._submit_btn.setText(text)
def set_submit_icon(self, icon: QtGui.QIcon):
self._submit_btn.setIcon(icon)
def set_submit_visible(self, visible: bool):
self._submit_btn.setVisible(visible)
def set_cancel_label(self, text: str):
self._cancel_btn.setText(text)
def set_cancel_icon(self, icon: QtGui.QIcon):
self._cancel_btn.setIcon(icon)
def set_cancel_visible(self, visible: bool):
self._cancel_btn.setVisible(visible)

View file

@ -22,6 +22,7 @@ from ayon_core.tools.utils import (
FocusSpinBox,
FocusDoubleSpinBox,
MultiSelectionComboBox,
MarkdownLabel,
PlaceholderLineEdit,
PlaceholderPlainTextEdit,
set_style_property,
@ -247,12 +248,10 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
def set_value(self, value):
new_value = copy.deepcopy(value)
unused_keys = set(new_value.keys())
for widget in self._widgets_by_id.values():
attr_def = widget.attr_def
if attr_def.key not in new_value:
continue
unused_keys.remove(attr_def.key)
widget_value = new_value[attr_def.key]
if widget_value is None:
@ -350,7 +349,7 @@ class SeparatorAttrWidget(_BaseAttrDefWidget):
class LabelAttrWidget(_BaseAttrDefWidget):
def _ui_init(self):
input_widget = QtWidgets.QLabel(self)
input_widget = MarkdownLabel(self)
label = self.attr_def.label
if label:
input_widget.setText(str(label))

View file

@ -1,4 +1,59 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional, Any
from ayon_core.tools.common_models import (
ProjectItem,
FolderItem,
FolderTypeItem,
TaskItem,
TaskTypeItem,
)
@dataclass
class WebactionContext:
"""Context used for methods related to webactions."""
identifier: str
project_name: str
folder_id: str
task_id: str
addon_name: str
addon_version: str
@dataclass
class ActionItem:
"""Item representing single action to trigger.
Attributes:
action_type (Literal["webaction", "local"]): Type of action.
identifier (str): Unique identifier of action item.
order (int): Action ordering.
label (str): Action label.
variant_label (Union[str, None]): Variant label, full label is
concatenated with space. Actions are grouped under single
action if it has same 'label' and have set 'variant_label'.
full_label (str): Full label, if not set it is generated
from 'label' and 'variant_label'.
icon (dict[str, str]): Icon definition.
addon_name (Optional[str]): Addon name.
addon_version (Optional[str]): Addon version.
config_fields (list[dict]): Config fields for webaction.
"""
action_type: str
identifier: str
order: int
label: str
variant_label: Optional[str]
full_label: str
icon: Optional[dict[str, str]]
config_fields: list[dict]
addon_name: Optional[str] = None
addon_version: Optional[str] = None
class AbstractLauncherCommon(ABC):
@ -88,7 +143,9 @@ class AbstractLauncherBackend(AbstractLauncherCommon):
class AbstractLauncherFrontEnd(AbstractLauncherCommon):
# Entity items for UI
@abstractmethod
def get_project_items(self, sender=None):
def get_project_items(
self, sender: Optional[str] = None
) -> list[ProjectItem]:
"""Project items for all projects.
This function may trigger events 'projects.refresh.started' and
@ -106,7 +163,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_folder_type_items(self, project_name, sender=None):
def get_folder_type_items(
self, project_name: str, sender: Optional[str] = None
) -> list[FolderTypeItem]:
"""Folder type items for a project.
This function may trigger events with topics
@ -126,7 +185,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_task_type_items(self, project_name, sender=None):
def get_task_type_items(
self, project_name: str, sender: Optional[str] = None
) -> list[TaskTypeItem]:
"""Task type items for a project.
This function may trigger events with topics
@ -146,7 +207,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_folder_items(self, project_name, sender=None):
def get_folder_items(
self, project_name: str, sender: Optional[str] = None
) -> list[FolderItem]:
"""Folder items to visualize project hierarchy.
This function may trigger events 'folders.refresh.started' and
@ -165,7 +228,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_task_items(self, project_name, folder_id, sender=None):
def get_task_items(
self, project_name: str, folder_id: str, sender: Optional[str] = None
) -> list[TaskItem]:
"""Task items.
This function may trigger events 'tasks.refresh.started' and
@ -185,7 +250,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_selected_project_name(self):
def get_selected_project_name(self) -> Optional[str]:
"""Selected project name.
Returns:
@ -195,7 +260,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_selected_folder_id(self):
def get_selected_folder_id(self) -> Optional[str]:
"""Selected folder id.
Returns:
@ -205,7 +270,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_selected_task_id(self):
def get_selected_task_id(self) -> Optional[str]:
"""Selected task id.
Returns:
@ -215,7 +280,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_selected_task_name(self):
def get_selected_task_name(self) -> Optional[str]:
"""Selected task name.
Returns:
@ -225,7 +290,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_selected_context(self):
def get_selected_context(self) -> dict[str, Optional[str]]:
"""Get whole selected context.
Example:
@ -243,7 +308,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def set_selected_project(self, project_name):
def set_selected_project(self, project_name: Optional[str]):
"""Change selected folder.
Args:
@ -254,7 +319,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def set_selected_folder(self, folder_id):
def set_selected_folder(self, folder_id: Optional[str]):
"""Change selected folder.
Args:
@ -265,7 +330,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def set_selected_task(self, task_id, task_name):
def set_selected_task(
self, task_id: Optional[str], task_name: Optional[str]
):
"""Change selected task.
Args:
@ -279,7 +346,12 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
# Actions
@abstractmethod
def get_action_items(self, project_name, folder_id, task_id):
def get_action_items(
self,
project_name: Optional[str],
folder_id: Optional[str],
task_id: Optional[str],
) -> list[ActionItem]:
"""Get action items for given context.
Args:
@ -295,30 +367,67 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def trigger_action(self, project_name, folder_id, task_id, action_id):
def trigger_action(
self,
action_id: str,
project_name: Optional[str],
folder_id: Optional[str],
task_id: Optional[str],
):
"""Trigger action on given context.
Args:
action_id (str): Action identifier.
project_name (Union[str, None]): Project name.
folder_id (Union[str, None]): Folder id.
task_id (Union[str, None]): Task id.
action_id (str): Action identifier.
"""
pass
@abstractmethod
def set_application_force_not_open_workfile(
self, project_name, folder_id, task_id, action_ids, enabled
def trigger_webaction(
self,
context: WebactionContext,
action_label: str,
form_data: Optional[dict[str, Any]] = None,
):
"""This is application action related to force not open last workfile.
"""Trigger action on the given context.
Args:
project_name (Union[str, None]): Project name.
folder_id (Union[str, None]): Folder id.
task_id (Union[str, None]): Task id.
action_ids (Iterable[str]): Action identifiers.
enabled (bool): New value of force not open workfile.
context (WebactionContext): Webaction context.
action_label (str): Action label.
form_data (Optional[dict[str, Any]]): Form values of action.
"""
pass
@abstractmethod
def get_action_config_values(
self, context: WebactionContext
) -> dict[str, Any]:
"""Get action config values.
Args:
context (WebactionContext): Webaction context.
Returns:
dict[str, Any]: Action config values.
"""
pass
@abstractmethod
def set_action_config_values(
self,
context: WebactionContext,
values: dict[str, Any],
):
"""Set action config values.
Args:
context (WebactionContext): Webaction context.
values (dict[str, Any]): Action config values.
"""
pass
@ -343,14 +452,16 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
pass
@abstractmethod
def get_my_tasks_entity_ids(self, project_name: str):
def get_my_tasks_entity_ids(
self, project_name: str
) -> dict[str, list[str]]:
"""Get entity ids for my tasks.
Args:
project_name (str): Project name.
Returns:
dict[str, Union[list[str]]]: Folder and task ids.
dict[str, list[str]]: Folder and task ids.
"""
pass

View file

@ -32,7 +32,7 @@ class BaseLauncherController(
@property
def event_system(self):
"""Inner event system for workfiles tool controller.
"""Inner event system for launcher tool controller.
Is used for communication with UI. Event system is created on demand.
@ -135,16 +135,30 @@ class BaseLauncherController(
return self._actions_model.get_action_items(
project_name, folder_id, task_id)
def set_application_force_not_open_workfile(
self, project_name, folder_id, task_id, action_ids, enabled
def trigger_action(
self,
identifier,
project_name,
folder_id,
task_id,
):
self._actions_model.set_application_force_not_open_workfile(
project_name, folder_id, task_id, action_ids, enabled
self._actions_model.trigger_action(
identifier,
project_name,
folder_id,
task_id,
)
def trigger_action(self, project_name, folder_id, task_id, identifier):
self._actions_model.trigger_action(
project_name, folder_id, task_id, identifier)
def trigger_webaction(self, context, action_label, form_data=None):
self._actions_model.trigger_webaction(
context, action_label, form_data
)
def get_action_config_values(self, context):
return self._actions_model.get_action_config_values(context)
def set_action_config_values(self, context, values):
return self._actions_model.set_action_config_values(context, values)
# General methods
def refresh(self):

View file

@ -1,219 +1,47 @@
import os
import uuid
from dataclasses import dataclass, asdict
from urllib.parse import urlencode, urlparse
from typing import Any, Optional
import webbrowser
import ayon_api
from ayon_core import resources
from ayon_core.lib import Logger, AYONSettingsRegistry
from ayon_core.lib import (
Logger,
NestedCacheItem,
CacheItem,
get_settings_variant,
run_detached_ayon_launcher_process,
)
from ayon_core.addon import AddonsManager
from ayon_core.pipeline.actions import (
discover_launcher_actions,
LauncherAction,
LauncherActionSelection,
register_launcher_action_path,
)
from ayon_core.pipeline.workfile import should_use_last_workfile_on_launch
try:
# Available since applications addon 0.2.4
from ayon_applications.action import ApplicationAction
except ImportError:
# Backwards compatibility from 0.3.3 (24/06/10)
# TODO: Remove in future releases
class ApplicationAction(LauncherAction):
"""Action to launch an application.
Application action based on 'ApplicationManager' system.
Handling of applications in launcher is not ideal and should be
completely redone from scratch. This is just a temporary solution
to keep backwards compatibility with AYON launcher.
Todos:
Move handling of errors to frontend.
"""
# Application object
application = None
# Action attributes
name = None
label = None
label_variant = None
group = None
icon = None
color = None
order = 0
data = {}
project_settings = {}
project_entities = {}
_log = None
@property
def log(self):
if self._log is None:
self._log = Logger.get_logger(self.__class__.__name__)
return self._log
def is_compatible(self, selection):
if not selection.is_task_selected:
return False
project_entity = self.project_entities[selection.project_name]
apps = project_entity["attrib"].get("applications")
if not apps or self.application.full_name not in apps:
return False
project_settings = self.project_settings[selection.project_name]
only_available = project_settings["applications"]["only_available"]
if only_available and not self.application.find_executable():
return False
return True
def _show_message_box(self, title, message, details=None):
from qtpy import QtWidgets, QtGui
from ayon_core import style
dialog = QtWidgets.QMessageBox()
icon = QtGui.QIcon(resources.get_ayon_icon_filepath())
dialog.setWindowIcon(icon)
dialog.setStyleSheet(style.load_stylesheet())
dialog.setWindowTitle(title)
dialog.setText(message)
if details:
dialog.setDetailedText(details)
dialog.exec_()
def process(self, selection, **kwargs):
"""Process the full Application action"""
from ayon_applications import (
ApplicationExecutableNotFound,
ApplicationLaunchFailed,
)
try:
self.application.launch(
project_name=selection.project_name,
folder_path=selection.folder_path,
task_name=selection.task_name,
**self.data
)
except ApplicationExecutableNotFound as exc:
details = exc.details
msg = exc.msg
log_msg = str(msg)
if details:
log_msg += "\n" + details
self.log.warning(log_msg)
self._show_message_box(
"Application executable not found", msg, details
)
except ApplicationLaunchFailed as exc:
msg = str(exc)
self.log.warning(msg, exc_info=True)
self._show_message_box("Application launch failed", msg)
from ayon_core.tools.launcher.abstract import ActionItem, WebactionContext
# class Action:
# def __init__(self, label, icon=None, identifier=None):
# self._label = label
# self._icon = icon
# self._callbacks = []
# self._identifier = identifier or uuid.uuid4().hex
# self._checked = True
# self._checkable = False
#
# def set_checked(self, checked):
# self._checked = checked
#
# def set_checkable(self, checkable):
# self._checkable = checkable
#
# def set_label(self, label):
# self._label = label
#
# def add_callback(self, callback):
# self._callbacks = callback
#
#
# class Menu:
# def __init__(self, label, icon=None):
# self.label = label
# self.icon = icon
# self._actions = []
#
# def add_action(self, action):
# self._actions.append(action)
@dataclass
class WebactionForm:
fields: list[dict[str, Any]]
title: str
submit_label: str
submit_icon: str
cancel_label: str
cancel_icon: str
class ActionItem:
"""Item representing single action to trigger.
Todos:
Get rid of application specific logic.
Args:
identifier (str): Unique identifier of action item.
label (str): Action label.
variant_label (Union[str, None]): Variant label, full label is
concatenated with space. Actions are grouped under single
action if it has same 'label' and have set 'variant_label'.
icon (dict[str, str]): Icon definition.
order (int): Action ordering.
is_application (bool): Is action application action.
force_not_open_workfile (bool): Force not open workfile. Application
related.
full_label (Optional[str]): Full label, if not set it is generated
from 'label' and 'variant_label'.
"""
def __init__(
self,
identifier,
label,
variant_label,
icon,
order,
is_application,
force_not_open_workfile,
full_label=None
):
self.identifier = identifier
self.label = label
self.variant_label = variant_label
self.icon = icon
self.order = order
self.is_application = is_application
self.force_not_open_workfile = force_not_open_workfile
self._full_label = full_label
def copy(self):
return self.from_data(self.to_data())
@property
def full_label(self):
if self._full_label is None:
if self.variant_label:
self._full_label = " ".join([self.label, self.variant_label])
else:
self._full_label = self.label
return self._full_label
def to_data(self):
return {
"identifier": self.identifier,
"label": self.label,
"variant_label": self.variant_label,
"icon": self.icon,
"order": self.order,
"is_application": self.is_application,
"force_not_open_workfile": self.force_not_open_workfile,
"full_label": self._full_label,
}
@classmethod
def from_data(cls, data):
return cls(**data)
@dataclass
class WebactionResponse:
response_type: str
success: bool
message: Optional[str] = None
clipboard_text: Optional[str] = None
form: Optional[WebactionForm] = None
error_message: Optional[str] = None
def get_action_icon(action):
@ -264,8 +92,6 @@ class ActionsModel:
controller (AbstractLauncherBackend): Controller instance.
"""
_not_open_workfile_reg_key = "force_not_open_workfile"
def __init__(self, controller):
self._controller = controller
@ -274,11 +100,21 @@ class ActionsModel:
self._discovered_actions = None
self._actions = None
self._action_items = {}
self._launcher_tool_reg = AYONSettingsRegistry("launcher_tool")
self._webaction_items = NestedCacheItem(
levels=2, default_factory=list, lifetime=20,
)
self._addons_manager = None
self._variant = get_settings_variant()
@staticmethod
def calculate_full_label(label: str, variant_label: Optional[str]) -> str:
"""Calculate full label from label and variant_label."""
if variant_label:
return " ".join([label, variant_label])
return label
@property
def log(self):
if self._log is None:
@ -289,39 +125,12 @@ class ActionsModel:
self._discovered_actions = None
self._actions = None
self._action_items = {}
self._webaction_items.reset()
self._controller.emit_event("actions.refresh.started")
self._get_action_objects()
self._controller.emit_event("actions.refresh.finished")
def _should_start_last_workfile(
self,
project_name,
task_id,
identifier,
host_name,
not_open_workfile_actions
):
if identifier in not_open_workfile_actions:
return not not_open_workfile_actions[identifier]
task_name = None
task_type = None
if task_id is not None:
task_entity = self._controller.get_task_entity(
project_name, task_id
)
task_name = task_entity["name"]
task_type = task_entity["taskType"]
output = should_use_last_workfile_on_launch(
project_name,
host_name,
task_name,
task_type
)
return output
def get_action_items(self, project_name, folder_id, task_id):
"""Get actions for project.
@ -332,53 +141,31 @@ class ActionsModel:
Returns:
list[ActionItem]: List of actions.
"""
not_open_workfile_actions = self._get_no_last_workfile_for_context(
project_name, folder_id, task_id)
selection = self._prepare_selection(project_name, folder_id, task_id)
output = []
action_items = self._get_action_items(project_name)
for identifier, action in self._get_action_objects().items():
if not action.is_compatible(selection):
continue
if action.is_compatible(selection):
output.append(action_items[identifier])
output.extend(self._get_webactions(selection))
action_item = action_items[identifier]
# Handling of 'force_not_open_workfile' for applications
if action_item.is_application:
action_item = action_item.copy()
start_last_workfile = self._should_start_last_workfile(
project_name,
task_id,
identifier,
action.application.host_name,
not_open_workfile_actions
)
action_item.force_not_open_workfile = (
not start_last_workfile
)
output.append(action_item)
return output
def set_application_force_not_open_workfile(
self, project_name, folder_id, task_id, action_ids, enabled
def trigger_action(
self,
identifier,
project_name,
folder_id,
task_id,
):
no_workfile_reg_data = self._get_no_last_workfile_reg_data()
project_data = no_workfile_reg_data.setdefault(project_name, {})
folder_data = project_data.setdefault(folder_id, {})
task_data = folder_data.setdefault(task_id, {})
for action_id in action_ids:
task_data[action_id] = enabled
self._launcher_tool_reg.set_item(
self._not_open_workfile_reg_key, no_workfile_reg_data
)
def trigger_action(self, project_name, folder_id, task_id, identifier):
selection = self._prepare_selection(project_name, folder_id, task_id)
failed = False
error_message = None
action_label = identifier
action_items = self._get_action_items(project_name)
trigger_id = uuid.uuid4().hex
try:
action = self._actions[identifier]
action_item = action_items[identifier]
@ -386,22 +173,11 @@ class ActionsModel:
self._controller.emit_event(
"action.trigger.started",
{
"trigger_id": trigger_id,
"identifier": identifier,
"full_label": action_label,
}
)
if isinstance(action, ApplicationAction):
per_action = self._get_no_last_workfile_for_context(
project_name, folder_id, task_id
)
start_last_workfile = self._should_start_last_workfile(
project_name,
task_id,
identifier,
action.application.host_name,
per_action
)
action.data["start_last_workfile"] = start_last_workfile
action.process(selection)
except Exception as exc:
@ -412,6 +188,7 @@ class ActionsModel:
self._controller.emit_event(
"action.trigger.finished",
{
"trigger_id": trigger_id,
"identifier": identifier,
"failed": failed,
"error_message": error_message,
@ -419,32 +196,148 @@ class ActionsModel:
}
)
def trigger_webaction(self, context, action_label, form_data):
entity_type = None
entity_ids = []
identifier = context.identifier
folder_id = context.folder_id
task_id = context.task_id
project_name = context.project_name
addon_name = context.addon_name
addon_version = context.addon_version
if task_id:
entity_type = "task"
entity_ids.append(task_id)
elif folder_id:
entity_type = "folder"
entity_ids.append(folder_id)
query = {
"addonName": addon_name,
"addonVersion": addon_version,
"identifier": identifier,
"variant": self._variant,
}
url = f"actions/execute?{urlencode(query)}"
request_data = {
"projectName": project_name,
"entityType": entity_type,
"entityIds": entity_ids,
}
if form_data is not None:
request_data["formData"] = form_data
trigger_id = uuid.uuid4().hex
failed = False
try:
self._controller.emit_event(
"webaction.trigger.started",
{
"trigger_id": trigger_id,
"identifier": identifier,
"full_label": action_label,
}
)
conn = ayon_api.get_server_api_connection()
# Add 'referer' header to the request
# - ayon-api 1.1.1 adds the value to the header automatically
headers = conn.get_headers()
if "referer" in headers:
headers = None
else:
headers["referer"] = conn.get_base_url()
response = ayon_api.raw_post(
url, headers=headers, json=request_data
)
response.raise_for_status()
handle_response = self._handle_webaction_response(response.data)
except Exception:
failed = True
self.log.warning("Action trigger failed.", exc_info=True)
handle_response = WebactionResponse(
"unknown",
False,
error_message="Failed to trigger webaction.",
)
data = asdict(handle_response)
data.update({
"trigger_failed": failed,
"trigger_id": trigger_id,
"identifier": identifier,
"full_label": action_label,
"project_name": project_name,
"folder_id": folder_id,
"task_id": task_id,
"addon_name": addon_name,
"addon_version": addon_version,
})
self._controller.emit_event(
"webaction.trigger.finished",
data,
)
def get_action_config_values(self, context: WebactionContext):
selection = self._prepare_selection(
context.project_name, context.folder_id, context.task_id
)
if not selection.is_project_selected:
return {}
request_data = self._get_webaction_request_data(selection)
query = {
"addonName": context.addon_name,
"addonVersion": context.addon_version,
"identifier": context.identifier,
"variant": self._variant,
}
url = f"actions/config?{urlencode(query)}"
try:
response = ayon_api.post(url, **request_data)
response.raise_for_status()
except Exception:
self.log.warning(
"Failed to collect webaction config values.",
exc_info=True
)
return {}
return response.data
def set_action_config_values(self, context, values):
selection = self._prepare_selection(
context.project_name, context.folder_id, context.task_id
)
if not selection.is_project_selected:
return {}
request_data = self._get_webaction_request_data(selection)
request_data["value"] = values
query = {
"addonName": context.addon_name,
"addonVersion": context.addon_version,
"identifier": context.identifier,
"variant": self._variant,
}
url = f"actions/config?{urlencode(query)}"
try:
response = ayon_api.post(url, **request_data)
response.raise_for_status()
except Exception:
self.log.warning(
"Failed to store webaction config values.",
exc_info=True
)
def _get_addons_manager(self):
if self._addons_manager is None:
self._addons_manager = AddonsManager()
return self._addons_manager
def _get_no_last_workfile_reg_data(self):
try:
no_workfile_reg_data = self._launcher_tool_reg.get_item(
self._not_open_workfile_reg_key)
except ValueError:
no_workfile_reg_data = {}
self._launcher_tool_reg.set_item(
self._not_open_workfile_reg_key, no_workfile_reg_data)
return no_workfile_reg_data
def _get_no_last_workfile_for_context(
self, project_name, folder_id, task_id
):
not_open_workfile_reg_data = self._get_no_last_workfile_reg_data()
return (
not_open_workfile_reg_data
.get(project_name, {})
.get(folder_id, {})
.get(task_id, {})
)
def _prepare_selection(self, project_name, folder_id, task_id):
project_entity = None
if project_name:
@ -458,6 +351,179 @@ class ActionsModel:
project_settings=project_settings,
)
def _get_webaction_request_data(self, selection: LauncherActionSelection):
if not selection.is_project_selected:
return None
entity_type = None
entity_id = None
entity_subtypes = []
if selection.is_task_selected:
entity_type = "task"
entity_id = selection.task_entity["id"]
entity_subtypes = [selection.task_entity["taskType"]]
elif selection.is_folder_selected:
entity_type = "folder"
entity_id = selection.folder_entity["id"]
entity_subtypes = [selection.folder_entity["folderType"]]
entity_ids = []
if entity_id:
entity_ids.append(entity_id)
project_name = selection.project_name
return {
"projectName": project_name,
"entityType": entity_type,
"entitySubtypes": entity_subtypes,
"entityIds": entity_ids,
}
def _get_webactions(self, selection: LauncherActionSelection):
if not selection.is_project_selected:
return []
request_data = self._get_webaction_request_data(selection)
project_name = selection.project_name
entity_id = None
if request_data["entityIds"]:
entity_id = request_data["entityIds"][0]
cache: CacheItem = self._webaction_items[project_name][entity_id]
if cache.is_valid:
return cache.get_data()
try:
response = ayon_api.post("actions/list", **request_data)
response.raise_for_status()
except Exception:
self.log.warning("Failed to collect webactions.", exc_info=True)
return []
action_items = []
for action in response.data["actions"]:
# NOTE Settings variant may be important for triggering?
# - action["variant"]
icon = action.get("icon")
if icon and icon["type"] == "url":
if not urlparse(icon["url"]).scheme:
icon["type"] = "ayon_url"
config_fields = action.get("configFields") or []
variant_label = action["label"]
group_label = action.get("groupLabel")
if not group_label:
group_label = variant_label
variant_label = None
full_label = self.calculate_full_label(
group_label, variant_label
)
action_items.append(ActionItem(
action_type="webaction",
identifier=action["identifier"],
order=action["order"],
label=group_label,
variant_label=variant_label,
full_label=full_label,
icon=icon,
addon_name=action["addonName"],
addon_version=action["addonVersion"],
config_fields=config_fields,
# category=action["category"],
))
cache.update_data(action_items)
return cache.get_data()
def _handle_webaction_response(self, data) -> WebactionResponse:
response_type = data["type"]
# Backwards compatibility -> 'server' type is not available since
# AYON backend 1.8.3
if response_type == "server":
return WebactionResponse(
response_type,
False,
error_message="Please use AYON web UI to run the action.",
)
payload = data.get("payload") or {}
download_uri = payload.get("extra_download")
if download_uri is not None:
# Find out if is relative or absolute URL
if not urlparse(download_uri).scheme:
ayon_url = ayon_api.get_base_url().rstrip("/")
path = download_uri.lstrip("/")
download_uri = f"{ayon_url}/{path}"
# Use webbrowser to open file
webbrowser.open_new_tab(download_uri)
response = WebactionResponse(
response_type,
data["success"],
data.get("message"),
payload.get("extra_clipboard"),
)
if response_type == "simple":
pass
elif response_type == "redirect":
# NOTE unused 'newTab' key because we always have to
# open new tab from desktop app.
if not webbrowser.open_new_tab(payload["uri"]):
payload.error_message = "Failed to open web browser."
elif response_type == "form":
submit_icon = payload["submit_icon"] or None
cancel_icon = payload["cancel_icon"] or None
if submit_icon:
submit_icon = {
"type": "material-symbols",
"name": submit_icon,
}
if cancel_icon:
cancel_icon = {
"type": "material-symbols",
"name": cancel_icon,
}
response.form = WebactionForm(
fields=payload["fields"],
title=payload["title"],
submit_label=payload["submit_label"],
cancel_label=payload["cancel_label"],
submit_icon=submit_icon,
cancel_icon=cancel_icon,
)
elif response_type == "launcher":
# Run AYON launcher process with uri in arguments
# NOTE This does pass environment variables of current process
# to the subprocess.
# NOTE We could 'take action' directly and use the arguments here
if payload is not None:
uri = payload["uri"]
else:
uri = data["uri"]
run_detached_ayon_launcher_process(uri)
elif response_type in ("query", "navigate"):
response.error_message = (
"Please use AYON web UI to run the action."
)
else:
self.log.warning(
f"Unknown webaction response type '{response_type}'"
)
response.error_message = "Unknown webaction response type."
return response
def _get_discovered_action_classes(self):
if self._discovered_actions is None:
# NOTE We don't need to register the paths, but that would
@ -470,7 +536,6 @@ class ActionsModel:
register_launcher_action_path(path)
self._discovered_actions = (
discover_launcher_actions()
+ self._get_applications_action_classes()
)
return self._discovered_actions
@ -498,62 +563,29 @@ class ActionsModel:
action_items = {}
for identifier, action in self._get_action_objects().items():
is_application = isinstance(action, ApplicationAction)
# Backwards compatibility from 0.3.3 (24/06/10)
# TODO: Remove in future releases
if is_application and hasattr(action, "project_settings"):
if hasattr(action, "project_settings"):
action.project_entities[project_name] = project_entity
action.project_settings[project_name] = project_settings
label = action.label or identifier
variant_label = getattr(action, "label_variant", None)
full_label = self.calculate_full_label(
label, variant_label
)
icon = get_action_icon(action)
item = ActionItem(
identifier,
label,
variant_label,
icon,
action.order,
is_application,
False
action_type="local",
identifier=identifier,
order=action.order,
label=label,
variant_label=variant_label,
full_label=full_label,
icon=icon,
config_fields=[],
)
action_items[identifier] = item
self._action_items[project_name] = action_items
return action_items
def _get_applications_action_classes(self):
addons_manager = self._get_addons_manager()
applications_addon = addons_manager.get_enabled_addon("applications")
if hasattr(applications_addon, "get_applications_action_classes"):
return applications_addon.get_applications_action_classes()
# Backwards compatibility from 0.3.3 (24/06/10)
# TODO: Remove in future releases
actions = []
if applications_addon is None:
return actions
manager = applications_addon.get_applications_manager()
for full_name, application in manager.applications.items():
if not application.enabled:
continue
action = type(
"app_{}".format(full_name),
(ApplicationAction,),
{
"identifier": "application.{}".format(full_name),
"application": application,
"name": application.name,
"label": application.group.label,
"label_variant": application.label,
"group": None,
"icon": application.icon,
"color": getattr(application, "color", None),
"order": getattr(application, "order", None) or 0,
"data": {}
}
)
actions.append(action)
return actions

File diff suppressed because it is too large Load diff

View file

@ -1,7 +0,0 @@
import os
RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__))
def get_options_image_path():
return os.path.join(RESOURCES_DIR, "options.png")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1,9 +1,9 @@
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core import style
from ayon_core import resources
from ayon_core import style, resources
from ayon_core.tools.launcher.control import BaseLauncherController
from ayon_core.tools.utils import MessageOverlayObject
from .projects_widget import ProjectsWidget
from .hierarchy_page import HierarchyPage
@ -41,6 +41,8 @@ class LauncherWindow(QtWidgets.QWidget):
self._controller = controller
overlay_object = MessageOverlayObject(self)
# Main content - Pages & Actions
content_body = QtWidgets.QSplitter(self)
@ -78,26 +80,18 @@ class LauncherWindow(QtWidgets.QWidget):
content_body.setSizes([580, 160])
# Footer
footer_widget = QtWidgets.QWidget(self)
# - Message label
message_label = QtWidgets.QLabel(footer_widget)
# footer_widget = QtWidgets.QWidget(self)
#
# action_history = ActionHistory(footer_widget)
# action_history.setStatusTip("Show Action History")
footer_layout = QtWidgets.QHBoxLayout(footer_widget)
footer_layout.setContentsMargins(0, 0, 0, 0)
footer_layout.addWidget(message_label, 1)
#
# footer_layout = QtWidgets.QHBoxLayout(footer_widget)
# footer_layout.setContentsMargins(0, 0, 0, 0)
# footer_layout.addWidget(action_history, 0)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(content_body, 1)
layout.addWidget(footer_widget, 0)
message_timer = QtCore.QTimer()
message_timer.setInterval(self.message_interval)
message_timer.setSingleShot(True)
# layout.addWidget(footer_widget, 0)
actions_refresh_timer = QtCore.QTimer()
actions_refresh_timer.setInterval(self.refresh_interval)
@ -109,7 +103,6 @@ class LauncherWindow(QtWidgets.QWidget):
page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad)
projects_page.refreshed.connect(self._on_projects_refresh)
message_timer.timeout.connect(self._on_message_timeout)
actions_refresh_timer.timeout.connect(
self._on_actions_refresh_timeout)
page_slide_anim.valueChanged.connect(
@ -128,6 +121,16 @@ class LauncherWindow(QtWidgets.QWidget):
"action.trigger.finished",
self._on_action_trigger_finished,
)
controller.register_event_callback(
"webaction.trigger.started",
self._on_webaction_trigger_started,
)
controller.register_event_callback(
"webaction.trigger.finished",
self._on_webaction_trigger_finished,
)
self._overlay_object = overlay_object
self._controller = controller
@ -141,11 +144,8 @@ class LauncherWindow(QtWidgets.QWidget):
self._projects_page = projects_page
self._hierarchy_page = hierarchy_page
self._actions_widget = actions_widget
self._message_label = message_label
# self._action_history = action_history
self._message_timer = message_timer
self._actions_refresh_timer = actions_refresh_timer
self._page_slide_anim = page_slide_anim
@ -185,13 +185,6 @@ class LauncherWindow(QtWidgets.QWidget):
else:
self._refresh_on_activate = True
def _echo(self, message):
self._message_label.setText(str(message))
self._message_timer.start()
def _on_message_timeout(self):
self._message_label.setText("")
def _on_project_selection_change(self, event):
project_name = event["project_name"]
self._selected_project_name = project_name
@ -215,13 +208,69 @@ class LauncherWindow(QtWidgets.QWidget):
self._hierarchy_page.refresh()
self._actions_widget.refresh()
def _show_toast_message(self, message, success=True, message_id=None):
message_type = None
if not success:
message_type = "error"
self._overlay_object.add_message(
message, message_type, message_id=message_id
)
def _on_action_trigger_started(self, event):
self._echo("Running action: {}".format(event["full_label"]))
self._show_toast_message(
"Running: {}".format(event["full_label"]),
message_id=event["trigger_id"],
)
def _on_action_trigger_finished(self, event):
if not event["failed"]:
action_label = event["full_label"]
if event["failed"]:
message = f"Failed to run: {action_label}"
else:
message = f"Finished: {action_label}"
self._show_toast_message(
message,
not event["failed"],
message_id=event["trigger_id"],
)
def _on_webaction_trigger_started(self, event):
self._show_toast_message(
"Running: {}".format(event["full_label"]),
message_id=event["trigger_id"],
)
def _on_webaction_trigger_finished(self, event):
clipboard_text = event["clipboard_text"]
if clipboard_text:
clipboard = QtWidgets.QApplication.clipboard()
clipboard.setText(clipboard_text)
action_label = event["full_label"]
# Avoid to show exception message
if event["trigger_failed"]:
self._show_toast_message(
f"Failed to run: {action_label}",
message_id=event["trigger_id"]
)
return
self._echo("Failed: {}".format(event["error_message"]))
# Failed to run webaction, e.g. because of missing webaction handling
# - not reported by server
if event["error_message"]:
self._show_toast_message(
event["error_message"],
success=False,
message_id=event["trigger_id"]
)
return
if event["message"]:
self._show_toast_message(event["message"], event["success"])
if event["form"]:
self._actions_widget.handle_webaction_form_event(event)
def _is_page_slide_anim_running(self):
return (

View file

@ -84,15 +84,17 @@ def _get_options(action, action_item, parent):
if not getattr(action, "optioned", False) or not options:
return {}
dialog_title = action.label + " Options"
if isinstance(options[0], AbstractAttrDef):
qargparse_options = False
dialog = AttributeDefinitionsDialog(options, parent)
dialog = AttributeDefinitionsDialog(
options, title=dialog_title, parent=parent
)
else:
qargparse_options = True
dialog = OptionDialog(parent)
dialog.create(options)
dialog.setWindowTitle(action.label + " Options")
dialog.setWindowTitle(dialog_title)
if not dialog.exec_():
return None

View file

@ -959,11 +959,13 @@ class SceneInventoryView(QtWidgets.QTreeView):
remove_container(container)
self.data_changed.emit()
def _show_version_error_dialog(self, version, item_ids):
def _show_version_error_dialog(self, version, item_ids, exception):
"""Shows QMessageBox when version switch doesn't work
Args:
version: str or int or None
item_ids (Iterable[str]): List of item ids to run the
exception (Exception): Exception that occurred
"""
if version == -1:
version_str = "latest"
@ -988,10 +990,11 @@ class SceneInventoryView(QtWidgets.QTreeView):
dialog.addButton(QtWidgets.QMessageBox.Cancel)
msg = (
"Version update to '{}' failed as representation doesn't exist."
"Version update to '{}' failed with the following error:\n"
"{}."
"\n\nPlease update to version with a valid representation"
" OR \n use 'Switch Folder' button to change folder."
).format(version_str)
).format(version_str, exception)
dialog.setText(msg)
dialog.exec_()
@ -1105,10 +1108,10 @@ class SceneInventoryView(QtWidgets.QTreeView):
container = containers_by_id[item_id]
try:
update_container(container, item_version)
except AssertionError:
except Exception as exc:
log.warning("Update failed", exc_info=True)
self._show_version_error_dialog(
item_version, [item_id]
item_version, [item_id], exc
)
finally:
# Always update the scene inventory view, even if errors occurred

View file

@ -6,6 +6,7 @@ from .widgets import (
CustomTextComboBox,
PlaceholderLineEdit,
PlaceholderPlainTextEdit,
MarkdownLabel,
ElideLabel,
HintedLineEdit,
ExpandingTextEdit,
@ -91,6 +92,7 @@ __all__ = (
"CustomTextComboBox",
"PlaceholderLineEdit",
"PlaceholderPlainTextEdit",
"MarkdownLabel",
"ElideLabel",
"HintedLineEdit",
"ExpandingTextEdit",

View file

@ -14,3 +14,4 @@ except AttributeError:
DEFAULT_PROJECT_LABEL = "< Default >"
PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 101
PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 102
DEFAULT_WEB_ICON_COLOR = "#f4f5f5"

View file

@ -1,11 +1,14 @@
import os
import sys
import io
import contextlib
import collections
import traceback
import urllib.request
from functools import partial
from typing import Union, Any
import ayon_api
from qtpy import QtWidgets, QtCore, QtGui
import qtawesome
import qtmaterialsymbols
@ -17,7 +20,12 @@ from ayon_core.style import (
from ayon_core.resources import get_image_path
from ayon_core.lib import Logger
from .constants import CHECKED_INT, UNCHECKED_INT, PARTIALLY_CHECKED_INT
from .constants import (
CHECKED_INT,
UNCHECKED_INT,
PARTIALLY_CHECKED_INT,
DEFAULT_WEB_ICON_COLOR,
)
log = Logger.get_logger(__name__)
@ -480,11 +488,27 @@ class _IconsCache:
if icon_type == "path":
parts = [icon_type, icon_def["path"]]
elif icon_type in {"awesome-font", "material-symbols"}:
color = icon_def["color"] or ""
elif icon_type == "awesome-font":
color = icon_def.get("color") or ""
if isinstance(color, QtGui.QColor):
color = color.name()
parts = [icon_type, icon_def["name"] or "", color]
elif icon_type == "material-symbols":
color = icon_def.get("color") or DEFAULT_WEB_ICON_COLOR
if isinstance(color, QtGui.QColor):
color = color.name()
parts = [icon_type, icon_def["name"] or "", color]
elif icon_type in {"url", "ayon_url"}:
parts = [icon_type, icon_def["url"]]
elif icon_type == "transparent":
size = icon_def.get("size")
if size is None:
size = 256
parts = [icon_type, str(size)]
return "|".join(parts)
@classmethod
@ -505,7 +529,7 @@ class _IconsCache:
elif icon_type == "awesome-font":
icon_name = icon_def["name"]
icon_color = icon_def["color"]
icon_color = icon_def.get("color")
icon = cls.get_qta_icon_by_name_and_color(icon_name, icon_color)
if icon is None:
icon = cls.get_qta_icon_by_name_and_color(
@ -513,10 +537,40 @@ class _IconsCache:
elif icon_type == "material-symbols":
icon_name = icon_def["name"]
icon_color = icon_def["color"]
icon_color = icon_def.get("color") or DEFAULT_WEB_ICON_COLOR
if qtmaterialsymbols.get_icon_name_char(icon_name) is not None:
icon = qtmaterialsymbols.get_icon(icon_name, icon_color)
elif icon_type == "url":
url = icon_def["url"]
try:
content = urllib.request.urlopen(url).read()
pix = QtGui.QPixmap()
pix.loadFromData(content)
icon = QtGui.QIcon(pix)
except Exception:
log.warning(
"Failed to download image '%s'", url, exc_info=True
)
icon = None
elif icon_type == "ayon_url":
url = icon_def["url"].lstrip("/")
url = f"{ayon_api.get_base_url()}/{url}"
stream = io.BytesIO()
ayon_api.download_file_to_stream(url, stream)
pix = QtGui.QPixmap()
pix.loadFromData(stream.getvalue())
icon = QtGui.QIcon(pix)
elif icon_type == "transparent":
size = icon_def.get("size")
if size is None:
size = 256
pix = QtGui.QPixmap(size, size)
pix.fill(QtCore.Qt.transparent)
icon = QtGui.QIcon(pix)
if icon is None:
icon = cls.get_default()
cls._cache[cache_key] = icon

View file

@ -6,6 +6,11 @@ from qtpy import QtWidgets, QtCore, QtGui
import qargparse
import qtawesome
try:
import markdown
except Exception:
markdown = None
from ayon_core.style import (
get_objected_colors,
get_style_image_path,
@ -131,6 +136,37 @@ class PlaceholderPlainTextEdit(QtWidgets.QPlainTextEdit):
viewport.setPalette(filter_palette)
class MarkdownLabel(QtWidgets.QLabel):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Enable word wrap by default
self.setWordWrap(True)
text_format_available = hasattr(QtCore.Qt, "MarkdownText")
if text_format_available:
self.setTextFormat(QtCore.Qt.MarkdownText)
self._text_format_available = text_format_available
self.setText(self.text())
def setText(self, text):
if not self._text_format_available:
text = self._md_to_html(text)
super().setText(text)
@staticmethod
def _md_to_html(text):
if markdown is None:
# This does add style definition to the markdown which does not
# feel natural in the UI (but still better than raw MD).
doc = QtGui.QTextDocument()
doc.setMarkdown(text)
return doc.toHtml()
return markdown.markdown(text)
class ElideLabel(QtWidgets.QLabel):
"""Label which elide text.
@ -459,15 +495,15 @@ class ClickableLabel(QtWidgets.QLabel):
"""Label that catch left mouse click and can trigger 'clicked' signal."""
clicked = QtCore.Signal()
def __init__(self, parent):
super(ClickableLabel, self).__init__(parent)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._mouse_pressed = False
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
self._mouse_pressed = True
super(ClickableLabel, self).mousePressEvent(event)
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
if self._mouse_pressed:
@ -475,7 +511,7 @@ class ClickableLabel(QtWidgets.QLabel):
if self.rect().contains(event.pos()):
self.clicked.emit()
super(ClickableLabel, self).mouseReleaseEvent(event)
super().mouseReleaseEvent(event)
class ExpandBtnLabel(QtWidgets.QLabel):
@ -704,7 +740,7 @@ class PixmapLabel(QtWidgets.QLabel):
def resizeEvent(self, event):
self._set_resized_pix()
super(PixmapLabel, self).resizeEvent(event)
super().resizeEvent(event)
class PixmapButtonPainter(QtWidgets.QWidget):

View file

@ -4,6 +4,7 @@ description="AYON core addon."
[tool.poetry.dependencies]
python = ">=3.9.1,<3.10"
markdown = "^3.4.1"
clique = "1.6.*"
jsonschema = "^2.6.0"
pyblish-base = "^1.8.11"

View file

@ -6,11 +6,12 @@ client_dir = "ayon_core"
plugin_for = ["ayon_server"]
ayon_server_version = ">=1.7.6,<2.0.0"
ayon_server_version = ">=1.8.4,<2.0.0"
ayon_launcher_version = ">=1.0.2"
ayon_required_addons = {}
ayon_compatible_addons = {
"ayon_ocio": ">=1.2.1",
"applications": ">=1.1.2",
"harmony": ">0.4.0",
"fusion": ">=0.3.3",
"openrv": ">=1.0.2",

View file

@ -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",
]

View file

@ -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
View file

@ -0,0 +1 @@
"""Tests."""

View file

@ -0,0 +1 @@
"""Tests for the representation traits."""

View 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"]

View 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]

View 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

View 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")

View file

@ -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

View 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"])

View file

@ -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")

View file

@ -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
}

View file

@ -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 () {