Merge remote-tracking branch 'origin/feature/909-define-basic-trait-type-using-dataclasses' into feature/911-new-traits-based-integrator

This commit is contained in:
Ondřej Samohel 2025-03-03 16:38:21 +01:00
commit 7d00dbf52c
No known key found for this signature in database
GPG key ID: 02376E18990A97C6
49 changed files with 4463 additions and 1013 deletions

View file

@ -8,7 +8,6 @@ from pathlib import Path
import warnings
import click
import acre
from ayon_core import AYON_CORE_ROOT
from ayon_core.addon import AddonsManager
@ -18,6 +17,11 @@ from ayon_core.lib import (
is_running_from_build,
Logger,
)
from ayon_core.lib.env_tools import (
parse_env_variables_structure,
compute_env_variables_structure,
merge_env_variables,
)
@ -235,19 +239,15 @@ def version(build):
def _set_global_environments() -> None:
"""Set global AYON environments."""
general_env = get_general_environments()
# First resolve general environment
general_env = parse_env_variables_structure(get_general_environments())
# first resolve general environment because merge doesn't expect
# values to be list.
# TODO: switch to AYON environment functions
merged_env = acre.merge(
acre.compute(acre.parse(general_env), cleanup=False),
# Merge environments with current environments and update values
merged_env = merge_env_variables(
compute_env_variables_structure(general_env),
dict(os.environ)
)
env = acre.compute(
merged_env,
cleanup=False
)
env = compute_env_variables_structure(merged_env)
os.environ.clear()
os.environ.update(env)
@ -263,8 +263,8 @@ def _set_addons_environments(addons_manager):
# Merge environments with current environments and update values
if module_envs := addons_manager.collect_global_environments():
parsed_envs = acre.parse(module_envs)
env = acre.merge(parsed_envs, dict(os.environ))
parsed_envs = parse_env_variables_structure(module_envs)
env = merge_env_variables(parsed_envs, dict(os.environ))
os.environ.clear()
os.environ.update(env)

View file

@ -1,7 +1,34 @@
from __future__ import annotations
import os
import re
import platform
import typing
import collections
from string import Formatter
from typing import Optional
if typing.TYPE_CHECKING:
from typing import Union, Literal
PlatformName = Literal["windows", "linux", "darwin"]
EnvValue = Union[str, list[str], dict[str, str], dict[str, list[str]]]
def env_value_to_bool(env_key=None, value=None, default=False):
class CycleError(ValueError):
"""Raised when a cycle is detected in dynamic env variables compute."""
pass
class DynamicKeyClashError(Exception):
"""Raised when dynamic key clashes with an existing key."""
pass
def env_value_to_bool(
env_key: Optional[str] = None,
value: Optional[str] = None,
default: bool = False,
) -> bool:
"""Convert environment variable value to boolean.
Function is based on value of the environemt variable. Value is lowered
@ -11,6 +38,7 @@ def env_value_to_bool(env_key=None, value=None, default=False):
bool: If value match to one of ["true", "yes", "1"] result if True
but if value match to ["false", "no", "0"] result is False else
default value is returned.
"""
if value is None and env_key is None:
return default
@ -27,18 +55,23 @@ def env_value_to_bool(env_key=None, value=None, default=False):
return default
def get_paths_from_environ(env_key=None, env_value=None, return_first=False):
def get_paths_from_environ(
env_key: Optional[str] = None,
env_value: Optional[str] = None,
return_first: bool = False,
) -> Optional[Union[str, list[str]]]:
"""Return existing paths from specific environment variable.
Args:
env_key (str): Environment key where should look for paths.
env_value (str): Value of environment variable. Argument `env_key` is
skipped if this argument is entered.
env_key (Optional[str]): Environment key where should look for paths.
env_value (Optional[str]): Value of environment variable.
Argument `env_key` is skipped if this argument is entered.
return_first (bool): Return first found value or return list of found
paths. `None` or empty list returned if nothing found.
Returns:
str, list, None: Result of found path/s.
Optional[Union[str, list[str]]]: Result of found path/s.
"""
existing_paths = []
if not env_key and not env_value:
@ -69,3 +102,225 @@ def get_paths_from_environ(env_key=None, env_value=None, return_first=False):
return None
# Return all existing paths from environment variable
return existing_paths
def parse_env_variables_structure(
env: dict[str, EnvValue],
platform_name: Optional[PlatformName] = None
) -> dict[str, str]:
"""Parse environment for platform-specific values and paths as lists.
Args:
env (dict): The source environment to read.
platform_name (Optional[PlatformName]): Name of platform to parse for.
Defaults to current platform.
Returns:
dict: The flattened environment for a platform.
"""
if platform_name is None:
platform_name = platform.system().lower()
# Separator based on OS 'os.pathsep' is ';' on Windows and ':' on Unix
sep = ";" if platform_name == "windows" else ":"
result = {}
for variable, value in env.items():
# Platform specific values
if isinstance(value, dict):
value = value.get(platform_name)
# Allow to have lists as values in the tool data
if isinstance(value, (list, tuple)):
value = sep.join(value)
if not value:
continue
if not isinstance(value, str):
raise TypeError(f"Expected 'str' got '{type(value)}'")
result[variable] = value
return result
def _topological_sort(
dependencies: dict[str, set[str]]
) -> tuple[list[str], list[str]]:
"""Sort values subject to dependency constraints.
Args:
dependencies (dict[str, set[str]): Mapping of environment variable
keys to a set of keys they depend on.
Returns:
tuple[list[str], list[str]]: A tuple of two lists. The first list
contains the ordered keys in which order should be environment
keys filled, the second list contains the keys that would cause
cyclic fill of values.
"""
num_heads = collections.defaultdict(int) # num arrows pointing in
tails = collections.defaultdict(list) # list of arrows going out
heads = [] # unique list of heads in order first seen
for head, tail_values in dependencies.items():
for tail_value in tail_values:
num_heads[tail_value] += 1
if head not in tails:
heads.append(head)
tails[head].append(tail_value)
ordered = [head for head in heads if head not in num_heads]
for head in ordered:
for tail in tails[head]:
num_heads[tail] -= 1
if not num_heads[tail]:
ordered.append(tail)
cyclic = [tail for tail, heads in num_heads.items() if heads]
return ordered, cyclic
class _PartialFormatDict(dict):
"""This supports partial formatting.
Missing keys are replaced with the return value of __missing__.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._missing_template: str = "{{{key}}}"
def set_missing_template(self, template: str):
self._missing_template = template
def __missing__(self, key: str) -> str:
return self._missing_template.format(key=key)
def _partial_format(
value: str,
data: dict[str, str],
missing_template: Optional[str] = None,
) -> str:
"""Return string `s` formatted by `data` allowing a partial format
Arguments:
value (str): The string that will be formatted
data (dict): The dictionary used to format with.
missing_template (Optional[str]): The template to use when a key is
missing from the data. If `None`, the key will remain unformatted.
Example:
>>> _partial_format("{d} {a} {b} {c} {d}", {'b': "and", 'd': "left"})
'left {a} and {c} left'
"""
mapping = _PartialFormatDict(**data)
if missing_template is not None:
mapping.set_missing_template(missing_template)
formatter = Formatter()
try:
output = formatter.vformat(value, (), mapping)
except Exception:
r_token = re.compile(r"({.*?})")
output = value
for match in re.findall(r_token, value):
try:
output = re.sub(match, match.format(**data), output)
except (KeyError, ValueError, IndexError):
continue
return output
def compute_env_variables_structure(
env: dict[str, str],
fill_dynamic_keys: bool = True,
) -> dict[str, str]:
"""Compute the result from recursive dynamic environment.
Note: Keys that are not present in the data will remain unformatted as the
original keys. So they can be formatted against the current user
environment when merging. So {"A": "{key}"} will remain {key} if not
present in the dynamic environment.
"""
env = env.copy()
# Collect dependencies
dependencies = collections.defaultdict(set)
for key, value in env.items():
dependent_keys = re.findall("{(.+?)}", value)
for dependent_key in dependent_keys:
# Ignore reference to itself or key is not in env
if dependent_key != key and dependent_key in env:
dependencies[key].add(dependent_key)
ordered, cyclic = _topological_sort(dependencies)
# Check cycle
if cyclic:
raise CycleError(f"A cycle is detected on: {cyclic}")
# Format dynamic values
for key in reversed(ordered):
if key in env:
if not isinstance(env[key], str):
continue
data = env.copy()
data.pop(key) # format without itself
env[key] = _partial_format(env[key], data=data)
# Format dynamic keys
if fill_dynamic_keys:
formatted = {}
for key, value in env.items():
if not isinstance(value, str):
formatted[key] = value
continue
new_key = _partial_format(key, data=env)
if new_key in formatted:
raise DynamicKeyClashError(
f"Key clashes on: {new_key} (source: {key})"
)
formatted[new_key] = value
env = formatted
return env
def merge_env_variables(
src_env: dict[str, str],
dst_env: dict[str, str],
missing_template: Optional[str] = None,
) -> dict[str, str]:
"""Merge the tools environment with the 'current_env'.
This finalizes the join with a current environment by formatting the
remainder of dynamic variables with that from the current environment.
Remaining missing variables result in an empty value.
Args:
src_env (dict): The dynamic environment
dst_env (dict): The target environment variables mapping to merge
the dynamic environment into.
missing_template (str): Argument passed to '_partial_format' during
merging. `None` should keep missing keys unchanged.
Returns:
dict[str, str]: The resulting environment after the merge.
"""
result = dst_env.copy()
for key, value in src_env.items():
result[key] = _partial_format(
str(value), dst_env, missing_template
)
return result

View file

@ -9,7 +9,7 @@ from datetime import datetime
from abc import ABC, abstractmethod
from functools import lru_cache
import appdirs
import platformdirs
import ayon_api
_PLACEHOLDER = object()
@ -17,7 +17,7 @@ _PLACEHOLDER = object()
def _get_ayon_appdirs(*args):
return os.path.join(
appdirs.user_data_dir("AYON", "Ynput"),
platformdirs.user_data_dir("AYON", "Ynput"),
*args
)

View file

@ -755,11 +755,19 @@ class CreateContext:
).format(creator_class.host_name, self.host_name))
continue
creator = creator_class(
project_settings,
self,
self.headless
)
# TODO report initialization error
try:
creator = creator_class(
project_settings,
self,
self.headless
)
except Exception:
self.log.error(
f"Failed to initialize plugin: {creator_class}",
exc_info=True
)
continue
if not creator.enabled:
disabled_creators[creator_identifier] = creator

View file

@ -184,10 +184,13 @@ to different packages based on their use:
| | Overscan | holds overscan/underscan information (added pixels to bottom/sides)
| | UDIM | Representation is UDIM tile set
Traits are [Pydantic models](https://docs.pydantic.dev/latest/) with optional
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. They can also implement pydantic form of
data validators.
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

View file

@ -1,13 +1,13 @@
"""Color management related traits."""
from __future__ import annotations
from dataclasses import dataclass
from typing import ClassVar, Optional
from pydantic import Field
from .trait import TraitBase
@dataclass
class ColorManaged(TraitBase):
"""Color managed trait.
@ -24,9 +24,7 @@ class ColorManaged(TraitBase):
id: ClassVar[str] = "ayon.color.ColorManaged.v1"
name: ClassVar[str] = "ColorManaged"
color_space: str
description: ClassVar[str] = "Color Managed trait."
color_space: str = Field(
...,
description="Color space."
)
config: Optional[str] = Field(default=None, description="Color config.")
persistent: ClassVar[bool] = True
config: Optional[str] = None

View file

@ -3,13 +3,12 @@ from __future__ import annotations
import contextlib
import re
from dataclasses import dataclass
# TC003 is there because Path in TYPECHECKING will fail in tests
from pathlib import Path # noqa: TC003
from typing import ClassVar, Generator, Optional
from pydantic import Field
from .representation import Representation
from .temporal import FrameRanged, Handles, Sequence
from .trait import (
@ -21,6 +20,7 @@ from .two_dimensional import UDIM
from .utils import get_sequence_from_files
@dataclass
class MimeType(TraitBase):
"""MimeType trait model.
@ -40,9 +40,11 @@ class MimeType(TraitBase):
name: ClassVar[str] = "MimeType"
description: ClassVar[str] = "MimeType Trait Model"
id: ClassVar[str] = "ayon.content.MimeType.v1"
mime_type: str = Field(..., title="Mime Type")
persistent: ClassVar[bool] = True
mime_type: str
@dataclass
class LocatableContent(TraitBase):
"""LocatableContent trait model.
@ -57,15 +59,18 @@ class LocatableContent(TraitBase):
description (str): Trait description.
id (str): id should be 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"
location: str = Field(..., title="Location")
is_templated: Optional[bool] = Field(default=None, title="Is Templated")
persistent: ClassVar[bool] = True
location: str
is_templated: Optional[bool] = None
@dataclass
class FileLocation(TraitBase):
"""FileLocation trait model.
@ -78,18 +83,20 @@ class FileLocation(TraitBase):
description (str): Trait description.
id (str): id should be namespaced trait name with version
file_path (str): File path.
file_size (int): File size in bytes.
file_hash (str): File hash.
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"
file_path: Path = Field(..., title="File Path")
file_size: Optional[int] = Field(default=None, title="File Size")
file_hash: Optional[str] = Field(default=None, title="File Hash")
persistent: ClassVar[bool] = True
file_path: Path
file_size: Optional[int] = None
file_hash: Optional[str] = None
@dataclass
class FileLocations(TraitBase):
"""FileLocation trait model.
@ -108,7 +115,8 @@ class FileLocations(TraitBase):
name: ClassVar[str] = "FileLocations"
description: ClassVar[str] = "FileLocations Trait Model"
id: ClassVar[str] = "ayon.content.FileLocations.v1"
file_paths: list[FileLocation] = Field(..., title="File Path")
persistent: ClassVar[bool] = True
file_paths: list[FileLocation]
def get_files(self) -> Generator[Path, None, None]:
"""Get all file paths from the trait.
@ -340,6 +348,7 @@ class FileLocations(TraitBase):
return frame_start_with_handles, frame_end_with_handles
@dataclass
class RootlessLocation(TraitBase):
"""RootlessLocation trait model.
@ -363,9 +372,11 @@ class RootlessLocation(TraitBase):
name: ClassVar[str] = "RootlessLocation"
description: ClassVar[str] = "RootlessLocation Trait Model"
id: ClassVar[str] = "ayon.content.RootlessLocation.v1"
rootless_path: str = Field(..., title="File Path")
persistent: ClassVar[bool] = True
rootless_path: str
@dataclass
class Compressed(TraitBase):
"""Compressed trait model.
@ -386,9 +397,11 @@ class Compressed(TraitBase):
name: ClassVar[str] = "Compressed"
description: ClassVar[str] = "Compressed Trait"
id: ClassVar[str] = "ayon.content.Compressed.v1"
compression_type: str = Field(..., title="Compression Type")
persistent: ClassVar[bool] = True
compression_type: str
@dataclass
class Bundle(TraitBase):
"""Bundle trait model.
@ -424,8 +437,8 @@ class Bundle(TraitBase):
name: ClassVar[str] = "Bundle"
description: ClassVar[str] = "Bundle Trait"
id: ClassVar[str] = "ayon.content.Bundle.v1"
items: list[list[TraitBase]] = Field(
..., title="Bundles of traits")
persistent: ClassVar[bool] = True
items: list[list[TraitBase]]
def to_representations(self) -> Generator[Representation]:
"""Convert bundle to representations.
@ -438,6 +451,7 @@ class Bundle(TraitBase):
yield Representation(name=f"{self.name} {idx}", traits=item)
@dataclass
class Fragment(TraitBase):
"""Fragment trait model.
@ -466,4 +480,5 @@ class Fragment(TraitBase):
name: ClassVar[str] = "Fragment"
description: ClassVar[str] = "Fragment Trait"
id: ClassVar[str] = "ayon.content.Fragment.v1"
parent: str = Field(..., title="Parent Representation Id")
persistent: ClassVar[bool] = True
parent: str

View file

@ -1,13 +1,13 @@
"""Cryptography traits."""
from __future__ import annotations
from dataclasses import dataclass
from typing import ClassVar, Optional
from pydantic import Field
from .trait import TraitBase
@dataclass
class DigitallySigned(TraitBase):
"""Digitally signed trait.
@ -20,25 +20,22 @@ class DigitallySigned(TraitBase):
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:
signature (str): PGP signature.
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."
signed_data: str = Field(
...,
description="Signed data."
)
clear_text: Optional[str] = Field(
None,
description="Clear text."
)
persistent: ClassVar[bool] = True
signed_data: str
clear_text: Optional[str] = None

View file

@ -1,9 +1,11 @@
"""Lifecycle traits."""
from dataclasses import dataclass
from typing import ClassVar
from .trait import TraitBase, TraitValidationError
@dataclass
class Transient(TraitBase):
"""Transient trait model.
@ -19,6 +21,7 @@ class Transient(TraitBase):
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.
@ -36,6 +39,7 @@ class Transient(TraitBase):
raise TraitValidationError(self.name, msg)
@dataclass
class Persistent(TraitBase):
"""Persistent trait model.
@ -52,6 +56,10 @@ class Persistent(TraitBase):
name: ClassVar[str] = "Persistent"
description: ClassVar[str] = "Persistent Trait Model"
id: ClassVar[str] = "ayon.lifecycle.Persistent.v1"
# note that this affects 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.

View file

@ -1,13 +1,13 @@
"""Metadata traits."""
from __future__ import annotations
from dataclasses import dataclass
from typing import ClassVar, List, Optional
from pydantic import Field
from .trait import TraitBase
@dataclass
class Tagged(TraitBase):
"""Tagged trait model.
@ -27,9 +27,11 @@ class Tagged(TraitBase):
name: ClassVar[str] = "Tagged"
description: ClassVar[str] = "Tagged Trait Model"
id: ClassVar[str] = "ayon.meta.Tagged.v1"
tags: List[str] = Field(..., title="Tags")
persistent: ClassVar[bool] = True
tags: List[str]
@dataclass
class TemplatePath(TraitBase):
"""TemplatePath trait model.
@ -51,10 +53,12 @@ class TemplatePath(TraitBase):
name: ClassVar[str] = "TemplatePath"
description: ClassVar[str] = "Template Path Trait Model"
id: ClassVar[str] = "ayon.meta.TemplatePath.v1"
template: str = Field(..., title="Template Path")
data: dict = Field(..., title="Formatting Data")
persistent: ClassVar[bool] = True
template: str
data: dict
@dataclass
class Variant(TraitBase):
"""Variant trait model.
@ -75,9 +79,11 @@ class Variant(TraitBase):
name: ClassVar[str] = "Variant"
description: ClassVar[str] = "Variant Trait Model"
id: ClassVar[str] = "ayon.meta.Variant.v1"
variant: str = Field(..., title="Variant")
persistent: ClassVar[bool] = True
variant: str
@dataclass
class KeepOriginalLocation(TraitBase):
"""Keep files in its original location.
@ -88,9 +94,10 @@ class KeepOriginalLocation(TraitBase):
name: ClassVar[str] = "KeepOriginalLocation"
description: ClassVar[str] = "Keep Original Location Trait Model"
id: ClassVar[str] = "ayon.meta.KeepOriginalLocation.v1"
persistent: bool = Field(default=False, title="Persistent")
persistent: ClassVar[bool] = False
@dataclass
class KeepOriginalName(TraitBase):
"""Keep files in its original name.
@ -101,9 +108,10 @@ class KeepOriginalName(TraitBase):
name: ClassVar[str] = "KeepOriginalName"
description: ClassVar[str] = "Keep Original Name Trait Model"
id: ClassVar[str] = "ayon.meta.KeepOriginalName.v1"
persistent: bool = Field(default=False, title="Persistent")
persistent: ClassVar[bool] = False
@dataclass
class SourceApplication(TraitBase):
"""Metadata about the source (producing) application.
@ -115,22 +123,26 @@ class SourceApplication(TraitBase):
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"
application: str = Field(..., title="Application Name")
variant: Optional[str] = Field(
None, title="Application Variant (e.g. Pro)")
version: Optional[str] = Field(
None, title="Application Version")
platform: Optional[str] = Field(
None, title="Platform Name (e.g. Windows)")
host_name: Optional[str] = Field(
None, title="AYON host Name if applicable")
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.
@ -138,9 +150,13 @@ class IntendedUse(TraitBase):
can be used in cases, where the other traits are not enough to
describe the intended use. For example txt file with tracking
points can be used as 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"
use: str = Field(..., title="Intended Use")
persistent: ClassVar[bool] = True
use: str

View file

@ -3,12 +3,12 @@ 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 pydantic import Field, field_validator
from .trait import MissingTraitError, TraitBase, TraitValidationError
@ -36,6 +36,7 @@ class GapPolicy(Enum):
black = auto()
@dataclass
class FrameRanged(TraitBase):
"""Frame ranged trait model.
@ -70,16 +71,16 @@ class FrameRanged(TraitBase):
name: ClassVar[str] = "FrameRanged"
description: ClassVar[str] = "Frame Ranged Trait"
id: ClassVar[str] = "ayon.time.FrameRanged.v1"
frame_start: int = Field(
..., title="Start Frame")
frame_end: int = Field(
..., title="Frame Start")
frame_in: Optional[int] = Field(default=None, title="In Frame")
frame_out: Optional[int] = Field(default=None, title="Out Frame")
frames_per_second: str = Field(..., title="Frames Per Second")
step: Optional[int] = Field(default=1, title="Step")
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.
@ -98,14 +99,13 @@ class Handles(TraitBase):
name: ClassVar[str] = "Handles"
description: ClassVar[str] = "Handles Trait"
id: ClassVar[str] = "ayon.time.Handles.v1"
inclusive: Optional[bool] = Field(
False, title="Handles are inclusive") # noqa: FBT003
frame_start_handle: Optional[int] = Field(
0, title="Frame Start Handle")
frame_end_handle: Optional[int] = Field(
0, title="Frame End Handle")
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.
@ -130,15 +130,12 @@ class Sequence(TraitBase):
name: ClassVar[str] = "Sequence"
description: ClassVar[str] = "Sequence Trait Model"
id: ClassVar[str] = "ayon.time.Sequence.v1"
gaps_policy: Optional[GapPolicy] = Field(
default=GapPolicy.forbidden, title="Gaps Policy")
frame_padding: int = Field(..., title="Frame Padding")
frame_regex: Optional[Pattern] = Field(
default=None, title="Frame Regex")
frame_spec: Optional[str] = Field(default=None,
title="Frame Specification")
persistent: ClassVar[bool] = True
frame_padding: int
gaps_policy: Optional[GapPolicy] = GapPolicy.forbidden
frame_regex: Optional[Pattern] = None
frame_spec: Optional[str] = None
@field_validator("frame_regex")
@classmethod
def validate_frame_regex(
cls, v: Optional[Pattern]
@ -432,15 +429,22 @@ class Sequence(TraitBase):
# Do we need one for drop and non-drop frame?
@dataclass
class SMPTETimecode(TraitBase):
"""SMPTE Timecode trait model."""
"""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"
timecode: str = Field(..., title="SMPTE Timecode HH:MM:SS:FF")
persistent: ClassVar[bool] = True
timecode: str
@dataclass
class Static(TraitBase):
"""Static time trait.
@ -450,3 +454,4 @@ class Static(TraitBase):
name: ClassVar[str] = "Static"
description: ClassVar[str] = "Static Time Trait"
id: ClassVar[str] = "ayon.time.Static.v1"
persistent: ClassVar[bool] = True

View file

@ -1,11 +1,11 @@
"""3D traits."""
from dataclasses import dataclass
from typing import ClassVar
from pydantic import Field
from .trait import TraitBase
@dataclass
class Spatial(TraitBase):
"""Spatial trait model.
@ -29,11 +29,13 @@ class Spatial(TraitBase):
id: ClassVar[str] = "ayon.3d.Spatial.v1"
name: ClassVar[str] = "Spatial"
description: ClassVar[str] = "Spatial trait model."
up_axis: str = Field(..., title="Up axis")
handedness: str = Field(..., title="Handedness")
meters_per_unit: float = Field(..., title="Meters per unit")
persistent: ClassVar[bool] = True
up_axis: str
handedness: str
meters_per_unit: float
@dataclass
class Geometry(TraitBase):
"""Geometry type trait model.
@ -45,8 +47,10 @@ class Geometry(TraitBase):
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.
@ -58,8 +62,10 @@ class Shader(TraitBase):
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.
@ -71,8 +77,10 @@ class Lighting(TraitBase):
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.
@ -82,3 +90,4 @@ class IESProfile(TraitBase):
id: ClassVar[str] = "ayon.3d.IESProfile.v1"
name: ClassVar[str] = "IESProfile"
description: ClassVar[str] = "IES profile trait model."
persistent: ClassVar[bool] = True

View file

@ -3,16 +3,9 @@ from __future__ import annotations
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Generic, Optional, TypeVar
import pydantic.alias_generators
from pydantic import (
AliasGenerator,
BaseModel,
ConfigDict,
Field,
)
if TYPE_CHECKING:
from .representation import Representation
@ -20,25 +13,15 @@ if TYPE_CHECKING:
T = TypeVar("T", bound="TraitBase")
class TraitBase(ABC, BaseModel):
@dataclass
class TraitBase(ABC):
"""Base trait model.
This model must be used as a base for all trait models.
It is using Pydantic BaseModel for serialization and validation.
``id``, ``name``, and ``description`` are abstract attributes that must be
implemented in the derived classes.
"""
model_config = ConfigDict(
alias_generator=AliasGenerator(
serialization_alias=pydantic.alias_generators.to_camel,
)
)
persistent: bool = Field(
default=True, title="Persistent",
description="Whether the trait is persistent (integrated) or not.")
@property
@abstractmethod
def id(self) -> str:

View file

@ -2,16 +2,16 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, ClassVar, Optional
from pydantic import Field, field_validator
from .trait import TraitBase
if TYPE_CHECKING:
from .content import FileLocation, FileLocations
@dataclass
class Image(TraitBase):
"""Image trait model.
@ -26,8 +26,10 @@ class Image(TraitBase):
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.
@ -45,11 +47,13 @@ class PixelBased(TraitBase):
name: ClassVar[str] = "PixelBased"
description: ClassVar[str] = "PixelBased Trait Model"
id: ClassVar[str] = "ayon.2d.PixelBased.v1"
display_window_width: int = Field(..., title="Display Window Width")
display_window_height: int = Field(..., title="Display Window Height")
pixel_aspect_ratio: float = Field(..., title="Pixel Aspect Ratio")
persistent: ClassVar[bool] = True
display_window_width: int
display_window_height: int
pixel_aspect_ratio: float
@dataclass
class Planar(TraitBase):
"""Planar trait model.
@ -57,7 +61,7 @@ class Planar(TraitBase):
Todo:
* (antirotor): Is this really a planar configuration? As with
bitplanes and everything? If it serves as differentiator for
bit planes and everything? If it serves as differentiator for
Deep images, should it be named differently? Like Raster?
Attributes:
@ -70,9 +74,11 @@ class Planar(TraitBase):
name: ClassVar[str] = "Planar"
description: ClassVar[str] = "Planar Trait Model"
id: ClassVar[str] = "ayon.2d.Planar.v1"
planar_configuration: str = Field(..., title="Planar-based Image")
persistent: ClassVar[bool] = True
planar_configuration: str
@dataclass
class Deep(TraitBase):
"""Deep trait model.
@ -87,8 +93,10 @@ class Deep(TraitBase):
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.
@ -108,12 +116,14 @@ class Overscan(TraitBase):
name: ClassVar[str] = "Overscan"
description: ClassVar[str] = "Overscan Trait"
id: ClassVar[str] = "ayon.2d.Overscan.v1"
left: int = Field(..., title="Left Overscan")
right: int = Field(..., title="Right Overscan")
top: int = Field(..., title="Top Overscan")
bottom: int = Field(..., title="Bottom Overscan")
persistent: ClassVar[bool] = True
left: int
right: int
top: int
bottom: int
@dataclass
class UDIM(TraitBase):
"""UDIM trait model.
@ -124,16 +134,18 @@ class UDIM(TraitBase):
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"
udim: list[int] = Field(..., title="UDIM")
udim_regex: Optional[str] = Field(
default=r"(?:\.|_)(?P<udim>\d+)\.\D+\d?$", title="UDIM Regex")
persistent: ClassVar[bool] = True
udim: list[int]
udim_regex: Optional[str] = r"(?:\.|_)(?P<udim>\d+)\.\D+\d?$"
@field_validator("udim_regex")
# field validator for udim_regex - this works in pydantic model v2 but not
# with the pure data classes
@classmethod
def validate_frame_regex(cls, v: Optional[str]) -> Optional[str]:
"""Validate udim regex.

View file

@ -54,6 +54,7 @@ from ayon_core.pipeline.plugin_discover import (
from ayon_core.pipeline.create import (
discover_legacy_creator_plugins,
CreateContext,
HiddenCreator,
)
_NOT_SET = object()
@ -309,7 +310,13 @@ class AbstractTemplateBuilder(ABC):
self._creators_by_name = creators_by_name
def _collect_creators(self):
self._creators_by_name = dict(self.create_context.creators)
self._creators_by_name = {
identifier: creator
for identifier, creator
in self.create_context.manual_creators.items()
# Do not list HiddenCreator even though it is a 'manual creator'
if not isinstance(creator, HiddenCreator)
}
def get_creators_by_name(self):
if self._creators_by_name is None:

View file

@ -116,11 +116,11 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
if not_found_folder_paths:
joined_folder_paths = ", ".join(
["\"{}\"".format(path) for path in not_found_folder_paths]
[f"\"{path}\"" for path in not_found_folder_paths]
)
self.log.warning(
f"Not found folder entities with paths {joined_folder_paths}."
)
self.log.warning((
"Not found folder entities with paths \"{}\"."
).format(joined_folder_paths))
def fill_missing_task_entities(self, context, project_name):
self.log.debug("Querying task entities for instances.")

View file

@ -1,78 +1,120 @@
"""Plugin for collecting OTIO frame ranges and related timing information.
This module contains a unified plugin that handles:
- Basic timeline frame ranges
- Source media frame ranges
- Retimed clip frame ranges
"""
Requires:
otioTimeline -> context data attribute
review -> instance data attribute
masterLayer -> instance data attribute
otioClipRange -> instance data attribute
"""
from pprint import pformat
import opentimelineio as otio
import pyblish.api
from ayon_core.pipeline.editorial import (
get_media_range_with_retimes,
otio_range_to_frame_range,
otio_range_with_handles,
)
class CollectOtioFrameRanges(pyblish.api.InstancePlugin):
"""Getting otio ranges from otio_clip
def validate_otio_clip(instance, logger):
"""Validate if instance has required OTIO clip data.
Adding timeline and source ranges to instance data"""
Args:
instance: The instance to validate
logger: Logger object to use for debug messages
label = "Collect OTIO Frame Ranges"
Returns:
bool: True if valid, False otherwise
"""
if not instance.data.get("otioClip"):
logger.debug("Skipping collect OTIO range - no clip found.")
return False
return True
class CollectOtioRanges(pyblish.api.InstancePlugin):
"""Collect all OTIO-related frame ranges and timing information.
This plugin handles collection of:
- Basic timeline frame ranges with handles
- Source media frame ranges with handles
- Retimed clip frame ranges
Requires:
otioClip (otio.schema.Clip): OTIO clip object
workfileFrameStart (int): Starting frame of work file
Optional:
shotDurationFromSource (int): Duration from source if retimed
Provides:
frameStart (int): Start frame in timeline
frameEnd (int): End frame in timeline
clipIn (int): Clip in point
clipOut (int): Clip out point
clipInH (int): Clip in point with handles
clipOutH (int): Clip out point with handles
sourceStart (int): Source media start frame
sourceEnd (int): Source media end frame
sourceStartH (int): Source media start frame with handles
sourceEndH (int): Source media end frame with handles
"""
label = "Collect OTIO Ranges"
order = pyblish.api.CollectorOrder - 0.08
families = ["shot", "clip"]
hosts = ["resolve", "hiero", "flame", "traypublisher"]
def process(self, instance):
# Not all hosts can import these modules.
import opentimelineio as otio
from ayon_core.pipeline.editorial import (
get_media_range_with_retimes,
otio_range_to_frame_range,
otio_range_with_handles
)
"""Process the instance to collect all frame ranges.
if not instance.data.get("otioClip"):
self.log.debug("Skipping collect OTIO frame range.")
Args:
instance: The instance to process
"""
if not validate_otio_clip(instance, self.log):
return
# get basic variables
otio_clip = instance.data["otioClip"]
# Collect timeline ranges if workfile start frame is available
if "workfileFrameStart" in instance.data:
self._collect_timeline_ranges(instance, otio_clip)
# Traypublisher Simple or Advanced editorial publishing is
# working with otio clips which are having no available range
# because they are not having any media references.
try:
otio_clip.available_range()
has_available_range = True
except otio._otio.CannotComputeAvailableRangeError:
self.log.info("Clip has no available range")
has_available_range = False
# Collect source ranges if clip has available range
if has_available_range:
self._collect_source_ranges(instance, otio_clip)
# Handle retimed ranges if source duration is available
if "shotDurationFromSource" in instance.data:
self._collect_retimed_ranges(instance, otio_clip)
def _collect_timeline_ranges(self, instance, otio_clip):
"""Collect basic timeline frame ranges."""
workfile_start = instance.data["workfileFrameStart"]
workfile_source_duration = instance.data.get("shotDurationFromSource")
# get ranges
# Get timeline ranges
otio_tl_range = otio_clip.range_in_parent()
otio_src_range = otio_clip.source_range
otio_avalable_range = otio_clip.available_range()
otio_tl_range_handles = otio_range_with_handles(
otio_tl_range, instance)
otio_src_range_handles = otio_range_with_handles(
otio_src_range, instance)
otio_tl_range,
instance
)
# get source avalable start frame
src_starting_from = otio.opentime.to_frames(
otio_avalable_range.start_time,
otio_avalable_range.start_time.rate)
# Convert to frames
tl_start, tl_end = otio_range_to_frame_range(otio_tl_range)
tl_start_h, tl_end_h = otio_range_to_frame_range(otio_tl_range_handles)
# convert to frames
range_convert = otio_range_to_frame_range
tl_start, tl_end = range_convert(otio_tl_range)
tl_start_h, tl_end_h = range_convert(otio_tl_range_handles)
src_start, src_end = range_convert(otio_src_range)
src_start_h, src_end_h = range_convert(otio_src_range_handles)
frame_start = workfile_start
frame_end = frame_start + otio.opentime.to_frames(
otio_tl_range.duration, otio_tl_range.duration.rate) - 1
# in case of retimed clip and frame range should not be retimed
if workfile_source_duration:
# get available range trimmed with processed retimes
retimed_attributes = get_media_range_with_retimes(
otio_clip, 0, 0)
self.log.debug(
">> retimed_attributes: {}".format(retimed_attributes))
media_in = int(retimed_attributes["mediaIn"])
media_out = int(retimed_attributes["mediaOut"])
frame_end = frame_start + (media_out - media_in) + 1
self.log.debug(frame_end)
frame_end = frame_start + otio_tl_range.duration.to_frames() - 1
data = {
"frameStart": frame_start,
@ -81,13 +123,77 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin):
"clipOut": tl_end - 1,
"clipInH": tl_start_h,
"clipOutH": tl_end_h - 1,
"sourceStart": src_starting_from + src_start,
"sourceEnd": src_starting_from + src_end - 1,
"sourceStartH": src_starting_from + src_start_h,
"sourceEndH": src_starting_from + src_end_h - 1,
}
instance.data.update(data)
self.log.debug(
"_ data: {}".format(pformat(data)))
self.log.debug(
"_ instance.data: {}".format(pformat(instance.data)))
self.log.debug(f"Added frame ranges: {pformat(data)}")
def _collect_source_ranges(self, instance, otio_clip):
"""Collect source media frame ranges."""
# Get source ranges
otio_src_range = otio_clip.source_range
otio_available_range = otio_clip.available_range()
# Backward-compatibility for Hiero OTIO exporter.
# NTSC compatibility might introduce floating rates, when these are
# not exactly the same (23.976 vs 23.976024627685547)
# this will cause precision issue in computation.
# Currently round to 2 decimals for comparison,
# but this should always rescale after that.
rounded_av_rate = round(otio_available_range.start_time.rate, 2)
rounded_src_rate = round(otio_src_range.start_time.rate, 2)
if rounded_av_rate != rounded_src_rate:
conformed_src_in = otio_src_range.start_time.rescaled_to(
otio_available_range.start_time.rate
)
conformed_src_duration = otio_src_range.duration.rescaled_to(
otio_available_range.duration.rate
)
conformed_source_range = otio.opentime.TimeRange(
start_time=conformed_src_in,
duration=conformed_src_duration
)
else:
conformed_source_range = otio_src_range
source_start = conformed_source_range.start_time
source_end = source_start + conformed_source_range.duration
handle_start = otio.opentime.RationalTime(
instance.data.get("handleStart", 0),
source_start.rate
)
handle_end = otio.opentime.RationalTime(
instance.data.get("handleEnd", 0),
source_start.rate
)
source_start_h = source_start - handle_start
source_end_h = source_end + handle_end
data = {
"sourceStart": source_start.to_frames(),
"sourceEnd": source_end.to_frames() - 1,
"sourceStartH": source_start_h.to_frames(),
"sourceEndH": source_end_h.to_frames() - 1,
}
instance.data.update(data)
self.log.debug(f"Added source ranges: {pformat(data)}")
def _collect_retimed_ranges(self, instance, otio_clip):
"""Handle retimed clip frame ranges."""
retimed_attributes = get_media_range_with_retimes(otio_clip, 0, 0)
self.log.debug(f"Retimed attributes: {retimed_attributes}")
frame_start = instance.data["frameStart"]
media_in = int(retimed_attributes["mediaIn"])
media_out = int(retimed_attributes["mediaOut"])
frame_end = frame_start + (media_out - media_in)
data = {
"frameStart": frame_start,
"frameEnd": frame_end,
"sourceStart": media_in,
"sourceEnd": media_out,
"sourceStartH": media_in - int(retimed_attributes["handleStart"]),
"sourceEndH": media_out + int(retimed_attributes["handleEnd"]),
}
instance.data.update(data)
self.log.debug(f"Updated retimed values: {data}")

View file

@ -286,7 +286,7 @@ class ExtractOTIOReview(
)
instance.data["representations"].append(representation)
self.log.info("Adding representation: {}".format(representation))
self.log.debug("Adding representation: {}".format(representation))
def _create_representation(self, start, duration):
"""

View file

@ -1,7 +1,7 @@
from operator import attrgetter
import dataclasses
import os
from typing import Dict
from typing import Any, Dict, List
import pyblish.api
try:
@ -14,7 +14,8 @@ from ayon_core.lib import (
BoolDef,
UISeparatorDef,
UILabelDef,
EnumDef
EnumDef,
filter_profiles
)
try:
from ayon_core.pipeline.usdlib import (
@ -281,6 +282,9 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"fx": 500,
"lighting": 600,
}
# Default profiles to set certain instance attribute defaults based on
# profiles in settings
profiles: List[Dict[str, Any]] = []
@classmethod
def apply_settings(cls, project_settings):
@ -298,6 +302,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
if contribution_layers:
cls.contribution_layers = contribution_layers
cls.profiles = plugin_settings.get("profiles", [])
def process(self, instance):
attr_values = self.get_attr_values_from_data(instance.data)
@ -463,6 +469,28 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
if not cls.instance_matches_plugin_families(instance):
return []
# Set default target layer based on product type
current_context_task_type = create_context.get_current_task_type()
profile = filter_profiles(cls.profiles, {
"product_types": instance.data["productType"],
"task_types": current_context_task_type
})
if not profile:
profile = {}
# Define defaults
default_contribution_layer = profile.get(
"contribution_layer", None)
default_apply_as_variant = profile.get(
"contribution_apply_as_variant", False)
default_target_product = profile.get(
"contribution_target_product", "usdAsset")
default_init_as = (
"asset"
if profile.get("contribution_target_product") == "usdAsset"
else "shot")
init_as_visible = False
# Attributes logic
publish_attributes = instance["publish_attributes"].get(
cls.__name__, {})
@ -495,7 +523,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"the contribution itself will be added to the "
"department layer."
),
default="usdAsset",
default=default_target_product,
visible=visible),
EnumDef("contribution_target_product_init",
label="Initialize as",
@ -507,8 +535,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"setting will do nothing."
),
items=["asset", "shot"],
default="asset",
visible=visible),
default=default_init_as,
visible=visible and init_as_visible),
# Asset layer, e.g. model.usd, look.usd, rig.usd
EnumDef("contribution_layer",
@ -520,7 +548,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"the list) will contribute as a stronger opinion."
),
items=list(cls.contribution_layers.keys()),
default="model",
default=default_contribution_layer,
visible=visible),
BoolDef("contribution_apply_as_variant",
label="Add as variant",
@ -532,7 +560,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"appended to as a sublayer to the department layer "
"instead."
),
default=True,
default=default_apply_as_variant,
visible=visible),
TextDef("contribution_variant_set_name",
label="Variant Set Name",
@ -588,31 +616,6 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
instance.set_publish_plugin_attr_defs(cls.__name__, new_attrs)
class CollectUSDLayerContributionsHoudiniLook(CollectUSDLayerContributions):
"""
This is solely here to expose the attribute definitions for the
Houdini "look" family.
"""
# TODO: Improve how this is built for the look family
hosts = ["houdini"]
families = ["look"]
label = CollectUSDLayerContributions.label + " (Look)"
@classmethod
def get_attr_defs_for_instance(cls, create_context, instance):
# Filtering of instance, if needed, can be customized
if not cls.instance_matches_plugin_families(instance):
return []
defs = super().get_attr_defs_for_instance(create_context, instance)
# Update default for department layer to look
layer_def = next(d for d in defs if d.key == "contribution_layer")
layer_def.default = "look"
return defs
class ValidateUSDDependencies(pyblish.api.InstancePlugin):
families = ["usdLayer"]

View file

@ -108,6 +108,7 @@ class VersionItem:
version (int): Version. Can be negative when is hero version.
is_hero (bool): Is hero version.
product_id (str): Product id.
task_id (Union[str, None]): Task id.
thumbnail_id (Union[str, None]): Thumbnail id.
published_time (Union[str, None]): Published time in format
'%Y%m%dT%H%M%SZ'.
@ -127,6 +128,7 @@ class VersionItem:
version,
is_hero,
product_id,
task_id,
thumbnail_id,
published_time,
author,
@ -140,6 +142,7 @@ class VersionItem:
):
self.version_id = version_id
self.product_id = product_id
self.task_id = task_id
self.thumbnail_id = thumbnail_id
self.version = version
self.is_hero = is_hero
@ -161,6 +164,7 @@ class VersionItem:
and self.version == other.version
and self.version_id == other.version_id
and self.product_id == other.product_id
and self.task_id == other.task_id
)
def __ne__(self, other):
@ -198,6 +202,7 @@ class VersionItem:
return {
"version_id": self.version_id,
"product_id": self.product_id,
"task_id": self.task_id,
"thumbnail_id": self.thumbnail_id,
"version": self.version,
"is_hero": self.is_hero,
@ -536,6 +541,55 @@ class FrontendLoaderController(_BaseLoaderController):
"""
pass
@abstractmethod
def get_task_items(self, project_name, folder_ids, sender=None):
"""Task items for folder ids.
Args:
project_name (str): Project name.
folder_ids (Iterable[str]): Folder ids.
sender (Optional[str]): Sender who requested the items.
Returns:
list[TaskItem]: List of task items.
"""
pass
@abstractmethod
def get_task_type_items(self, project_name, sender=None):
"""Task type items for a project.
This function may trigger events with topics
'projects.task_types.refresh.started' and
'projects.task_types.refresh.finished' which will contain 'sender'
value in data.
That may help to avoid re-refresh of items in UI elements.
Args:
project_name (str): Project name.
sender (str): Who requested task type items.
Returns:
list[TaskTypeItem]: Task type information.
"""
pass
@abstractmethod
def get_folder_labels(self, project_name, folder_ids):
"""Get folder labels for folder ids.
Args:
project_name (str): Project name.
folder_ids (Iterable[str]): Folder ids.
Returns:
dict[str, Optional[str]]: Folder labels by folder id.
"""
pass
@abstractmethod
def get_project_status_items(self, project_name, sender=None):
"""Items for all projects available on server.
@ -717,8 +771,30 @@ class FrontendLoaderController(_BaseLoaderController):
Returns:
list[str]: Selected folder ids.
"""
"""
pass
@abstractmethod
def get_selected_task_ids(self):
"""Get selected task ids.
The information is based on last selection from UI.
Returns:
list[str]: Selected folder ids.
"""
pass
@abstractmethod
def set_selected_tasks(self, task_ids):
"""Set selected tasks.
Args:
task_ids (Iterable[str]): Selected task ids.
"""
pass
@abstractmethod
@ -729,8 +805,8 @@ class FrontendLoaderController(_BaseLoaderController):
Returns:
list[str]: Selected version ids.
"""
"""
pass
@abstractmethod

View file

@ -198,6 +198,31 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
def get_folder_items(self, project_name, sender=None):
return self._hierarchy_model.get_folder_items(project_name, sender)
def get_task_items(self, project_name, folder_ids, sender=None):
output = []
for folder_id in folder_ids:
output.extend(self._hierarchy_model.get_task_items(
project_name, folder_id, sender
))
return output
def get_task_type_items(self, project_name, sender=None):
return self._projects_model.get_task_type_items(
project_name, sender
)
def get_folder_labels(self, project_name, folder_ids):
folder_items_by_id = self._hierarchy_model.get_folder_items_by_id(
project_name, folder_ids
)
output = {}
for folder_id, folder_item in folder_items_by_id.items():
label = None
if folder_item is not None:
label = folder_item.label
output[folder_id] = label
return output
def get_product_items(self, project_name, folder_ids, sender=None):
return self._products_model.get_product_items(
project_name, folder_ids, sender)
@ -299,6 +324,12 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
def set_selected_folders(self, folder_ids):
self._selection_model.set_selected_folders(folder_ids)
def get_selected_task_ids(self):
return self._selection_model.get_selected_task_ids()
def set_selected_tasks(self, task_ids):
self._selection_model.set_selected_tasks(task_ids)
def get_selected_version_ids(self):
return self._selection_model.get_selected_version_ids()

View file

@ -55,6 +55,7 @@ def version_item_from_entity(version):
version=version_num,
is_hero=is_hero,
product_id=version["productId"],
task_id=version["taskId"],
thumbnail_id=version["thumbnailId"],
published_time=published_time,
author=author,

View file

@ -14,6 +14,7 @@ class SelectionModel(object):
self._project_name = None
self._folder_ids = set()
self._task_ids = set()
self._version_ids = set()
self._representation_ids = set()
@ -48,6 +49,23 @@ class SelectionModel(object):
self.event_source
)
def get_selected_task_ids(self):
return self._task_ids
def set_selected_tasks(self, task_ids):
if task_ids == self._task_ids:
return
self._task_ids = task_ids
self._controller.emit_event(
"selection.tasks.changed",
{
"project_name": self._project_name,
"task_ids": task_ids,
},
self.event_source
)
def get_selected_version_ids(self):
return self._version_ids

View file

@ -1,7 +1,10 @@
from __future__ import annotations
import typing
from typing import List, Tuple, Optional, Iterable, Any
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.tools.utils import get_qt_icon
from ayon_core.tools.utils.lib import (
checkstate_int_to_enum,
checkstate_enum_to_int,
@ -11,14 +14,269 @@ from ayon_core.tools.utils.constants import (
UNCHECKED_INT,
ITEM_IS_USER_TRISTATE,
)
if typing.TYPE_CHECKING:
from ayon_core.tools.loader.abstract import FrontendLoaderController
VALUE_ITEM_TYPE = 0
STANDARD_ITEM_TYPE = 1
SEPARATOR_ITEM_TYPE = 2
VALUE_ITEM_SUBTYPE = 0
SELECT_ALL_SUBTYPE = 1
DESELECT_ALL_SUBTYPE = 2
SWAP_STATE_SUBTYPE = 3
class BaseQtModel(QtGui.QStandardItemModel):
_empty_icon = None
def __init__(
self,
item_type_role: int,
item_subtype_role: int,
empty_values_label: str,
controller: FrontendLoaderController,
):
self._item_type_role = item_type_role
self._item_subtype_role = item_subtype_role
self._empty_values_label = empty_values_label
self._controller = controller
self._last_project = None
self._select_project_item = None
self._empty_values_item = None
self._select_all_item = None
self._deselect_all_item = None
self._swap_states_item = None
super().__init__()
self.refresh(None)
def _get_standard_items(self) -> list[QtGui.QStandardItem]:
raise NotImplementedError(
"'_get_standard_items' is not implemented"
f" for {self.__class__}"
)
def _clear_standard_items(self):
raise NotImplementedError(
"'_clear_standard_items' is not implemented"
f" for {self.__class__}"
)
def _prepare_new_value_items(
self, project_name: str, project_changed: bool
) -> tuple[
list[QtGui.QStandardItem], list[QtGui.QStandardItem]
]:
raise NotImplementedError(
"'_prepare_new_value_items' is not implemented"
f" for {self.__class__}"
)
def refresh(self, project_name: Optional[str]):
# New project was selected
project_changed = False
if project_name != self._last_project:
self._last_project = project_name
project_changed = True
if project_name is None:
self._add_select_project_item()
return
value_items, items_to_remove = self._prepare_new_value_items(
project_name, project_changed
)
if not value_items:
self._add_empty_values_item()
return
self._remove_empty_items()
root_item = self.invisibleRootItem()
for row_idx, value_item in enumerate(value_items):
if value_item.row() == row_idx:
continue
if value_item.row() >= 0:
root_item.takeRow(value_item.row())
root_item.insertRow(row_idx, value_item)
for item in items_to_remove:
root_item.removeRow(item.row())
self._add_selection_items()
def setData(self, index, value, role):
if role == QtCore.Qt.CheckStateRole and index.isValid():
item_subtype = index.data(self._item_subtype_role)
if item_subtype == SELECT_ALL_SUBTYPE:
for item in self._get_standard_items():
item.setCheckState(QtCore.Qt.Checked)
return True
if item_subtype == DESELECT_ALL_SUBTYPE:
for item in self._get_standard_items():
item.setCheckState(QtCore.Qt.Unchecked)
return True
if item_subtype == SWAP_STATE_SUBTYPE:
for item in self._get_standard_items():
current_state = item.checkState()
item.setCheckState(
QtCore.Qt.Checked
if current_state == QtCore.Qt.Unchecked
else QtCore.Qt.Unchecked
)
return True
return super().setData(index, value, role)
@classmethod
def _get_empty_icon(cls):
if cls._empty_icon is None:
pix = QtGui.QPixmap(1, 1)
pix.fill(QtCore.Qt.transparent)
cls._empty_icon = QtGui.QIcon(pix)
return cls._empty_icon
def _init_default_items(self):
if self._empty_values_item is not None:
return
empty_values_item = QtGui.QStandardItem(self._empty_values_label)
select_project_item = QtGui.QStandardItem("Select project...")
select_all_item = QtGui.QStandardItem("Select all")
deselect_all_item = QtGui.QStandardItem("Deselect all")
swap_states_item = QtGui.QStandardItem("Swap")
for item in (
empty_values_item,
select_project_item,
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setData(STANDARD_ITEM_TYPE, self._item_type_role)
select_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "done_all",
"color": "white"
}))
deselect_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "remove_done",
"color": "white"
}))
swap_states_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "swap_horiz",
"color": "white"
}))
for item in (
empty_values_item,
select_project_item,
):
item.setFlags(QtCore.Qt.NoItemFlags)
for item, item_type in (
(select_all_item, SELECT_ALL_SUBTYPE),
(deselect_all_item, DESELECT_ALL_SUBTYPE),
(swap_states_item, SWAP_STATE_SUBTYPE),
):
item.setData(item_type, self._item_subtype_role)
for item in (
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setFlags(
QtCore.Qt.ItemIsEnabled
| QtCore.Qt.ItemIsSelectable
| QtCore.Qt.ItemIsUserCheckable
)
self._empty_values_item = empty_values_item
self._select_project_item = select_project_item
self._select_all_item = select_all_item
self._deselect_all_item = deselect_all_item
self._swap_states_item = swap_states_item
def _get_empty_values_item(self):
self._init_default_items()
return self._empty_values_item
def _get_select_project_item(self):
self._init_default_items()
return self._select_project_item
def _get_empty_items(self):
self._init_default_items()
return [
self._empty_values_item,
self._select_project_item,
]
def _get_selection_items(self):
self._init_default_items()
return [
self._select_all_item,
self._deselect_all_item,
self._swap_states_item,
]
def _get_default_items(self):
return self._get_empty_items() + self._get_selection_items()
def _add_select_project_item(self):
item = self._get_select_project_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_empty_values_item(self):
item = self._get_empty_values_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_selection_items(self):
root_item = self.invisibleRootItem()
items = self._get_selection_items()
for item in self._get_selection_items():
row = item.row()
if row >= 0:
root_item.takeRow(row)
root_item.appendRows(items)
def _remove_items(self):
root_item = self.invisibleRootItem()
for item in self._get_default_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
root_item.removeRows(0, root_item.rowCount())
self._clear_standard_items()
def _remove_empty_items(self):
root_item = self.invisibleRootItem()
for item in self._get_empty_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
class CustomPaintDelegate(QtWidgets.QStyledItemDelegate):
"""Delegate showing status name and short name."""
_empty_icon = None
_checked_value = checkstate_enum_to_int(QtCore.Qt.Checked)
_checked_bg_color = QtGui.QColor("#2C3B4C")
@ -38,6 +296,14 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate):
self._icon_role = icon_role
self._item_type_role = item_type_role
@classmethod
def _get_empty_icon(cls):
if cls._empty_icon is None:
pix = QtGui.QPixmap(1, 1)
pix.fill(QtCore.Qt.transparent)
cls._empty_icon = QtGui.QIcon(pix)
return cls._empty_icon
def paint(self, painter, option, index):
item_type = None
if self._item_type_role is not None:
@ -70,6 +336,9 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate):
if option.state & QtWidgets.QStyle.State_Open:
state = QtGui.QIcon.On
icon = self._get_index_icon(index)
if icon is None or icon.isNull():
icon = self._get_empty_icon()
option.features |= QtWidgets.QStyleOptionViewItem.HasDecoration
# Disable visible check indicator
@ -241,6 +510,10 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
QtCore.Qt.Key_Home,
QtCore.Qt.Key_End,
}
_top_bottom_margins = 1
_top_bottom_padding = 2
_left_right_padding = 3
_item_bg_color = QtGui.QColor("#31424e")
def __init__(
self,
@ -433,14 +706,14 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
idxs = self._get_checked_idx()
# draw the icon and text
draw_text = True
draw_items = False
combotext = None
if self._custom_text is not None:
combotext = self._custom_text
elif not idxs:
combotext = self._placeholder_text
else:
draw_text = False
draw_items = True
content_field_rect = self.style().subControlRect(
QtWidgets.QStyle.CC_ComboBox,
@ -448,7 +721,9 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
QtWidgets.QStyle.SC_ComboBoxEditField
).adjusted(1, 0, -1, 0)
if draw_text:
if draw_items:
self._paint_items(painter, idxs, content_field_rect)
else:
color = option.palette.color(QtGui.QPalette.Text)
color.setAlpha(67)
pen = painter.pen()
@ -459,15 +734,12 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
combotext
)
else:
self._paint_items(painter, idxs, content_field_rect)
painter.end()
def _paint_items(self, painter, indexes, content_rect):
origin_rect = QtCore.QRect(content_rect)
metrics = self.fontMetrics()
model = self.model()
available_width = content_rect.width()
total_used_width = 0
@ -482,31 +754,80 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
continue
icon = index.data(self._icon_role)
# TODO handle this case
if icon is None or icon.isNull():
continue
text = index.data(self._text_role)
valid_icon = icon is not None and not icon.isNull()
if valid_icon:
sizes = icon.availableSizes()
if sizes:
valid_icon = any(size.width() > 1 for size in sizes)
icon_rect = QtCore.QRect(content_rect)
diff = icon_rect.height() - metrics.height()
if diff < 0:
diff = 0
top_offset = diff // 2
bottom_offset = diff - top_offset
icon_rect.adjust(0, top_offset, 0, -bottom_offset)
icon_rect.setWidth(metrics.height())
icon.paint(
painter,
icon_rect,
QtCore.Qt.AlignCenter,
QtGui.QIcon.Normal,
QtGui.QIcon.On
)
content_rect.setLeft(icon_rect.right() + spacing)
if total_used_width > 0:
total_used_width += spacing
total_used_width += icon_rect.width()
if total_used_width > available_width:
break
if valid_icon:
metrics = self.fontMetrics()
icon_rect = QtCore.QRect(content_rect)
diff = icon_rect.height() - metrics.height()
if diff < 0:
diff = 0
top_offset = diff // 2
bottom_offset = diff - top_offset
icon_rect.adjust(0, top_offset, 0, -bottom_offset)
used_width = metrics.height()
if total_used_width > 0:
total_used_width += spacing
total_used_width += used_width
if total_used_width > available_width:
break
icon_rect.setWidth(used_width)
icon.paint(
painter,
icon_rect,
QtCore.Qt.AlignCenter,
QtGui.QIcon.Normal,
QtGui.QIcon.On
)
content_rect.setLeft(icon_rect.right() + spacing)
elif text:
bg_height = (
content_rect.height()
- (2 * self._top_bottom_margins)
)
font_height = bg_height - (2 * self._top_bottom_padding)
bg_top_y = content_rect.y() + self._top_bottom_margins
font = self.font()
font.setPixelSize(font_height)
metrics = QtGui.QFontMetrics(font)
painter.setFont(font)
label_rect = metrics.boundingRect(text)
bg_width = label_rect.width() + (2 * self._left_right_padding)
if total_used_width > 0:
total_used_width += spacing
total_used_width += bg_width
if total_used_width > available_width:
break
bg_rect = QtCore.QRectF(label_rect)
bg_rect.moveTop(bg_top_y)
bg_rect.moveLeft(content_rect.left())
bg_rect.setWidth(bg_width)
bg_rect.setHeight(bg_height)
label_rect.moveTop(bg_top_y)
label_rect.moveLeft(
content_rect.left() + self._left_right_padding
)
path = QtGui.QPainterPath()
path.addRoundedRect(bg_rect, 5, 5)
painter.fillPath(path, self._item_bg_color)
painter.drawText(label_rect, QtCore.Qt.AlignCenter, text)
content_rect.setLeft(bg_rect.right() + spacing)
painter.restore()

View file

@ -0,0 +1,169 @@
from qtpy import QtGui, QtCore
from ._multicombobox import (
CustomPaintMultiselectComboBox,
BaseQtModel,
)
STATUS_ITEM_TYPE = 0
SELECT_ALL_TYPE = 1
DESELECT_ALL_TYPE = 2
SWAP_STATE_TYPE = 3
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1
ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 2
ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 3
class ProductTypesQtModel(BaseQtModel):
refreshed = QtCore.Signal()
def __init__(self, controller):
self._reset_filters_on_refresh = True
self._refreshing = False
self._bulk_change = False
self._items_by_name = {}
super().__init__(
item_type_role=ITEM_TYPE_ROLE,
item_subtype_role=ITEM_SUBTYPE_ROLE,
empty_values_label="No product types...",
controller=controller,
)
def is_refreshing(self):
return self._refreshing
def refresh(self, project_name):
self._refreshing = True
super().refresh(project_name)
self._reset_filters_on_refresh = False
self._refreshing = False
self.refreshed.emit()
def reset_product_types_filter_on_refresh(self):
self._reset_filters_on_refresh = True
def _get_standard_items(self) -> list[QtGui.QStandardItem]:
return list(self._items_by_name.values())
def _clear_standard_items(self):
self._items_by_name.clear()
def _prepare_new_value_items(self, project_name: str, _: bool) -> tuple[
list[QtGui.QStandardItem], list[QtGui.QStandardItem]
]:
product_type_items = self._controller.get_product_type_items(
project_name)
self._last_project = project_name
names_to_remove = set(self._items_by_name.keys())
items = []
items_filter_required = {}
for product_type_item in product_type_items:
name = product_type_item.name
names_to_remove.discard(name)
item = self._items_by_name.get(name)
# Apply filter to new items or if filters reset is requested
filter_required = self._reset_filters_on_refresh
if item is None:
filter_required = True
item = QtGui.QStandardItem(name)
item.setData(name, PRODUCT_TYPE_ROLE)
item.setEditable(False)
item.setCheckable(True)
self._items_by_name[name] = item
items.append(item)
if filter_required:
items_filter_required[name] = item
if items_filter_required:
product_types_filter = self._controller.get_product_types_filter()
for product_type, item in items_filter_required.items():
matching = (
int(product_type in product_types_filter.product_types)
+ int(product_types_filter.is_allow_list)
)
item.setCheckState(
QtCore.Qt.Checked
if matching % 2 == 0
else QtCore.Qt.Unchecked
)
items_to_remove = []
for name in names_to_remove:
items_to_remove.append(
self._items_by_name.pop(name)
)
# Uncheck all if all are checked (same result)
if all(
item.checkState() == QtCore.Qt.Checked
for item in items
):
for item in items:
item.setCheckState(QtCore.Qt.Unchecked)
return items, items_to_remove
class ProductTypesCombobox(CustomPaintMultiselectComboBox):
def __init__(self, controller, parent):
self._controller = controller
model = ProductTypesQtModel(controller)
super().__init__(
PRODUCT_TYPE_ROLE,
PRODUCT_TYPE_ROLE,
QtCore.Qt.ForegroundRole,
QtCore.Qt.DecorationRole,
item_type_role=ITEM_TYPE_ROLE,
model=model,
parent=parent
)
model.refreshed.connect(self._on_model_refresh)
self.set_placeholder_text("Product types filter...")
self._model = model
self._last_project_name = None
self._fully_disabled_filter = False
controller.register_event_callback(
"selection.project.changed",
self._on_project_change
)
controller.register_event_callback(
"projects.refresh.finished",
self._on_projects_refresh
)
self.setToolTip("Product types filter")
self.value_changed.connect(
self._on_product_type_filter_change
)
def reset_product_types_filter_on_refresh(self):
self._model.reset_product_types_filter_on_refresh()
def _on_model_refresh(self):
self.value_changed.emit()
def _on_product_type_filter_change(self):
lines = ["Product types filter"]
for item in self.get_value_info():
status_name, enabled = item
lines.append(f"{'' if enabled else ''} {status_name}")
self.setToolTip("\n".join(lines))
def _on_project_change(self, event):
project_name = event["project_name"]
self._last_project_name = project_name
self._model.refresh(project_name)
def _on_projects_refresh(self):
if self._last_project_name:
self._model.refresh(self._last_project_name)
self._on_product_type_filter_change()

View file

@ -1,256 +0,0 @@
from qtpy import QtWidgets, QtGui, QtCore
from ayon_core.tools.utils import get_qt_icon
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1
class ProductTypesQtModel(QtGui.QStandardItemModel):
refreshed = QtCore.Signal()
filter_changed = QtCore.Signal()
def __init__(self, controller):
super(ProductTypesQtModel, self).__init__()
self._controller = controller
self._reset_filters_on_refresh = True
self._refreshing = False
self._bulk_change = False
self._last_project = None
self._items_by_name = {}
controller.register_event_callback(
"controller.reset.finished",
self._on_controller_reset_finish,
)
def is_refreshing(self):
return self._refreshing
def get_filter_info(self):
"""Product types filtering info.
Returns:
dict[str, bool]: Filtering value by product type name. False value
means to hide product type.
"""
return {
name: item.checkState() == QtCore.Qt.Checked
for name, item in self._items_by_name.items()
}
def refresh(self, project_name):
self._refreshing = True
product_type_items = self._controller.get_product_type_items(
project_name)
self._last_project = project_name
items_to_remove = set(self._items_by_name.keys())
new_items = []
items_filter_required = {}
for product_type_item in product_type_items:
name = product_type_item.name
items_to_remove.discard(name)
item = self._items_by_name.get(name)
# Apply filter to new items or if filters reset is requested
filter_required = self._reset_filters_on_refresh
if item is None:
filter_required = True
item = QtGui.QStandardItem(name)
item.setData(name, PRODUCT_TYPE_ROLE)
item.setEditable(False)
item.setCheckable(True)
new_items.append(item)
self._items_by_name[name] = item
if filter_required:
items_filter_required[name] = item
icon = get_qt_icon(product_type_item.icon)
item.setData(icon, QtCore.Qt.DecorationRole)
if items_filter_required:
product_types_filter = self._controller.get_product_types_filter()
for product_type, item in items_filter_required.items():
matching = (
int(product_type in product_types_filter.product_types)
+ int(product_types_filter.is_allow_list)
)
state = (
QtCore.Qt.Checked
if matching % 2 == 0
else QtCore.Qt.Unchecked
)
item.setCheckState(state)
root_item = self.invisibleRootItem()
if new_items:
root_item.appendRows(new_items)
for name in items_to_remove:
item = self._items_by_name.pop(name)
root_item.removeRow(item.row())
self._reset_filters_on_refresh = False
self._refreshing = False
self.refreshed.emit()
def reset_product_types_filter_on_refresh(self):
self._reset_filters_on_refresh = True
def setData(self, index, value, role=None):
checkstate_changed = False
if role is None:
role = QtCore.Qt.EditRole
elif role == QtCore.Qt.CheckStateRole:
checkstate_changed = True
output = super(ProductTypesQtModel, self).setData(index, value, role)
if checkstate_changed and not self._bulk_change:
self.filter_changed.emit()
return output
def change_state_for_all(self, checked):
if self._items_by_name:
self.change_states(checked, self._items_by_name.keys())
def change_states(self, checked, product_types):
product_types = set(product_types)
if not product_types:
return
if checked is None:
state = None
elif checked:
state = QtCore.Qt.Checked
else:
state = QtCore.Qt.Unchecked
self._bulk_change = True
changed = False
for product_type in product_types:
item = self._items_by_name.get(product_type)
if item is None:
continue
new_state = state
item_checkstate = item.checkState()
if new_state is None:
if item_checkstate == QtCore.Qt.Checked:
new_state = QtCore.Qt.Unchecked
else:
new_state = QtCore.Qt.Checked
elif item_checkstate == new_state:
continue
changed = True
item.setCheckState(new_state)
self._bulk_change = False
if changed:
self.filter_changed.emit()
def _on_controller_reset_finish(self):
self.refresh(self._last_project)
class ProductTypesView(QtWidgets.QListView):
filter_changed = QtCore.Signal()
def __init__(self, controller, parent):
super(ProductTypesView, self).__init__(parent)
self.setSelectionMode(
QtWidgets.QAbstractItemView.ExtendedSelection
)
self.setAlternatingRowColors(True)
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
product_types_model = ProductTypesQtModel(controller)
product_types_proxy_model = QtCore.QSortFilterProxyModel()
product_types_proxy_model.setSourceModel(product_types_model)
self.setModel(product_types_proxy_model)
product_types_model.refreshed.connect(self._on_refresh_finished)
product_types_model.filter_changed.connect(self._on_filter_change)
self.customContextMenuRequested.connect(self._on_context_menu)
controller.register_event_callback(
"selection.project.changed",
self._on_project_change
)
self._controller = controller
self._refresh_product_types_filter = False
self._product_types_model = product_types_model
self._product_types_proxy_model = product_types_proxy_model
def get_filter_info(self):
return self._product_types_model.get_filter_info()
def reset_product_types_filter_on_refresh(self):
self._product_types_model.reset_product_types_filter_on_refresh()
def _on_project_change(self, event):
project_name = event["project_name"]
self._product_types_model.refresh(project_name)
def _on_refresh_finished(self):
# Apply product types filter on first show
self.filter_changed.emit()
def _on_filter_change(self):
if not self._product_types_model.is_refreshing():
self.filter_changed.emit()
def _change_selection_state(self, checkstate):
selection_model = self.selectionModel()
product_types = {
index.data(PRODUCT_TYPE_ROLE)
for index in selection_model.selectedIndexes()
}
product_types.discard(None)
self._product_types_model.change_states(checkstate, product_types)
def _on_enable_all(self):
self._product_types_model.change_state_for_all(True)
def _on_disable_all(self):
self._product_types_model.change_state_for_all(False)
def _on_context_menu(self, pos):
menu = QtWidgets.QMenu(self)
# Add enable all action
action_check_all = QtWidgets.QAction(menu)
action_check_all.setText("Enable All")
action_check_all.triggered.connect(self._on_enable_all)
# Add disable all action
action_uncheck_all = QtWidgets.QAction(menu)
action_uncheck_all.setText("Disable All")
action_uncheck_all.triggered.connect(self._on_disable_all)
menu.addAction(action_check_all)
menu.addAction(action_uncheck_all)
# Get mouse position
global_pos = self.viewport().mapToGlobal(pos)
menu.exec_(global_pos)
def event(self, event):
if event.type() == QtCore.QEvent.KeyPress:
if event.key() == QtCore.Qt.Key_Space:
self._change_selection_state(None)
return True
if event.key() == QtCore.Qt.Key_Backspace:
self._change_selection_state(False)
return True
if event.key() == QtCore.Qt.Key_Return:
self._change_selection_state(True)
return True
return super(ProductTypesView, self).event(event)

View file

@ -19,6 +19,7 @@ from .products_model import (
)
STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1
TASK_ID_ROLE = QtCore.Qt.UserRole + 2
class VersionsModel(QtGui.QStandardItemModel):
@ -48,6 +49,7 @@ class VersionsModel(QtGui.QStandardItemModel):
item.setData(version_id, QtCore.Qt.UserRole)
self._items_by_id[version_id] = item
item.setData(version_item.status, STATUS_NAME_ROLE)
item.setData(version_item.task_id, TASK_ID_ROLE)
if item.row() != idx:
root_item.insertRow(idx, item)
@ -57,17 +59,30 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel):
def __init__(self):
super().__init__()
self._status_filter = None
self._task_ids_filter = None
def filterAcceptsRow(self, row, parent):
if self._status_filter is None:
return True
if self._status_filter is not None:
if not self._status_filter:
return False
if not self._status_filter:
return False
index = self.sourceModel().index(row, 0, parent)
status = index.data(STATUS_NAME_ROLE)
if status not in self._status_filter:
return False
index = self.sourceModel().index(row, 0, parent)
status = index.data(STATUS_NAME_ROLE)
return status in self._status_filter
if self._task_ids_filter:
index = self.sourceModel().index(row, 0, parent)
task_id = index.data(TASK_ID_ROLE)
if task_id not in self._task_ids_filter:
return False
return True
def set_tasks_filter(self, task_ids):
if self._task_ids_filter == task_ids:
return
self._task_ids_filter = task_ids
self.invalidateFilter()
def set_statuses_filter(self, status_names):
if self._status_filter == status_names:
@ -101,6 +116,13 @@ class VersionComboBox(QtWidgets.QComboBox):
def get_product_id(self):
return self._product_id
def set_tasks_filter(self, task_ids):
self._proxy_model.set_tasks_filter(task_ids)
if self.count() == 0:
return
if self.currentIndex() != 0:
self.setCurrentIndex(0)
def set_statuses_filter(self, status_names):
self._proxy_model.set_statuses_filter(status_names)
if self.count() == 0:
@ -149,6 +171,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
super().__init__(*args, **kwargs)
self._editor_by_id: Dict[str, VersionComboBox] = {}
self._task_ids_filter = None
self._statuses_filter = None
def displayText(self, value, locale):
@ -156,6 +179,11 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
return "N/A"
return format_version(value)
def set_tasks_filter(self, task_ids):
self._task_ids_filter = set(task_ids)
for widget in self._editor_by_id.values():
widget.set_tasks_filter(task_ids)
def set_statuses_filter(self, status_names):
self._statuses_filter = set(status_names)
for widget in self._editor_by_id.values():
@ -239,6 +267,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
version_id = index.data(VERSION_ID_ROLE)
editor.update_versions(versions, version_id)
editor.set_tasks_filter(self._task_ids_filter)
editor.set_statuses_filter(self._statuses_filter)
def setModelData(self, editor, model, index):

View file

@ -12,34 +12,35 @@ GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 1
MERGED_COLOR_ROLE = QtCore.Qt.UserRole + 2
FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 3
FOLDER_ID_ROLE = QtCore.Qt.UserRole + 4
PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 5
PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 6
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 7
PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 8
PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 9
VERSION_ID_ROLE = QtCore.Qt.UserRole + 10
VERSION_HERO_ROLE = QtCore.Qt.UserRole + 11
VERSION_NAME_ROLE = QtCore.Qt.UserRole + 12
VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 13
VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 14
VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 15
VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 16
VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 17
VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 18
VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 19
VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 20
VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 21
VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 22
VERSION_STEP_ROLE = QtCore.Qt.UserRole + 23
VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 24
VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 25
ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 26
REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 27
REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 28
SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 29
SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30
TASK_ID_ROLE = QtCore.Qt.UserRole + 5
PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 6
PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 7
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 8
PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 9
PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 10
VERSION_ID_ROLE = QtCore.Qt.UserRole + 11
VERSION_HERO_ROLE = QtCore.Qt.UserRole + 12
VERSION_NAME_ROLE = QtCore.Qt.UserRole + 13
VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 14
VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 15
VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 16
VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 17
VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 18
VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 19
VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 20
VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 21
VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 22
VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 23
VERSION_STEP_ROLE = QtCore.Qt.UserRole + 24
VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 25
VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 26
ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 27
REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 28
REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 29
SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30
SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 31
STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 31
STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 32
class ProductsModel(QtGui.QStandardItemModel):
@ -368,6 +369,7 @@ class ProductsModel(QtGui.QStandardItemModel):
"""
model_item.setData(version_item.version_id, VERSION_ID_ROLE)
model_item.setData(version_item.task_id, TASK_ID_ROLE)
model_item.setData(version_item.version, VERSION_NAME_ROLE)
model_item.setData(version_item.is_hero, VERSION_HERO_ROLE)
model_item.setData(

View file

@ -1,4 +1,6 @@
from __future__ import annotations
import collections
from typing import Optional
from qtpy import QtWidgets, QtCore
@ -15,6 +17,7 @@ from .products_model import (
GROUP_TYPE_ROLE,
MERGED_COLOR_ROLE,
FOLDER_ID_ROLE,
TASK_ID_ROLE,
PRODUCT_ID_ROLE,
VERSION_ID_ROLE,
VERSION_STATUS_NAME_ROLE,
@ -36,8 +39,9 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel):
def __init__(self, parent=None):
super().__init__(parent)
self._product_type_filters = {}
self._product_type_filters = None
self._statuses_filter = None
self._task_ids_filter = None
self._ascending_sort = True
def get_statuses_filter(self):
@ -45,7 +49,15 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel):
return None
return set(self._statuses_filter)
def set_tasks_filter(self, task_ids_filter):
if self._task_ids_filter == task_ids_filter:
return
self._task_ids_filter = task_ids_filter
self.invalidateFilter()
def set_product_type_filters(self, product_type_filters):
if self._product_type_filters == product_type_filters:
return
self._product_type_filters = product_type_filters
self.invalidateFilter()
@ -58,29 +70,41 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel):
def filterAcceptsRow(self, source_row, source_parent):
source_model = self.sourceModel()
index = source_model.index(source_row, 0, source_parent)
product_types_s = source_model.data(index, PRODUCT_TYPE_ROLE)
product_types = []
if product_types_s:
product_types = product_types_s.split("|")
for product_type in product_types:
if not self._product_type_filters.get(product_type, True):
return False
if not self._accept_row_by_statuses(index):
if not self._accept_task_ids_filter(index):
return False
if not self._accept_row_by_role_value(
index, self._product_type_filters, PRODUCT_TYPE_ROLE
):
return False
if not self._accept_row_by_role_value(
index, self._statuses_filter, STATUS_NAME_FILTER_ROLE
):
return False
return super().filterAcceptsRow(source_row, source_parent)
def _accept_row_by_statuses(self, index):
if self._statuses_filter is None:
def _accept_task_ids_filter(self, index):
if not self._task_ids_filter:
return True
if not self._statuses_filter:
task_id = index.data(TASK_ID_ROLE)
return task_id in self._task_ids_filter
def _accept_row_by_role_value(
self,
index: QtCore.QModelIndex,
filter_value: Optional[set[str]],
role: int
):
if filter_value is None:
return True
if not filter_value:
return False
status_s = index.data(STATUS_NAME_FILTER_ROLE)
status_s = index.data(role)
for status in status_s.split("|"):
if status in self._statuses_filter:
if status in filter_value:
return True
return False
@ -120,7 +144,7 @@ class ProductsWidget(QtWidgets.QWidget):
90, # Product type
130, # Folder label
60, # Version
100, # Status
100, # Status
125, # Time
75, # Author
75, # Frames
@ -246,6 +270,16 @@ class ProductsWidget(QtWidgets.QWidget):
"""
self._products_proxy_model.setFilterFixedString(name)
def set_tasks_filter(self, task_ids):
"""Set filter of version tasks.
Args:
task_ids (set[str]): Task ids.
"""
self._version_delegate.set_tasks_filter(task_ids)
self._products_proxy_model.set_tasks_filter(task_ids)
def set_statuses_filter(self, status_names):
"""Set filter of version statuses.

View file

@ -1,4 +1,4 @@
from typing import List, Dict
from __future__ import annotations
from qtpy import QtCore, QtGui
@ -7,7 +7,7 @@ from ayon_core.tools.common_models import StatusItem
from ._multicombobox import (
CustomPaintMultiselectComboBox,
STANDARD_ITEM_TYPE,
BaseQtModel,
)
STATUS_ITEM_TYPE = 0
@ -24,62 +24,43 @@ ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5
ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 6
class StatusesQtModel(QtGui.QStandardItemModel):
class StatusesQtModel(BaseQtModel):
def __init__(self, controller):
self._controller = controller
self._items_by_name: Dict[str, QtGui.QStandardItem] = {}
self._icons_by_name_n_color: Dict[str, QtGui.QIcon] = {}
self._last_project = None
self._items_by_name: dict[str, QtGui.QStandardItem] = {}
self._icons_by_name_n_color: dict[str, QtGui.QIcon] = {}
super().__init__(
ITEM_TYPE_ROLE,
ITEM_SUBTYPE_ROLE,
"No statuses...",
controller,
)
self._select_project_item = None
self._empty_statuses_item = None
def _get_standard_items(self) -> list[QtGui.QStandardItem]:
return list(self._items_by_name.values())
self._select_all_item = None
self._deselect_all_item = None
self._swap_states_item = None
def _clear_standard_items(self):
self._items_by_name.clear()
super().__init__()
self.refresh(None)
def get_placeholder_text(self):
return self._placeholder
def refresh(self, project_name):
# New project was selected
# status filter is reset to show all statuses
uncheck_all = False
if project_name != self._last_project:
self._last_project = project_name
uncheck_all = True
if project_name is None:
self._add_select_project_item()
return
status_items: List[StatusItem] = (
def _prepare_new_value_items(
self, project_name: str, project_changed: bool
):
status_items: list[StatusItem] = (
self._controller.get_project_status_items(
project_name, sender=STATUSES_FILTER_SENDER
)
)
items = []
items_to_remove = []
if not status_items:
self._add_empty_statuses_item()
return
return items, items_to_remove
self._remove_empty_items()
items_to_remove = set(self._items_by_name)
root_item = self.invisibleRootItem()
names_to_remove = set(self._items_by_name)
for row_idx, status_item in enumerate(status_items):
name = status_item.name
if name in self._items_by_name:
is_new = False
item = self._items_by_name[name]
if uncheck_all:
item.setCheckState(QtCore.Qt.Unchecked)
items_to_remove.discard(name)
names_to_remove.discard(name)
else:
is_new = True
item = QtGui.QStandardItem()
item.setData(ITEM_SUBTYPE_ROLE, STATUS_ITEM_TYPE)
item.setCheckState(QtCore.Qt.Unchecked)
@ -100,36 +81,14 @@ class StatusesQtModel(QtGui.QStandardItemModel):
if item.data(role) != value:
item.setData(value, role)
if is_new:
root_item.insertRow(row_idx, item)
if project_changed:
item.setCheckState(QtCore.Qt.Unchecked)
items.append(item)
for name in items_to_remove:
item = self._items_by_name.pop(name)
root_item.removeRow(item.row())
for name in names_to_remove:
items_to_remove.append(self._items_by_name.pop(name))
self._add_selection_items()
def setData(self, index, value, role):
if role == QtCore.Qt.CheckStateRole and index.isValid():
item_type = index.data(ITEM_SUBTYPE_ROLE)
if item_type == SELECT_ALL_TYPE:
for item in self._items_by_name.values():
item.setCheckState(QtCore.Qt.Checked)
return True
if item_type == DESELECT_ALL_TYPE:
for item in self._items_by_name.values():
item.setCheckState(QtCore.Qt.Unchecked)
return True
if item_type == SWAP_STATE_TYPE:
for item in self._items_by_name.values():
current_state = item.checkState()
item.setCheckState(
QtCore.Qt.Checked
if current_state == QtCore.Qt.Unchecked
else QtCore.Qt.Unchecked
)
return True
return super().setData(index, value, role)
return items, items_to_remove
def _get_icon(self, status_item: StatusItem) -> QtGui.QIcon:
name = status_item.name
@ -147,139 +106,6 @@ class StatusesQtModel(QtGui.QStandardItemModel):
self._icons_by_name_n_color[unique_id] = icon
return icon
def _init_default_items(self):
if self._empty_statuses_item is not None:
return
empty_statuses_item = QtGui.QStandardItem("No statuses...")
select_project_item = QtGui.QStandardItem("Select project...")
select_all_item = QtGui.QStandardItem("Select all")
deselect_all_item = QtGui.QStandardItem("Deselect all")
swap_states_item = QtGui.QStandardItem("Swap")
for item in (
empty_statuses_item,
select_project_item,
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setData(STANDARD_ITEM_TYPE, ITEM_TYPE_ROLE)
select_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "done_all",
"color": "white"
}))
deselect_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "remove_done",
"color": "white"
}))
swap_states_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "swap_horiz",
"color": "white"
}))
for item in (
empty_statuses_item,
select_project_item,
):
item.setFlags(QtCore.Qt.NoItemFlags)
for item, item_type in (
(select_all_item, SELECT_ALL_TYPE),
(deselect_all_item, DESELECT_ALL_TYPE),
(swap_states_item, SWAP_STATE_TYPE),
):
item.setData(item_type, ITEM_SUBTYPE_ROLE)
for item in (
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setFlags(
QtCore.Qt.ItemIsEnabled
| QtCore.Qt.ItemIsSelectable
| QtCore.Qt.ItemIsUserCheckable
)
self._empty_statuses_item = empty_statuses_item
self._select_project_item = select_project_item
self._select_all_item = select_all_item
self._deselect_all_item = deselect_all_item
self._swap_states_item = swap_states_item
def _get_empty_statuses_item(self):
self._init_default_items()
return self._empty_statuses_item
def _get_select_project_item(self):
self._init_default_items()
return self._select_project_item
def _get_empty_items(self):
self._init_default_items()
return [
self._empty_statuses_item,
self._select_project_item,
]
def _get_selection_items(self):
self._init_default_items()
return [
self._select_all_item,
self._deselect_all_item,
self._swap_states_item,
]
def _get_default_items(self):
return self._get_empty_items() + self._get_selection_items()
def _add_select_project_item(self):
item = self._get_select_project_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_empty_statuses_item(self):
item = self._get_empty_statuses_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_selection_items(self):
root_item = self.invisibleRootItem()
items = self._get_selection_items()
for item in self._get_selection_items():
row = item.row()
if row >= 0:
root_item.takeRow(row)
root_item.appendRows(items)
def _remove_items(self):
root_item = self.invisibleRootItem()
for item in self._get_default_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
root_item.removeRows(0, root_item.rowCount())
self._items_by_name.clear()
def _remove_empty_items(self):
root_item = self.invisibleRootItem()
for item in self._get_empty_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
class StatusesCombobox(CustomPaintMultiselectComboBox):
def __init__(self, controller, parent):

View file

@ -0,0 +1,405 @@
import collections
import hashlib
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.style import get_default_entity_icon_color
from ayon_core.tools.utils import (
RecursiveSortFilterProxyModel,
DeselectableTreeView,
TasksQtModel,
TASKS_MODEL_SENDER_NAME,
)
from ayon_core.tools.utils.tasks_widget import (
ITEM_ID_ROLE,
ITEM_NAME_ROLE,
PARENT_ID_ROLE,
TASK_TYPE_ROLE,
)
from ayon_core.tools.utils.lib import RefreshThread, get_qt_icon
# Role that can't clash with default 'tasks_widget' roles
FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 100
NO_TASKS_ID = "--no-task--"
class LoaderTasksQtModel(TasksQtModel):
column_labels = [
"Task name",
"Task type",
"Folder"
]
def __init__(self, controller):
super().__init__(controller)
self._items_by_id = {}
self._groups_by_name = {}
self._last_folder_ids = set()
# This item is used to be able filter versions without any task
# - do not mismatch with '_empty_tasks_item' item from 'TasksQtModel'
self._no_tasks_item = None
def refresh(self):
"""Refresh tasks for selected folders."""
self._refresh(self._last_project_name, self._last_folder_ids)
def set_context(self, project_name, folder_ids):
self._refresh(project_name, folder_ids)
# Mark some functions from 'TasksQtModel' as not implemented
def get_index_by_name(self, task_name):
raise NotImplementedError(
"Method 'get_index_by_name' is not implemented."
)
def get_last_folder_id(self):
raise NotImplementedError(
"Method 'get_last_folder_id' is not implemented."
)
def flags(self, index):
if index.column() != 0:
index = self.index(index.row(), 0, index.parent())
return super().flags(index)
def _get_no_tasks_item(self):
if self._no_tasks_item is None:
item = QtGui.QStandardItem("No task")
icon = get_qt_icon({
"type": "material-symbols",
"name": "indeterminate_check_box",
"color": get_default_entity_icon_color(),
})
item.setData(icon, QtCore.Qt.DecorationRole)
item.setData(NO_TASKS_ID, ITEM_ID_ROLE)
item.setEditable(False)
self._no_tasks_item = item
return self._no_tasks_item
def _refresh(self, project_name, folder_ids):
self._is_refreshing = True
self._last_project_name = project_name
self._last_folder_ids = folder_ids
if not folder_ids:
self._add_invalid_selection_item()
self._current_refresh_thread = None
self._is_refreshing = False
self.refreshed.emit()
return
thread_id = hashlib.sha256(
"|".join(sorted(folder_ids)).encode()
).hexdigest()
thread = self._refresh_threads.get(thread_id)
if thread is not None:
self._current_refresh_thread = thread
return
thread = RefreshThread(
thread_id,
self._thread_getter,
project_name,
folder_ids
)
self._current_refresh_thread = thread
self._refresh_threads[thread.id] = thread
thread.refresh_finished.connect(self._on_refresh_thread)
thread.start()
def _thread_getter(self, project_name, folder_ids):
task_items = self._controller.get_task_items(
project_name, folder_ids, sender=TASKS_MODEL_SENDER_NAME
)
task_type_items = {}
if hasattr(self._controller, "get_task_type_items"):
task_type_items = self._controller.get_task_type_items(
project_name, sender=TASKS_MODEL_SENDER_NAME
)
folder_ids = {
task_item.parent_id
for task_item in task_items
}
folder_labels_by_id = self._controller.get_folder_labels(
project_name, folder_ids
)
return task_items, task_type_items, folder_labels_by_id
def _on_refresh_thread(self, thread_id):
"""Callback when refresh thread is finished.
Technically can be running multiple refresh threads at the same time,
to avoid using values from wrong thread, we check if thread id is
current refresh thread id.
Tasks are stored by name, so if a folder has same task name as
previously selected folder it keeps the selection.
Args:
thread_id (str): Thread id.
"""
# Make sure to remove thread from '_refresh_threads' dict
thread = self._refresh_threads.pop(thread_id)
if (
self._current_refresh_thread is None
or thread_id != self._current_refresh_thread.id
):
return
self._fill_data_from_thread(thread)
root_item = self.invisibleRootItem()
self._has_content = root_item.rowCount() > 0
self._current_refresh_thread = None
self._is_refreshing = False
self.refreshed.emit()
def _clear_items(self):
self._items_by_id = {}
self._groups_by_name = {}
super()._clear_items()
def _fill_data_from_thread(self, thread):
task_items, task_type_items, folder_labels_by_id = thread.get_result()
# Task items are refreshed
if task_items is None:
return
# No tasks are available on folder
if not task_items:
self._add_empty_task_item()
return
self._remove_invalid_items()
task_type_item_by_name = {
task_type_item.name: task_type_item
for task_type_item in task_type_items
}
task_type_icon_cache = {}
current_ids = set()
items_by_name = collections.defaultdict(list)
for task_item in task_items:
task_id = task_item.task_id
current_ids.add(task_id)
item = self._items_by_id.get(task_id)
if item is None:
item = QtGui.QStandardItem()
item.setColumnCount(self.columnCount())
item.setEditable(False)
self._items_by_id[task_id] = item
icon = self._get_task_item_icon(
task_item,
task_type_item_by_name,
task_type_icon_cache
)
name = task_item.name
folder_id = task_item.parent_id
folder_label = folder_labels_by_id.get(folder_id)
item.setData(name, QtCore.Qt.DisplayRole)
item.setData(name, ITEM_NAME_ROLE)
item.setData(task_item.id, ITEM_ID_ROLE)
item.setData(task_item.task_type, TASK_TYPE_ROLE)
item.setData(folder_id, PARENT_ID_ROLE)
item.setData(folder_label, FOLDER_LABEL_ROLE)
item.setData(icon, QtCore.Qt.DecorationRole)
items_by_name[name].append(item)
root_item = self.invisibleRootItem()
# Make sure item is not parented
# - this is laziness to avoid re-parenting items which does
# complicate the code with no benefit
queue = collections.deque()
queue.append((None, root_item))
while queue:
(parent, item) = queue.popleft()
if not item.hasChildren():
if parent:
parent.takeRow(item.row())
continue
for row in range(item.rowCount()):
queue.append((item, item.child(row, 0)))
queue.append((parent, item))
used_group_names = set()
new_root_items = [
self._get_no_tasks_item()
]
for name, items in items_by_name.items():
if len(items) == 1:
new_root_items.extend(items)
continue
used_group_names.add(name)
group_item = self._groups_by_name.get(name)
if group_item is None:
group_item = QtGui.QStandardItem()
group_item.setData(name, QtCore.Qt.DisplayRole)
group_item.setEditable(False)
group_item.setColumnCount(self.columnCount())
self._groups_by_name[name] = group_item
# Use icon from first item
first_item_icon = items[0].data(QtCore.Qt.DecorationRole)
task_ids = [
item.data(ITEM_ID_ROLE)
for item in items
]
group_item.setData(first_item_icon, QtCore.Qt.DecorationRole)
group_item.setData("|".join(task_ids), ITEM_ID_ROLE)
group_item.appendRows(items)
new_root_items.append(group_item)
# Remove unused caches
for task_id in set(self._items_by_id) - current_ids:
self._items_by_id.pop(task_id)
for name in set(self._groups_by_name) - used_group_names:
self._groups_by_name.pop(name)
if new_root_items:
root_item.appendRows(new_root_items)
def data(self, index, role=None):
if not index.isValid():
return None
if role is None:
role = QtCore.Qt.DisplayRole
col = index.column()
if col != 0:
index = self.index(index.row(), 0, index.parent())
if col == 1:
if role == QtCore.Qt.DisplayRole:
role = TASK_TYPE_ROLE
else:
return None
if col == 2:
if role == QtCore.Qt.DisplayRole:
role = FOLDER_LABEL_ROLE
else:
return None
return super().data(index, role)
class LoaderTasksProxyModel(RecursiveSortFilterProxyModel):
def lessThan(self, left, right):
if left.data(ITEM_ID_ROLE) == NO_TASKS_ID:
return False
if right.data(ITEM_ID_ROLE) == NO_TASKS_ID:
return True
return super().lessThan(left, right)
class LoaderTasksWidget(QtWidgets.QWidget):
refreshed = QtCore.Signal()
def __init__(self, controller, parent):
super().__init__(parent)
tasks_view = DeselectableTreeView(self)
tasks_view.setSelectionMode(
QtWidgets.QAbstractItemView.ExtendedSelection
)
tasks_model = LoaderTasksQtModel(controller)
tasks_proxy_model = LoaderTasksProxyModel()
tasks_proxy_model.setSourceModel(tasks_model)
tasks_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
tasks_view.setModel(tasks_proxy_model)
# Hide folder column by default
tasks_view.setColumnHidden(2, True)
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(tasks_view, 1)
controller.register_event_callback(
"selection.folders.changed",
self._on_folders_selection_changed,
)
controller.register_event_callback(
"tasks.refresh.finished",
self._on_tasks_refresh_finished
)
selection_model = tasks_view.selectionModel()
selection_model.selectionChanged.connect(self._on_selection_change)
tasks_model.refreshed.connect(self._on_model_refresh)
self._controller = controller
self._tasks_view = tasks_view
self._tasks_model = tasks_model
self._tasks_proxy_model = tasks_proxy_model
self._fisrt_show = True
def showEvent(self, event):
super().showEvent(event)
if self._fisrt_show:
self._fisrt_show = False
header_widget = self._tasks_view.header()
header_widget.resizeSection(0, 200)
def set_name_filter(self, name):
"""Set filter of folder name.
Args:
name (str): The string filter.
"""
self._tasks_proxy_model.setFilterFixedString(name)
if name:
self._tasks_view.expandAll()
def refresh(self):
self._tasks_model.refresh()
def _clear(self):
self._tasks_model.clear()
def _on_tasks_refresh_finished(self, event):
if event["sender"] != TASKS_MODEL_SENDER_NAME:
self._set_project_name(event["project_name"])
def _on_folders_selection_changed(self, event):
project_name = event["project_name"]
folder_ids = event["folder_ids"]
self._tasks_view.setColumnHidden(2, len(folder_ids) == 1)
self._tasks_model.set_context(project_name, folder_ids)
def _on_model_refresh(self):
self._tasks_proxy_model.sort(0)
self.refreshed.emit()
def _get_selected_item_ids(self):
selection_model = self._tasks_view.selectionModel()
item_ids = set()
for index in selection_model.selectedIndexes():
item_id = index.data(ITEM_ID_ROLE)
if item_id is None:
continue
if item_id == NO_TASKS_ID:
item_ids.add(None)
else:
item_ids |= set(item_id.split("|"))
return item_ids
def _on_selection_change(self):
item_ids = self._get_selected_item_ids()
self._controller.set_selected_tasks(item_ids)

View file

@ -14,8 +14,9 @@ from ayon_core.tools.utils import ProjectsCombobox
from ayon_core.tools.loader.control import LoaderController
from .folders_widget import LoaderFoldersWidget
from .tasks_widget import LoaderTasksWidget
from .products_widget import ProductsWidget
from .product_types_widget import ProductTypesView
from .product_types_combo import ProductTypesCombobox
from .product_group_dialog import ProductGroupDialog
from .info_widget import InfoWidget
from .repres_widget import RepresentationsWidget
@ -164,16 +165,16 @@ class LoaderWindow(QtWidgets.QWidget):
folders_widget = LoaderFoldersWidget(controller, context_widget)
product_types_widget = ProductTypesView(controller, context_splitter)
context_layout = QtWidgets.QVBoxLayout(context_widget)
context_layout.setContentsMargins(0, 0, 0, 0)
context_layout.addWidget(context_top_widget, 0)
context_layout.addWidget(folders_filter_input, 0)
context_layout.addWidget(folders_widget, 1)
tasks_widget = LoaderTasksWidget(controller, context_widget)
context_splitter.addWidget(context_widget)
context_splitter.addWidget(product_types_widget)
context_splitter.addWidget(tasks_widget)
context_splitter.setStretchFactor(0, 65)
context_splitter.setStretchFactor(1, 35)
@ -185,6 +186,10 @@ class LoaderWindow(QtWidgets.QWidget):
products_filter_input = PlaceholderLineEdit(products_inputs_widget)
products_filter_input.setPlaceholderText("Product name filter...")
product_types_filter_combo = ProductTypesCombobox(
controller, products_inputs_widget
)
product_status_filter_combo = StatusesCombobox(controller, self)
product_group_checkbox = QtWidgets.QCheckBox(
@ -196,6 +201,7 @@ class LoaderWindow(QtWidgets.QWidget):
products_inputs_layout = QtWidgets.QHBoxLayout(products_inputs_widget)
products_inputs_layout.setContentsMargins(0, 0, 0, 0)
products_inputs_layout.addWidget(products_filter_input, 1)
products_inputs_layout.addWidget(product_types_filter_combo, 1)
products_inputs_layout.addWidget(product_status_filter_combo, 1)
products_inputs_layout.addWidget(product_group_checkbox, 0)
@ -244,12 +250,12 @@ class LoaderWindow(QtWidgets.QWidget):
folders_filter_input.textChanged.connect(
self._on_folder_filter_change
)
product_types_widget.filter_changed.connect(
self._on_product_type_filter_change
)
products_filter_input.textChanged.connect(
self._on_product_filter_change
)
product_types_filter_combo.value_changed.connect(
self._on_product_type_filter_change
)
product_status_filter_combo.value_changed.connect(
self._on_status_filter_change
)
@ -280,6 +286,10 @@ class LoaderWindow(QtWidgets.QWidget):
"selection.folders.changed",
self._on_folders_selection_changed,
)
controller.register_event_callback(
"selection.tasks.changed",
self._on_tasks_selection_change,
)
controller.register_event_callback(
"selection.versions.changed",
self._on_versions_selection_changed,
@ -304,9 +314,10 @@ class LoaderWindow(QtWidgets.QWidget):
self._folders_filter_input = folders_filter_input
self._folders_widget = folders_widget
self._product_types_widget = product_types_widget
self._tasks_widget = tasks_widget
self._products_filter_input = products_filter_input
self._product_types_filter_combo = product_types_filter_combo
self._product_status_filter_combo = product_status_filter_combo
self._product_group_checkbox = product_group_checkbox
self._products_widget = products_widget
@ -335,7 +346,7 @@ class LoaderWindow(QtWidgets.QWidget):
self._controller.reset()
def showEvent(self, event):
super(LoaderWindow, self).showEvent(event)
super().showEvent(event)
if self._first_show:
self._on_first_show()
@ -343,9 +354,13 @@ class LoaderWindow(QtWidgets.QWidget):
self._show_timer.start()
def closeEvent(self, event):
super(LoaderWindow, self).closeEvent(event)
super().closeEvent(event)
self._product_types_widget.reset_product_types_filter_on_refresh()
(
self
._product_types_filter_combo
.reset_product_types_filter_on_refresh()
)
self._reset_on_show = True
@ -363,7 +378,7 @@ class LoaderWindow(QtWidgets.QWidget):
event.setAccepted(True)
return
super(LoaderWindow, self).keyPressEvent(event)
super().keyPressEvent(event)
def _on_first_show(self):
self._first_show = False
@ -423,14 +438,16 @@ class LoaderWindow(QtWidgets.QWidget):
def _on_product_filter_change(self, text):
self._products_widget.set_name_filter(text)
def _on_tasks_selection_change(self, event):
self._products_widget.set_tasks_filter(event["task_ids"])
def _on_status_filter_change(self):
status_names = self._product_status_filter_combo.get_value()
self._products_widget.set_statuses_filter(status_names)
def _on_product_type_filter_change(self):
self._products_widget.set_product_type_filter(
self._product_types_widget.get_filter_info()
)
product_types = self._product_types_filter_combo.get_value()
self._products_widget.set_product_type_filter(product_types)
def _on_merged_products_selection_change(self):
items = self._products_widget.get_selected_merged_products()

View file

@ -31,7 +31,6 @@ from . import settings, util
from .awesome import tags as awesome
from qtpy import QtCore, QtGui
import qtawesome
from six import text_type
from .constants import PluginStates, InstanceStates, GroupStates, Roles
@ -985,7 +984,7 @@ class TerminalModel(QtGui.QStandardItemModel):
record_item = record
else:
record_item = {
"label": text_type(record.msg),
"label": str(record.msg),
"type": "record",
"levelno": record.levelno,
"threadName": record.threadName,
@ -993,7 +992,7 @@ class TerminalModel(QtGui.QStandardItemModel):
"filename": record.filename,
"pathname": record.pathname,
"lineno": record.lineno,
"msg": text_type(record.msg),
"msg": str(record.msg),
"msecs": record.msecs,
"levelname": record.levelname
}

View file

@ -10,7 +10,6 @@ import sys
import collections
from qtpy import QtCore
from six import text_type
import pyblish.api
root = os.path.dirname(__file__)
@ -64,7 +63,7 @@ def u_print(msg, **kwargs):
**kwargs: Keyword argument for `print` function.
"""
if isinstance(msg, text_type):
if isinstance(msg, str):
encoding = None
try:
encoding = os.getenv('PYTHONIOENCODING', sys.stdout.encoding)

View file

@ -5,7 +5,6 @@ from __future__ import print_function
import json
import os
import six
from qtpy import QtCore, QtGui
@ -152,7 +151,7 @@ class IconicFont(QtCore.QObject):
def hook(obj):
result = {}
for key in obj:
result[key] = six.unichr(int(obj[key], 16))
result[key] = chr(int(obj[key], 16))
return result
if directory is None:

View file

@ -7,7 +7,7 @@ import os
import pyblish.api
from ayon_core.host import ILoadHost
from ayon_core.host import ILoadHost, IPublishHost
from ayon_core.lib import Logger
from ayon_core.pipeline import registered_host
@ -236,7 +236,7 @@ class HostToolsHelper:
from ayon_core.tools.publisher.window import PublisherWindow
host = registered_host()
ILoadHost.validate_load_methods(host)
IPublishHost.validate_publish_methods(host)
publisher_window = PublisherWindow(
controller=controller,

View file

@ -24,9 +24,14 @@ class TasksQtModel(QtGui.QStandardItemModel):
"""
_default_task_icon = None
refreshed = QtCore.Signal()
column_labels = ["Tasks"]
def __init__(self, controller):
super(TasksQtModel, self).__init__()
super().__init__()
self.setColumnCount(len(self.column_labels))
for idx, label in enumerate(self.column_labels):
self.setHeaderData(idx, QtCore.Qt.Horizontal, label)
self._controller = controller
@ -53,7 +58,8 @@ class TasksQtModel(QtGui.QStandardItemModel):
self._has_content = False
self._remove_invalid_items()
root_item = self.invisibleRootItem()
root_item.removeRows(0, root_item.rowCount())
while root_item.rowCount() != 0:
root_item.takeRow(0)
def refresh(self):
"""Refresh tasks for last project and folder."""
@ -336,19 +342,6 @@ class TasksQtModel(QtGui.QStandardItemModel):
return self._has_content
def headerData(self, section, orientation, role):
# Show nice labels in the header
if (
role == QtCore.Qt.DisplayRole
and orientation == QtCore.Qt.Horizontal
):
if section == 0:
return "Tasks"
return super(TasksQtModel, self).headerData(
section, orientation, role
)
class TasksWidget(QtWidgets.QWidget):
"""Tasks widget.
@ -365,7 +358,7 @@ class TasksWidget(QtWidgets.QWidget):
selection_changed = QtCore.Signal()
def __init__(self, controller, parent, handle_expected_selection=False):
super(TasksWidget, self).__init__(parent)
super().__init__(parent)
tasks_view = DeselectableTreeView(self)
tasks_view.setIndentation(0)

View file

@ -7,7 +7,6 @@ class DeselectableTreeView(QtWidgets.QTreeView):
"""A tree view that deselects on clicking on an empty area in the view"""
def mousePressEvent(self, event):
index = self.indexAt(event.pos())
if not index.isValid():
# clear the selection
@ -15,7 +14,14 @@ class DeselectableTreeView(QtWidgets.QTreeView):
# clear the current index
self.setCurrentIndex(QtCore.QModelIndex())
QtWidgets.QTreeView.mousePressEvent(self, event)
elif (
self.selectionModel().isSelected(index)
and len(self.selectionModel().selectedRows()) == 1
and event.modifiers() == QtCore.Qt.NoModifier
):
event.setModifiers(QtCore.Qt.ControlModifier)
super().mousePressEvent(event)
class TreeView(QtWidgets.QTreeView):

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring AYON addon 'core' version."""
__version__ = "1.1.0+dev"
__version__ = "1.1.3+dev"

View file

@ -10,7 +10,6 @@ pyblish-base = "^1.8.11"
speedcopy = "^2.1"
six = "^1.15"
qtawesome = "0.7.3"
pydantic = "^2.9.2"
[ayon.runtimeDependencies]
aiohttp-middlewares = "^2.0.0"

View file

@ -1,6 +1,6 @@
name = "core"
title = "Core"
version = "1.1.0+dev"
version = "1.1.3+dev"
client_dir = "ayon_core"

256
poetry.lock generated
View file

@ -1,15 +1,4 @@
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
[[package]]
name = "annotated-types"
version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = false
python-versions = ">=3.8"
files = [
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
]
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
[[package]]
name = "appdirs"
@ -24,13 +13,13 @@ files = [
[[package]]
name = "attrs"
version = "24.3.0"
version = "25.1.0"
description = "Classes Without Boilerplate"
optional = false
python-versions = ">=3.8"
files = [
{file = "attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308"},
{file = "attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff"},
{file = "attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a"},
{file = "attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e"},
]
[package.extras]
@ -317,6 +306,22 @@ files = [
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
]
[[package]]
name = "mock"
version = "5.2.0"
description = "Rolling backport of unittest.mock for all Pythons"
optional = false
python-versions = ">=3.6"
files = [
{file = "mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f"},
{file = "mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0"},
]
[package.extras]
build = ["blurb", "twine", "wheel"]
docs = ["sphinx"]
test = ["pytest", "pytest-cov"]
[[package]]
name = "mypy"
version = "1.14.0"
@ -392,6 +397,55 @@ files = [
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
]
[[package]]
name = "opentimelineio"
version = "0.17.0"
description = "Editorial interchange format and API"
optional = false
python-versions = "!=3.9.0,>=3.7"
files = [
{file = "OpenTimelineIO-0.17.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:2dd31a570cabfd6227c1b1dd0cc038da10787492c26c55de058326e21fe8a313"},
{file = "OpenTimelineIO-0.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5a1da5d4803d1ba5e846b181a9e0f4a392c76b9acc5e08947772bc086f2ebfc0"},
{file = "OpenTimelineIO-0.17.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3527977aec8202789a42d60e1e0dc11b4154f585ef72921760445f43e7967a00"},
{file = "OpenTimelineIO-0.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b3aafb4c50455832ed2627c2cac654b896473a5c1f8348ddc07c10be5cfbd59"},
{file = "OpenTimelineIO-0.17.0-cp310-cp310-win32.whl", hash = "sha256:fee45af9f6330773893cd0858e92f8256bb5bde4229b44a76f03e59a9fb1b1b6"},
{file = "OpenTimelineIO-0.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:d51887619689c21d67cc4b11b1088f99ae44094513315e7a144be00f1393bfa8"},
{file = "OpenTimelineIO-0.17.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:cbf05c3e8c0187969f79e91f7495d1f0dc3609557874d8e601ba2e072c70ddb1"},
{file = "OpenTimelineIO-0.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d3430c3f4e88c5365d7b6afbee920b0815b62ecf141abe44cd739c9eedc04284"},
{file = "OpenTimelineIO-0.17.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1912345227b0bd1654c7153863eadbcee60362aa46340678e576e5d2aa3106a"},
{file = "OpenTimelineIO-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51e06eb11a868d970c1534e39faf916228d5163bf3598076d408d8f393ab0bd4"},
{file = "OpenTimelineIO-0.17.0-cp311-cp311-win32.whl", hash = "sha256:5c3a3f4780b25a8c1a80d788becba691d12b629069ad8783d0db21027639276f"},
{file = "OpenTimelineIO-0.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:43c8726b33af30ba42928972192311ea0f986edbbd5f74651bada182d4fe805c"},
{file = "OpenTimelineIO-0.17.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:9a9af4105a088c0ab131780e49db268db7e37871aac33db842de6b2b16f14e39"},
{file = "OpenTimelineIO-0.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e653ad1dd3b85f5c312a742dc24b61b330964aa391dc5bc072fe8b9c85adff1"},
{file = "OpenTimelineIO-0.17.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02a77823c27a1b93c6b87682372c3734ac5fddc10bfe53875e657d43c60fb885"},
{file = "OpenTimelineIO-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4f4efcf3ddd81b62c4feb49a0bcc309b50ffeb6a8c48ab173d169a029006f4d"},
{file = "OpenTimelineIO-0.17.0-cp312-cp312-win32.whl", hash = "sha256:9872ab74a20bb2bb3a50af04e80fe9238998d67d6be4e30e45aebe25d3eefac6"},
{file = "OpenTimelineIO-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:c83b78be3312d3152d7e07ab32b0086fe220acc2a5b035b70ad69a787c0ece62"},
{file = "OpenTimelineIO-0.17.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:0e671a6f2a1f772445bb326c7640dc977cfc3db589fe108a783a0311939cfac8"},
{file = "OpenTimelineIO-0.17.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b931a3189b4ce064f06f15a89fe08ef4de01f7dcf0abc441fe2e02ef2a3311bb"},
{file = "OpenTimelineIO-0.17.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923cb54d806c981cf1e91916c3e57fba5664c22f37763dd012bad5a5a7bd4db4"},
{file = "OpenTimelineIO-0.17.0-cp37-cp37m-win32.whl", hash = "sha256:8e16598c5084dcb21df3d83978b0e5f72300af9edd4cdcb85e3b0ba5da0df4e8"},
{file = "OpenTimelineIO-0.17.0-cp37-cp37m-win_amd64.whl", hash = "sha256:7eed5033494888fb3f802af50e60559e279b2f398802748872903c2f54efd2c9"},
{file = "OpenTimelineIO-0.17.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:118baa22b9227da5003bee653601a68686ae2823682dcd7d13c88178c63081c3"},
{file = "OpenTimelineIO-0.17.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:43389eacdee2169de454e1c79ecfea82f54a9e73b67151427a9b621349a22b7f"},
{file = "OpenTimelineIO-0.17.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17659b1e6aa42ed617a942f7a2bfc6ecc375d0464ec127ce9edf896278ecaee9"},
{file = "OpenTimelineIO-0.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36d5ea8cfbebf3c9013cc680eef5be48bffb515aafa9dc31e99bf66052a4ca3d"},
{file = "OpenTimelineIO-0.17.0-cp38-cp38-win32.whl", hash = "sha256:cc67c74eb4b73bc0f7d135d3ff3dbbd86b2d451a9b142690a8d1631ad79c46f2"},
{file = "OpenTimelineIO-0.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:69b39079bee6fa4aff34c6ad6544df394bc7388483fa5ce958ecd16e243a53ad"},
{file = "OpenTimelineIO-0.17.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:a33554894dea17c22feec0201991e705c2c90a679ba2a012a0c558a7130df711"},
{file = "OpenTimelineIO-0.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6b1ad3b3155370245b851b2f7b60006b2ebbb5bb76dd0fdc49bb4dce73fa7d96"},
{file = "OpenTimelineIO-0.17.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:030454a9c0e9e82e5a153119f9afb8f3f4e64a3b27f80ac0dcde44b029fd3f3f"},
{file = "OpenTimelineIO-0.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bce64376a28919533bd4f744ff8885118abefa73f78fd408f95fa7a9489855b6"},
{file = "OpenTimelineIO-0.17.0-cp39-cp39-win32.whl", hash = "sha256:fa8cdceb25f9003c3c0b5b32baef2c764949d88b867161ddc6f44f48f6bbfa4a"},
{file = "OpenTimelineIO-0.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:fbcf8a000cd688633c8dc5d22e91912013c67c674329eba603358e3b54da32bf"},
{file = "opentimelineio-0.17.0.tar.gz", hash = "sha256:10ef324e710457e9977387cd9ef91eb24a9837bfb370aec3330f9c0f146cea85"},
]
[package.extras]
dev = ["check-manifest", "coverage (>=4.5)", "flake8 (>=3.5)", "urllib3 (>=1.24.3)"]
view = ["PySide2 (>=5.11,<6.0)", "PySide6 (>=6.2,<7.0)"]
[[package]]
name = "packaging"
version = "24.2"
@ -463,138 +517,6 @@ files = [
{file = "pyblish_base-1.8.12-py2.py3-none-any.whl", hash = "sha256:2cbe956bfbd4175a2d7d22b344cd345800f4d4437153434ab658fc12646a11e8"},
]
[[package]]
name = "pydantic"
version = "2.10.4"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic-2.10.4-py3-none-any.whl", hash = "sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d"},
{file = "pydantic-2.10.4.tar.gz", hash = "sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06"},
]
[package.dependencies]
annotated-types = ">=0.6.0"
pydantic-core = "2.27.2"
typing-extensions = ">=4.12.2"
[package.extras]
email = ["email-validator (>=2.0.0)"]
timezone = ["tzdata"]
[[package]]
name = "pydantic-core"
version = "2.27.2"
description = "Core functionality for Pydantic validation and serialization"
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"},
{file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"},
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a"},
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5"},
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c"},
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7"},
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a"},
{file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236"},
{file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962"},
{file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9"},
{file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af"},
{file = "pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4"},
{file = "pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31"},
{file = "pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc"},
{file = "pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7"},
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15"},
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306"},
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99"},
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459"},
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048"},
{file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d"},
{file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b"},
{file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474"},
{file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6"},
{file = "pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c"},
{file = "pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc"},
{file = "pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4"},
{file = "pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0"},
{file = "pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef"},
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7"},
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934"},
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6"},
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c"},
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2"},
{file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4"},
{file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3"},
{file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4"},
{file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57"},
{file = "pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc"},
{file = "pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9"},
{file = "pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b"},
{file = "pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b"},
{file = "pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154"},
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9"},
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9"},
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1"},
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a"},
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e"},
{file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4"},
{file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27"},
{file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee"},
{file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1"},
{file = "pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130"},
{file = "pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee"},
{file = "pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b"},
{file = "pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506"},
{file = "pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320"},
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145"},
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1"},
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228"},
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046"},
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5"},
{file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a"},
{file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d"},
{file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9"},
{file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da"},
{file = "pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b"},
{file = "pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad"},
{file = "pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993"},
{file = "pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308"},
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4"},
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf"},
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76"},
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118"},
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630"},
{file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54"},
{file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f"},
{file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362"},
{file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96"},
{file = "pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e"},
{file = "pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9"},
{file = "pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2"},
{file = "pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35"},
{file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"},
]
[package.dependencies]
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]]
name = "pytest"
version = "8.3.4"
@ -719,29 +641,29 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "ruff"
version = "0.9.4"
version = "0.9.9"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.9.4-py3-none-linux_armv6l.whl", hash = "sha256:64e73d25b954f71ff100bb70f39f1ee09e880728efb4250c632ceed4e4cdf706"},
{file = "ruff-0.9.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6ce6743ed64d9afab4fafeaea70d3631b4d4b28b592db21a5c2d1f0ef52934bf"},
{file = "ruff-0.9.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:54499fb08408e32b57360f6f9de7157a5fec24ad79cb3f42ef2c3f3f728dfe2b"},
{file = "ruff-0.9.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37c892540108314a6f01f105040b5106aeb829fa5fb0561d2dcaf71485021137"},
{file = "ruff-0.9.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de9edf2ce4b9ddf43fd93e20ef635a900e25f622f87ed6e3047a664d0e8f810e"},
{file = "ruff-0.9.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87c90c32357c74f11deb7fbb065126d91771b207bf9bfaaee01277ca59b574ec"},
{file = "ruff-0.9.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:56acd6c694da3695a7461cc55775f3a409c3815ac467279dfa126061d84b314b"},
{file = "ruff-0.9.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0c93e7d47ed951b9394cf352d6695b31498e68fd5782d6cbc282425655f687a"},
{file = "ruff-0.9.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d4c8772670aecf037d1bf7a07c39106574d143b26cfe5ed1787d2f31e800214"},
{file = "ruff-0.9.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfc5f1d7afeda8d5d37660eeca6d389b142d7f2b5a1ab659d9214ebd0e025231"},
{file = "ruff-0.9.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:faa935fc00ae854d8b638c16a5f1ce881bc3f67446957dd6f2af440a5fc8526b"},
{file = "ruff-0.9.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6c634fc6f5a0ceae1ab3e13c58183978185d131a29c425e4eaa9f40afe1e6d6"},
{file = "ruff-0.9.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:433dedf6ddfdec7f1ac7575ec1eb9844fa60c4c8c2f8887a070672b8d353d34c"},
{file = "ruff-0.9.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d612dbd0f3a919a8cc1d12037168bfa536862066808960e0cc901404b77968f0"},
{file = "ruff-0.9.4-py3-none-win32.whl", hash = "sha256:db1192ddda2200671f9ef61d9597fcef89d934f5d1705e571a93a67fb13a4402"},
{file = "ruff-0.9.4-py3-none-win_amd64.whl", hash = "sha256:05bebf4cdbe3ef75430d26c375773978950bbf4ee3c95ccb5448940dc092408e"},
{file = "ruff-0.9.4-py3-none-win_arm64.whl", hash = "sha256:585792f1e81509e38ac5123492f8875fbc36f3ede8185af0a26df348e5154f41"},
{file = "ruff-0.9.4.tar.gz", hash = "sha256:6907ee3529244bb0ed066683e075f09285b38dd5b4039370df6ff06041ca19e7"},
{file = "ruff-0.9.9-py3-none-linux_armv6l.whl", hash = "sha256:628abb5ea10345e53dff55b167595a159d3e174d6720bf19761f5e467e68d367"},
{file = "ruff-0.9.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6cd1428e834b35d7493354723543b28cc11dc14d1ce19b685f6e68e07c05ec7"},
{file = "ruff-0.9.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5ee162652869120ad260670706f3cd36cd3f32b0c651f02b6da142652c54941d"},
{file = "ruff-0.9.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3aa0f6b75082c9be1ec5a1db78c6d4b02e2375c3068438241dc19c7c306cc61a"},
{file = "ruff-0.9.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:584cc66e89fb5f80f84b05133dd677a17cdd86901d6479712c96597a3f28e7fe"},
{file = "ruff-0.9.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abf3369325761a35aba75cd5c55ba1b5eb17d772f12ab168fbfac54be85cf18c"},
{file = "ruff-0.9.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3403a53a32a90ce929aa2f758542aca9234befa133e29f4933dcef28a24317be"},
{file = "ruff-0.9.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:18454e7fa4e4d72cffe28a37cf6a73cb2594f81ec9f4eca31a0aaa9ccdfb1590"},
{file = "ruff-0.9.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fadfe2c88724c9617339f62319ed40dcdadadf2888d5afb88bf3adee7b35bfb"},
{file = "ruff-0.9.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6df104d08c442a1aabcfd254279b8cc1e2cbf41a605aa3e26610ba1ec4acf0b0"},
{file = "ruff-0.9.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d7c62939daf5b2a15af48abbd23bea1efdd38c312d6e7c4cedf5a24e03207e17"},
{file = "ruff-0.9.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9494ba82a37a4b81b6a798076e4a3251c13243fc37967e998efe4cce58c8a8d1"},
{file = "ruff-0.9.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4efd7a96ed6d36ef011ae798bf794c5501a514be369296c672dab7921087fa57"},
{file = "ruff-0.9.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ab90a7944c5a1296f3ecb08d1cbf8c2da34c7e68114b1271a431a3ad30cb660e"},
{file = "ruff-0.9.9-py3-none-win32.whl", hash = "sha256:6b4c376d929c25ecd6d87e182a230fa4377b8e5125a4ff52d506ee8c087153c1"},
{file = "ruff-0.9.9-py3-none-win_amd64.whl", hash = "sha256:837982ea24091d4c1700ddb2f63b7070e5baec508e43b01de013dc7eff974ff1"},
{file = "ruff-0.9.9-py3-none-win_arm64.whl", hash = "sha256:3ac78f127517209fe6d96ab00f3ba97cafe38718b23b1db3e96d8b2d39e37ddf"},
{file = "ruff-0.9.9.tar.gz", hash = "sha256:0062ed13f22173e85f8f7056f9a24016e692efeea8704d1a5e8011b8aa850933"},
]
[[package]]
@ -858,4 +780,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
[metadata]
lock-version = "2.0"
python-versions = ">=3.9.1,<3.10"
content-hash = "1b63a1723ed91520787257791fe7d9b55557e5e12b5c41661a83f1911bc42675"
content-hash = "8e5b1f886eb608198752e1ce84f6bc89d1c8d53a5eeabff84a4272538f81403f"

View file

@ -5,23 +5,16 @@
[tool.poetry]
name = "ayon-core"
version = "1.1.0+dev"
version = "1.1.3+dev"
description = ""
authors = ["Ynput Team <team@ynput.io>"]
readme = "README.md"
package-mode = false
[tool.poetry.dependencies]
python = ">=3.9.1,<3.10"
pydantic = "^2.9.2"
pre-commit = "^4.0.0"
clique = "^2"
pyblish-base = "^1.8"
attrs = "^24.2.0"
pydantic-core = "==2.29.0"
speedcopy = "^2.0.0"
[tool.poetry.dev-dependencies]
[tool.poetry.group.dev.dependencies]
# test dependencies
pytest = "^8.0"
pytest-print = "^1.0"
@ -31,7 +24,12 @@ ruff = "^0.9.3"
pre-commit = "^4"
codespell = "^2.2.6"
semver = "^3.0.2"
mypy = "==1.15.0"
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"
[tool.ruff]

View file

@ -68,6 +68,59 @@ class ContributionLayersModel(BaseSettingsModel):
"layer on top.")
class CollectUSDLayerContributionsProfileModel(BaseSettingsModel):
"""Profiles to define instance attribute defaults for USD contribution."""
_layout = "expanded"
product_types: list[str] = SettingsField(
default_factory=list,
title="Product types",
description=(
"The product types to match this profile to. When matched, the"
" settings below would apply to the instance as default"
" attributes."
),
section="Filter"
)
task_types: list[str] = SettingsField(
default_factory=list,
title="Task Types",
enum_resolver=task_types_enum,
description=(
"The current create context task type to filter against. This"
" allows to filter the profile to only be valid if currently "
" creating from within that task type."
),
)
contribution_layer: str = SettingsField(
"",
title="Contribution Department Layer",
description=(
"The default contribution layer to apply the contribution to when"
" matching this profile. The layer name should be in the"
" 'Department Layer Orders' list to get a sensible order."
),
section="Instance attribute defaults",
)
contribution_apply_as_variant: bool = SettingsField(
True,
title="Apply as variant",
description=(
"The default 'Apply as variant' state for instances matching this"
" profile. Usually enabled for asset contributions and disabled"
" for shot contributions."
),
)
contribution_target_product: str = SettingsField(
"usdAsset",
title="Target Product",
description=(
"The default destination product name to apply the contribution to"
" when matching this profile."
" Usually e.g. 'usdAsset' or 'usdShot'."
),
)
class CollectUSDLayerContributionsModel(BaseSettingsModel):
enabled: bool = SettingsField(True, title="Enabled")
contribution_layers: list[ContributionLayersModel] = SettingsField(
@ -77,6 +130,14 @@ class CollectUSDLayerContributionsModel(BaseSettingsModel):
"ordering inside the USD contribution workflow."
)
)
profiles: list[CollectUSDLayerContributionsProfileModel] = SettingsField(
default_factory=list,
title="Profiles",
description=(
"Define attribute defaults for USD Contributions on publish"
" instances."
)
)
@validator("contribution_layers")
def validate_unique_outputs(cls, value):
@ -1017,6 +1078,43 @@ DEFAULT_PUBLISH_VALUES = {
{"name": "fx", "order": 500},
{"name": "lighting", "order": 600},
],
"profiles": [
{
"product_types": ["model"],
"task_types": [],
"contribution_layer": "model",
"contribution_apply_as_variant": True,
"contribution_target_product": "usdAsset"
},
{
"product_types": ["look"],
"task_types": [],
"contribution_layer": "look",
"contribution_apply_as_variant": True,
"contribution_target_product": "usdAsset"
},
{
"product_types": ["groom"],
"task_types": [],
"contribution_layer": "groom",
"contribution_apply_as_variant": True,
"contribution_target_product": "usdAsset"
},
{
"product_types": ["rig"],
"task_types": [],
"contribution_layer": "rig",
"contribution_apply_as_variant": True,
"contribution_target_product": "usdAsset"
},
{
"product_types": ["usd"],
"task_types": [],
"contribution_layer": "assembly",
"contribution_apply_as_variant": False,
"contribution_target_product": "usdShot"
},
]
},
"ValidateEditorialAssetName": {
"enabled": True,

View file

@ -0,0 +1,135 @@
import unittest
from unittest.mock import patch
from ayon_core.lib.env_tools import (
CycleError,
DynamicKeyClashError,
parse_env_variables_structure,
compute_env_variables_structure,
)
# --- Test data ---
COMPUTE_SRC_ENV = {
"COMPUTE_VERSION": "1.0.0",
# Will be available only for darwin
"COMPUTE_ONE_PLATFORM": {
"darwin": "Compute macOs",
},
"COMPUTE_LOCATION": {
"darwin": "/compute-app-{COMPUTE_VERSION}",
"linux": "/usr/compute-app-{COMPUTE_VERSION}",
"windows": "C:/Program Files/compute-app-{COMPUTE_VERSION}"
},
"PATH_LIST": {
"darwin": ["{COMPUTE_LOCATION}/bin", "{COMPUTE_LOCATION}/bin2"],
"linux": ["{COMPUTE_LOCATION}/bin", "{COMPUTE_LOCATION}/bin2"],
"windows": ["{COMPUTE_LOCATION}/bin", "{COMPUTE_LOCATION}/bin2"],
},
"PATH_STR": {
"darwin": "{COMPUTE_LOCATION}/bin:{COMPUTE_LOCATION}/bin2",
"linux": "{COMPUTE_LOCATION}/bin:{COMPUTE_LOCATION}/bin2",
"windows": "{COMPUTE_LOCATION}/bin;{COMPUTE_LOCATION}/bin2",
},
}
# --- RESULTS ---
# --- Parse results ---
PARSE_RESULT_WINDOWS = {
"COMPUTE_VERSION": "1.0.0",
"COMPUTE_LOCATION": "C:/Program Files/compute-app-{COMPUTE_VERSION}",
"PATH_LIST": "{COMPUTE_LOCATION}/bin;{COMPUTE_LOCATION}/bin2",
"PATH_STR": "{COMPUTE_LOCATION}/bin;{COMPUTE_LOCATION}/bin2",
}
PARSE_RESULT_LINUX = {
"COMPUTE_VERSION": "1.0.0",
"COMPUTE_LOCATION": "/usr/compute-app-{COMPUTE_VERSION}",
"PATH_LIST": "{COMPUTE_LOCATION}/bin:{COMPUTE_LOCATION}/bin2",
"PATH_STR": "{COMPUTE_LOCATION}/bin:{COMPUTE_LOCATION}/bin2",
}
PARSE_RESULT_DARWIN = {
"COMPUTE_VERSION": "1.0.0",
"COMPUTE_ONE_PLATFORM": "Compute macOs",
"COMPUTE_LOCATION": "/compute-app-{COMPUTE_VERSION}",
"PATH_LIST": "{COMPUTE_LOCATION}/bin:{COMPUTE_LOCATION}/bin2",
"PATH_STR": "{COMPUTE_LOCATION}/bin:{COMPUTE_LOCATION}/bin2",
}
# --- Compute results ---
COMPUTE_RESULT_WINDOWS = {
"COMPUTE_VERSION": "1.0.0",
"COMPUTE_LOCATION": "C:/Program Files/compute-app-1.0.0",
"PATH_LIST": (
"C:/Program Files/compute-app-1.0.0/bin"
";C:/Program Files/compute-app-1.0.0/bin2"
),
"PATH_STR": (
"C:/Program Files/compute-app-1.0.0/bin"
";C:/Program Files/compute-app-1.0.0/bin2"
)
}
COMPUTE_RESULT_LINUX = {
"COMPUTE_VERSION": "1.0.0",
"COMPUTE_LOCATION": "/usr/compute-app-1.0.0",
"PATH_LIST": "/usr/compute-app-1.0.0/bin:/usr/compute-app-1.0.0/bin2",
"PATH_STR": "/usr/compute-app-1.0.0/bin:/usr/compute-app-1.0.0/bin2"
}
COMPUTE_RESULT_DARWIN = {
"COMPUTE_VERSION": "1.0.0",
"COMPUTE_ONE_PLATFORM": "Compute macOs",
"COMPUTE_LOCATION": "/compute-app-1.0.0",
"PATH_LIST": "/compute-app-1.0.0/bin:/compute-app-1.0.0/bin2",
"PATH_STR": "/compute-app-1.0.0/bin:/compute-app-1.0.0/bin2"
}
class EnvParseCompute(unittest.TestCase):
def test_parse_env(self):
with patch("platform.system", return_value="windows"):
result = parse_env_variables_structure(COMPUTE_SRC_ENV)
assert result == PARSE_RESULT_WINDOWS
with patch("platform.system", return_value="linux"):
result = parse_env_variables_structure(COMPUTE_SRC_ENV)
assert result == PARSE_RESULT_LINUX
with patch("platform.system", return_value="darwin"):
result = parse_env_variables_structure(COMPUTE_SRC_ENV)
assert result == PARSE_RESULT_DARWIN
def test_compute_env(self):
with patch("platform.system", return_value="windows"):
result = compute_env_variables_structure(
parse_env_variables_structure(COMPUTE_SRC_ENV)
)
assert result == COMPUTE_RESULT_WINDOWS
with patch("platform.system", return_value="linux"):
result = compute_env_variables_structure(
parse_env_variables_structure(COMPUTE_SRC_ENV)
)
assert result == COMPUTE_RESULT_LINUX
with patch("platform.system", return_value="darwin"):
result = compute_env_variables_structure(
parse_env_variables_structure(COMPUTE_SRC_ENV)
)
assert result == COMPUTE_RESULT_DARWIN
def test_cycle_error(self):
with self.assertRaises(CycleError):
compute_env_variables_structure({
"KEY_1": "{KEY_2}",
"KEY_2": "{KEY_1}",
})
def test_dynamic_key_error(self):
with self.assertRaises(DynamicKeyClashError):
compute_env_variables_structure({
"KEY_A": "Occupied",
"SUBKEY": "A",
"KEY_{SUBKEY}": "Resolves as occupied key",
})

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,128 @@
import os
import opentimelineio as otio
from ayon_core.plugins.publish import collect_otio_frame_ranges
_RESOURCE_DIR = os.path.join(
os.path.dirname(__file__),
"resources",
"timeline"
)
class MockInstance():
""" Mock pyblish instance for testing purpose.
"""
def __init__(self, data: dict):
self.data = data
self.context = self
def _check_expected_frame_range_values(
clip_name: str,
expected_data: dict,
handle_start: int = 10,
handle_end: int = 10,
retimed: bool = False,
):
file_path = os.path.join(_RESOURCE_DIR, "timeline.json")
otio_timeline = otio.schema.Timeline.from_json_file(file_path)
for otio_clip in otio_timeline.find_clips():
if otio_clip.name == clip_name:
break
instance_data = {
"otioClip": otio_clip,
"handleStart": handle_start,
"handleEnd": handle_end,
"workfileFrameStart": 1001,
}
if retimed:
instance_data["shotDurationFromSource"] = True
instance = MockInstance(instance_data)
processor = collect_otio_frame_ranges.CollectOtioRanges()
processor.process(instance)
# Assert expected data is subset of edited instance.
assert expected_data.items() <= instance.data.items()
def test_movie_with_timecode():
"""
Movie clip (with embedded timecode)
available_range = 86531-86590 23.976fps
source_range = 86535-86586 23.976fps
"""
expected_data = {
'frameStart': 1001,
'frameEnd': 1052,
'clipIn': 24,
'clipOut': 75,
'clipInH': 14,
'clipOutH': 85,
'sourceStart': 86535,
'sourceStartH': 86525,
'sourceEnd': 86586,
'sourceEndH': 86596,
}
_check_expected_frame_range_values(
"sh010",
expected_data,
)
def test_image_sequence():
"""
EXR image sequence.
available_range = 87399-87482 24fps
source_range = 87311-87336 23.976fps
"""
expected_data = {
'frameStart': 1001,
'frameEnd': 1026,
'clipIn': 76,
'clipOut': 101,
'clipInH': 66,
'clipOutH': 111,
'sourceStart': 87399,
'sourceStartH': 87389,
'sourceEnd': 87424,
'sourceEndH': 87434,
}
_check_expected_frame_range_values(
"img_sequence_exr",
expected_data,
)
def test_media_retimed():
"""
EXR image sequence.
available_range = 345619-345691 23.976fps
source_range = 345623-345687 23.976fps
TimeWarp = frozen frame.
"""
expected_data = {
'frameStart': 1001,
'frameEnd': 1065,
'clipIn': 127,
'clipOut': 191,
'clipInH': 117,
'clipOutH': 201,
'sourceStart': 1001,
'sourceStartH': 1001,
'sourceEnd': 1065,
'sourceEndH': 1065,
}
_check_expected_frame_range_values(
"P01default_twsh010",
expected_data,
retimed=True,
)

View file

@ -240,6 +240,13 @@ function Run-From-Code {
& $Poetry $RunArgs @arguments
}
function Run-Tests {
$Poetry = "$RepoRoot\.poetry\bin\poetry.exe"
$RunArgs = @( "run", "pytest", "$($RepoRoot)/tests")
& $Poetry $RunArgs @arguments
}
function Write-Help {
<#
.SYNOPSIS
@ -256,6 +263,7 @@ function Write-Help {
Write-Info -Text " ruff-fix ", "Run Ruff fix for the repository" -Color White, Cyan
Write-Info -Text " codespell ", "Run codespell check for the repository" -Color White, Cyan
Write-Info -Text " run ", "Run a poetry command in the repository environment" -Color White, Cyan
Write-Info -Text " run-tests ", "Run ayon-core tests" -Color White, Cyan
Write-Host ""
}
@ -280,6 +288,9 @@ function Resolve-Function {
} elseif ($FunctionName -eq "run") {
Set-Cwd
Run-From-Code
} elseif ($FunctionName -eq "runtests") {
Set-Cwd
Run-Tests
} else {
Write-Host "Unknown function ""$FunctionName"""
Write-Help

View file

@ -158,6 +158,7 @@ default_help() {
echo -e " ${BWhite}ruff-fix${RST} ${BCyan}Run Ruff fix for the repository${RST}"
echo -e " ${BWhite}codespell${RST} ${BCyan}Run codespell check for the repository${RST}"
echo -e " ${BWhite}run${RST} ${BCyan}Run a poetry command in the repository environment${RST}"
echo -e " ${BWhite}run-tests${RST} ${BCyan}Run ayon-core tests${RST}"
echo ""
}
@ -182,6 +183,12 @@ run_command () {
"$POETRY_HOME/bin/poetry" run "$@"
}
run_tests () {
echo -e "${BIGreen}>>>${RST} Running tests..."
shift; # will remove first arg ("run-tests") from the "$@"
"$POETRY_HOME/bin/poetry" run pytest ./tests
}
main () {
detect_python || return 1
@ -218,6 +225,10 @@ main () {
run_command "$@" || return_code=$?
exit $return_code
;;
"runtests")
run_tests "$@" || return_code=$?
exit $return_code
;;
esac
if [ "$function_name" != "" ]; then