ayon-core/openpype/settings/entities/base_entity.py
2021-04-01 18:54:46 +02:00

874 lines
28 KiB
Python

from uuid import uuid4
from abc import ABCMeta, abstractmethod, abstractproperty
import six
from .lib import (
NOT_SET,
OverrideState
)
from .exceptions import (
InvalidValueType,
SchemeGroupHierarchyBug,
EntitySchemaError
)
from openpype.lib import PypeLogger
@six.add_metaclass(ABCMeta)
class BaseEntity:
"""Base entity class for Setting's item type workflow.
Args:
schema_data (dict): Schema data that defines entity behavior.
"""
def __init__(self, schema_data, *args, **kwargs):
self.schema_data = schema_data
# Entity id
self._id = uuid4()
def __hash__(self):
"""Make entity hashable by it's id.
Helps to store entities as keys in dictionary.
"""
return self.id
@property
def id(self):
"""Unified identifier of an entity."""
return self._id
@abstractproperty
def gui_type(self):
"""Is entity GUI type entity."""
pass
@abstractmethod
def schema_validations(self):
"""Validation of schema."""
pass
class GUIEntity(BaseEntity):
"""Entity without any specific logic that should be handled only in GUI."""
gui_type = True
schema_types = ["separator", "splitter", "label"]
def __getitem__(self, key):
return self.schema_data[key]
def schema_validations(self):
"""TODO validate GUI schemas."""
pass
class BaseItemEntity(BaseEntity):
"""Base of item entity that is not only for GUI but can modify values.
Defines minimum attributes of all entities that are not `gui_type`.
Args:
schema_data (dict): Schema data that defines entity behavior.
"""
gui_type = False
def __init__(self, schema_data):
super(BaseItemEntity, self).__init__(schema_data)
# Parent entity
self.parent = None
# Entity is dynamically created (in list or dict with mutable keys)
# - can be also dynamically removed
self.is_dynamic_item = False
# Log object created on demand with `log` attribute
self._log = None
# Item path attribute (may be filled or be dynamic)
self._path = None
# These should be set on initialization and not change then
self.valid_value_types = getattr(self, "valid_value_types", NOT_SET)
self.value_on_not_set = getattr(self, "value_on_not_set", NOT_SET)
# Entity represents group entity
# - all children entities will be saved on modification of overrides
self.is_group = False
# Entity's value will be stored into file with name of it's key
self.is_file = False
# Reference to parent entity which has `is_group` == True
# - stays as None if none of parents is group
self.group_item = None
# Reference to parent entity which has `is_file` == True
self.file_item = None
# Reference to `RootEntity`
self.root_item = None
# Entity is in hierarchy of dynamically created entity
self.is_in_dynamic_item = False
# Entity will save metadata about environments
# - this is current possible only for RawJsonEnity
self.is_env_group = False
# Key of environment group key must be unique across system settings
self.env_group_key = None
# Roles of an entity
self.roles = None
# Key must be specified in schema data otherwise won't work as expected
self.require_key = True
# Key and label of an entity
self.key = None
self.label = None
# Override state defines which values are used, saved and how.
# TODO convert to private attribute
self._override_state = OverrideState.NOT_DEFINED
# These attributes may change values during existence of an object
# Default value, studio override values and project override values
# - these should be set only with `update_default_value` etc.
# TODO convert to private attributes
self._default_value = NOT_SET
self._studio_override_value = NOT_SET
self._project_override_value = NOT_SET
# Entity has set `_default_value` (is not NOT_SET)
self.has_default_value = False
# Entity is marked as it contain studio override data so it's value
# will be stored to studio overrides. This is relevant attribute
# only if current override state is set to STUDIO.
self._has_studio_override = False
# Entity has set `_studio_override_value` (is not NOT_SET)
self.had_studio_override = False
# Entity is marked as it contain project override data so it's value
# will be stored to project overrides. This is relevant attribute
# only if current override state is set to PROJECT.
self._has_project_override = False
# Entity has set `_project_override_value` (is not NOT_SET)
self.had_project_override = False
# Callbacks that are called on change.
# - main current purspose is to register GUI callbacks
self.on_change_callbacks = []
roles = schema_data.get("roles")
if roles is None:
roles = []
elif not isinstance(roles, list):
roles = [roles]
self.roles = roles
@property
def has_studio_override(self):
"""Says if entity or it's children has studio overrides."""
if self._override_state >= OverrideState.STUDIO:
return self._has_studio_override
return False
@property
def has_project_override(self):
"""Says if entity or it's children has project overrides."""
if self._override_state >= OverrideState.PROJECT:
return self._has_project_override
return False
@property
def path(self):
"""Full path of an entity in settings hierarchy.
It is not possible to use this attribute during initialization because
initialization happens before entity is added to parent's children.
"""
if self._path is not None:
return self._path
path = self.parent.get_child_path(self)
if not self.is_in_dynamic_item and not self.is_dynamic_item:
self._path = path
return path
@abstractmethod
def get_child_path(self, child_entity):
"""Return path for a direct child entity."""
pass
@abstractmethod
def get_entity_from_path(self, path):
"""Return system settings entity."""
pass
def schema_validations(self):
"""Validate schema of entity and it's hierachy.
Contain default validations same for all entities.
"""
# Entity must have defined valid value types.
if self.valid_value_types is NOT_SET:
raise EntitySchemaError(
self, "Attribute `valid_value_types` is not filled."
)
# Check if entity has defined key when is required.
if self.require_key and not self.key:
error_msg = "Missing \"key\" in schema data. {}".format(
str(self.schema_data).replace("'", '"')
)
raise EntitySchemaError(self, error_msg)
# Group entity must have defined label. (UI specific)
# QUESTION this should not be required?
if not self.label and self.is_group:
raise EntitySchemaError(
self, "Item is set as `is_group` but has empty `label`."
)
# Group item can be only once in on hierarchy branch.
if self.is_group and self.group_item:
raise SchemeGroupHierarchyBug(self)
# Validate that env group entities will be stored into file.
# - env group entities must store metadata which is not possible if
# metadata would be outside of file
if not self.file_item and self.is_env_group:
reason = (
"Environment item is not inside file"
" item so can't store metadata for defaults."
)
raise EntitySchemaError(self, reason)
# Dynamic items must not have defined labels. (UI specific)
if self.label and self.is_dynamic_item:
raise EntitySchemaError(
self, "Item has set label but is used as dynamic item."
)
# Dynamic items or items in dynamic item must not have set `is_group`
if self.is_group and (self.is_dynamic_item or self.is_in_dynamic_item):
raise EntitySchemaError(
self, "Dynamic entity has set `is_group` to true."
)
@abstractmethod
def set_override_state(self, state):
"""Set override state and trigger it on children.
Method discard all changes in hierarchy and use values, metadata
and all kind of values for defined override state. May be used to
apply updated values (default, studio overrides, project overrides).
Should start on root entity and when triggered then must be called on
all entities in hierarchy.
Args:
state (OverrideState): State to which should be data changed.
"""
pass
@abstractmethod
def on_change(self):
"""Trigger change callbacks and tell parent that has changed.
Can be any kind of change. Value has changed, has studio overrides
changed from True to False, etc.
"""
pass
@abstractmethod
def on_child_change(self, child_entity):
"""Triggered by children when they've changed.
Args:
child_entity (BaseItemEntity): Direct child entity that has
changed.
"""
pass
@abstractmethod
def set(self, value):
"""Set value of entity.
Args:
value (Any): Setter of value for this entity.
"""
pass
def is_value_valid_type(self, value):
"""Validate passed value type by entity's defined valid types.
Returns:
bool: True if value is in entity's defined types.
"""
return isinstance(value, self.valid_value_types)
def _validate_value_type(self, value):
"""Validate entered value.
Raises:
InvalidValueType: If value's type is not valid by entity's
definition.
"""
if self.is_value_valid_type(value):
return
raise InvalidValueType(self.valid_value_types, type(value), self.path)
def _convert_to_valid_type(self, value):
"""Private method of entity to convert value.
NOTE: Method is not abstract as more entities won't have implemented
logic inside.
Must return NOT_SET if can't convert the value.
"""
return NOT_SET
def convert_to_valid_type(self, value):
"""Check value type with possibility of conversion to valid.
If entered value has right type than is returned as it is. otherwise
is used privete method of entity to try convert.
Raises:
InvalidValueType: If value's type is not valid by entity's
definition and can't be converted by entity logic.
"""
#
if self.is_value_valid_type(value):
return value
new_value = self._convert_to_valid_type(value)
if new_value is not NOT_SET and self.is_value_valid_type(new_value):
return new_value
raise InvalidValueType(self.valid_value_types, type(value), self.path)
# TODO convert to private method
def _check_update_value(self, value, value_source):
"""Validation of value on update methods.
Update methods update data from currently saved settings so it is
possible to have invalid type mainly during development.
Args:
value (Any): Value that got to update method.
value_source (str): Source update method. Is used for logging and
is not used as part of logic ("default", "studio override",
"project override").
Returns:
Any: Return value itself if has valid type.
NOT_SET: If value has invalid type.
"""
# Nothing to validate if is NOT_SET
if value is NOT_SET:
return value
try:
new_value = self.convert_to_valid_type(value)
except InvalidValueType:
new_value = NOT_SET
if new_value is not NOT_SET:
return new_value
# Warning log about invalid value type.
self.log.warning(
(
"{} Got invalid value type for {} values."
" Expected types: {} | Got Type: {} | Value: \"{}\""
).format(
self.path, value_source,
self.valid_value_types, type(value), str(value)
)
)
return NOT_SET
def available_for_role(self, role_name=None):
"""Is entity valid for role.
Args:
role_name (str): Name of role that will be validated. Entity's
`user_role` attribute is used if not defined.
Returns:
bool: True if is available for role.
"""
if not self.roles:
return True
if role_name is None:
role_name = self.user_role
return role_name in self.roles
@property
def user_role(self):
"""Entity is using user role.
Returns:
str: user role as string.
"""
return self.parent.user_role
@property
def log(self):
"""Auto created logger for debugging or warnings."""
if self._log is None:
self._log = PypeLogger.get_logger(self.__class__.__name__)
return self._log
@abstractproperty
def schema_types(self):
pass
@abstractproperty
def has_unsaved_changes(self):
pass
@abstractmethod
def settings_value(self):
"""Value of an item without key."""
pass
@abstractmethod
def save(self):
"""Save data for current state."""
pass
@abstractmethod
def _item_initalization(self):
"""Entity specific initialization process."""
pass
@abstractproperty
def value(self):
"""Value of entity without metadata."""
pass
@property
def can_discard_changes(self):
"""Result defines if `discard_changes` will be processed.
Also can be used as validation before the method is called.
"""
return self.has_unsaved_changes
@property
def can_add_to_studio_default(self):
"""Result defines if `add_to_studio_default` will be processed.
Also can be used as validation before the method is called.
"""
if self._override_state is not OverrideState.STUDIO:
return False
if self.is_dynamic_item or self.is_in_dynamic_item:
return False
# Skip if entity is under group
if self.group_item:
return False
# Skip if is group and any children is already marked with studio
# overrides
if self.is_group and self.has_studio_override:
return False
return True
@property
def can_remove_from_studio_default(self):
"""Result defines if `remove_from_studio_default` can be triggered.
This can be also used as validation before the method is called.
"""
if self._override_state is not OverrideState.STUDIO:
return False
if self.is_dynamic_item or self.is_in_dynamic_item:
return False
if not self.has_studio_override:
return False
return True
@property
def can_add_to_project_override(self):
"""Result defines if `add_to_project_override` can be triggered.
Also can be used as validation before the method is called.
"""
if self.is_dynamic_item or self.is_in_dynamic_item:
return False
# Show only when project overrides are set
if self._override_state is not OverrideState.PROJECT:
return False
# Do not show on items under group item
if self.group_item:
return False
# Skip if already is marked to save project overrides
if self.is_group and self.has_project_override:
return False
return True
@property
def can_remove_from_project_override(self):
"""Result defines if `remove_from_project_override` can be triggered.
This can be also used as validation before the method is called.
"""
if self.is_dynamic_item or self.is_in_dynamic_item:
return False
if self._override_state is not OverrideState.PROJECT:
return False
# Dynamic items can't have these actions
if self.is_dynamic_item or self.is_in_dynamic_item:
return False
if not self.has_project_override:
return False
return True
def discard_changes(self, on_change_trigger=None):
"""Discard changes on entity and it's children.
Reset all values to same values as had when `set_override_state` was
called last time.
Must not affect `had_studio_override` value or `had_project_override`
value. It must be marked that there are keys/values which are not in
defaults or overrides.
Won't affect if will be stored as overrides if entity is under
group entity in hierarchy.
This is wrapper method that handles on_change callbacks only when all
`_discard_changes` on all children happened. That is important as
value changes may trigger change callbacks that must be ignored.
Callbacks are triggered by entity where method was called.
Args:
on_change_trigger (list): Callbacks of `on_change` should be stored
to trigger them afterwards.
"""
initialized = False
if on_change_trigger is None:
if not self.can_discard_changes:
return
initialized = True
on_change_trigger = []
self._discard_changes(on_change_trigger)
if initialized:
for callback in on_change_trigger:
callback()
@abstractmethod
def _discard_changes(self, on_change_trigger):
"""Entity's implementation to discard all changes made by user."""
pass
def add_to_studio_default(self, on_change_trigger=None):
initialized = False
if on_change_trigger is None:
if not self.can_add_to_studio_default:
return
initialized = True
on_change_trigger = []
self._add_to_studio_default(on_change_trigger)
if initialized:
for callback in on_change_trigger:
callback()
@abstractmethod
def _add_to_studio_default(self, on_change_trigger):
"""Item's implementation to set current values as studio's overrides.
Mark item and it's children as they have studio overrides.
"""
pass
def remove_from_studio_default(self, on_change_trigger=None):
"""Remove studio overrides from entity and it's children.
Reset values to openpype's default and mark entity to not store values
as studio overrides if entity is not under group.
This is wrapper method that handles on_change callbacks only when all
`_remove_from_studio_default` on all children happened. That is
important as value changes may trigger change callbacks that must be
ignored. Callbacks are triggered by entity where method was called.
Args:
on_change_trigger (list): Callbacks of `on_change` should be stored
to trigger them afterwards.
"""
initialized = False
if on_change_trigger is None:
if not self.can_remove_from_studio_default:
return
initialized = True
on_change_trigger = []
self._remove_from_studio_default(on_change_trigger)
if initialized:
for callback in on_change_trigger:
callback()
@abstractmethod
def _remove_from_studio_default(self, on_change_trigger):
"""Item's implementation to remove studio overrides.
Mark item as it does not have studio overrides unset studio
override values.
"""
pass
def add_to_project_override(self, on_change_trigger=None):
initialized = False
if on_change_trigger is None:
if not self.can_add_to_project_override:
return
initialized = True
on_change_trigger = []
self._add_to_project_override(on_change_trigger)
if initialized:
for callback in on_change_trigger:
callback()
@abstractmethod
def _add_to_project_override(self, on_change_trigger):
"""Item's implementation to set values as overriden for project.
Mark item and all it's children to be stored as project overrides.
"""
pass
def remove_from_project_override(self, on_change_trigger=None):
"""Remove project overrides from entity and it's children.
Reset values to studio overrides or openpype's default and mark entity
to not store values as project overrides if entity is not under group.
This is wrapper method that handles on_change callbacks only when all
`_remove_from_project_override` on all children happened. That is
important as value changes may trigger change callbacks that must be
ignored. Callbacks are triggered by entity where method was called.
Args:
on_change_trigger (list): Callbacks of `on_change` should be stored
to trigger them afterwards.
"""
if self._override_state is not OverrideState.PROJECT:
return
initialized = False
if on_change_trigger is None:
if not self.can_remove_from_project_override:
return
initialized = True
on_change_trigger = []
self._remove_from_project_override(on_change_trigger)
if initialized:
for callback in on_change_trigger:
callback()
@abstractmethod
def _remove_from_project_override(self, on_change_trigger):
"""Item's implementation to remove project overrides.
Mark item as does not have project overrides. Must not change
`was_overriden` attribute value.
Args:
on_change_trigger (list): Callbacks of `on_change` should be stored
to trigger them afterwards.
"""
pass
def reset_callbacks(self):
"""Clear any registered callbacks on entity and all children."""
self.on_change_callbacks = []
class ItemEntity(BaseItemEntity):
"""Item that is used as hierarchical entity.
Entity must have defined parent and can't be created outside it's parent.
Dynamically created entity is entity that can be removed from settings
hierarchy and it's key or existence is not defined in schemas. Are
created by `ListEntity` or `DictMutableKeysEntity`. Their information about
default value or modification is not relevant.
Args:
schema_data (dict): Schema data that defines entity behavior.
parent (BaseItemEntity): Parent entity that created this entity.
is_dynamic_item (bool): Entity should behave like dynamically created
entity.
"""
_default_label_wrap = {
"use_label_wrap": False,
"collapsible": True,
"collapsed": False
}
def __init__(self, schema_data, parent, is_dynamic_item=False):
super(ItemEntity, self).__init__(schema_data)
self.parent = parent
self.is_dynamic_item = is_dynamic_item
self.is_file = self.schema_data.get("is_file", False)
self.is_group = self.schema_data.get("is_group", False)
self.is_in_dynamic_item = bool(
not self.is_dynamic_item
and (self.parent.is_dynamic_item or self.parent.is_in_dynamic_item)
)
# Dynamic item can't have key defined in it-self
# - key is defined by it's parent
if self.is_dynamic_item:
self.require_key = False
# If value should be stored to environments and uder which group key
# - the key may be dynamically changed by it's parent on save
self.env_group_key = self.schema_data.get("env_group_key")
self.is_env_group = bool(self.env_group_key is not None)
# Root item reference
self.root_item = self.parent.root_item
# File item reference
if self.parent.is_file:
self.file_item = self.parent
elif self.parent.file_item:
self.file_item = self.parent.file_item
# Group item reference
if self.parent.is_group:
self.group_item = self.parent
elif self.parent.group_item:
self.group_item = self.parent.group_item
self.key = self.schema_data.get("key")
self.label = self.schema_data.get("label")
# GUI attributes
_default_label_wrap = self.__class__._default_label_wrap
for key, value in ItemEntity._default_label_wrap.items():
if key not in _default_label_wrap:
self.log.warning(
"Class {} miss default label wrap key \"{}\"".format(
self.__class__.__name__, key
)
)
_default_label_wrap[key] = value
use_label_wrap = self.schema_data.get("use_label_wrap")
if use_label_wrap is None:
if not self.label:
use_label_wrap = False
else:
use_label_wrap = _default_label_wrap["use_label_wrap"]
self.use_label_wrap = use_label_wrap
# Used only if `use_label_wrap` is set to True
self.collapsible = self.schema_data.get(
"collapsible",
_default_label_wrap["collapsible"]
)
self.collapsed = self.schema_data.get(
"collapsed",
_default_label_wrap["collapsed"]
)
self._item_initalization()
def save(self):
"""Call save on root item."""
self.root_item.save()
def schema_validations(self):
if not self.label and self.use_label_wrap:
reason = (
"Entity has set `use_label_wrap` to true but"
" does not have set `label`."
)
raise EntitySchemaError(self, reason)
super(ItemEntity, self).schema_validations()
def create_schema_object(self, *args, **kwargs):
"""Reference method for creation of entities defined in RootEntity."""
return self.root_item.create_schema_object(*args, **kwargs)
def get_entity_from_path(self, path):
return self.root_item.get_entity_from_path(path)
@abstractmethod
def update_default_value(self, parent_values):
"""Fill default values on startup or on refresh.
Default values stored in `openpype` repository should update all items
in schema. Each item should take values for his key and set it's value
or pass values down to children items.
Args:
parent_values (dict): Values of parent's item. But in case item is
used as widget, `parent_values` contain value for item.
"""
pass
@abstractmethod
def update_studio_value(self, parent_values):
"""Fill studio override values on startup or on refresh.
Set studio value if is not set to NOT_SET, in that case studio
overrides are not set yet.
Args:
parent_values (dict): Values of parent's item. But in case item is
used as widget, `parent_values` contain value for item.
"""
pass
@abstractmethod
def update_project_value(self, parent_values):
"""Fill project override values on startup, refresh or project change.
Set project value if is not set to NOT_SET, in that case project
overrides are not set yet.
Args:
parent_values (dict): Values of parent's item. But in case item is
used as widget, `parent_values` contain value for item.
"""
pass