Merge branch 'develop' into feature/AY-971_Use-custom-staging-dir-functions

This commit is contained in:
robin@ynput.io 2024-11-18 15:23:28 -05:00
commit acaae18966
37 changed files with 1477 additions and 502 deletions

View file

@ -2,10 +2,23 @@ name: 🚀 Release Trigger
on:
workflow_dispatch:
inputs:
draft:
type: boolean
description: "Create Release Draft"
required: false
default: false
release_overwrite:
type: string
description: "Set Version Release Tag"
required: false
jobs:
call-release-trigger:
uses: ynput/ops-repo-automation/.github/workflows/release_trigger.yml@main
with:
draft: ${{ inputs.draft }}
release_overwrite: ${{ inputs.release_overwrite }}
secrets:
token: ${{ secrets.YNPUT_BOT_TOKEN }}
email: ${{ secrets.CI_EMAIL }}

View file

@ -0,0 +1,16 @@
name: 📤 Upload to Ynput Cloud
on:
workflow_dispatch:
release:
types: [published]
jobs:
call-upload-to-ynput-cloud:
uses: ynput/ops-repo-automation/.github/workflows/upload_to_ynput_cloud.yml@main
secrets:
CI_EMAIL: ${{ secrets.CI_EMAIL }}
CI_USER: ${{ secrets.CI_USER }}
YNPUT_BOT_TOKEN: ${{ secrets.YNPUT_BOT_TOKEN }}
YNPUT_CLOUD_URL: ${{ secrets.YNPUT_CLOUD_URL }}
YNPUT_CLOUD_TOKEN: ${{ secrets.YNPUT_CLOUD_TOKEN }}

View file

@ -6,82 +6,58 @@ import json
import copy
import warnings
from abc import ABCMeta, abstractmethod
from typing import Any, Optional
import typing
from typing import (
Any,
Optional,
List,
Set,
Dict,
Iterable,
TypeVar,
)
import clique
if typing.TYPE_CHECKING:
from typing import Self, Tuple, Union, TypedDict, Pattern
class EnumItemDict(TypedDict):
label: str
value: Any
EnumItemsInputType = Union[
Dict[Any, str],
List[Tuple[Any, str]],
List[Any],
List[EnumItemDict]
]
class FileDefItemDict(TypedDict):
directory: str
filenames: List[str]
frames: Optional[List[int]]
template: Optional[str]
is_sequence: Optional[bool]
# Global variable which store attribute definitions by type
# - default types are registered on import
_attr_defs_by_type = {}
def register_attr_def_class(cls):
"""Register attribute definition.
Currently registered definitions are used to deserialize data to objects.
Attrs:
cls (AbstractAttrDef): Non-abstract class to be registered with unique
'type' attribute.
Raises:
KeyError: When type was already registered.
"""
if cls.type in _attr_defs_by_type:
raise KeyError("Type \"{}\" was already registered".format(cls.type))
_attr_defs_by_type[cls.type] = cls
def get_attributes_keys(attribute_definitions):
"""Collect keys from list of attribute definitions.
Args:
attribute_definitions (List[AbstractAttrDef]): Objects of attribute
definitions.
Returns:
Set[str]: Keys that will be created using passed attribute definitions.
"""
keys = set()
if not attribute_definitions:
return keys
for attribute_def in attribute_definitions:
if not isinstance(attribute_def, UIDef):
keys.add(attribute_def.key)
return keys
def get_default_values(attribute_definitions):
"""Receive default values for attribute definitions.
Args:
attribute_definitions (List[AbstractAttrDef]): Attribute definitions
for which default values should be collected.
Returns:
Dict[str, Any]: Default values for passed attribute definitions.
"""
output = {}
if not attribute_definitions:
return output
for attr_def in attribute_definitions:
# Skip UI definitions
if not isinstance(attr_def, UIDef):
output[attr_def.key] = attr_def.default
return output
# Type hint helpers
IntFloatType = "Union[int, float]"
class AbstractAttrDefMeta(ABCMeta):
"""Metaclass to validate the existence of 'key' attribute.
Each object of `AbstractAttrDef` must have defined 'key' attribute.
"""
"""
def __call__(cls, *args, **kwargs):
obj = super(AbstractAttrDefMeta, cls).__call__(*args, **kwargs)
init_class = getattr(obj, "__init__class__", None)
@ -93,8 +69,12 @@ class AbstractAttrDefMeta(ABCMeta):
def _convert_reversed_attr(
main_value, depr_value, main_label, depr_label, default
):
main_value: Any,
depr_value: Any,
main_label: str,
depr_label: str,
default: Any,
) -> Any:
if main_value is not None and depr_value is not None:
if main_value == depr_value:
print(
@ -140,8 +120,8 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta):
enabled (Optional[bool]): Item is enabled (for UI purposes).
hidden (Optional[bool]): DEPRECATED: Use 'visible' instead.
disabled (Optional[bool]): DEPRECATED: Use 'enabled' instead.
"""
"""
type_attributes = []
is_value_def = True
@ -183,7 +163,7 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta):
def id(self) -> str:
return self._id
def clone(self):
def clone(self) -> "Self":
data = self.serialize()
data.pop("type")
return self.deserialize(data)
@ -251,28 +231,28 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta):
Returns:
str: Type of attribute definition.
"""
"""
pass
@abstractmethod
def convert_value(self, value):
def convert_value(self, value: Any) -> Any:
"""Convert value to a valid one.
Convert passed value to a valid type. Use default if value can't be
converted.
"""
"""
pass
def serialize(self):
def serialize(self) -> Dict[str, Any]:
"""Serialize object to data so it's possible to recreate it.
Returns:
Dict[str, Any]: Serialized object that can be passed to
'deserialize' method.
"""
"""
data = {
"type": self.type,
"key": self.key,
@ -288,7 +268,7 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta):
return data
@classmethod
def deserialize(cls, data):
def deserialize(cls, data: Dict[str, Any]) -> "Self":
"""Recreate object from data.
Data can be received using 'serialize' method.
@ -299,10 +279,12 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta):
return cls(**data)
def _def_type_compare(self, other: "AbstractAttrDef") -> bool:
def _def_type_compare(self, other: "Self") -> bool:
return True
AttrDefType = TypeVar("AttrDefType", bound=AbstractAttrDef)
# -----------------------------------------
# UI attribute definitions won't hold value
# -----------------------------------------
@ -310,13 +292,19 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta):
class UIDef(AbstractAttrDef):
is_value_def = False
def __init__(self, key=None, default=None, *args, **kwargs):
def __init__(
self,
key: Optional[str] = None,
default: Optional[Any] = None,
*args,
**kwargs
):
super().__init__(key, default, *args, **kwargs)
def is_value_valid(self, value: Any) -> bool:
return True
def convert_value(self, value):
def convert_value(self, value: Any) -> Any:
return value
@ -327,8 +315,8 @@ class UISeparatorDef(UIDef):
class UILabelDef(UIDef):
type = "label"
def __init__(self, label, key=None):
super().__init__(label=label, key=key)
def __init__(self, label, key=None, *args, **kwargs):
super().__init__(label=label, key=key, *args, **kwargs)
def _def_type_compare(self, other: "UILabelDef") -> bool:
return self.label == other.label
@ -343,18 +331,18 @@ class UnknownDef(AbstractAttrDef):
This attribute can be used to keep existing data unchanged but does not
have known definition of type.
"""
"""
type = "unknown"
def __init__(self, key, default=None, **kwargs):
def __init__(self, key: str, default: Optional[Any] = None, **kwargs):
kwargs["default"] = default
super().__init__(key, **kwargs)
def is_value_valid(self, value: Any) -> bool:
return True
def convert_value(self, value):
def convert_value(self, value: Any) -> Any:
return value
@ -365,11 +353,11 @@ class HiddenDef(AbstractAttrDef):
to other attributes (e.g. in multi-page UIs).
Keep in mind the value should be possible to parse by json parser.
"""
"""
type = "hidden"
def __init__(self, key, default=None, **kwargs):
def __init__(self, key: str, default: Optional[Any] = None, **kwargs):
kwargs["default"] = default
kwargs["visible"] = False
super().__init__(key, **kwargs)
@ -377,7 +365,7 @@ class HiddenDef(AbstractAttrDef):
def is_value_valid(self, value: Any) -> bool:
return True
def convert_value(self, value):
def convert_value(self, value: Any) -> Any:
return value
@ -392,8 +380,8 @@ class NumberDef(AbstractAttrDef):
maximum(int, float): Maximum possible value.
decimals(int): Maximum decimal points of value.
default(int, float): Default value for conversion.
"""
"""
type = "number"
type_attributes = [
"minimum",
@ -402,7 +390,12 @@ class NumberDef(AbstractAttrDef):
]
def __init__(
self, key, minimum=None, maximum=None, decimals=None, default=None,
self,
key: str,
minimum: Optional[IntFloatType] = None,
maximum: Optional[IntFloatType] = None,
decimals: Optional[int] = None,
default: Optional[IntFloatType] = None,
**kwargs
):
minimum = 0 if minimum is None else minimum
@ -428,9 +421,9 @@ class NumberDef(AbstractAttrDef):
super().__init__(key, default=default, **kwargs)
self.minimum = minimum
self.maximum = maximum
self.decimals = 0 if decimals is None else decimals
self.minimum: IntFloatType = minimum
self.maximum: IntFloatType = maximum
self.decimals: int = 0 if decimals is None else decimals
def is_value_valid(self, value: Any) -> bool:
if self.decimals == 0:
@ -442,7 +435,7 @@ class NumberDef(AbstractAttrDef):
return False
return True
def convert_value(self, value):
def convert_value(self, value: Any) -> IntFloatType:
if isinstance(value, str):
try:
value = float(value)
@ -477,8 +470,8 @@ class TextDef(AbstractAttrDef):
regex(str, re.Pattern): Regex validation.
placeholder(str): UI placeholder for attribute.
default(str, None): Default value. Empty string used when not defined.
"""
"""
type = "text"
type_attributes = [
"multiline",
@ -486,7 +479,12 @@ class TextDef(AbstractAttrDef):
]
def __init__(
self, key, multiline=None, regex=None, placeholder=None, default=None,
self,
key: str,
multiline: Optional[bool] = None,
regex: Optional[str] = None,
placeholder: Optional[str] = None,
default: Optional[str] = None,
**kwargs
):
if default is None:
@ -505,9 +503,9 @@ class TextDef(AbstractAttrDef):
if isinstance(regex, str):
regex = re.compile(regex)
self.multiline = multiline
self.placeholder = placeholder
self.regex = regex
self.multiline: bool = multiline
self.placeholder: Optional[str] = placeholder
self.regex: Optional["Pattern"] = regex
def is_value_valid(self, value: Any) -> bool:
if not isinstance(value, str):
@ -516,14 +514,17 @@ class TextDef(AbstractAttrDef):
return False
return True
def convert_value(self, value):
def convert_value(self, value: Any) -> str:
if isinstance(value, str):
return value
return self.default
def serialize(self):
def serialize(self) -> Dict[str, Any]:
data = super().serialize()
data["regex"] = self.regex.pattern
regex = None
if self.regex is not None:
regex = self.regex.pattern
data["regex"] = regex
data["multiline"] = self.multiline
data["placeholder"] = self.placeholder
return data
@ -542,18 +543,24 @@ class EnumDef(AbstractAttrDef):
is enabled.
Args:
items (Union[list[str], list[dict[str, Any]]): Items definition that
can be converted using 'prepare_enum_items'.
key (str): Key under which value is stored.
items (EnumItemsInputType): Items definition that can be converted
using 'prepare_enum_items'.
default (Optional[Any]): Default value. Must be one key(value) from
passed items or list of values for multiselection.
multiselection (Optional[bool]): If True, multiselection is allowed.
Output is list of selected items.
"""
"""
type = "enum"
def __init__(
self, key, items, default=None, multiselection=False, **kwargs
self,
key: str,
items: "EnumItemsInputType",
default: "Union[str, List[Any]]" = None,
multiselection: Optional[bool] = False,
**kwargs
):
if not items:
raise ValueError((
@ -564,6 +571,9 @@ class EnumDef(AbstractAttrDef):
items = self.prepare_enum_items(items)
item_values = [item["value"] for item in items]
item_values_set = set(item_values)
if multiselection is None:
multiselection = False
if multiselection:
if default is None:
default = []
@ -574,9 +584,9 @@ class EnumDef(AbstractAttrDef):
super().__init__(key, default=default, **kwargs)
self.items = items
self._item_values = item_values_set
self.multiselection = multiselection
self.items: List["EnumItemDict"] = items
self._item_values: Set[Any] = item_values_set
self.multiselection: bool = multiselection
def convert_value(self, value):
if not self.multiselection:
@ -606,7 +616,7 @@ class EnumDef(AbstractAttrDef):
return data
@staticmethod
def prepare_enum_items(items):
def prepare_enum_items(items: "EnumItemsInputType") -> List["EnumItemDict"]:
"""Convert items to unified structure.
Output is a list where each item is dictionary with 'value'
@ -622,13 +632,12 @@ class EnumDef(AbstractAttrDef):
```
Args:
items (Union[Dict[str, Any], List[Any], List[Dict[str, Any]]): The
items to convert.
items (EnumItemsInputType): The items to convert.
Returns:
List[Dict[str, Any]]: Unified structure of items.
"""
List[EnumItemDict]: Unified structure of items.
"""
output = []
if isinstance(items, dict):
for value, label in items.items():
@ -679,11 +688,11 @@ class BoolDef(AbstractAttrDef):
Args:
default(bool): Default value. Set to `False` if not defined.
"""
"""
type = "bool"
def __init__(self, key, default=None, **kwargs):
def __init__(self, key: str, default: Optional[bool] = None, **kwargs):
if default is None:
default = False
super().__init__(key, default=default, **kwargs)
@ -691,7 +700,7 @@ class BoolDef(AbstractAttrDef):
def is_value_valid(self, value: Any) -> bool:
return isinstance(value, bool)
def convert_value(self, value):
def convert_value(self, value: Any) -> bool:
if isinstance(value, bool):
return value
return self.default
@ -699,7 +708,11 @@ class BoolDef(AbstractAttrDef):
class FileDefItem:
def __init__(
self, directory, filenames, frames=None, template=None
self,
directory: str,
filenames: List[str],
frames: Optional[List[int]] = None,
template: Optional[str] = None,
):
self.directory = directory
@ -728,7 +741,7 @@ class FileDefItem:
)
@property
def label(self):
def label(self) -> Optional[str]:
if self.is_empty:
return None
@ -771,7 +784,7 @@ class FileDefItem:
filename_template, ",".join(ranges)
)
def split_sequence(self):
def split_sequence(self) -> List["Self"]:
if not self.is_sequence:
raise ValueError("Cannot split single file item")
@ -782,7 +795,7 @@ class FileDefItem:
return self.from_paths(paths, False)
@property
def ext(self):
def ext(self) -> Optional[str]:
if self.is_empty:
return None
_, ext = os.path.splitext(self.filenames[0])
@ -791,14 +804,14 @@ class FileDefItem:
return None
@property
def lower_ext(self):
def lower_ext(self) -> Optional[str]:
ext = self.ext
if ext is not None:
return ext.lower()
return ext
@property
def is_dir(self):
def is_dir(self) -> bool:
if self.is_empty:
return False
@ -807,10 +820,15 @@ class FileDefItem:
return False
return True
def set_directory(self, directory):
def set_directory(self, directory: str):
self.directory = directory
def set_filenames(self, filenames, frames=None, template=None):
def set_filenames(
self,
filenames: List[str],
frames: Optional[List[int]] = None,
template: Optional[str] = None,
):
if frames is None:
frames = []
is_sequence = False
@ -827,17 +845,21 @@ class FileDefItem:
self.is_sequence = is_sequence
@classmethod
def create_empty_item(cls):
def create_empty_item(cls) -> "Self":
return cls("", "")
@classmethod
def from_value(cls, value, allow_sequences):
def from_value(
cls,
value: "Union[List[FileDefItemDict], FileDefItemDict]",
allow_sequences: bool,
) -> List["Self"]:
"""Convert passed value to FileDefItem objects.
Returns:
list: Created FileDefItem objects.
"""
"""
# Convert single item to iterable
if not isinstance(value, (list, tuple, set)):
value = [value]
@ -869,7 +891,7 @@ class FileDefItem:
return output
@classmethod
def from_dict(cls, data):
def from_dict(cls, data: "FileDefItemDict") -> "Self":
return cls(
data["directory"],
data["filenames"],
@ -878,7 +900,11 @@ class FileDefItem:
)
@classmethod
def from_paths(cls, paths, allow_sequences):
def from_paths(
cls,
paths: List[str],
allow_sequences: bool,
) -> List["Self"]:
filenames_by_dir = collections.defaultdict(list)
for path in paths:
normalized = os.path.normpath(path)
@ -907,7 +933,7 @@ class FileDefItem:
return output
def to_dict(self):
def to_dict(self) -> "FileDefItemDict":
output = {
"is_sequence": self.is_sequence,
"directory": self.directory,
@ -945,8 +971,15 @@ class FileDef(AbstractAttrDef):
]
def __init__(
self, key, single_item=True, folders=None, extensions=None,
allow_sequences=True, extensions_label=None, default=None, **kwargs
self,
key: str,
single_item: Optional[bool] = True,
folders: Optional[bool] = None,
extensions: Optional[Iterable[str]] = None,
allow_sequences: Optional[bool] = True,
extensions_label: Optional[str] = None,
default: Optional["Union[FileDefItemDict, List[str]]"] = None,
**kwargs
):
if folders is None and extensions is None:
folders = True
@ -963,7 +996,9 @@ class FileDef(AbstractAttrDef):
FileDefItem.from_dict(default)
elif isinstance(default, str):
default = FileDefItem.from_paths([default.strip()])[0]
default = FileDefItem.from_paths(
[default.strip()], allow_sequences
)[0]
else:
raise TypeError((
@ -982,14 +1017,14 @@ class FileDef(AbstractAttrDef):
if is_label_horizontal is None:
kwargs["is_label_horizontal"] = False
self.single_item = single_item
self.folders = folders
self.extensions = set(extensions)
self.allow_sequences = allow_sequences
self.extensions_label = extensions_label
self.single_item: bool = single_item
self.folders: bool = folders
self.extensions: Set[str] = set(extensions)
self.allow_sequences: bool = allow_sequences
self.extensions_label: Optional[str] = extensions_label
super().__init__(key, default=default, **kwargs)
def __eq__(self, other):
def __eq__(self, other: Any) -> bool:
if not super().__eq__(other):
return False
@ -1023,7 +1058,9 @@ class FileDef(AbstractAttrDef):
return False
return True
def convert_value(self, value):
def convert_value(
self, value: Any
) -> "Union[FileDefItemDict, List[FileDefItemDict]]":
if isinstance(value, (str, dict)):
value = [value]
@ -1041,7 +1078,9 @@ class FileDef(AbstractAttrDef):
pass
if string_paths:
file_items = FileDefItem.from_paths(string_paths)
file_items = FileDefItem.from_paths(
string_paths, self.allow_sequences
)
dict_items.extend([
file_item.to_dict()
for file_item in file_items
@ -1059,55 +1098,124 @@ class FileDef(AbstractAttrDef):
return []
def serialize_attr_def(attr_def):
def register_attr_def_class(cls: AttrDefType):
"""Register attribute definition.
Currently registered definitions are used to deserialize data to objects.
Attrs:
cls (AttrDefType): Non-abstract class to be registered with unique
'type' attribute.
Raises:
KeyError: When type was already registered.
"""
if cls.type in _attr_defs_by_type:
raise KeyError("Type \"{}\" was already registered".format(cls.type))
_attr_defs_by_type[cls.type] = cls
def get_attributes_keys(
attribute_definitions: List[AttrDefType]
) -> Set[str]:
"""Collect keys from list of attribute definitions.
Args:
attribute_definitions (List[AttrDefType]): Objects of attribute
definitions.
Returns:
Set[str]: Keys that will be created using passed attribute definitions.
"""
keys = set()
if not attribute_definitions:
return keys
for attribute_def in attribute_definitions:
if not isinstance(attribute_def, UIDef):
keys.add(attribute_def.key)
return keys
def get_default_values(
attribute_definitions: List[AttrDefType]
) -> Dict[str, Any]:
"""Receive default values for attribute definitions.
Args:
attribute_definitions (List[AttrDefType]): Attribute definitions
for which default values should be collected.
Returns:
Dict[str, Any]: Default values for passed attribute definitions.
"""
output = {}
if not attribute_definitions:
return output
for attr_def in attribute_definitions:
# Skip UI definitions
if not isinstance(attr_def, UIDef):
output[attr_def.key] = attr_def.default
return output
def serialize_attr_def(attr_def: AttrDefType) -> Dict[str, Any]:
"""Serialize attribute definition to data.
Args:
attr_def (AbstractAttrDef): Attribute definition to serialize.
attr_def (AttrDefType): Attribute definition to serialize.
Returns:
Dict[str, Any]: Serialized data.
"""
"""
return attr_def.serialize()
def serialize_attr_defs(attr_defs):
def serialize_attr_defs(
attr_defs: List[AttrDefType]
) -> List[Dict[str, Any]]:
"""Serialize attribute definitions to data.
Args:
attr_defs (List[AbstractAttrDef]): Attribute definitions to serialize.
attr_defs (List[AttrDefType]): Attribute definitions to serialize.
Returns:
List[Dict[str, Any]]: Serialized data.
"""
"""
return [
serialize_attr_def(attr_def)
for attr_def in attr_defs
]
def deserialize_attr_def(attr_def_data):
def deserialize_attr_def(attr_def_data: Dict[str, Any]) -> AttrDefType:
"""Deserialize attribute definition from data.
Args:
attr_def_data (Dict[str, Any]): Attribute definition data to
deserialize.
"""
"""
attr_type = attr_def_data.pop("type")
cls = _attr_defs_by_type[attr_type]
return cls.deserialize(attr_def_data)
def deserialize_attr_defs(attr_defs_data):
def deserialize_attr_defs(
attr_defs_data: List[Dict[str, Any]]
) -> List[AttrDefType]:
"""Deserialize attribute definitions.
Args:
List[Dict[str, Any]]: List of attribute definitions.
"""
"""
return [
deserialize_attr_def(attr_def_data)
for attr_def_data in attr_defs_data

View file

@ -1,6 +1,5 @@
import os
import sys
import uuid
import getpass
import logging
import platform
@ -11,12 +10,12 @@ import copy
from . import Terminal
# Check for `unicode` in builtins
USE_UNICODE = hasattr(__builtins__, "unicode")
class LogStreamHandler(logging.StreamHandler):
""" StreamHandler class designed to handle utf errors in python 2.x hosts.
"""StreamHandler class.
This was originally designed to handle UTF errors in python 2.x hosts,
however currently solely remains for backwards compatibility.
"""
@ -25,49 +24,27 @@ class LogStreamHandler(logging.StreamHandler):
self.enabled = True
def enable(self):
""" Enable StreamHandler
"""Enable StreamHandler
Used to silence output
Make StreamHandler output again
"""
self.enabled = True
def disable(self):
""" Disable StreamHandler
"""Disable StreamHandler
Make StreamHandler output again
Used to silence output
"""
self.enabled = False
def emit(self, record):
if not self.enable:
if not self.enabled or self.stream is None:
return
try:
msg = self.format(record)
msg = Terminal.log(msg)
stream = self.stream
if stream is None:
return
fs = "%s\n"
# if no unicode support...
if not USE_UNICODE:
stream.write(fs % msg)
else:
try:
if (isinstance(msg, unicode) and # noqa: F821
getattr(stream, 'encoding', None)):
ufs = u'%s\n'
try:
stream.write(ufs % msg)
except UnicodeEncodeError:
stream.write((ufs % msg).encode(stream.encoding))
else:
if (getattr(stream, 'encoding', 'utf-8')):
ufs = u'%s\n'
stream.write(ufs % unicode(msg)) # noqa: F821
else:
stream.write(fs % msg)
except UnicodeError:
stream.write(fs % msg.encode("UTF-8"))
stream.write(f"{msg}\n")
self.flush()
except (KeyboardInterrupt, SystemExit):
raise
@ -141,8 +118,6 @@ class Logger:
process_data = None
# Cached process name or ability to set different process name
_process_name = None
# TODO Remove 'mongo_process_id' in 1.x.x
mongo_process_id = uuid.uuid4().hex
@classmethod
def get_logger(cls, name=None):

View file

@ -1,7 +1,6 @@
import os
import re
import logging
import platform
import clique
@ -38,31 +37,7 @@ def create_hard_link(src_path, dst_path):
dst_path(str): Full path to a file where a link of source will be
added.
"""
# Use `os.link` if is available
# - should be for all platforms with newer python versions
if hasattr(os, "link"):
os.link(src_path, dst_path)
return
# Windows implementation of hardlinks
# - used in Python 2
if platform.system().lower() == "windows":
import ctypes
from ctypes.wintypes import BOOL
CreateHardLink = ctypes.windll.kernel32.CreateHardLinkW
CreateHardLink.argtypes = [
ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_void_p
]
CreateHardLink.restype = BOOL
res = CreateHardLink(dst_path, src_path, None)
if res == 0:
raise ctypes.WinError()
return
# Raises not implemented error if gets here
raise NotImplementedError(
"Implementation of hardlink for current environment is missing."
)
os.link(src_path, dst_path)
def collect_frames(files):
@ -210,7 +185,7 @@ def get_last_version_from_path(path_dir, filter):
assert isinstance(filter, list) and (
len(filter) != 0), "`filter` argument needs to be list and not empty"
filtred_files = list()
filtered_files = list()
# form regex for filtering
pattern = r".*".join(filter)
@ -218,10 +193,10 @@ def get_last_version_from_path(path_dir, filter):
for file in os.listdir(path_dir):
if not re.findall(pattern, file):
continue
filtred_files.append(file)
filtered_files.append(file)
if filtred_files:
sorted(filtred_files)
return filtred_files[-1]
if filtered_files:
filtered_files.sort()
return filtered_files[-1]
return None

View file

@ -12,6 +12,7 @@ from typing import (
Iterable,
Tuple,
List,
Set,
Dict,
Any,
Callable,
@ -252,8 +253,10 @@ class CreateContext:
# Shared data across creators during collection phase
self._collection_shared_data = None
# Context validation cache
self._folder_id_by_folder_path = {}
# Entities cache
self._folder_entities_by_path = {}
self._task_entities_by_id = {}
self._task_ids_by_folder_path = {}
self._task_names_by_folder_path = {}
self.thumbnail_paths_by_instance_id = {}
@ -356,12 +359,12 @@ class CreateContext:
return self._host_is_valid
@property
def host_name(self):
def host_name(self) -> str:
if hasattr(self.host, "name"):
return self.host.name
return os.environ["AYON_HOST_NAME"]
def get_current_project_name(self):
def get_current_project_name(self) -> Optional[str]:
"""Project name which was used as current context on context reset.
Returns:
@ -370,7 +373,7 @@ class CreateContext:
return self._current_project_name
def get_current_folder_path(self):
def get_current_folder_path(self) -> Optional[str]:
"""Folder path which was used as current context on context reset.
Returns:
@ -379,7 +382,7 @@ class CreateContext:
return self._current_folder_path
def get_current_task_name(self):
def get_current_task_name(self) -> Optional[str]:
"""Task name which was used as current context on context reset.
Returns:
@ -388,7 +391,7 @@ class CreateContext:
return self._current_task_name
def get_current_task_type(self):
def get_current_task_type(self) -> Optional[str]:
"""Task type which was used as current context on context reset.
Returns:
@ -403,7 +406,7 @@ class CreateContext:
self._current_task_type = task_type
return self._current_task_type
def get_current_project_entity(self):
def get_current_project_entity(self) -> Optional[Dict[str, Any]]:
"""Project entity for current context project.
Returns:
@ -419,26 +422,21 @@ class CreateContext:
self._current_project_entity = project_entity
return copy.deepcopy(self._current_project_entity)
def get_current_folder_entity(self):
def get_current_folder_entity(self) -> Optional[Dict[str, Any]]:
"""Folder entity for current context folder.
Returns:
Union[dict[str, Any], None]: Folder entity.
Optional[dict[str, Any]]: Folder entity.
"""
if self._current_folder_entity is not _NOT_SET:
return copy.deepcopy(self._current_folder_entity)
folder_entity = None
folder_path = self.get_current_folder_path()
if folder_path:
project_name = self.get_current_project_name()
folder_entity = ayon_api.get_folder_by_path(
project_name, folder_path
)
self._current_folder_entity = folder_entity
self._current_folder_entity = self.get_folder_entity(folder_path)
return copy.deepcopy(self._current_folder_entity)
def get_current_task_entity(self):
def get_current_task_entity(self) -> Optional[Dict[str, Any]]:
"""Task entity for current context task.
Returns:
@ -447,18 +445,12 @@ class CreateContext:
"""
if self._current_task_entity is not _NOT_SET:
return copy.deepcopy(self._current_task_entity)
task_entity = None
folder_path = self.get_current_folder_path()
task_name = self.get_current_task_name()
if task_name:
folder_entity = self.get_current_folder_entity()
if folder_entity:
project_name = self.get_current_project_name()
task_entity = ayon_api.get_task_by_name(
project_name,
folder_id=folder_entity["id"],
task_name=task_name
)
self._current_task_entity = task_entity
self._current_task_entity = self.get_task_entity(
folder_path, task_name
)
return copy.deepcopy(self._current_task_entity)
def get_current_workfile_path(self):
@ -566,8 +558,13 @@ class CreateContext:
# Give ability to store shared data for collection phase
self._collection_shared_data = {}
self._folder_id_by_folder_path = {}
self._folder_entities_by_path = {}
self._task_entities_by_id = {}
self._task_ids_by_folder_path = {}
self._task_names_by_folder_path = {}
self._event_hub.clear_callbacks()
def reset_finalization(self):
@ -1468,6 +1465,260 @@ class CreateContext:
if failed_info:
raise CreatorsCreateFailed(failed_info)
def get_folder_entities(self, folder_paths: Iterable[str]):
"""Get folder entities by paths.
Args:
folder_paths (Iterable[str]): Folder paths.
Returns:
Dict[str, Optional[Dict[str, Any]]]: Folder entities by path.
"""
output = {
folder_path: None
for folder_path in folder_paths
}
remainder_paths = set()
for folder_path in output:
# Skip invalid folder paths (folder name or empty path)
if not folder_path or "/" not in folder_path:
continue
if folder_path not in self._folder_entities_by_path:
remainder_paths.add(folder_path)
continue
output[folder_path] = self._folder_entities_by_path[folder_path]
if not remainder_paths:
return output
found_paths = set()
for folder_entity in ayon_api.get_folders(
self.project_name,
folder_paths=remainder_paths,
):
folder_path = folder_entity["path"]
found_paths.add(folder_path)
output[folder_path] = folder_entity
self._folder_entities_by_path[folder_path] = folder_entity
# Cache empty folder entities
for path in remainder_paths - found_paths:
self._folder_entities_by_path[path] = None
return output
def get_task_entities(
self,
task_names_by_folder_paths: Dict[str, Set[str]]
) -> Dict[str, Dict[str, Optional[Dict[str, Any]]]]:
"""Get task entities by folder path and task name.
Entities are cached until reset.
Args:
task_names_by_folder_paths (Dict[str, Set[str]]): Task names by
folder path.
Returns:
Dict[str, Dict[str, Dict[str, Any]]]: Task entities by folder path
and task name.
"""
output = {}
for folder_path, task_names in task_names_by_folder_paths.items():
if folder_path is None:
continue
output[folder_path] = {
task_name: None
for task_name in task_names
if task_name is not None
}
missing_folder_paths = set()
for folder_path, output_task_entities_by_name in output.items():
if not output_task_entities_by_name:
continue
if folder_path not in self._task_ids_by_folder_path:
missing_folder_paths.add(folder_path)
continue
all_tasks_filled = True
task_ids = self._task_ids_by_folder_path[folder_path]
task_entities_by_name = {}
for task_id in task_ids:
task_entity = self._task_entities_by_id.get(task_id)
if task_entity is None:
all_tasks_filled = False
continue
task_entities_by_name[task_entity["name"]] = task_entity
any_missing = False
for task_name in set(output_task_entities_by_name):
task_entity = task_entities_by_name.get(task_name)
if task_entity is None:
any_missing = True
continue
output_task_entities_by_name[task_name] = task_entity
if any_missing and not all_tasks_filled:
missing_folder_paths.add(folder_path)
if not missing_folder_paths:
return output
folder_entities_by_path = self.get_folder_entities(
missing_folder_paths
)
folder_path_by_id = {}
for folder_path, folder_entity in folder_entities_by_path.items():
if folder_entity is not None:
folder_path_by_id[folder_entity["id"]] = folder_path
if not folder_path_by_id:
return output
task_entities_by_parent_id = collections.defaultdict(list)
for task_entity in ayon_api.get_tasks(
self.project_name,
folder_ids=folder_path_by_id.keys()
):
folder_id = task_entity["folderId"]
task_entities_by_parent_id[folder_id].append(task_entity)
for folder_id, task_entities in task_entities_by_parent_id.items():
folder_path = folder_path_by_id[folder_id]
task_ids = set()
task_names = set()
for task_entity in task_entities:
task_id = task_entity["id"]
task_name = task_entity["name"]
task_ids.add(task_id)
task_names.add(task_name)
self._task_entities_by_id[task_id] = task_entity
output[folder_path][task_name] = task_entity
self._task_ids_by_folder_path[folder_path] = task_ids
self._task_names_by_folder_path[folder_path] = task_names
return output
def get_folder_entity(
self,
folder_path: Optional[str],
) -> Optional[Dict[str, Any]]:
"""Get folder entity by path.
Entities are cached until reset.
Args:
folder_path (Optional[str]): Folder path.
Returns:
Optional[Dict[str, Any]]: Folder entity.
"""
if not folder_path:
return None
return self.get_folder_entities([folder_path]).get(folder_path)
def get_task_entity(
self,
folder_path: Optional[str],
task_name: Optional[str],
) -> Optional[Dict[str, Any]]:
"""Get task entity by name and folder path.
Entities are cached until reset.
Args:
folder_path (Optional[str]): Folder path.
task_name (Optional[str]): Task name.
Returns:
Optional[Dict[str, Any]]: Task entity.
"""
if not folder_path or not task_name:
return None
output = self.get_task_entities({folder_path: {task_name}})
return output.get(folder_path, {}).get(task_name)
def get_instances_folder_entities(
self, instances: Optional[Iterable["CreatedInstance"]] = None
) -> Dict[str, Optional[Dict[str, Any]]]:
if instances is None:
instances = self._instances_by_id.values()
instances = list(instances)
output = {
instance.id: None
for instance in instances
}
if not instances:
return output
folder_paths = {
instance.get("folderPath")
for instance in instances
}
folder_paths.discard(None)
folder_entities_by_path = self.get_folder_entities(folder_paths)
for instance in instances:
folder_path = instance.get("folderPath")
output[instance.id] = folder_entities_by_path.get(folder_path)
return output
def get_instances_task_entities(
self, instances: Optional[Iterable["CreatedInstance"]] = None
):
"""Get task entities for instances.
Args:
instances (Optional[Iterable[CreatedInstance]]): Instances to
get task entities. If not provided all instances are used.
Returns:
Dict[str, Optional[Dict[str, Any]]]: Task entity by instance id.
"""
if instances is None:
instances = self._instances_by_id.values()
instances = list(instances)
output = {
instance.id: None
for instance in instances
}
if not instances:
return output
filtered_instances = []
task_names_by_folder_path = collections.defaultdict(set)
for instance in instances:
folder_path = instance.get("folderPath")
task_name = instance.get("task")
if not folder_path or not task_name:
continue
filtered_instances.append(instance)
task_names_by_folder_path[folder_path].add(task_name)
task_entities_by_folder_path = self.get_task_entities(
task_names_by_folder_path
)
for instance in filtered_instances:
folder_path = instance["folderPath"]
task_name = instance["task"]
output[instance.id] = (
task_entities_by_folder_path[folder_path][task_name]
)
return output
def get_instances_context_info(
self, instances: Optional[Iterable["CreatedInstance"]] = None
) -> Dict[str, InstanceContextInfo]:
@ -1508,15 +1759,16 @@ class CreateContext:
if instance.has_promised_context:
context_info.folder_is_valid = True
context_info.task_is_valid = True
# NOTE missing task type
continue
# TODO allow context promise
folder_path = context_info.folder_path
if not folder_path:
continue
if folder_path in self._folder_id_by_folder_path:
folder_id = self._folder_id_by_folder_path[folder_path]
if folder_id is None:
if folder_path in self._folder_entities_by_path:
folder_entity = self._folder_entities_by_path[folder_path]
if folder_entity is None:
continue
context_info.folder_is_valid = True
@ -1535,72 +1787,78 @@ class CreateContext:
# Backwards compatibility for cases where folder name is set instead
# of folder path
folder_names = set()
folder_paths = set()
for folder_path in task_names_by_folder_path.keys():
task_names_by_folder_name = {}
task_names_by_folder_path_clean = {}
for folder_path, task_names in task_names_by_folder_path.items():
if folder_path is None:
pass
elif "/" in folder_path:
folder_paths.add(folder_path)
else:
folder_names.add(folder_path)
continue
folder_paths_by_id = {}
if folder_paths:
clean_task_names = {
task_name
for task_name in task_names
if task_name
}
if "/" not in folder_path:
task_names_by_folder_name[folder_path] = clean_task_names
continue
folder_paths.add(folder_path)
if not clean_task_names:
continue
task_names_by_folder_path_clean[folder_path] = clean_task_names
folder_paths_by_name = collections.defaultdict(list)
if task_names_by_folder_name:
for folder_entity in ayon_api.get_folders(
project_name,
folder_paths=folder_paths,
fields={"id", "path"}
folder_names=task_names_by_folder_name.keys(),
fields={"name", "path"}
):
folder_id = folder_entity["id"]
folder_path = folder_entity["path"]
folder_paths_by_id[folder_id] = folder_path
self._folder_id_by_folder_path[folder_path] = folder_id
folder_entities_by_name = collections.defaultdict(list)
if folder_names:
for folder_entity in ayon_api.get_folders(
project_name,
folder_names=folder_names,
fields={"id", "name", "path"}
):
folder_id = folder_entity["id"]
folder_name = folder_entity["name"]
folder_path = folder_entity["path"]
folder_paths_by_id[folder_id] = folder_path
folder_entities_by_name[folder_name].append(folder_entity)
self._folder_id_by_folder_path[folder_path] = folder_id
folder_paths_by_name[folder_name].append(folder_path)
tasks_entities = ayon_api.get_tasks(
project_name,
folder_ids=folder_paths_by_id.keys(),
fields={"name", "folderId"}
folder_path_by_name = {}
for folder_name, paths in folder_paths_by_name.items():
if len(paths) != 1:
continue
path = paths[0]
folder_path_by_name[folder_name] = path
folder_paths.add(path)
clean_task_names = task_names_by_folder_name[folder_name]
if not clean_task_names:
continue
folder_task_names = task_names_by_folder_path_clean.setdefault(
path, set()
)
folder_task_names |= clean_task_names
folder_entities_by_path = self.get_folder_entities(folder_paths)
task_entities_by_folder_path = self.get_task_entities(
task_names_by_folder_path_clean
)
task_names_by_folder_path = collections.defaultdict(set)
for task_entity in tasks_entities:
folder_id = task_entity["folderId"]
folder_path = folder_paths_by_id[folder_id]
task_names_by_folder_path[folder_path].add(task_entity["name"])
self._task_names_by_folder_path.update(task_names_by_folder_path)
for instance in to_validate:
folder_path = instance["folderPath"]
task_name = instance.get("task")
if folder_path and "/" not in folder_path:
folder_entities = folder_entities_by_name.get(folder_path)
if len(folder_entities) == 1:
folder_path = folder_entities[0]["path"]
instance["folderPath"] = folder_path
new_folder_path = folder_path_by_name.get(folder_path)
if new_folder_path:
folder_path = new_folder_path
instance["folderPath"] = new_folder_path
if folder_path not in task_names_by_folder_path:
folder_entity = folder_entities_by_path.get(folder_path)
if not folder_entity:
continue
context_info = info_by_instance_id[instance.id]
context_info.folder_is_valid = True
if (
not task_name
or task_name in task_names_by_folder_path[folder_path]
or task_name in task_entities_by_folder_path[folder_path]
):
context_info.task_is_valid = True
return info_by_instance_id

View file

@ -132,6 +132,10 @@ class AttributeValues:
def __contains__(self, key):
return key in self._attr_defs_by_key
def __iter__(self):
for key in self._attr_defs_by_key:
yield key
def get(self, key, default=None):
if key in self._attr_defs_by_key:
return self[key]
@ -425,11 +429,18 @@ class CreatedInstance:
__immutable_keys = (
"id",
"instance_id",
"product_type",
"productType",
"creator_identifier",
"creator_attributes",
"publish_attributes"
)
# Keys that can be changed, but should not be removed from instance
__required_keys = {
"folderPath": None,
"task": None,
"productName": None,
"active": True,
}
def __init__(
self,
@ -511,6 +522,9 @@ class CreatedInstance:
if data:
self._data.update(data)
for key, default in self.__required_keys.items():
self._data.setdefault(key, default)
if not self._data.get("instance_id"):
self._data["instance_id"] = str(uuid4())
@ -563,6 +577,8 @@ class CreatedInstance:
has_key = key in self._data
output = self._data.pop(key, *args, **kwargs)
if has_key:
if key in self.__required_keys:
self._data[key] = self.__required_keys[key]
self._create_context.instance_values_changed(
self.id, {key: None}
)

View file

@ -383,6 +383,13 @@ def get_representations_delivery_template_data(
continue
template_data = repre_entity["context"]
# Bug in 'ayon_api', 'get_representations_hierarchy' did not fully
# convert representation entity. Fixed in 'ayon_api' 1.0.10.
if isinstance(template_data, str):
con = ayon_api.get_server_api_connection()
repre_entity = con._representation_conversion(repre_entity)
template_data = repre_entity["context"]
template_data.update(copy.deepcopy(general_template_data))
template_data.update(get_folder_template_data(
repre_hierarchy.folder, project_name
@ -402,5 +409,9 @@ def get_representations_delivery_template_data(
"version": version_entity["version"],
})
_merge_data(template_data, repre_entity["context"])
# Remove roots from template data to auto-fill them with anatomy data
template_data.pop("root", None)
output[repre_id] = template_data
return output

View file

@ -205,9 +205,9 @@ class AYONPyblishPluginMixin:
if not cls.__instanceEnabled__:
return False
for _ in pyblish.logic.plugins_by_families(
[cls], [instance.product_type]
):
families = [instance.product_type]
families.extend(instance.get("families", []))
for _ in pyblish.logic.plugins_by_families([cls], families):
return True
return False

View file

@ -99,7 +99,7 @@ class OpenTaskPath(LauncherAction):
if platform_name == "windows":
args = ["start", path]
elif platform_name == "darwin":
args = ["open", "-na", path]
args = ["open", "-R", path]
elif platform_name == "linux":
args = ["xdg-open", path]
else:

View file

@ -95,9 +95,42 @@ class CollectOtioReview(pyblish.api.InstancePlugin):
instance.data["label"] = label + " (review)"
instance.data["families"] += ["review", "ftrack"]
instance.data["otioReviewClips"] = otio_review_clips
self.log.info(
"Creating review track: {}".format(otio_review_clips))
# get colorspace from metadata if available
# get metadata from first clip with media reference
r_otio_cl = next(
(
clip
for clip in otio_review_clips
if (
isinstance(clip, otio.schema.Clip)
and clip.media_reference
)
),
None
)
if r_otio_cl is not None:
media_ref = r_otio_cl.media_reference
media_metadata = media_ref.metadata
# TODO: we might need some alternative method since
# native OTIO exports do not support ayon metadata
review_colorspace = media_metadata.get(
"ayon.source.colorspace"
)
if review_colorspace is None:
# Backwards compatibility for older scenes
review_colorspace = media_metadata.get(
"openpype.source.colourtransform"
)
if review_colorspace:
instance.data["reviewColorspace"] = review_colorspace
self.log.info(
"Review colorspace: {}".format(review_colorspace))
self.log.debug(
"_ instance.data: {}".format(pformat(instance.data)))
self.log.debug(

View file

@ -10,12 +10,16 @@ import os
import clique
import pyblish.api
from ayon_core.pipeline import publish
from ayon_core.pipeline.publish import (
get_publish_template_name
)
class CollectOtioSubsetResources(pyblish.api.InstancePlugin):
class CollectOtioSubsetResources(
pyblish.api.InstancePlugin,
publish.ColormanagedPyblishPluginMixin
):
"""Get Resources for a product version"""
label = "Collect OTIO Subset Resources"
@ -190,9 +194,13 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin):
instance.data["originalDirname"] = self.staging_dir
if repre:
colorspace = instance.data.get("colorspace")
# add colorspace data to representation
self.set_representation_colorspace(
repre, instance.context, colorspace)
# add representation to instance data
instance.data["representations"].append(repre)
self.log.debug(">>>>>>>> {}".format(repre))
self.log.debug(instance.data)

View file

@ -157,12 +157,15 @@ class ExtractOIIOTranscode(publish.Extractor):
files_to_convert = self._translate_to_sequence(
files_to_convert)
self.log.debug("Files to convert: {}".format(files_to_convert))
for file_name in files_to_convert:
self.log.debug("Transcoding file: `{}`".format(file_name))
input_path = os.path.join(original_staging_dir,
file_name)
output_path = self._get_output_file_path(input_path,
new_staging_dir,
output_extension)
convert_colorspace(
input_path,
output_path,

View file

@ -37,6 +37,9 @@ class ExtractColorspaceData(publish.Extractor,
# get colorspace settings
context = instance.context
# colorspace name could be kept in instance.data
colorspace = instance.data.get("colorspace")
# loop representations
for representation in representations:
# skip if colorspaceData is already at representation
@ -44,5 +47,4 @@ class ExtractColorspaceData(publish.Extractor,
continue
self.set_representation_colorspace(
representation, context
)
representation, context, colorspace)

View file

@ -154,7 +154,9 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin):
# TODO check if existing entity have 'task' type
if task_entity is None:
task_entity = entity_hub.add_new_task(
task_info["type"],
task_type=task_info["type"],
# TODO change 'parent_id' to 'folder_id' when ayon api
# is updated
parent_id=entity.id,
name=task_name
)
@ -182,7 +184,7 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin):
folder_type = "Folder"
child_entity = entity_hub.add_new_folder(
folder_type,
folder_type=folder_type,
parent_id=entity.id,
name=child_name
)

View file

@ -26,7 +26,10 @@ from ayon_core.lib import (
from ayon_core.pipeline import publish
class ExtractOTIOReview(publish.Extractor):
class ExtractOTIOReview(
publish.Extractor,
publish.ColormanagedPyblishPluginMixin
):
"""
Extract OTIO timeline into one concuted image sequence file.
@ -71,14 +74,19 @@ class ExtractOTIOReview(publish.Extractor):
# TODO: what if handles are different in `versionData`?
handle_start = instance.data["handleStart"]
handle_end = instance.data["handleEnd"]
otio_review_clips = instance.data["otioReviewClips"]
otio_review_clips = instance.data.get("otioReviewClips")
if otio_review_clips is None:
self.log.info(f"Instance `{instance}` has no otioReviewClips")
# add plugin wide attributes
self.representation_files = []
self.used_frames = []
self.workfile_start = int(instance.data.get(
"workfileFrameStart", 1001)) - handle_start
self.padding = len(str(self.workfile_start))
# NOTE: padding has to be converted from
# end frame since start could be lower then 1000
self.padding = len(str(instance.data.get("frameEnd", 1001)))
self.used_frames.append(self.workfile_start)
self.to_width = instance.data.get(
"resolutionWidth") or self.to_width
@ -86,8 +94,10 @@ class ExtractOTIOReview(publish.Extractor):
"resolutionHeight") or self.to_height
# skip instance if no reviewable data available
if (not isinstance(otio_review_clips[0], otio.schema.Clip)) \
and (len(otio_review_clips) == 1):
if (
not isinstance(otio_review_clips[0], otio.schema.Clip)
and len(otio_review_clips) == 1
):
self.log.warning(
"Instance `{}` has nothing to process".format(instance))
return
@ -168,7 +178,7 @@ class ExtractOTIOReview(publish.Extractor):
start -= clip_handle_start
duration += clip_handle_start
elif len(otio_review_clips) > 1 \
and (index == len(otio_review_clips) - 1):
and (index == len(otio_review_clips) - 1):
# more clips | last clip reframing with handle
duration += clip_handle_end
elif len(otio_review_clips) == 1:
@ -263,6 +273,13 @@ class ExtractOTIOReview(publish.Extractor):
# creating and registering representation
representation = self._create_representation(start, duration)
# add colorspace data to representation
if colorspace := instance.data.get("reviewColorspace"):
self.set_representation_colorspace(
representation, instance.context, colorspace
)
instance.data["representations"].append(representation)
self.log.info("Adding representation: {}".format(representation))

View file

@ -458,7 +458,18 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
return new_instance
@classmethod
def get_attribute_defs(cls):
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 []
# Attributes logic
publish_attributes = instance["publish_attributes"].get(
cls.__name__, {})
visible = publish_attributes.get("contribution_enabled", True)
variant_visible = visible and publish_attributes.get(
"contribution_apply_as_variant", True)
return [
UISeparatorDef("usd_container_settings1"),
@ -484,7 +495,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"the contribution itself will be added to the "
"department layer."
),
default="usdAsset"),
default="usdAsset",
visible=visible),
EnumDef("contribution_target_product_init",
label="Initialize as",
tooltip=(
@ -495,7 +507,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"setting will do nothing."
),
items=["asset", "shot"],
default="asset"),
default="asset",
visible=visible),
# Asset layer, e.g. model.usd, look.usd, rig.usd
EnumDef("contribution_layer",
@ -507,7 +520,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"the list) will contribute as a stronger opinion."
),
items=list(cls.contribution_layers.keys()),
default="model"),
default="model",
visible=visible),
BoolDef("contribution_apply_as_variant",
label="Add as variant",
tooltip=(
@ -518,13 +532,16 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"appended to as a sublayer to the department layer "
"instead."
),
default=True),
default=True,
visible=visible),
TextDef("contribution_variant_set_name",
label="Variant Set Name",
default="{layer}"),
default="{layer}",
visible=variant_visible),
TextDef("contribution_variant",
label="Variant Name",
default="{variant}"),
default="{variant}",
visible=variant_visible),
BoolDef("contribution_variant_is_default",
label="Set as default variant selection",
tooltip=(
@ -535,10 +552,41 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"The behavior is unpredictable if multiple instances "
"for the same variant set have this enabled."
),
default=False),
default=False,
visible=variant_visible),
UISeparatorDef("usd_container_settings3"),
]
@classmethod
def register_create_context_callbacks(cls, create_context):
create_context.add_value_changed_callback(cls.on_values_changed)
@classmethod
def on_values_changed(cls, event):
"""Update instance attribute definitions on attribute changes."""
# Update attributes if any of the following plug-in attributes
# change:
keys = ["contribution_enabled", "contribution_apply_as_variant"]
for instance_change in event["changes"]:
instance = instance_change["instance"]
if not cls.instance_matches_plugin_families(instance):
continue
value_changes = instance_change["changes"]
plugin_attribute_changes = (
value_changes.get("publish_attributes", {})
.get(cls.__name__, {}))
if not any(key in plugin_attribute_changes for key in keys):
continue
# Update the attribute definitions
new_attrs = cls.get_attr_defs_for_instance(
event["create_context"], instance
)
instance.set_publish_plugin_attr_defs(cls.__name__, new_attrs)
class CollectUSDLayerContributionsHoudiniLook(CollectUSDLayerContributions):
"""
@ -551,9 +599,12 @@ class CollectUSDLayerContributionsHoudiniLook(CollectUSDLayerContributions):
label = CollectUSDLayerContributions.label + " (Look)"
@classmethod
def get_attribute_defs(cls):
defs = super(CollectUSDLayerContributionsHoudiniLook,
cls).get_attribute_defs()
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")

View file

@ -60,7 +60,11 @@
"icon-alert-tools": "#AA5050",
"icon-entity-default": "#bfccd6",
"icon-entity-disabled": "#808080",
"font-entity-deprecated": "#666666",
"font-overridden": "#91CDFC",
"overlay-messages": {
"close-btn": "#D3D8DE",
"bg-success": "#458056",

View file

@ -1585,6 +1585,10 @@ CreateNextPageOverlay {
}
/* Attribute Definition widgets */
AttributeDefinitionsLabel[overridden="1"] {
color: {color:font-overridden};
}
AttributeDefinitionsWidget QAbstractSpinBox, QLineEdit, QPlainTextEdit, QTextEdit {
padding: 1px;
}

View file

@ -1,6 +1,7 @@
from .widgets import (
create_widget_for_attr_def,
AttributeDefinitionsWidget,
AttributeDefinitionsLabel,
)
from .dialog import (
@ -11,6 +12,7 @@ from .dialog import (
__all__ = (
"create_widget_for_attr_def",
"AttributeDefinitionsWidget",
"AttributeDefinitionsLabel",
"AttributeDefinitionsDialog",
)

View file

@ -0,0 +1 @@
REVERT_TO_DEFAULT_LABEL = "Revert to default"

View file

@ -17,6 +17,8 @@ from ayon_core.tools.utils import (
PixmapLabel
)
from ._constants import REVERT_TO_DEFAULT_LABEL
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
ITEM_LABEL_ROLE = QtCore.Qt.UserRole + 2
ITEM_ICON_ROLE = QtCore.Qt.UserRole + 3
@ -598,7 +600,7 @@ class FilesView(QtWidgets.QListView):
"""View showing instances and their groups."""
remove_requested = QtCore.Signal()
context_menu_requested = QtCore.Signal(QtCore.QPoint)
context_menu_requested = QtCore.Signal(QtCore.QPoint, bool)
def __init__(self, *args, **kwargs):
super(FilesView, self).__init__(*args, **kwargs)
@ -690,9 +692,8 @@ class FilesView(QtWidgets.QListView):
def _on_context_menu_request(self, pos):
index = self.indexAt(pos)
if index.isValid():
point = self.viewport().mapToGlobal(pos)
self.context_menu_requested.emit(point)
point = self.viewport().mapToGlobal(pos)
self.context_menu_requested.emit(point, index.isValid())
def _on_selection_change(self):
self._remove_btn.setEnabled(self.has_selected_item_ids())
@ -721,27 +722,34 @@ class FilesView(QtWidgets.QListView):
class FilesWidget(QtWidgets.QFrame):
value_changed = QtCore.Signal()
revert_requested = QtCore.Signal()
def __init__(self, single_item, allow_sequences, extensions_label, parent):
super(FilesWidget, self).__init__(parent)
super().__init__(parent)
self.setAcceptDrops(True)
wrapper_widget = QtWidgets.QWidget(self)
empty_widget = DropEmpty(
single_item, allow_sequences, extensions_label, self
single_item, allow_sequences, extensions_label, wrapper_widget
)
files_model = FilesModel(single_item, allow_sequences)
files_proxy_model = FilesProxyModel()
files_proxy_model.setSourceModel(files_model)
files_view = FilesView(self)
files_view = FilesView(wrapper_widget)
files_view.setModel(files_proxy_model)
layout = QtWidgets.QStackedLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setStackingMode(QtWidgets.QStackedLayout.StackAll)
layout.addWidget(empty_widget)
layout.addWidget(files_view)
layout.setCurrentWidget(empty_widget)
wrapper_layout = QtWidgets.QStackedLayout(wrapper_widget)
wrapper_layout.setContentsMargins(0, 0, 0, 0)
wrapper_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll)
wrapper_layout.addWidget(empty_widget)
wrapper_layout.addWidget(files_view)
wrapper_layout.setCurrentWidget(empty_widget)
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(wrapper_widget, 1)
files_proxy_model.rowsInserted.connect(self._on_rows_inserted)
files_proxy_model.rowsRemoved.connect(self._on_rows_removed)
@ -761,7 +769,11 @@ class FilesWidget(QtWidgets.QFrame):
self._widgets_by_id = {}
self._layout = layout
self._wrapper_widget = wrapper_widget
self._wrapper_layout = wrapper_layout
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self._on_context_menu)
def _set_multivalue(self, multivalue):
if self._multivalue is multivalue:
@ -770,7 +782,7 @@ class FilesWidget(QtWidgets.QFrame):
self._files_view.set_multivalue(multivalue)
self._files_model.set_multivalue(multivalue)
self._files_proxy_model.set_multivalue(multivalue)
self.setEnabled(not multivalue)
self._wrapper_widget.setEnabled(not multivalue)
def set_value(self, value, multivalue):
self._in_set_value = True
@ -888,22 +900,28 @@ class FilesWidget(QtWidgets.QFrame):
if items_to_delete:
self._remove_item_by_ids(items_to_delete)
def _on_context_menu_requested(self, pos):
if self._multivalue:
return
def _on_context_menu(self, pos):
self._on_context_menu_requested(pos, False)
def _on_context_menu_requested(self, pos, valid_index):
menu = QtWidgets.QMenu(self._files_view)
if valid_index and not self._multivalue:
if self._files_view.has_selected_sequence():
split_action = QtWidgets.QAction("Split sequence", menu)
split_action.triggered.connect(self._on_split_request)
menu.addAction(split_action)
if self._files_view.has_selected_sequence():
split_action = QtWidgets.QAction("Split sequence", menu)
split_action.triggered.connect(self._on_split_request)
menu.addAction(split_action)
remove_action = QtWidgets.QAction("Remove", menu)
remove_action.triggered.connect(self._on_remove_requested)
menu.addAction(remove_action)
remove_action = QtWidgets.QAction("Remove", menu)
remove_action.triggered.connect(self._on_remove_requested)
menu.addAction(remove_action)
if not valid_index:
revert_action = QtWidgets.QAction(REVERT_TO_DEFAULT_LABEL, menu)
revert_action.triggered.connect(self.revert_requested)
menu.addAction(revert_action)
menu.popup(pos)
if menu.actions():
menu.popup(pos)
def dragEnterEvent(self, event):
if self._multivalue:
@ -1011,5 +1029,5 @@ class FilesWidget(QtWidgets.QFrame):
current_widget = self._files_view
else:
current_widget = self._empty_widget
self._layout.setCurrentWidget(current_widget)
self._wrapper_layout.setCurrentWidget(current_widget)
self._files_view.update_remove_btn_visibility()

View file

@ -1,4 +1,6 @@
import copy
import typing
from typing import Optional
from qtpy import QtWidgets, QtCore
@ -20,14 +22,25 @@ from ayon_core.tools.utils import (
FocusSpinBox,
FocusDoubleSpinBox,
MultiSelectionComboBox,
set_style_property,
)
from ayon_core.tools.utils import NiceCheckbox
from ._constants import REVERT_TO_DEFAULT_LABEL
from .files_widget import FilesWidget
if typing.TYPE_CHECKING:
from typing import Union
def create_widget_for_attr_def(attr_def, parent=None):
widget = _create_widget_for_attr_def(attr_def, parent)
def create_widget_for_attr_def(
attr_def: AbstractAttrDef,
parent: Optional[QtWidgets.QWidget] = None,
handle_revert_to_default: Optional[bool] = True,
):
widget = _create_widget_for_attr_def(
attr_def, parent, handle_revert_to_default
)
if not attr_def.visible:
widget.setVisible(False)
@ -36,42 +49,96 @@ def create_widget_for_attr_def(attr_def, parent=None):
return widget
def _create_widget_for_attr_def(attr_def, parent=None):
def _create_widget_for_attr_def(
attr_def: AbstractAttrDef,
parent: "Union[QtWidgets.QWidget, None]",
handle_revert_to_default: bool,
):
if not isinstance(attr_def, AbstractAttrDef):
raise TypeError("Unexpected type \"{}\" expected \"{}\"".format(
str(type(attr_def)), AbstractAttrDef
))
cls = None
if isinstance(attr_def, NumberDef):
return NumberAttrWidget(attr_def, parent)
cls = NumberAttrWidget
if isinstance(attr_def, TextDef):
return TextAttrWidget(attr_def, parent)
elif isinstance(attr_def, TextDef):
cls = TextAttrWidget
if isinstance(attr_def, EnumDef):
return EnumAttrWidget(attr_def, parent)
elif isinstance(attr_def, EnumDef):
cls = EnumAttrWidget
if isinstance(attr_def, BoolDef):
return BoolAttrWidget(attr_def, parent)
elif isinstance(attr_def, BoolDef):
cls = BoolAttrWidget
if isinstance(attr_def, UnknownDef):
return UnknownAttrWidget(attr_def, parent)
elif isinstance(attr_def, UnknownDef):
cls = UnknownAttrWidget
if isinstance(attr_def, HiddenDef):
return HiddenAttrWidget(attr_def, parent)
elif isinstance(attr_def, HiddenDef):
cls = HiddenAttrWidget
if isinstance(attr_def, FileDef):
return FileAttrWidget(attr_def, parent)
elif isinstance(attr_def, FileDef):
cls = FileAttrWidget
if isinstance(attr_def, UISeparatorDef):
return SeparatorAttrWidget(attr_def, parent)
elif isinstance(attr_def, UISeparatorDef):
cls = SeparatorAttrWidget
if isinstance(attr_def, UILabelDef):
return LabelAttrWidget(attr_def, parent)
elif isinstance(attr_def, UILabelDef):
cls = LabelAttrWidget
raise ValueError("Unknown attribute definition \"{}\"".format(
str(type(attr_def))
))
if cls is None:
raise ValueError("Unknown attribute definition \"{}\"".format(
str(type(attr_def))
))
return cls(attr_def, parent, handle_revert_to_default)
class AttributeDefinitionsLabel(QtWidgets.QLabel):
"""Label related to value attribute definition.
Label is used to show attribute definition label and to show if value
is overridden.
Label can be right-clicked to revert value to default.
"""
revert_to_default_requested = QtCore.Signal(str)
def __init__(
self,
attr_id: str,
label: str,
parent: QtWidgets.QWidget,
):
super().__init__(label, parent)
self._attr_id = attr_id
self._overridden = False
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self._on_context_menu)
def set_overridden(self, overridden: bool):
if self._overridden == overridden:
return
self._overridden = overridden
set_style_property(
self,
"overridden",
"1" if overridden else ""
)
def _on_context_menu(self, point: QtCore.QPoint):
menu = QtWidgets.QMenu(self)
action = QtWidgets.QAction(menu)
action.setText(REVERT_TO_DEFAULT_LABEL)
action.triggered.connect(self._request_revert_to_default)
menu.addAction(action)
menu.exec_(self.mapToGlobal(point))
def _request_revert_to_default(self):
self.revert_to_default_requested.emit(self._attr_id)
class AttributeDefinitionsWidget(QtWidgets.QWidget):
@ -83,16 +150,18 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
"""
def __init__(self, attr_defs=None, parent=None):
super(AttributeDefinitionsWidget, self).__init__(parent)
super().__init__(parent)
self._widgets = []
self._widgets_by_id = {}
self._labels_by_id = {}
self._current_keys = set()
self.set_attr_defs(attr_defs)
def clear_attr_defs(self):
"""Remove all existing widgets and reset layout if needed."""
self._widgets = []
self._widgets_by_id = {}
self._labels_by_id = {}
self._current_keys = set()
layout = self.layout()
@ -133,7 +202,7 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
self._current_keys.add(attr_def.key)
widget = create_widget_for_attr_def(attr_def, self)
self._widgets.append(widget)
self._widgets_by_id[attr_def.id] = widget
if not attr_def.visible:
continue
@ -145,7 +214,13 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
col_num = 2 - expand_cols
if attr_def.is_value_def and attr_def.label:
label_widget = QtWidgets.QLabel(attr_def.label, self)
label_widget = AttributeDefinitionsLabel(
attr_def.id, attr_def.label, self
)
label_widget.revert_to_default_requested.connect(
self._on_revert_request
)
self._labels_by_id[attr_def.id] = label_widget
tooltip = attr_def.tooltip
if tooltip:
label_widget.setToolTip(tooltip)
@ -160,6 +235,9 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
if not attr_def.is_label_horizontal:
row += 1
if attr_def.is_value_def:
widget.value_changed.connect(self._on_value_change)
layout.addWidget(
widget, row, col_num, 1, expand_cols
)
@ -168,7 +246,7 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
def set_value(self, value):
new_value = copy.deepcopy(value)
unused_keys = set(new_value.keys())
for widget in self._widgets:
for widget in self._widgets_by_id.values():
attr_def = widget.attr_def
if attr_def.key not in new_value:
continue
@ -181,22 +259,42 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
def current_value(self):
output = {}
for widget in self._widgets:
for widget in self._widgets_by_id.values():
attr_def = widget.attr_def
if not isinstance(attr_def, UIDef):
output[attr_def.key] = widget.current_value()
return output
def _on_revert_request(self, attr_id):
widget = self._widgets_by_id.get(attr_id)
if widget is not None:
widget.set_value(widget.attr_def.default)
def _on_value_change(self, value, attr_id):
widget = self._widgets_by_id.get(attr_id)
if widget is None:
return
label = self._labels_by_id.get(attr_id)
if label is not None:
label.set_overridden(value != widget.attr_def.default)
class _BaseAttrDefWidget(QtWidgets.QWidget):
# Type 'object' may not work with older PySide versions
value_changed = QtCore.Signal(object, str)
revert_to_default_requested = QtCore.Signal(str)
def __init__(self, attr_def, parent):
super(_BaseAttrDefWidget, self).__init__(parent)
def __init__(
self,
attr_def: AbstractAttrDef,
parent: "Union[QtWidgets.QWidget, None]",
handle_revert_to_default: Optional[bool] = True,
):
super().__init__(parent)
self.attr_def = attr_def
self.attr_def: AbstractAttrDef = attr_def
self._handle_revert_to_default: bool = handle_revert_to_default
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
@ -205,6 +303,15 @@ class _BaseAttrDefWidget(QtWidgets.QWidget):
self._ui_init()
def revert_to_default_value(self):
if not self.attr_def.is_value_def:
return
if self._handle_revert_to_default:
self.set_value(self.attr_def.default)
else:
self.revert_to_default_requested.emit(self.attr_def.id)
def _ui_init(self):
raise NotImplementedError(
"Method '_ui_init' is not implemented. {}".format(
@ -255,7 +362,7 @@ class ClickableLineEdit(QtWidgets.QLineEdit):
clicked = QtCore.Signal()
def __init__(self, text, parent):
super(ClickableLineEdit, self).__init__(parent)
super().__init__(parent)
self.setText(text)
self.setReadOnly(True)
@ -264,7 +371,7 @@ class ClickableLineEdit(QtWidgets.QLineEdit):
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
self._mouse_pressed = True
super(ClickableLineEdit, self).mousePressEvent(event)
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
if self._mouse_pressed:
@ -272,7 +379,7 @@ class ClickableLineEdit(QtWidgets.QLineEdit):
if self.rect().contains(event.pos()):
self.clicked.emit()
super(ClickableLineEdit, self).mouseReleaseEvent(event)
super().mouseReleaseEvent(event)
class NumberAttrWidget(_BaseAttrDefWidget):
@ -284,6 +391,9 @@ class NumberAttrWidget(_BaseAttrDefWidget):
else:
input_widget = FocusSpinBox(self)
# Override context menu event to add revert to default action
input_widget.contextMenuEvent = self._input_widget_context_event
if self.attr_def.tooltip:
input_widget.setToolTip(self.attr_def.tooltip)
@ -321,6 +431,16 @@ class NumberAttrWidget(_BaseAttrDefWidget):
self._set_multiselection_visible(True)
return False
def _input_widget_context_event(self, event):
line_edit = self._input_widget.lineEdit()
menu = line_edit.createStandardContextMenu()
menu.setAttribute(QtCore.Qt.WA_DeleteOnClose)
action = QtWidgets.QAction(menu)
action.setText(REVERT_TO_DEFAULT_LABEL)
action.triggered.connect(self.revert_to_default_value)
menu.addAction(action)
menu.popup(event.globalPos())
def current_value(self):
return self._input_widget.value()
@ -386,6 +506,9 @@ class TextAttrWidget(_BaseAttrDefWidget):
else:
input_widget = QtWidgets.QLineEdit(self)
# Override context menu event to add revert to default action
input_widget.contextMenuEvent = self._input_widget_context_event
if (
self.attr_def.placeholder
and hasattr(input_widget, "setPlaceholderText")
@ -407,6 +530,15 @@ class TextAttrWidget(_BaseAttrDefWidget):
self.main_layout.addWidget(input_widget, 0)
def _input_widget_context_event(self, event):
menu = self._input_widget.createStandardContextMenu()
menu.setAttribute(QtCore.Qt.WA_DeleteOnClose)
action = QtWidgets.QAction(menu)
action.setText(REVERT_TO_DEFAULT_LABEL)
action.triggered.connect(self.revert_to_default_value)
menu.addAction(action)
menu.popup(event.globalPos())
def _on_value_change(self):
if self.multiline:
new_value = self._input_widget.toPlainText()
@ -459,6 +591,20 @@ class BoolAttrWidget(_BaseAttrDefWidget):
self.main_layout.addWidget(input_widget, 0)
self.main_layout.addStretch(1)
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self._on_context_menu)
def _on_context_menu(self, pos):
self._menu = QtWidgets.QMenu(self)
action = QtWidgets.QAction(self._menu)
action.setText(REVERT_TO_DEFAULT_LABEL)
action.triggered.connect(self.revert_to_default_value)
self._menu.addAction(action)
global_pos = self.mapToGlobal(pos)
self._menu.exec_(global_pos)
def _on_value_change(self):
new_value = self._input_widget.isChecked()
self.value_changed.emit(new_value, self.attr_def.id)
@ -487,7 +633,7 @@ class BoolAttrWidget(_BaseAttrDefWidget):
class EnumAttrWidget(_BaseAttrDefWidget):
def __init__(self, *args, **kwargs):
self._multivalue = False
super(EnumAttrWidget, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
@property
def multiselection(self):
@ -522,6 +668,20 @@ class EnumAttrWidget(_BaseAttrDefWidget):
self.main_layout.addWidget(input_widget, 0)
input_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
input_widget.customContextMenuRequested.connect(self._on_context_menu)
def _on_context_menu(self, pos):
menu = QtWidgets.QMenu(self)
action = QtWidgets.QAction(menu)
action.setText(REVERT_TO_DEFAULT_LABEL)
action.triggered.connect(self.revert_to_default_value)
menu.addAction(action)
global_pos = self.mapToGlobal(pos)
menu.exec_(global_pos)
def _on_value_change(self):
new_value = self.current_value()
if self._multivalue:
@ -614,7 +774,7 @@ class HiddenAttrWidget(_BaseAttrDefWidget):
def setVisible(self, visible):
if visible:
visible = False
super(HiddenAttrWidget, self).setVisible(visible)
super().setVisible(visible)
def current_value(self):
if self._multivalue:
@ -650,10 +810,25 @@ class FileAttrWidget(_BaseAttrDefWidget):
self.main_layout.addWidget(input_widget, 0)
input_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
input_widget.customContextMenuRequested.connect(self._on_context_menu)
input_widget.revert_requested.connect(self.revert_to_default_value)
def _on_value_change(self):
new_value = self.current_value()
self.value_changed.emit(new_value, self.attr_def.id)
def _on_context_menu(self, pos):
menu = QtWidgets.QMenu(self)
action = QtWidgets.QAction(menu)
action.setText(REVERT_TO_DEFAULT_LABEL)
action.triggered.connect(self.revert_to_default_value)
menu.addAction(action)
global_pos = self.mapToGlobal(pos)
menu.exec_(global_pos)
def current_value(self):
return self._input_widget.current_value()

View file

@ -222,6 +222,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
editor = VersionComboBox(product_id, parent)
editor.setProperty("itemId", item_id)
editor.setFocusPolicy(QtCore.Qt.NoFocus)
editor.value_changed.connect(self._on_editor_change)
editor.destroyed.connect(self._on_destroy)

View file

@ -366,7 +366,7 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
@abstractmethod
def get_creator_attribute_definitions(
self, instance_ids: Iterable[str]
) -> List[Tuple[AbstractAttrDef, List[str], List[Any]]]:
) -> List[Tuple[AbstractAttrDef, Dict[str, Dict[str, Any]]]]:
pass
@abstractmethod
@ -375,6 +375,14 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
):
pass
@abstractmethod
def revert_instances_create_attr_values(
self,
instance_ids: List["Union[str, None]"],
key: str,
):
pass
@abstractmethod
def get_publish_attribute_definitions(
self,
@ -383,7 +391,7 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
) -> List[Tuple[
str,
List[AbstractAttrDef],
Dict[str, List[Tuple[str, Any]]]
Dict[str, List[Tuple[str, Any, Any]]]
]]:
pass
@ -397,6 +405,15 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
):
pass
@abstractmethod
def revert_instances_publish_attr_values(
self,
instance_ids: List["Union[str, None]"],
plugin_name: str,
key: str,
):
pass
@abstractmethod
def get_product_name(
self,

View file

@ -412,6 +412,11 @@ class PublisherController(
instance_ids, key, value
)
def revert_instances_create_attr_values(self, instance_ids, key):
self._create_model.revert_instances_create_attr_values(
instance_ids, key
)
def get_publish_attribute_definitions(self, instance_ids, include_context):
"""Collect publish attribute definitions for passed instances.
@ -432,6 +437,13 @@ class PublisherController(
instance_ids, plugin_name, key, value
)
def revert_instances_publish_attr_values(
self, instance_ids, plugin_name, key
):
return self._create_model.revert_instances_publish_attr_values(
instance_ids, plugin_name, key
)
def get_product_name(
self,
creator_identifier,

View file

@ -40,6 +40,7 @@ from ayon_core.tools.publisher.abstract import (
)
CREATE_EVENT_SOURCE = "publisher.create.model"
_DEFAULT_VALUE = object()
class CreatorType:
@ -295,7 +296,7 @@ class InstanceItem:
return InstanceItem(
instance.id,
instance.creator_identifier,
instance.label,
instance.label or "N/A",
instance.group_label,
instance.product_type,
instance.product_name,
@ -752,24 +753,16 @@ class CreateModel:
self._remove_instances_from_context(instance_ids)
def set_instances_create_attr_values(self, instance_ids, key, value):
with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE):
for instance_id in instance_ids:
instance = self._get_instance_by_id(instance_id)
creator_attributes = instance["creator_attributes"]
attr_def = creator_attributes.get_attr_def(key)
if (
attr_def is None
or not attr_def.is_value_def
or not attr_def.visible
or not attr_def.enabled
or not attr_def.is_value_valid(value)
):
continue
creator_attributes[key] = value
self._set_instances_create_attr_values(instance_ids, key, value)
def revert_instances_create_attr_values(self, instance_ids, key):
self._set_instances_create_attr_values(
instance_ids, key, _DEFAULT_VALUE
)
def get_creator_attribute_definitions(
self, instance_ids: List[str]
) -> List[Tuple[AbstractAttrDef, List[str], List[Any]]]:
) -> List[Tuple[AbstractAttrDef, Dict[str, Dict[str, Any]]]]:
"""Collect creator attribute definitions for multuple instances.
Args:
@ -796,37 +789,38 @@ class CreateModel:
if found_idx is None:
idx = len(output)
output.append((attr_def, [instance_id], [value]))
output.append((
attr_def,
{
instance_id: {
"value": value,
"default": attr_def.default
}
}
))
_attr_defs[idx] = attr_def
else:
_, ids, values = output[found_idx]
ids.append(instance_id)
values.append(value)
_, info_by_id = output[found_idx]
info_by_id[instance_id] = {
"value": value,
"default": attr_def.default
}
return output
def set_instances_publish_attr_values(
self, instance_ids, plugin_name, key, value
self, instance_ids, plugin_name, key, value
):
with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE):
for instance_id in instance_ids:
if instance_id is None:
instance = self._create_context
else:
instance = self._get_instance_by_id(instance_id)
plugin_val = instance.publish_attributes[plugin_name]
attr_def = plugin_val.get_attr_def(key)
# Ignore if attribute is not available or enabled/visible
# on the instance, or the value is not valid for definition
if (
attr_def is None
or not attr_def.is_value_def
or not attr_def.visible
or not attr_def.enabled
or not attr_def.is_value_valid(value)
):
continue
self._set_instances_publish_attr_values(
instance_ids, plugin_name, key, value
)
plugin_val[key] = value
def revert_instances_publish_attr_values(
self, instance_ids, plugin_name, key
):
self._set_instances_publish_attr_values(
instance_ids, plugin_name, key, _DEFAULT_VALUE
)
def get_publish_attribute_definitions(
self,
@ -835,7 +829,7 @@ class CreateModel:
) -> List[Tuple[
str,
List[AbstractAttrDef],
Dict[str, List[Tuple[str, Any]]]
Dict[str, List[Tuple[str, Any, Any]]]
]]:
"""Collect publish attribute definitions for passed instances.
@ -865,21 +859,21 @@ class CreateModel:
attr_defs = attr_val.attr_defs
if not attr_defs:
continue
plugin_attr_defs = all_defs_by_plugin_name.setdefault(
plugin_name, []
)
plugin_attr_defs.append(attr_defs)
plugin_values = all_plugin_values.setdefault(plugin_name, {})
plugin_attr_defs.append(attr_defs)
for attr_def in attr_defs:
if isinstance(attr_def, UIDef):
continue
attr_values = plugin_values.setdefault(attr_def.key, [])
value = attr_val[attr_def.key]
attr_values.append((item_id, value))
attr_values.append(
(item_id, attr_val[attr_def.key], attr_def.default)
)
attr_defs_by_plugin_name = {}
for plugin_name, attr_defs in all_defs_by_plugin_name.items():
@ -893,7 +887,7 @@ class CreateModel:
output.append((
plugin_name,
attr_defs_by_plugin_name[plugin_name],
all_plugin_values
all_plugin_values[plugin_name],
))
return output
@ -1053,6 +1047,53 @@ class CreateModel:
CreatorItem.from_creator(creator)
)
def _set_instances_create_attr_values(self, instance_ids, key, value):
with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE):
for instance_id in instance_ids:
instance = self._get_instance_by_id(instance_id)
creator_attributes = instance["creator_attributes"]
attr_def = creator_attributes.get_attr_def(key)
if (
attr_def is None
or not attr_def.is_value_def
or not attr_def.visible
or not attr_def.enabled
):
continue
if value is _DEFAULT_VALUE:
creator_attributes[key] = attr_def.default
elif attr_def.is_value_valid(value):
creator_attributes[key] = value
def _set_instances_publish_attr_values(
self, instance_ids, plugin_name, key, value
):
with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE):
for instance_id in instance_ids:
if instance_id is None:
instance = self._create_context
else:
instance = self._get_instance_by_id(instance_id)
plugin_val = instance.publish_attributes[plugin_name]
attr_def = plugin_val.get_attr_def(key)
# Ignore if attribute is not available or enabled/visible
# on the instance, or the value is not valid for definition
if (
attr_def is None
or not attr_def.is_value_def
or not attr_def.visible
or not attr_def.enabled
):
continue
if value is _DEFAULT_VALUE:
plugin_val[key] = attr_def.default
elif attr_def.is_value_valid(value):
plugin_val[key] = value
def _cc_added_instance(self, event):
instance_ids = {
instance.id

View file

@ -1,13 +1,58 @@
import typing
from typing import Dict, List, Any
from qtpy import QtWidgets, QtCore
from ayon_core.lib.attribute_definitions import UnknownDef
from ayon_core.tools.attribute_defs import create_widget_for_attr_def
from ayon_core.lib.attribute_definitions import AbstractAttrDef, UnknownDef
from ayon_core.tools.attribute_defs import (
create_widget_for_attr_def,
AttributeDefinitionsLabel,
)
from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
from ayon_core.tools.publisher.constants import (
INPUTS_LAYOUT_HSPACING,
INPUTS_LAYOUT_VSPACING,
)
if typing.TYPE_CHECKING:
from typing import Union
class _CreateAttrDefInfo:
"""Helper class to store information about create attribute definition."""
def __init__(
self,
attr_def: AbstractAttrDef,
instance_ids: List["Union[str, None]"],
defaults: List[Any],
label_widget: "Union[AttributeDefinitionsLabel, None]",
):
self.attr_def: AbstractAttrDef = attr_def
self.instance_ids: List["Union[str, None]"] = instance_ids
self.defaults: List[Any] = defaults
self.label_widget: "Union[AttributeDefinitionsLabel, None]" = (
label_widget
)
class _PublishAttrDefInfo:
"""Helper class to store information about publish attribute definition."""
def __init__(
self,
attr_def: AbstractAttrDef,
plugin_name: str,
instance_ids: List["Union[str, None]"],
defaults: List[Any],
label_widget: "Union[AttributeDefinitionsLabel, None]",
):
self.attr_def: AbstractAttrDef = attr_def
self.plugin_name: str = plugin_name
self.instance_ids: List["Union[str, None]"] = instance_ids
self.defaults: List[Any] = defaults
self.label_widget: "Union[AttributeDefinitionsLabel, None]" = (
label_widget
)
class CreatorAttrsWidget(QtWidgets.QWidget):
"""Widget showing creator specific attributes for selected instances.
@ -51,8 +96,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
self._controller: AbstractPublisherFrontend = controller
self._scroll_area = scroll_area
self._attr_def_id_to_instances = {}
self._attr_def_id_to_attr_def = {}
self._attr_def_info_by_id: Dict[str, _CreateAttrDefInfo] = {}
self._current_instance_ids = set()
# To store content of scroll area to prevent garbage collection
@ -81,8 +125,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
prev_content_widget.deleteLater()
self._content_widget = None
self._attr_def_id_to_instances = {}
self._attr_def_id_to_attr_def = {}
self._attr_def_info_by_id = {}
result = self._controller.get_creator_attribute_definitions(
self._current_instance_ids
@ -97,9 +140,21 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
content_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING)
row = 0
for attr_def, instance_ids, values in result:
widget = create_widget_for_attr_def(attr_def, content_widget)
for attr_def, info_by_id in result:
widget = create_widget_for_attr_def(
attr_def, content_widget, handle_revert_to_default=False
)
default_values = []
if attr_def.is_value_def:
values = []
for item in info_by_id.values():
values.append(item["value"])
# 'set' cannot be used for default values because they can
# be unhashable types, e.g. 'list'.
default = item["default"]
if default not in default_values:
default_values.append(default)
if len(values) == 1:
value = values[0]
if value is not None:
@ -108,8 +163,13 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
widget.set_value(values, True)
widget.value_changed.connect(self._input_value_changed)
self._attr_def_id_to_instances[attr_def.id] = instance_ids
self._attr_def_id_to_attr_def[attr_def.id] = attr_def
widget.revert_to_default_requested.connect(
self._on_request_revert_to_default
)
attr_def_info = _CreateAttrDefInfo(
attr_def, list(info_by_id), default_values, None
)
self._attr_def_info_by_id[attr_def.id] = attr_def_info
if not attr_def.visible:
continue
@ -121,10 +181,18 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
col_num = 2 - expand_cols
label = None
is_overriden = False
if attr_def.is_value_def:
is_overriden = any(
item["value"] != item["default"]
for item in info_by_id.values()
)
label = attr_def.label or attr_def.key
if label:
label_widget = QtWidgets.QLabel(label, self)
label_widget = AttributeDefinitionsLabel(
attr_def.id, label, self
)
tooltip = attr_def.tooltip
if tooltip:
label_widget.setToolTip(tooltip)
@ -138,6 +206,11 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
)
if not attr_def.is_label_horizontal:
row += 1
attr_def_info.label_widget = label_widget
label_widget.set_overridden(is_overriden)
label_widget.revert_to_default_requested.connect(
self._on_request_revert_to_default
)
content_layout.addWidget(
widget, row, col_num, 1, expand_cols
@ -159,20 +232,37 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
for instance_id, changes in event["instance_changes"].items():
if (
instance_id in self._current_instance_ids
and "creator_attributes" not in changes
and "creator_attributes" in changes
):
self._refresh_content()
break
def _input_value_changed(self, value, attr_id):
instance_ids = self._attr_def_id_to_instances.get(attr_id)
attr_def = self._attr_def_id_to_attr_def.get(attr_id)
if not instance_ids or not attr_def:
attr_def_info = self._attr_def_info_by_id.get(attr_id)
if attr_def_info is None:
return
if attr_def_info.label_widget is not None:
defaults = attr_def_info.defaults
is_overriden = len(defaults) != 1 or value not in defaults
attr_def_info.label_widget.set_overridden(is_overriden)
self._controller.set_instances_create_attr_values(
instance_ids, attr_def.key, value
attr_def_info.instance_ids,
attr_def_info.attr_def.key,
value
)
def _on_request_revert_to_default(self, attr_id):
attr_def_info = self._attr_def_info_by_id.get(attr_id)
if attr_def_info is None:
return
self._controller.revert_instances_create_attr_values(
attr_def_info.instance_ids,
attr_def_info.attr_def.key,
)
self._refresh_content()
class PublishPluginAttrsWidget(QtWidgets.QWidget):
"""Widget showing publish plugin attributes for selected instances.
@ -223,9 +313,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget):
self._controller: AbstractPublisherFrontend = controller
self._scroll_area = scroll_area
self._attr_def_id_to_instances = {}
self._attr_def_id_to_attr_def = {}
self._attr_def_id_to_plugin_name = {}
self._attr_def_info_by_id: Dict[str, _PublishAttrDefInfo] = {}
# Store content of scroll area to prevent garbage collection
self._content_widget = None
@ -254,9 +342,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget):
self._content_widget = None
self._attr_def_id_to_instances = {}
self._attr_def_id_to_attr_def = {}
self._attr_def_id_to_plugin_name = {}
self._attr_def_info_by_id = {}
result = self._controller.get_publish_attribute_definitions(
self._current_instance_ids, self._context_selected
@ -275,12 +361,10 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget):
content_layout.addStretch(1)
row = 0
for plugin_name, attr_defs, all_plugin_values in result:
plugin_values = all_plugin_values[plugin_name]
for plugin_name, attr_defs, plugin_values in result:
for attr_def in attr_defs:
widget = create_widget_for_attr_def(
attr_def, content_widget
attr_def, content_widget, handle_revert_to_default=False
)
visible_widget = attr_def.visible
# Hide unknown values of publish plugins
@ -290,6 +374,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget):
widget.setVisible(False)
visible_widget = False
label_widget = None
if visible_widget:
expand_cols = 2
if attr_def.is_value_def and attr_def.is_label_horizontal:
@ -300,7 +385,12 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget):
if attr_def.is_value_def:
label = attr_def.label or attr_def.key
if label:
label_widget = QtWidgets.QLabel(label, content_widget)
label_widget = AttributeDefinitionsLabel(
attr_def.id, label, content_widget
)
label_widget.revert_to_default_requested.connect(
self._on_request_revert_to_default
)
tooltip = attr_def.tooltip
if tooltip:
label_widget.setToolTip(tooltip)
@ -323,38 +413,76 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget):
continue
widget.value_changed.connect(self._input_value_changed)
widget.revert_to_default_requested.connect(
self._on_request_revert_to_default
)
attr_values = plugin_values[attr_def.key]
multivalue = len(attr_values) > 1
instance_ids = []
values = []
instances = []
for instance, value in attr_values:
default_values = []
is_overriden = False
for (instance_id, value, default_value) in (
plugin_values.get(attr_def.key, [])
):
instance_ids.append(instance_id)
values.append(value)
instances.append(instance)
if not is_overriden and value != default_value:
is_overriden = True
# 'set' cannot be used for default values because they can
# be unhashable types, e.g. 'list'.
if default_value not in default_values:
default_values.append(default_value)
self._attr_def_id_to_attr_def[attr_def.id] = attr_def
self._attr_def_id_to_instances[attr_def.id] = instances
self._attr_def_id_to_plugin_name[attr_def.id] = plugin_name
multivalue = len(values) > 1
self._attr_def_info_by_id[attr_def.id] = _PublishAttrDefInfo(
attr_def,
plugin_name,
instance_ids,
default_values,
label_widget,
)
if multivalue:
widget.set_value(values, multivalue)
else:
widget.set_value(values[0])
if label_widget is not None:
label_widget.set_overridden(is_overriden)
self._scroll_area.setWidget(content_widget)
self._content_widget = content_widget
def _input_value_changed(self, value, attr_id):
instance_ids = self._attr_def_id_to_instances.get(attr_id)
attr_def = self._attr_def_id_to_attr_def.get(attr_id)
plugin_name = self._attr_def_id_to_plugin_name.get(attr_id)
if not instance_ids or not attr_def or not plugin_name:
attr_def_info = self._attr_def_info_by_id.get(attr_id)
if attr_def_info is None:
return
if attr_def_info.label_widget is not None:
defaults = attr_def_info.defaults
is_overriden = len(defaults) != 1 or value not in defaults
attr_def_info.label_widget.set_overridden(is_overriden)
self._controller.set_instances_publish_attr_values(
instance_ids, plugin_name, attr_def.key, value
attr_def_info.instance_ids,
attr_def_info.plugin_name,
attr_def_info.attr_def.key,
value
)
def _on_request_revert_to_default(self, attr_id):
attr_def_info = self._attr_def_info_by_id.get(attr_id)
if attr_def_info is None:
return
self._controller.revert_instances_publish_attr_values(
attr_def_info.instance_ids,
attr_def_info.plugin_name,
attr_def_info.attr_def.key,
)
self._refresh_content()
def _on_instance_attr_defs_change(self, event):
for instance_id in event.data:
if (
@ -370,7 +498,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget):
for instance_id, changes in event["instance_changes"].items():
if (
instance_id in self._current_instance_ids
and "publish_attributes" not in changes
and "publish_attributes" in changes
):
self._refresh_content()
break

View file

@ -194,14 +194,14 @@ class InventoryModel(QtGui.QStandardItemModel):
group_items = []
for repre_id, container_items in items_by_repre_id.items():
repre_info = repre_info_by_id[repre_id]
version_label = "N/A"
version_color = None
is_latest = False
is_hero = False
status_name = None
if not repre_info.is_valid:
version_label = "N/A"
group_name = "< Entity N/A >"
item_icon = invalid_item_icon
is_latest = False
is_hero = False
status_name = None
else:
group_name = "{}_{}: ({})".format(
@ -217,6 +217,7 @@ class InventoryModel(QtGui.QStandardItemModel):
version_item = version_items[repre_info.version_id]
version_label = format_version(version_item.version)
is_hero = version_item.version < 0
is_latest = version_item.is_latest
if not version_item.is_latest:
version_color = self.OUTDATED_COLOR
status_name = version_item.status
@ -425,7 +426,7 @@ class FilterProxyModel(QtCore.QSortFilterProxyModel):
state = bool(state)
if state != self._filter_outdated:
self._filter_outdated = bool(state)
self._filter_outdated = state
self.invalidateFilter()
def set_hierarchy_view(self, state):

View file

@ -383,7 +383,6 @@ class ContainersModel:
container_items_by_id[item.item_id] = item
container_items.append(item)
self._containers_by_id = containers_by_id
self._container_items_by_id = container_items_by_id
self._items_cache = container_items

View file

@ -3,12 +3,9 @@ import sys
import json
import hashlib
import platform
import subprocess
import csv
import time
import signal
import locale
from typing import Optional, Dict, Tuple, Any
from typing import Optional, List, Dict, Tuple, Any
import requests
from ayon_api.utils import get_default_settings_variant
@ -53,15 +50,101 @@ def _get_server_and_variant(
return server_url, variant
def _windows_get_pid_args(pid: int) -> Optional[List[str]]:
import ctypes
from ctypes import wintypes
# Define constants
PROCESS_COMMANDLINE_INFO = 60
STATUS_NOT_FOUND = 0xC0000225
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
# Define the UNICODE_STRING structure
class UNICODE_STRING(ctypes.Structure):
_fields_ = [
("Length", wintypes.USHORT),
("MaximumLength", wintypes.USHORT),
("Buffer", wintypes.LPWSTR)
]
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
CommandLineToArgvW = shell32.CommandLineToArgvW
CommandLineToArgvW.argtypes = [
wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)
]
CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR)
output = None
# Open the process
handle = ctypes.windll.kernel32.OpenProcess(
PROCESS_QUERY_LIMITED_INFORMATION, False, pid
)
if not handle:
return output
try:
buffer_len = wintypes.ULONG()
# Get the right buffer size first
status = ctypes.windll.ntdll.NtQueryInformationProcess(
handle,
PROCESS_COMMANDLINE_INFO,
ctypes.c_void_p(None),
0,
ctypes.byref(buffer_len)
)
if status == STATUS_NOT_FOUND:
return output
# Create buffer with collected size
buffer = ctypes.create_string_buffer(buffer_len.value)
# Get the command line
status = ctypes.windll.ntdll.NtQueryInformationProcess(
handle,
PROCESS_COMMANDLINE_INFO,
buffer,
buffer_len,
ctypes.byref(buffer_len)
)
if status:
return output
# Build the string
tmp = ctypes.cast(buffer, ctypes.POINTER(UNICODE_STRING)).contents
size = tmp.Length // 2 + 1
cmdline_buffer = ctypes.create_unicode_buffer(size)
ctypes.cdll.msvcrt.wcscpy(cmdline_buffer, tmp.Buffer)
args_len = ctypes.c_int()
args = CommandLineToArgvW(
cmdline_buffer, ctypes.byref(args_len)
)
output = [args[idx] for idx in range(args_len.value)]
ctypes.windll.kernel32.LocalFree(args)
finally:
ctypes.windll.kernel32.CloseHandle(handle)
return output
def _windows_pid_is_running(pid: int) -> bool:
args = ["tasklist.exe", "/fo", "csv", "/fi", f"PID eq {pid}"]
output = subprocess.check_output(args)
encoding = locale.getpreferredencoding()
csv_content = csv.DictReader(output.decode(encoding).splitlines())
# if "PID" not in csv_content.fieldnames:
# return False
for _ in csv_content:
args = _windows_get_pid_args(pid)
if not args:
return False
executable_path = args[0]
filename = os.path.basename(executable_path).lower()
if "ayon" in filename:
return True
# Try to handle tray running from code
# - this might be potential danger that kills other python process running
# 'start.py' script (low chance, but still)
if "python" in filename and len(args) > 1:
script_filename = os.path.basename(args[1].lower())
if script_filename == "start.py":
return True
return False

View file

@ -1,6 +1,6 @@
import os
from qtpy import QtWidgets
from qtpy import QtWidgets, QT6
class Action(QtWidgets.QAction):
@ -112,20 +112,21 @@ module.{module_name}()"""
Run the command of the instance or copy the command to the active shelf
based on the current modifiers.
If callbacks have been registered with fouind modifier integer the
If callbacks have been registered with found modifier integer the
function will trigger all callbacks. When a callback function returns a
non zero integer it will not execute the action's command
"""
# get the current application and its linked keyboard modifiers
app = QtWidgets.QApplication.instance()
modifiers = app.keyboardModifiers()
if not QT6:
modifiers = int(modifiers)
# If the menu has a callback registered for the current modifier
# we run the callback instead of the action itself.
registered = self._root.registered_callbacks
callbacks = registered.get(int(modifiers), [])
callbacks = registered.get(modifiers, [])
for callback in callbacks:
signal = callback(self)
if signal != 0:

View file

@ -4,7 +4,7 @@ import maya.cmds as cmds
import maya.mel as mel
import scriptsmenu
from qtpy import QtCore, QtWidgets
from qtpy import QtCore, QtWidgets, QT6
log = logging.getLogger(__name__)
@ -130,7 +130,7 @@ def main(title="Scripts", parent=None, objectName=None):
# Register control + shift callback to add to shelf (maya behavior)
modifiers = QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier
if int(cmds.about(version=True)) < 2025:
if not QT6:
modifiers = int(modifiers)
menu.register_callback(modifiers, to_shelf)

View file

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

View file

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

View file

@ -5,7 +5,7 @@
[tool.poetry]
name = "ayon-core"
version = "1.0.4+dev"
version = "1.0.8+dev"
description = ""
authors = ["Ynput Team <team@ynput.io>"]
readme = "README.md"

View file

@ -459,8 +459,8 @@ class ExtractReviewFilterModel(BaseSettingsModel):
single_frame_filter: str = SettingsField(
"everytime", # codespell:ignore everytime
description=(
"Use output <b>always</b> / only if input <b>is 1 frame</b>"
" image / only if has <b>2+ frames</b> or <b>is video</b>"
"Use output **always** / only if input **is 1 frame**"
" image / only if has **2+ frames** or **is video**"
),
enum_resolver=extract_review_filter_enum
)