Merge remote-tracking branch 'origin/enhancement/1294-product-base-types-support-in-loading' into enhancement/1294-product-base-types-support-in-loading

This commit is contained in:
Ondrej Samohel 2025-06-18 19:03:29 +02:00
commit f758420199
No known key found for this signature in database
GPG key ID: 02376E18990A97C6
42 changed files with 6583 additions and 404 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

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

@ -8,7 +8,7 @@ targeted by task types and names.
Placeholders are created using placeholder plugins which should care about
logic and data of placeholder items. 'PlaceholderItem' is used to keep track
about it's progress.
about its progress.
"""
import os
@ -17,6 +17,7 @@ import collections
import copy
from abc import ABC, abstractmethod
import ayon_api
from ayon_api import (
get_folders,
get_folder_by_path,
@ -60,6 +61,32 @@ from ayon_core.pipeline.create import (
_NOT_SET = object()
class EntityResolutionError(Exception):
"""Exception raised when entity URI resolution fails."""
def resolve_entity_uri(entity_uri: str) -> str:
"""Resolve AYON entity URI to a filesystem path for local system."""
response = ayon_api.post(
"resolve",
resolveRoots=True,
uris=[entity_uri]
)
if response.status_code != 200:
raise RuntimeError(
f"Unable to resolve AYON entity URI filepath for "
f"'{entity_uri}': {response.text}"
)
entities = response.data[0]["entities"]
if len(entities) != 1:
raise EntityResolutionError(
f"Unable to resolve AYON entity URI '{entity_uri}' to a "
f"single filepath. Received data: {response.data}"
)
return entities[0]["filePath"]
class TemplateNotFound(Exception):
"""Exception raised when template does not exist."""
pass
@ -823,7 +850,6 @@ class AbstractTemplateBuilder(ABC):
"""
host_name = self.host_name
project_name = self.project_name
task_name = self.current_task_name
task_type = self.current_task_type
@ -835,7 +861,6 @@ class AbstractTemplateBuilder(ABC):
"task_names": task_name
}
)
if not profile:
raise TemplateProfileNotFound((
"No matching profile found for task '{}' of type '{}' "
@ -843,6 +868,22 @@ class AbstractTemplateBuilder(ABC):
).format(task_name, task_type, host_name))
path = profile["path"]
if not path:
raise TemplateLoadFailed((
"Template path is not set.\n"
"Path need to be set in {}\\Template Workfile Build "
"Settings\\Profiles"
).format(host_name.title()))
resolved_path = self.resolve_template_path(path)
if not resolved_path or not os.path.exists(resolved_path):
raise TemplateNotFound(
"Template file found in AYON settings for task '{}' with host "
"'{}' does not exists. (Not found : {})".format(
task_name, host_name, resolved_path)
)
self.log.info(f"Found template at: '{resolved_path}'")
# switch to remove placeholders after they are used
keep_placeholder = profile.get("keep_placeholder")
@ -852,44 +893,86 @@ class AbstractTemplateBuilder(ABC):
if keep_placeholder is None:
keep_placeholder = True
if not path:
raise TemplateLoadFailed((
"Template path is not set.\n"
"Path need to be set in {}\\Template Workfile Build "
"Settings\\Profiles"
).format(host_name.title()))
# Try to fill path with environments and anatomy roots
anatomy = Anatomy(project_name)
fill_data = {
key: value
for key, value in os.environ.items()
return {
"path": resolved_path,
"keep_placeholder": keep_placeholder,
"create_first_version": create_first_version
}
fill_data["root"] = anatomy.roots
fill_data["project"] = {
"name": project_name,
"code": anatomy.project_code,
}
def resolve_template_path(self, path, fill_data=None) -> str:
"""Resolve the template path.
path = self.resolve_template_path(path, fill_data)
By default, this:
- Resolves AYON entity URI to a filesystem path
- Returns path directly if it exists on disk.
- Resolves template keys through anatomy and environment variables.
This can be overridden in host integrations to perform additional
resolving over the template. Like, `hou.text.expandString` in Houdini.
It's recommended to still call the super().resolve_template_path()
to ensure the basic resolving is done across all integrations.
Arguments:
path (str): The input path.
fill_data (dict[str, str]): Deprecated. This is computed inside
the method using the current environment and project settings.
Used to be the data to use for template formatting.
Returns:
str: The resolved path.
"""
# If the path is an AYON entity URI, then resolve the filepath
# through the backend
if path.startswith("ayon+entity://") or path.startswith("ayon://"):
# This is a special case where the path is an AYON entity URI
# We need to resolve it to a filesystem path
resolved_path = resolve_entity_uri(path)
return resolved_path
# If the path is set and it's found on disk, return it directly
if path and os.path.exists(path):
self.log.info("Found template at: '{}'".format(path))
return {
"path": path,
"keep_placeholder": keep_placeholder,
"create_first_version": create_first_version
return path
# We may have path for another platform, like C:/path/to/file
# or a path with template keys, like {project[code]} or both.
# Try to fill path with environments and anatomy roots
project_name = self.project_name
anatomy = Anatomy(project_name)
# Simple check whether the path contains any template keys
if "{" in path:
fill_data = {
key: value
for key, value in os.environ.items()
}
fill_data["root"] = anatomy.roots
fill_data["project"] = {
"name": project_name,
"code": anatomy.project_code,
}
solved_path = None
# Format the template using local fill data
result = StringTemplate.format_template(path, fill_data)
if not result.solved:
return path
path = result.normalized()
if os.path.exists(path):
return path
# If the path were set in settings using a Windows path and we
# are now on a Linux system, we try to convert the solved path to
# the current platform.
while True:
try:
solved_path = anatomy.path_remapper(path)
except KeyError as missing_key:
raise KeyError(
"Could not solve key '{}' in template path '{}'".format(
missing_key, path))
f"Could not solve key '{missing_key}'"
f" in template path '{path}'"
)
if solved_path is None:
solved_path = path
@ -898,40 +981,7 @@ class AbstractTemplateBuilder(ABC):
path = solved_path
solved_path = os.path.normpath(solved_path)
if not os.path.exists(solved_path):
raise TemplateNotFound(
"Template found in AYON settings for task '{}' with host "
"'{}' does not exists. (Not found : {})".format(
task_name, host_name, solved_path))
self.log.info("Found template at: '{}'".format(solved_path))
return {
"path": solved_path,
"keep_placeholder": keep_placeholder,
"create_first_version": create_first_version
}
def resolve_template_path(self, path, fill_data) -> str:
"""Resolve the template path.
By default, this does nothing except returning the path directly.
This can be overridden in host integrations to perform additional
resolving over the template. Like, `hou.text.expandString` in Houdini.
Arguments:
path (str): The input path.
fill_data (dict[str, str]): Data to use for template formatting.
Returns:
str: The resolved path.
"""
result = StringTemplate.format_template(path, fill_data)
if result.solved:
path = result.normalized()
return path
return solved_path
def emit_event(self, topic, data=None, source=None) -> Event:
return self._event_system.emit(topic, data, source)

View file

@ -58,7 +58,7 @@ class ExtractOIIOTranscode(publish.Extractor):
optional = True
# Supported extensions
supported_exts = ["exr", "jpg", "jpeg", "png", "dpx"]
supported_exts = {"exr", "jpg", "jpeg", "png", "dpx"}
# Configurable by Settings
profiles = None

View file

@ -135,11 +135,11 @@ class ExtractReview(pyblish.api.InstancePlugin):
]
# Supported extensions
image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"]
video_exts = ["mov", "mp4"]
supported_exts = image_exts + video_exts
image_exts = {"exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"}
video_exts = {"mov", "mp4"}
supported_exts = image_exts | video_exts
alpha_exts = ["exr", "png", "dpx"]
alpha_exts = {"exr", "png", "dpx"}
# Preset attributes
profiles = []

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,8 @@
from __future__ import annotations
import contextlib
from abc import ABC, abstractmethod
from typing import Dict, Any
from dataclasses import dataclass
import ayon_api
@ -140,6 +142,7 @@ class TaskTypeItem:
)
@dataclass
class ProjectItem:
"""Item representing folder entity on a server.
@ -150,21 +153,14 @@ class ProjectItem:
active (Union[str, None]): Parent folder id. If 'None' then project
is parent.
"""
def __init__(self, name, active, is_library, icon=None):
self.name = name
self.active = active
self.is_library = is_library
if icon is None:
icon = {
"type": "awesome-font",
"name": "fa.book" if is_library else "fa.map",
"color": get_default_entity_icon_color(),
}
self.icon = icon
name: str
active: bool
is_library: bool
icon: dict[str, Any]
is_pinned: bool = False
@classmethod
def from_entity(cls, project_entity):
def from_entity(cls, project_entity: dict[str, Any]) -> "ProjectItem":
"""Creates folder item from entity.
Args:
@ -174,10 +170,16 @@ class ProjectItem:
ProjectItem: Project item.
"""
icon = {
"type": "awesome-font",
"name": "fa.book" if project_entity["library"] else "fa.map",
"color": get_default_entity_icon_color(),
}
return cls(
project_entity["name"],
project_entity["active"],
project_entity["library"],
icon
)
def to_data(self):
@ -208,16 +210,18 @@ class ProjectItem:
return cls(**data)
def _get_project_items_from_entitiy(projects):
def _get_project_items_from_entitiy(
projects: list[dict[str, Any]]
) -> list[ProjectItem]:
"""
Args:
projects (list[dict[str, Any]]): List of projects.
Returns:
ProjectItem: Project item.
"""
list[ProjectItem]: Project item.
"""
return [
ProjectItem.from_entity(project)
for project in projects
@ -428,9 +432,20 @@ class ProjectsModel(object):
self._projects_cache.update_data(project_items)
return self._projects_cache.get_data()
def _query_projects(self):
def _query_projects(self) -> list[ProjectItem]:
projects = ayon_api.get_projects(fields=["name", "active", "library"])
return _get_project_items_from_entitiy(projects)
user = ayon_api.get_user()
pinned_projects = (
user
.get("data", {})
.get("frontendPreferences", {})
.get("pinnedProjects")
) or []
pinned_projects = set(pinned_projects)
project_items = _get_project_items_from_entitiy(list(projects))
for project in project_items:
project.is_pinned = project.name in pinned_projects
return project_items
def _status_items_getter(self, project_entity):
if not project_entity:

View file

@ -1,6 +1,6 @@
from ayon_core.lib import Logger, get_ayon_username
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.settings import get_project_settings
from ayon_core.settings import get_project_settings, get_studio_settings
from ayon_core.tools.common_models import ProjectsModel, HierarchyModel
from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend
@ -85,7 +85,10 @@ class BaseLauncherController(
def get_project_settings(self, project_name):
if project_name in self._project_settings:
return self._project_settings[project_name]
settings = get_project_settings(project_name)
if project_name:
settings = get_project_settings(project_name)
else:
settings = get_studio_settings()
self._project_settings[project_name] = settings
return settings

View file

@ -1,154 +0,0 @@
from qtpy import QtWidgets, QtCore
from ayon_core.tools.flickcharm import FlickCharm
from ayon_core.tools.utils import (
PlaceholderLineEdit,
RefreshButton,
ProjectsQtModel,
ProjectSortFilterProxy,
)
from ayon_core.tools.common_models import PROJECTS_MODEL_SENDER
class ProjectIconView(QtWidgets.QListView):
"""Styled ListView that allows to toggle between icon and list mode.
Toggling between the two modes is done by Right Mouse Click.
"""
IconMode = 0
ListMode = 1
def __init__(self, parent=None, mode=ListMode):
super(ProjectIconView, self).__init__(parent=parent)
# Workaround for scrolling being super slow or fast when
# toggling between the two visual modes
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
self.setObjectName("IconView")
self._mode = None
self.set_mode(mode)
def set_mode(self, mode):
if mode == self._mode:
return
self._mode = mode
if mode == self.IconMode:
self.setViewMode(QtWidgets.QListView.IconMode)
self.setResizeMode(QtWidgets.QListView.Adjust)
self.setWrapping(True)
self.setWordWrap(True)
self.setGridSize(QtCore.QSize(151, 90))
self.setIconSize(QtCore.QSize(50, 50))
self.setSpacing(0)
self.setAlternatingRowColors(False)
self.setProperty("mode", "icon")
self.style().polish(self)
self.verticalScrollBar().setSingleStep(30)
elif self.ListMode:
self.setProperty("mode", "list")
self.style().polish(self)
self.setViewMode(QtWidgets.QListView.ListMode)
self.setResizeMode(QtWidgets.QListView.Adjust)
self.setWrapping(False)
self.setWordWrap(False)
self.setIconSize(QtCore.QSize(20, 20))
self.setGridSize(QtCore.QSize(100, 25))
self.setSpacing(0)
self.setAlternatingRowColors(False)
self.verticalScrollBar().setSingleStep(34)
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.RightButton:
self.set_mode(int(not self._mode))
return super(ProjectIconView, self).mousePressEvent(event)
class ProjectsWidget(QtWidgets.QWidget):
"""Projects Page"""
refreshed = QtCore.Signal()
def __init__(self, controller, parent=None):
super(ProjectsWidget, self).__init__(parent=parent)
header_widget = QtWidgets.QWidget(self)
projects_filter_text = PlaceholderLineEdit(header_widget)
projects_filter_text.setPlaceholderText("Filter projects...")
refresh_btn = RefreshButton(header_widget)
header_layout = QtWidgets.QHBoxLayout(header_widget)
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.addWidget(projects_filter_text, 1)
header_layout.addWidget(refresh_btn, 0)
projects_view = ProjectIconView(parent=self)
projects_view.setSelectionMode(QtWidgets.QListView.NoSelection)
flick = FlickCharm(parent=self)
flick.activateOn(projects_view)
projects_model = ProjectsQtModel(controller)
projects_proxy_model = ProjectSortFilterProxy()
projects_proxy_model.setSourceModel(projects_model)
projects_view.setModel(projects_proxy_model)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(header_widget, 0)
main_layout.addWidget(projects_view, 1)
projects_view.clicked.connect(self._on_view_clicked)
projects_model.refreshed.connect(self.refreshed)
projects_filter_text.textChanged.connect(
self._on_project_filter_change)
refresh_btn.clicked.connect(self._on_refresh_clicked)
controller.register_event_callback(
"projects.refresh.finished",
self._on_projects_refresh_finished
)
self._controller = controller
self._projects_view = projects_view
self._projects_model = projects_model
self._projects_proxy_model = projects_proxy_model
def has_content(self):
"""Model has at least one project.
Returns:
bool: True if there is any content in the model.
"""
return self._projects_model.has_content()
def _on_view_clicked(self, index):
if not index.isValid():
return
model = index.model()
flags = model.flags(index)
if not flags & QtCore.Qt.ItemIsEnabled:
return
project_name = index.data(QtCore.Qt.DisplayRole)
self._controller.set_selected_project(project_name)
def _on_project_filter_change(self, text):
self._projects_proxy_model.setFilterFixedString(text)
def _on_refresh_clicked(self):
self._controller.refresh()
def _on_projects_refresh_finished(self, event):
if event["sender"] != PROJECTS_MODEL_SENDER:
self._projects_model.refresh()

View file

@ -3,9 +3,13 @@ from qtpy import QtWidgets, QtCore, QtGui
from ayon_core import style, resources
from ayon_core.tools.launcher.control import BaseLauncherController
from ayon_core.tools.utils import MessageOverlayObject
from ayon_core.tools.utils import (
MessageOverlayObject,
PlaceholderLineEdit,
RefreshButton,
ProjectsWidget,
)
from .projects_widget import ProjectsWidget
from .hierarchy_page import HierarchyPage
from .actions_widget import ActionsWidget
@ -50,7 +54,25 @@ class LauncherWindow(QtWidgets.QWidget):
pages_widget = QtWidgets.QWidget(content_body)
# - First page - Projects
projects_page = ProjectsWidget(controller, pages_widget)
projects_page = QtWidgets.QWidget(pages_widget)
projects_header_widget = QtWidgets.QWidget(projects_page)
projects_filter_text = PlaceholderLineEdit(projects_header_widget)
projects_filter_text.setPlaceholderText("Filter projects...")
refresh_btn = RefreshButton(projects_header_widget)
projects_header_layout = QtWidgets.QHBoxLayout(projects_header_widget)
projects_header_layout.setContentsMargins(0, 0, 0, 0)
projects_header_layout.addWidget(projects_filter_text, 1)
projects_header_layout.addWidget(refresh_btn, 0)
projects_widget = ProjectsWidget(controller, pages_widget)
projects_layout = QtWidgets.QVBoxLayout(projects_page)
projects_layout.setContentsMargins(0, 0, 0, 0)
projects_layout.addWidget(projects_header_widget, 0)
projects_layout.addWidget(projects_widget, 1)
# - Second page - Hierarchy (folders & tasks)
hierarchy_page = HierarchyPage(controller, pages_widget)
@ -102,12 +124,16 @@ class LauncherWindow(QtWidgets.QWidget):
page_slide_anim.setEndValue(1.0)
page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad)
projects_page.refreshed.connect(self._on_projects_refresh)
refresh_btn.clicked.connect(self._on_refresh_request)
projects_widget.refreshed.connect(self._on_projects_refresh)
actions_refresh_timer.timeout.connect(
self._on_actions_refresh_timeout)
page_slide_anim.valueChanged.connect(
self._on_page_slide_value_changed)
page_slide_anim.finished.connect(self._on_page_slide_finished)
projects_filter_text.textChanged.connect(
self._on_project_filter_change)
controller.register_event_callback(
"selection.project.changed",
@ -142,6 +168,7 @@ class LauncherWindow(QtWidgets.QWidget):
self._pages_widget = pages_widget
self._pages_layout = pages_layout
self._projects_page = projects_page
self._projects_widget = projects_widget
self._hierarchy_page = hierarchy_page
self._actions_widget = actions_widget
# self._action_history = action_history
@ -194,6 +221,12 @@ class LauncherWindow(QtWidgets.QWidget):
elif self._is_on_projects_page:
self._go_to_hierarchy_page(project_name)
def _on_project_filter_change(self, text):
self._projects_widget.set_name_filter(text)
def _on_refresh_request(self):
self._controller.refresh()
def _on_projects_refresh(self):
# Refresh only actions on projects page
if self._is_on_projects_page:
@ -201,7 +234,7 @@ class LauncherWindow(QtWidgets.QWidget):
return
# No projects were found -> go back to projects page
if not self._projects_page.has_content():
if not self._projects_widget.has_content():
self._go_to_projects_page()
return
@ -280,6 +313,9 @@ class LauncherWindow(QtWidgets.QWidget):
def _go_to_projects_page(self):
if self._is_on_projects_page:
return
# Deselect project in projects widget
self._projects_widget.set_selected_project(None)
self._is_on_projects_page = True
self._hierarchy_page.set_page_visible(False)

View file

@ -29,6 +29,7 @@ from .widgets import (
from .views import (
DeselectableTreeView,
TreeView,
ListView,
)
from .error_dialog import ErrorMessageBox
from .lib import (
@ -61,6 +62,7 @@ from .dialogs import (
)
from .projects_widget import (
ProjectsCombobox,
ProjectsWidget,
ProjectsQtModel,
ProjectSortFilterProxy,
PROJECT_NAME_ROLE,
@ -114,6 +116,7 @@ __all__ = (
"DeselectableTreeView",
"TreeView",
"ListView",
"ErrorMessageBox",
@ -145,6 +148,7 @@ __all__ = (
"PopupUpdateKeys",
"ProjectsCombobox",
"ProjectsWidget",
"ProjectsQtModel",
"ProjectSortFilterProxy",
"PROJECT_NAME_ROLE",

View file

@ -1,21 +1,69 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable
import typing
from typing import Optional
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.tools.common_models import PROJECTS_MODEL_SENDER
from ayon_core.tools.common_models import (
ProjectItem,
PROJECTS_MODEL_SENDER,
)
from .views import ListView
from .lib import RefreshThread, get_qt_icon
if typing.TYPE_CHECKING:
from typing import TypedDict
class ExpectedProjectSelectionData(TypedDict):
name: Optional[str]
current: Optional[str]
selected: Optional[str]
class ExpectedSelectionData(TypedDict):
project: ExpectedProjectSelectionData
PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1
PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2
PROJECT_IS_LIBRARY_ROLE = QtCore.Qt.UserRole + 3
PROJECT_IS_CURRENT_ROLE = QtCore.Qt.UserRole + 4
LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 5
PROJECT_IS_PINNED_ROLE = QtCore.Qt.UserRole + 5
LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 6
class AbstractProjectController(ABC):
@abstractmethod
def register_event_callback(self, topic: str, callback: Callable):
pass
@abstractmethod
def get_project_items(
self, sender: Optional[str] = None
) -> list[str]:
pass
@abstractmethod
def set_selected_project(self, project_name: str):
pass
# These are required only if widget should handle expected selection
@abstractmethod
def expected_project_selected(self, project_name: str):
pass
@abstractmethod
def get_expected_selection_data(self) -> "ExpectedSelectionData":
pass
class ProjectsQtModel(QtGui.QStandardItemModel):
refreshed = QtCore.Signal()
def __init__(self, controller):
super(ProjectsQtModel, self).__init__()
def __init__(self, controller: AbstractProjectController):
super().__init__()
self._controller = controller
self._project_items = {}
@ -213,7 +261,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel):
else:
self.refreshed.emit()
def _fill_items(self, project_items):
def _fill_items(self, project_items: list[ProjectItem]):
new_project_names = {
project_item.name
for project_item in project_items
@ -252,6 +300,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel):
item.setData(project_name, PROJECT_NAME_ROLE)
item.setData(project_item.active, PROJECT_IS_ACTIVE_ROLE)
item.setData(project_item.is_library, PROJECT_IS_LIBRARY_ROLE)
item.setData(project_item.is_pinned, PROJECT_IS_PINNED_ROLE)
is_current = project_name == self._current_context_project
item.setData(is_current, PROJECT_IS_CURRENT_ROLE)
self._project_items[project_name] = item
@ -279,7 +328,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel):
class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
def __init__(self, *args, **kwargs):
super(ProjectSortFilterProxy, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self._filter_inactive = True
self._filter_standard = False
self._filter_library = False
@ -323,26 +372,51 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
return False
# Library separator should be before library projects
result = self._type_sort(left_index, right_index)
if result is not None:
return result
l_is_library = left_index.data(PROJECT_IS_LIBRARY_ROLE)
r_is_library = right_index.data(PROJECT_IS_LIBRARY_ROLE)
l_is_sep = left_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE)
r_is_sep = right_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE)
if l_is_sep:
return bool(r_is_library)
if left_index.data(PROJECT_NAME_ROLE) is None:
if r_is_sep:
return not l_is_library
# Non project items should be on top
l_project_name = left_index.data(PROJECT_NAME_ROLE)
r_project_name = right_index.data(PROJECT_NAME_ROLE)
if l_project_name is None:
return True
if right_index.data(PROJECT_NAME_ROLE) is None:
if r_project_name is None:
return False
left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE)
right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE)
if right_is_active == left_is_active:
return super(ProjectSortFilterProxy, self).lessThan(
left_index, right_index
)
if right_is_active != left_is_active:
return left_is_active
if left_is_active:
l_is_pinned = left_index.data(PROJECT_IS_PINNED_ROLE)
r_is_pinned = right_index.data(PROJECT_IS_PINNED_ROLE)
if l_is_pinned is True and not r_is_pinned:
return True
return False
if r_is_pinned is True and not l_is_pinned:
return False
# Move inactive projects to the end
left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE)
right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE)
if right_is_active != left_is_active:
return left_is_active
# Move library projects after standard projects
if (
l_is_library is not None
and r_is_library is not None
and l_is_library != r_is_library
):
return r_is_library
return super().lessThan(left_index, right_index)
def filterAcceptsRow(self, source_row, source_parent):
index = self.sourceModel().index(source_row, 0, source_parent)
@ -415,15 +489,153 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
self.invalidate()
class ProjectsDelegate(QtWidgets.QStyledItemDelegate):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._pin_icon = None
def paint(self, painter, option, index):
is_pinned = index.data(PROJECT_IS_PINNED_ROLE)
if not is_pinned:
super().paint(painter, option, index)
return
opt = QtWidgets.QStyleOptionViewItem(option)
self.initStyleOption(opt, index)
widget = option.widget
if widget is None:
style = QtWidgets.QApplication.style()
else:
style = widget.style()
# CE_ItemViewItem
proxy = style.proxy()
painter.save()
painter.setClipRect(option.rect)
decor_rect = proxy.subElementRect(
QtWidgets.QStyle.SE_ItemViewItemDecoration, opt, widget
)
text_rect = proxy.subElementRect(
QtWidgets.QStyle.SE_ItemViewItemText, opt, widget
)
proxy.drawPrimitive(
QtWidgets.QStyle.PE_PanelItemViewItem, opt, painter, widget
)
mode = QtGui.QIcon.Normal
if not opt.state & QtWidgets.QStyle.State_Enabled:
mode = QtGui.QIcon.Disabled
elif opt.state & QtWidgets.QStyle.State_Selected:
mode = QtGui.QIcon.Selected
state = QtGui.QIcon.Off
if opt.state & QtWidgets.QStyle.State_Open:
state = QtGui.QIcon.On
# Draw project icon
opt.icon.paint(
painter, decor_rect, opt.decorationAlignment, mode, state
)
# Draw pin icon
if index.data(PROJECT_IS_PINNED_ROLE):
pin_icon = self._get_pin_icon()
pin_rect = QtCore.QRect(decor_rect)
diff = option.rect.width() - pin_rect.width()
pin_rect.moveLeft(diff)
pin_icon.paint(
painter, pin_rect, opt.decorationAlignment, mode, state
)
# Draw text
if opt.text:
if not opt.state & QtWidgets.QStyle.State_Enabled:
cg = QtGui.QPalette.Disabled
elif not (opt.state & QtWidgets.QStyle.State_Active):
cg = QtGui.QPalette.Inactive
else:
cg = QtGui.QPalette.Normal
if opt.state & QtWidgets.QStyle.State_Selected:
painter.setPen(
opt.palette.color(cg, QtGui.QPalette.HighlightedText)
)
else:
painter.setPen(opt.palette.color(cg, QtGui.QPalette.Text))
if opt.state & QtWidgets.QStyle.State_Editing:
painter.setPen(opt.palette.color(cg, QtGui.QPalette.Text))
painter.drawRect(text_rect.adjusted(0, 0, -1, -1))
margin = proxy.pixelMetric(
QtWidgets.QStyle.PM_FocusFrameHMargin, None, widget
) + 1
text_rect.adjust(margin, 0, -margin, 0)
# NOTE skipping some steps e.g. word wrapping and elided
# text (adding '...' when too long).
painter.drawText(
text_rect,
opt.displayAlignment,
opt.text
)
# Draw focus rect
if opt.state & QtWidgets.QStyle.State_HasFocus:
focus_opt = QtWidgets.QStyleOptionFocusRect()
focus_opt.state = option.state
focus_opt.direction = option.direction
focus_opt.rect = option.rect
focus_opt.fontMetrics = option.fontMetrics
focus_opt.palette = option.palette
focus_opt.rect = style.subElementRect(
QtWidgets.QCommonStyle.SE_ItemViewItemFocusRect,
option,
option.widget
)
focus_opt.state |= (
QtWidgets.QStyle.State_KeyboardFocusChange
| QtWidgets.QStyle.State_Item
)
focus_opt.backgroundColor = option.palette.color(
(
QtGui.QPalette.Normal
if option.state & QtWidgets.QStyle.State_Enabled
else QtGui.QPalette.Disabled
),
(
QtGui.QPalette.Highlight
if option.state & QtWidgets.QStyle.State_Selected
else QtGui.QPalette.Window
)
)
style.drawPrimitive(
QtWidgets.QCommonStyle.PE_FrameFocusRect,
focus_opt,
painter,
option.widget
)
painter.restore()
def _get_pin_icon(self):
if self._pin_icon is None:
self._pin_icon = get_qt_icon({
"type": "material-symbols",
"name": "keep",
})
return self._pin_icon
class ProjectsCombobox(QtWidgets.QWidget):
refreshed = QtCore.Signal()
selection_changed = QtCore.Signal()
selection_changed = QtCore.Signal(str)
def __init__(self, controller, parent, handle_expected_selection=False):
super(ProjectsCombobox, self).__init__(parent)
def __init__(
self,
controller: AbstractProjectController,
parent: QtWidgets.QWidget,
handle_expected_selection: bool = False,
):
super().__init__(parent)
projects_combobox = QtWidgets.QComboBox(self)
combobox_delegate = QtWidgets.QStyledItemDelegate(projects_combobox)
combobox_delegate = ProjectsDelegate(projects_combobox)
projects_combobox.setItemDelegate(combobox_delegate)
projects_model = ProjectsQtModel(controller)
projects_proxy_model = ProjectSortFilterProxy()
@ -468,7 +680,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
def refresh(self):
self._projects_model.refresh()
def set_selection(self, project_name):
def set_selection(self, project_name: str):
"""Set selection to a given project.
Selection change is ignored if project is not found.
@ -480,8 +692,8 @@ class ProjectsCombobox(QtWidgets.QWidget):
bool: True if selection was changed, False otherwise. NOTE:
Selection may not be changed if project is not found, or if
project is already selected.
"""
"""
idx = self._projects_combobox.findData(
project_name, PROJECT_NAME_ROLE)
if idx < 0:
@ -491,7 +703,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
return True
return False
def set_listen_to_selection_change(self, listen):
def set_listen_to_selection_change(self, listen: bool):
"""Disable listening to changes of the selection.
Because combobox is triggering selection change when it's model
@ -517,11 +729,11 @@ class ProjectsCombobox(QtWidgets.QWidget):
return None
return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE)
def set_current_context_project(self, project_name):
def set_current_context_project(self, project_name: str):
self._projects_model.set_current_context_project(project_name)
self._projects_proxy_model.invalidateFilter()
def set_select_item_visible(self, visible):
def set_select_item_visible(self, visible: bool):
self._select_item_visible = visible
self._projects_model.set_select_item_visible(visible)
self._update_select_item_visiblity()
@ -559,7 +771,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
idx, PROJECT_NAME_ROLE)
self._update_select_item_visiblity(project_name=project_name)
self._controller.set_selected_project(project_name)
self.selection_changed.emit()
self.selection_changed.emit(project_name or "")
def _on_model_refresh(self):
self._projects_proxy_model.sort(0)
@ -614,5 +826,119 @@ class ProjectsCombobox(QtWidgets.QWidget):
class ProjectsWidget(QtWidgets.QWidget):
# TODO implement
pass
"""Projects widget showing projects in list.
Warnings:
This widget does not support expected selection handling.
"""
refreshed = QtCore.Signal()
selection_changed = QtCore.Signal(str)
double_clicked = QtCore.Signal()
def __init__(
self,
controller: AbstractProjectController,
parent: Optional[QtWidgets.QWidget] = None
):
super().__init__(parent=parent)
projects_view = ListView(parent=self)
projects_view.setResizeMode(QtWidgets.QListView.Adjust)
projects_view.setVerticalScrollMode(
QtWidgets.QAbstractItemView.ScrollPerPixel
)
projects_view.setAlternatingRowColors(False)
projects_view.setWrapping(False)
projects_view.setWordWrap(False)
projects_view.setSpacing(0)
projects_delegate = ProjectsDelegate(projects_view)
projects_view.setItemDelegate(projects_delegate)
projects_view.activate_flick_charm()
projects_view.set_deselectable(True)
projects_model = ProjectsQtModel(controller)
projects_proxy_model = ProjectSortFilterProxy()
projects_proxy_model.setSourceModel(projects_model)
projects_view.setModel(projects_proxy_model)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(projects_view, 1)
projects_view.selectionModel().selectionChanged.connect(
self._on_selection_change
)
projects_view.double_clicked.connect(self.double_clicked)
projects_model.refreshed.connect(self._on_model_refresh)
controller.register_event_callback(
"projects.refresh.finished",
self._on_projects_refresh_finished
)
self._controller = controller
self._projects_view = projects_view
self._projects_model = projects_model
self._projects_proxy_model = projects_proxy_model
self._projects_delegate = projects_delegate
def refresh(self):
self._projects_model.refresh()
def has_content(self) -> bool:
"""Model has at least one project.
Returns:
bool: True if there is any content in the model.
"""
return self._projects_model.has_content()
def set_name_filter(self, text: str):
self._projects_proxy_model.setFilterFixedString(text)
def get_selected_project(self) -> Optional[str]:
selection_model = self._projects_view.selectionModel()
for index in selection_model.selectedIndexes():
project_name = index.data(PROJECT_NAME_ROLE)
if project_name:
return project_name
return None
def set_selected_project(self, project_name: Optional[str]):
if project_name is None:
self._projects_view.clearSelection()
self._projects_view.setCurrentIndex(QtCore.QModelIndex())
return
index = self._projects_model.get_index_by_project_name(project_name)
if not index.isValid():
return
proxy_index = self._projects_proxy_model.mapFromSource(index)
if proxy_index.isValid():
selection_model = self._projects_view.selectionModel()
selection_model.select(
proxy_index,
QtCore.QItemSelectionModel.ClearAndSelect
)
def _on_model_refresh(self):
self._projects_proxy_model.sort(0)
self._projects_proxy_model.invalidateFilter()
self.refreshed.emit()
def _on_selection_change(self, new_selection, _old_selection):
project_name = None
for index in new_selection.indexes():
name = index.data(PROJECT_NAME_ROLE)
if name:
project_name = name
break
self.selection_changed.emit(project_name or "")
self._controller.set_selected_project(project_name)
def _on_projects_refresh_finished(self, event):
if event["sender"] != PROJECTS_MODEL_SENDER:
self._projects_model.refresh()

View file

@ -37,7 +37,7 @@ class TreeView(QtWidgets.QTreeView):
double_clicked = QtCore.Signal(QtGui.QMouseEvent)
def __init__(self, *args, **kwargs):
super(TreeView, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self._deselectable = False
self._flick_charm_activated = False
@ -60,12 +60,64 @@ class TreeView(QtWidgets.QTreeView):
self.clearSelection()
# clear the current index
self.setCurrentIndex(QtCore.QModelIndex())
super(TreeView, self).mousePressEvent(event)
super().mousePressEvent(event)
def mouseDoubleClickEvent(self, event):
self.double_clicked.emit(event)
return super(TreeView, self).mouseDoubleClickEvent(event)
return super().mouseDoubleClickEvent(event)
def activate_flick_charm(self):
if self._flick_charm_activated:
return
self._flick_charm_activated = True
self._before_flick_scroll_mode = self.verticalScrollMode()
self._flick_charm.activateOn(self)
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
def deactivate_flick_charm(self):
if not self._flick_charm_activated:
return
self._flick_charm_activated = False
self._flick_charm.deactivateFrom(self)
if self._before_flick_scroll_mode is not None:
self.setVerticalScrollMode(self._before_flick_scroll_mode)
class ListView(QtWidgets.QListView):
"""A tree view that deselects on clicking on an empty area in the view"""
double_clicked = QtCore.Signal(QtGui.QMouseEvent)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._deselectable = False
self._flick_charm_activated = False
self._flick_charm = FlickCharm(parent=self)
self._before_flick_scroll_mode = None
def is_deselectable(self):
return self._deselectable
def set_deselectable(self, deselectable):
self._deselectable = deselectable
deselectable = property(is_deselectable, set_deselectable)
def mousePressEvent(self, event):
if self._deselectable:
index = self.indexAt(event.pos())
if not index.isValid():
# clear the selection
self.clearSelection()
# clear the current index
self.setCurrentIndex(QtCore.QModelIndex())
super().mousePressEvent(event)
def mouseDoubleClickEvent(self, event):
self.double_clicked.emit(event)
return super().mouseDoubleClickEvent(event)
def activate_flick_charm(self):
if self._flick_charm_activated:

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