ayon-core/openpype/lib/attribute_definitions.py
2023-03-30 13:59:22 +02:00

959 lines
26 KiB
Python

import os
import re
import collections
import uuid
import json
import copy
from abc import ABCMeta, abstractmethod, abstractproperty
import six
import clique
# 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 are registered definitions 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 passet 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
class AbstractAttrDefMeta(ABCMeta):
"""Metaclass to validate existence of 'key' attribute.
Each object of `AbstractAttrDef` mus have defined 'key' attribute.
"""
def __call__(self, *args, **kwargs):
obj = super(AbstractAttrDefMeta, self).__call__(*args, **kwargs)
init_class = getattr(obj, "__init__class__", None)
if init_class is not AbstractAttrDef:
raise TypeError("{} super was not called in __init__.".format(
type(obj)
))
return obj
@six.add_metaclass(AbstractAttrDefMeta)
class AbstractAttrDef(object):
"""Abstraction of attribute definition.
Each attribute definition must have implemented validation and
conversion method.
Attribute definition should have ability to return "default" value. That
can be based on passed data into `__init__` so is not abstracted to
attribute.
QUESTION:
How to force to set `key` attribute?
Args:
key (str): Under which key will be attribute value stored.
default (Any): Default value of an attribute.
label (str): Attribute label.
tooltip (str): Attribute tooltip.
is_label_horizontal (bool): UI specific argument. Specify if label is
next to value input or ahead.
hidden (bool): Will be item hidden (for UI purposes).
disabled (bool): Item will be visible but disabled (for UI purposes).
"""
type_attributes = []
is_value_def = True
def __init__(
self,
key,
default,
label=None,
tooltip=None,
is_label_horizontal=None,
hidden=False,
disabled=False
):
if is_label_horizontal is None:
is_label_horizontal = True
if hidden is None:
hidden = False
self.key = key
self.label = label
self.tooltip = tooltip
self.default = default
self.is_label_horizontal = is_label_horizontal
self.hidden = hidden
self.disabled = disabled
self._id = uuid.uuid4().hex
self.__init__class__ = AbstractAttrDef
@property
def id(self):
return self._id
def __eq__(self, other):
if not isinstance(other, self.__class__):
return False
return (
self.key == other.key
and self.hidden == other.hidden
and self.default == other.default
and self.disabled == other.disabled
)
def __ne__(self, other):
return not self.__eq__(other)
@abstractproperty
def type(self):
"""Attribute definition type also used as identifier of class.
Returns:
str: Type of attribute definition.
"""
pass
@abstractmethod
def convert_value(self, value):
"""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):
"""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,
"label": self.label,
"tooltip": self.tooltip,
"default": self.default,
"is_label_horizontal": self.is_label_horizontal,
"hidden": self.hidden,
"disabled": self.disabled
}
for attr in self.type_attributes:
data[attr] = getattr(self, attr)
return data
@classmethod
def deserialize(cls, data):
"""Recreate object from data.
Data can be received using 'serialize' method.
"""
return cls(**data)
# -----------------------------------------
# UI attribute definitoins won't hold value
# -----------------------------------------
class UIDef(AbstractAttrDef):
is_value_def = False
def __init__(self, key=None, default=None, *args, **kwargs):
super(UIDef, self).__init__(key, default, *args, **kwargs)
def convert_value(self, value):
return value
class UISeparatorDef(UIDef):
type = "separator"
class UILabelDef(UIDef):
type = "label"
def __init__(self, label):
super(UILabelDef, self).__init__(label=label)
# ---------------------------------------
# Attribute defintioins should hold value
# ---------------------------------------
class UnknownDef(AbstractAttrDef):
"""Definition is not known because definition is not available.
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):
kwargs["default"] = default
super(UnknownDef, self).__init__(key, **kwargs)
def convert_value(self, value):
return value
class HiddenDef(AbstractAttrDef):
"""Hidden value of Any type.
This attribute can be used for UI purposes to pass values related
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):
kwargs["default"] = default
kwargs["hidden"] = True
super(UnknownDef, self).__init__(key, **kwargs)
def convert_value(self, value):
return value
class NumberDef(AbstractAttrDef):
"""Number definition.
Number can have defined minimum/maximum value and decimal points. Value
is integer if decimals are 0.
Args:
minimum(int, float): Minimum possible value.
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",
"maximum",
"decimals"
]
def __init__(
self, key, minimum=None, maximum=None, decimals=None, default=None,
**kwargs
):
minimum = 0 if minimum is None else minimum
maximum = 999999 if maximum is None else maximum
# Swap min/max when are passed in opposited order
if minimum > maximum:
maximum, minimum = minimum, maximum
if default is None:
default = 0
elif not isinstance(default, (int, float)):
raise TypeError((
"'default' argument must be 'int' or 'float', not '{}'"
).format(type(default)))
# Fix default value by mim/max values
if default < minimum:
default = minimum
elif default > maximum:
default = maximum
super(NumberDef, self).__init__(key, default=default, **kwargs)
self.minimum = minimum
self.maximum = maximum
self.decimals = 0 if decimals is None else decimals
def __eq__(self, other):
if not super(NumberDef, self).__eq__(other):
return False
return (
self.decimals == other.decimals
and self.maximum == other.maximum
and self.maximum == other.maximum
)
def convert_value(self, value):
if isinstance(value, six.string_types):
try:
value = float(value)
except Exception:
pass
if not isinstance(value, (int, float)):
return self.default
if self.decimals == 0:
return int(value)
return round(float(value), self.decimals)
class TextDef(AbstractAttrDef):
"""Text definition.
Text can have multiline option so endline characters are allowed regex
validation can be applied placeholder for UI purposes and default value.
Regex validation is not part of attribute implemntentation.
Args:
multiline(bool): Text has single or multiline support.
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",
"placeholder",
]
def __init__(
self, key, multiline=None, regex=None, placeholder=None, default=None,
**kwargs
):
if default is None:
default = ""
super(TextDef, self).__init__(key, default=default, **kwargs)
if multiline is None:
multiline = False
elif not isinstance(default, six.string_types):
raise TypeError((
"'default' argument must be a {}, not '{}'"
).format(six.string_types, type(default)))
if isinstance(regex, six.string_types):
regex = re.compile(regex)
self.multiline = multiline
self.placeholder = placeholder
self.regex = regex
def __eq__(self, other):
if not super(TextDef, self).__eq__(other):
return False
return (
self.multiline == other.multiline
and self.regex == other.regex
)
def convert_value(self, value):
if isinstance(value, six.string_types):
return value
return self.default
def serialize(self):
data = super(TextDef, self).serialize()
data["regex"] = self.regex.pattern
return data
class EnumDef(AbstractAttrDef):
"""Enumeration of single item from items.
Args:
items: Items definition that can be converted using
'prepare_enum_items'.
default: Default value. Must be one key(value) from passed items.
"""
type = "enum"
def __init__(self, key, items, default=None, **kwargs):
if not items:
raise ValueError((
"Empty 'items' value. {} must have"
" defined values on initialization."
).format(self.__class__.__name__))
items = self.prepare_enum_items(items)
item_values = [item["value"] for item in items]
if default not in item_values:
for value in item_values:
default = value
break
super(EnumDef, self).__init__(key, default=default, **kwargs)
self.items = items
self._item_values = set(item_values)
def __eq__(self, other):
if not super(EnumDef, self).__eq__(other):
return False
return self.items == other.items
def convert_value(self, value):
if value in self._item_values:
return value
return self.default
def serialize(self):
data = super(EnumDef, self).serialize()
data["items"] = copy.deepcopy(self.items)
return data
@staticmethod
def prepare_enum_items(items):
"""Convert items to unified structure.
Output is a list where each item is dictionary with 'value'
and 'label'.
```python
# Example output
[
{"label": "Option 1", "value": 1},
{"label": "Option 2", "value": 2},
{"label": "Option 3", "value": 3}
]
```
Args:
items (Union[Dict[str, Any], List[Any], List[Dict[str, Any]]): The
items to convert.
Returns:
List[Dict[str, Any]]: Unified structure of items.
"""
output = []
if isinstance(items, dict):
for value, label in items.items():
output.append({"label": label, "value": value})
elif isinstance(items, (tuple, list, set)):
for item in items:
if isinstance(item, dict):
# Validate if 'value' is available
if "value" not in item:
raise KeyError("Item does not contain 'value' key.")
if "label" not in item:
item["label"] = str(item["value"])
elif isinstance(item, (list, tuple)):
if len(item) == 2:
value, label = item
elif len(item) == 1:
value = item[0]
label = str(value)
else:
raise ValueError((
"Invalid items count {}."
" Expected 1 or 2. Value: {}"
).format(len(item), str(item)))
item = {"label": label, "value": value}
else:
item = {"label": str(item), "value": item}
output.append(item)
else:
raise TypeError(
"Unknown type for enum items '{}'".format(type(items))
)
return output
class BoolDef(AbstractAttrDef):
"""Boolean representation.
Args:
default(bool): Default value. Set to `False` if not defined.
"""
type = "bool"
def __init__(self, key, default=None, **kwargs):
if default is None:
default = False
super(BoolDef, self).__init__(key, default=default, **kwargs)
def convert_value(self, value):
if isinstance(value, bool):
return value
return self.default
class FileDefItem(object):
def __init__(
self, directory, filenames, frames=None, template=None
):
self.directory = directory
self.filenames = []
self.is_sequence = False
self.template = None
self.frames = []
self.is_empty = True
self.set_filenames(filenames, frames, template)
def __str__(self):
return json.dumps(self.to_dict())
def __repr__(self):
if self.is_empty:
filename = "< empty >"
elif self.is_sequence:
filename = self.template
else:
filename = self.filenames[0]
return "<{}: \"{}\">".format(
self.__class__.__name__,
os.path.join(self.directory, filename)
)
@property
def label(self):
if self.is_empty:
return None
if not self.is_sequence:
return self.filenames[0]
frame_start = self.frames[0]
filename_template = os.path.basename(self.template)
if len(self.frames) == 1:
return "{} [{}]".format(filename_template, frame_start)
frame_end = self.frames[-1]
expected_len = (frame_end - frame_start) + 1
if expected_len == len(self.frames):
return "{} [{}-{}]".format(
filename_template, frame_start, frame_end
)
ranges = []
_frame_start = None
_frame_end = None
for frame in range(frame_start, frame_end + 1):
if frame not in self.frames:
add_to_ranges = _frame_start is not None
elif _frame_start is None:
_frame_start = _frame_end = frame
add_to_ranges = frame == frame_end
else:
_frame_end = frame
add_to_ranges = frame == frame_end
if add_to_ranges:
if _frame_start != _frame_end:
_range = "{}-{}".format(_frame_start, _frame_end)
else:
_range = str(_frame_start)
ranges.append(_range)
_frame_start = _frame_end = None
return "{} [{}]".format(
filename_template, ",".join(ranges)
)
def split_sequence(self):
if not self.is_sequence:
raise ValueError("Cannot split single file item")
paths = [
os.path.join(self.directory, filename)
for filename in self.filenames
]
return self.from_paths(paths, False)
@property
def ext(self):
if self.is_empty:
return None
_, ext = os.path.splitext(self.filenames[0])
if ext:
return ext
return None
@property
def lower_ext(self):
ext = self.ext
if ext is not None:
return ext.lower()
return ext
@property
def is_dir(self):
if self.is_empty:
return False
# QUESTION a better way how to define folder (in init argument?)
if self.ext:
return False
return True
def set_directory(self, directory):
self.directory = directory
def set_filenames(self, filenames, frames=None, template=None):
if frames is None:
frames = []
is_sequence = False
if frames:
is_sequence = True
if is_sequence and not template:
raise ValueError("Missing template for sequence")
self.is_empty = len(filenames) == 0
self.filenames = filenames
self.template = template
self.frames = frames
self.is_sequence = is_sequence
@classmethod
def create_empty_item(cls):
return cls("", "")
@classmethod
def from_value(cls, value, allow_sequences):
"""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]
output = []
str_filepaths = []
for item in value:
if isinstance(item, dict):
item = cls.from_dict(item)
if isinstance(item, FileDefItem):
if not allow_sequences and item.is_sequence:
output.extend(item.split_sequence())
else:
output.append(item)
elif isinstance(item, six.string_types):
str_filepaths.append(item)
else:
raise TypeError(
"Unknown type \"{}\". Can't convert to {}".format(
str(type(item)), cls.__name__
)
)
if str_filepaths:
output.extend(cls.from_paths(str_filepaths, allow_sequences))
return output
@classmethod
def from_dict(cls, data):
return cls(
data["directory"],
data["filenames"],
data.get("frames"),
data.get("template")
)
@classmethod
def from_paths(cls, paths, allow_sequences):
filenames_by_dir = collections.defaultdict(list)
for path in paths:
normalized = os.path.normpath(path)
directory, filename = os.path.split(normalized)
filenames_by_dir[directory].append(filename)
output = []
for directory, filenames in filenames_by_dir.items():
if allow_sequences:
cols, remainders = clique.assemble(filenames)
else:
cols = []
remainders = filenames
for remainder in remainders:
output.append(cls(directory, [remainder]))
for col in cols:
frames = list(col.indexes)
paths = [filename for filename in col]
template = col.format("{head}{padding}{tail}")
output.append(cls(
directory, paths, frames, template
))
return output
def to_dict(self):
output = {
"is_sequence": self.is_sequence,
"directory": self.directory,
"filenames": list(self.filenames),
}
if self.is_sequence:
output.update({
"template": self.template,
"frames": list(sorted(self.frames)),
})
return output
class FileDef(AbstractAttrDef):
"""File definition.
It is possible to define filters of allowed file extensions and if supports
folders.
Args:
single_item(bool): Allow only single path item.
folders(bool): Allow folder paths.
extensions(List[str]): Allow files with extensions. Empty list will
allow all extensions and None will disable files completely.
extensions_label(str): Custom label shown instead of extensions in UI.
default(str, List[str]): Default value.
"""
type = "path"
type_attributes = [
"single_item",
"folders",
"extensions",
"allow_sequences",
"extensions_label",
]
def __init__(
self, key, single_item=True, folders=None, extensions=None,
allow_sequences=True, extensions_label=None, default=None, **kwargs
):
if folders is None and extensions is None:
folders = True
extensions = []
if default is None:
if single_item:
default = FileDefItem.create_empty_item().to_dict()
else:
default = []
else:
if single_item:
if isinstance(default, dict):
FileDefItem.from_dict(default)
elif isinstance(default, six.string_types):
default = FileDefItem.from_paths([default.strip()])[0]
else:
raise TypeError((
"'default' argument must be 'str' or 'dict' not '{}'"
).format(type(default)))
else:
if not isinstance(default, (tuple, list, set)):
raise TypeError((
"'default' argument must be 'list', 'tuple' or 'set'"
", not '{}'"
).format(type(default)))
# Change horizontal label
is_label_horizontal = kwargs.get("is_label_horizontal")
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
super(FileDef, self).__init__(key, default=default, **kwargs)
def __eq__(self, other):
if not super(FileDef, self).__eq__(other):
return False
return (
self.single_item == other.single_item
and self.folders == other.folders
and self.extensions == other.extensions
and self.allow_sequences == other.allow_sequences
)
def convert_value(self, value):
if isinstance(value, six.string_types) or isinstance(value, dict):
value = [value]
if isinstance(value, (tuple, list, set)):
string_paths = []
dict_items = []
for item in value:
if isinstance(item, six.string_types):
string_paths.append(item.strip())
elif isinstance(item, dict):
try:
FileDefItem.from_dict(item)
dict_items.append(item)
except (ValueError, KeyError):
pass
if string_paths:
file_items = FileDefItem.from_paths(string_paths)
dict_items.extend([
file_item.to_dict()
for file_item in file_items
])
if not self.single_item:
return dict_items
if not dict_items:
return self.default
return dict_items[0]
if self.single_item:
return FileDefItem.create_empty_item().to_dict()
return []
def serialize_attr_def(attr_def):
"""Serialize attribute definition to data.
Args:
attr_def (AbstractAttrDef): Attribute definition to serialize.
Returns:
Dict[str, Any]: Serialized data.
"""
return attr_def.serialize()
def serialize_attr_defs(attr_defs):
"""Serialize attribute definitions to data.
Args:
attr_defs (List[AbstractAttrDef]): 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):
"""Deserialize attribute definition from data.
Args:
attr_def (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):
"""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
]
# Register attribute definitions
for _attr_class in (
UISeparatorDef,
UILabelDef,
UnknownDef,
NumberDef,
TextDef,
EnumDef,
BoolDef,
FileDef
):
register_attr_def_class(_attr_class)