ayon-core/openpype/settings/entities/base_entity.py
2022-08-24 15:52:47 +02:00

1015 lines
33 KiB
Python

from uuid import uuid4
from abc import ABCMeta, abstractmethod, abstractproperty
import six
from .lib import (
NOT_SET,
OverrideState
)
from .exceptions import (
BaseInvalidValue,
InvalidValueType,
SchemeGroupHierarchyBug,
EntitySchemaError
)
from openpype.lib import Logger
@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
tooltip = None
if schema_data:
tooltip = schema_data.get("tooltip")
self.tooltip = tooltip
# 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
# Default values are not stored to an openpype file
# - these must not be set through schemas directly
self.dynamic_schema_id = None
self.is_dynamic_schema_node = False
self.is_in_dynamic_schema_node = 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
# Change of value requires restart of OpenPype
self._require_restart_on_change = False
# Entity is in hierarchy of dynamically created entity
self.is_in_dynamic_item = False
# 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
self._ignore_missing_defaults = None
# 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
self._default_log_invalid_types = True
self._studio_log_invalid_types = True
self._project_log_invalid_types = True
# 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
@abstractmethod
def collect_static_entities_by_path(self):
"""Collect all paths of all static path entities.
Static path is entity which is not dynamic or under dynamic entity.
"""
pass
@property
def require_restart_on_change(self):
return self._require_restart_on_change
@property
def require_restart(self):
return False
@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
@abstractmethod
def has_child_with_key(self, key):
"""Entity contains key as children."""
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 is not None:
raise SchemeGroupHierarchyBug(self)
# Group item can be only once in on hierarchy branch.
if self.group_item is not None and self.is_dynamic_schema_node:
reason = (
"Dynamic schema is inside grouped item {}."
" Change group hierarchy or remove dynamic"
" schema to be able work properly."
).format(self.group_item.path)
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."
)
if (
self.require_restart_on_change
and (self.is_dynamic_item or self.is_in_dynamic_item)
):
raise EntitySchemaError(
self, "Dynamic entity can't require restart."
)
@abstractproperty
def root_key(self):
"""Root is represented as this dictionary key."""
pass
@abstractmethod
def set_override_state(self, state, ignore_missing_defaults):
"""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.
Argument `ignore_missing_defaults` should be used when entity has
children that are not saved or used all the time but override statu
must be changed and children must have any default value.
Args:
state (OverrideState): State to which should be data changed.
ignore_missing_defaults (bool): Ignore missing default values.
Entity won't raise `DefaultsNotDefined` and
`StudioDefaultsNotDefined`.
"""
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, log_invalid_types=True):
"""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 BaseInvalidValue:
new_value = NOT_SET
if new_value is not NOT_SET:
return new_value
if log_invalid_types:
# 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 = Logger.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 without dynamic items."""
pass
@abstractmethod
def collect_dynamic_schema_entities(self):
"""Collect entities that are on top of dynamically added schemas.
This method make sence only when defaults are saved.
"""
pass
@abstractmethod
def save(self):
"""Save data for current state."""
pass
@abstractmethod
def _item_initialization(self):
"""Entity specific initialization process."""
pass
@abstractproperty
def value(self):
"""Value of entity without metadata."""
pass
@property
def _can_discard_changes(self):
"""Defines if `discard_changes` will be processed."""
return self.has_unsaved_changes
@property
def _can_add_to_studio_default(self):
"""Defines if `add_to_studio_default` will be processed."""
if self._override_state is not OverrideState.STUDIO:
return False
# Skip if entity is under group
if self.group_item is not None:
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):
"""Defines if `remove_from_studio_default` can be processed."""
if self._override_state is not OverrideState.STUDIO:
return False
if not self.has_studio_override:
return False
return True
@property
def _can_add_to_project_override(self):
"""Defines if `add_to_project_override` can be processed."""
# 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 is not None:
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):
"""Defines if `remove_from_project_override` can be processed."""
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
@property
def can_trigger_discard_changes(self):
"""Defines if can trigger `discard_changes`.
Also can be used as validation before the method is called.
"""
return self._can_discard_changes
@property
def can_trigger_add_to_studio_default(self):
"""Defines if can trigger `add_to_studio_default`.
Also can be used as validation before the method is called.
"""
if self.is_dynamic_item or self.is_in_dynamic_item:
return False
return self._can_add_to_studio_default
@property
def can_trigger_remove_from_studio_default(self):
"""Defines if can trigger `remove_from_studio_default`.
Also can be used as validation before the method is called.
"""
if self.is_dynamic_item or self.is_in_dynamic_item:
return False
return self._can_remove_from_studio_default
@property
def can_trigger_add_to_project_override(self):
"""Defines if can trigger `add_to_project_override`.
Also can be used as validation before the method is called.
"""
if self.is_dynamic_item or self.is_in_dynamic_item:
return False
return self._can_add_to_project_override
@property
def can_trigger_remove_from_project_override(self):
"""Defines if can trigger `remove_from_project_override`.
Also can be used as validation before the method is called.
"""
if self.is_dynamic_item or self.is_in_dynamic_item:
return False
return self._can_remove_from_project_override
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_trigger_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_trigger_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_trigger_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_trigger_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 overridden 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_trigger_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_overridden` 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)
# These keys have underscore as they must not be set in schemas
self.dynamic_schema_id = self.schema_data.get(
"_dynamic_schema_id", None
)
self.is_dynamic_schema_node = self.dynamic_schema_id is not None
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
# Root item reference
self.root_item = self.parent.root_item
# Item require restart on value change
require_restart_on_change = self.schema_data.get("require_restart")
if (
require_restart_on_change is None
and not (self.is_dynamic_item or self.is_in_dynamic_item)
):
require_restart_on_change = self.parent.require_restart_on_change
self._require_restart_on_change = require_restart_on_change
# File item reference
if not self.is_dynamic_schema_node:
self.is_in_dynamic_schema_node = (
self.parent.is_dynamic_schema_node
or self.parent.is_in_dynamic_schema_node
)
if (
not self.is_dynamic_schema_node
and not self.is_in_dynamic_schema_node
):
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 is not None:
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_initialization()
def save(self):
"""Call save on root item."""
self.root_item.save()
@property
def root_key(self):
return self.root_item.root_key
@abstractmethod
def collect_dynamic_schema_entities(self, collector):
"""Collect entities that are on top of dynamically added schemas.
This method make sence only when defaults are saved.
Args:
collector(DynamicSchemaValueCollector): Object where dynamic
entities are stored.
"""
pass
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)
if (
not self.is_dynamic_schema_node
and not self.is_in_dynamic_schema_node
and self.is_file
and self.file_item is not None
):
reason = (
"Entity has set `is_file` to true but"
" it's parent is already marked as file item."
)
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.schema_hub.create_schema_object(*args, **kwargs)
@property
def schema_hub(self):
return self.root_item.schema_hub
def get_entity_from_path(self, path):
return self.root_item.get_entity_from_path(path)
@abstractmethod
def update_default_value(self, parent_values, log_invalid_types=True):
"""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.
log_invalid_types (bool): Log invalid type of value. Used when
entity can have children with same keys and different types.
"""
pass
@abstractmethod
def update_studio_value(self, parent_values, log_invalid_types=True):
"""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.
log_invalid_types (bool): Log invalid type of value. Used when
entity can have children with same keys and different types.
"""
pass
@abstractmethod
def update_project_value(self, parent_values, log_invalid_types=True):
"""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.
log_invalid_types (bool): Log invalid type of value. Used when
entity can have children with same keys and different types.
"""
pass