diff --git a/source/ftrack_api/attribute.py b/source/ftrack_api/attribute.py index 4a8fdbab..a83d0658 100644 --- a/source/ftrack_api/attribute.py +++ b/source/ftrack_api/attribute.py @@ -7,15 +7,19 @@ import collections from six.moves import collections_abc import copy +import typing import logging +import warnings import functools -import ftrack_api.symbol import ftrack_api.exception import ftrack_api.collection import ftrack_api.inspection import ftrack_api.operation +from ftrack_api.symbol import NOT_SET +from ftrack_api.attribute_storage import get_entity_storage + logger = logging.getLogger(__name__) @@ -25,6 +29,11 @@ def merge_references(function): @functools.wraps(function) def get_value(attribute, entity): """Merge the attribute with the local cache.""" + mergable_types = ( + ftrack_api.entity.base.Entity, + ftrack_api.collection.Collection, + ftrack_api.collection.MappedCollectionProxy, + ) if attribute.name not in entity._inflated: # Only merge on first access to avoid @@ -39,11 +48,7 @@ def get_value(attribute, entity): local_value = attribute.get_local_value(entity) if isinstance( local_value, - ( - ftrack_api.entity.base.Entity, - ftrack_api.collection.Collection, - ftrack_api.collection.MappedCollectionProxy, - ), + mergable_types, ): logger.debug("Merging local value for attribute {0}.".format(attribute)) @@ -57,11 +62,7 @@ def get_value(attribute, entity): remote_value = attribute.get_remote_value(entity) if isinstance( remote_value, - ( - ftrack_api.entity.base.Entity, - ftrack_api.collection.Collection, - ftrack_api.collection.MappedCollectionProxy, - ), + mergable_types, ): logger.debug( "Merging remote value for attribute {0}.".format(attribute) @@ -139,7 +140,7 @@ class Attribute(object): def __init__( self, name, - default_value=ftrack_api.symbol.NOT_SET, + default_value=NOT_SET, mutable=True, computed=False, ): @@ -164,29 +165,23 @@ def __init__( self._computed = computed self.default_value = default_value - self._local_key = "local" - self._remote_key = "remote" - def __repr__(self): """Return representation of entity.""" return "<{0}.{1}({2}) object at {3}>".format( self.__module__, self.__class__.__name__, self.name, id(self) ) - def get_entity_storage(self, entity): + @staticmethod + def get_entity_storage(entity): """Return attribute storage on *entity* creating if missing.""" - storage_key = "_ftrack_attribute_storage" - storage = getattr(entity, storage_key, None) - if storage is None: - storage = collections.defaultdict( - lambda: { - self._local_key: ftrack_api.symbol.NOT_SET, - self._remote_key: ftrack_api.symbol.NOT_SET, - } - ) - setattr(entity, storage_key, storage) - return storage + warnings.warn( + "Use of Attribute.get_entity_storage is deprecated, use ftrack_api.attribute_storage" + ".get_entity_storage function instead.", + DeprecationWarning, + ) + + return get_entity_storage(entity) @property def name(self): @@ -211,24 +206,26 @@ def get_value(self, entity): via the session and block until available. """ - value = self.get_local_value(entity) - if value is not ftrack_api.symbol.NOT_SET: - return value + local_value, remote_remote = get_entity_storage(entity).get_local_remote_pair( + self.name + ) + + if local_value is not NOT_SET: + return local_value - value = self.get_remote_value(entity) - if value is not ftrack_api.symbol.NOT_SET: - return value + if remote_remote is not NOT_SET: + return remote_remote if not entity.session.auto_populate: - return value + return remote_remote self.populate_remote_value(entity) + return self.get_remote_value(entity) def get_local_value(self, entity): """Return locally set value for *entity*.""" - storage = self.get_entity_storage(entity) - return storage[self.name][self._local_key] + return get_entity_storage(entity).get_local(self.name) def get_remote_value(self, entity): """Return remote value for *entity*. @@ -238,22 +235,16 @@ def get_remote_value(self, entity): Only return locally stored remote value, do not fetch from remote. """ - storage = self.get_entity_storage(entity) - return storage[self.name][self._remote_key] + return get_entity_storage(entity).get_remote(self.name) def set_local_value(self, entity, value): """Set local *value* for *entity*.""" - if ( - not self.mutable - and self.is_set(entity) - and value is not ftrack_api.symbol.NOT_SET - ): + if not self.mutable and self.is_set(entity) and value is not NOT_SET: raise ftrack_api.exception.ImmutableAttributeError(self) old_value = self.get_local_value(entity) - storage = self.get_entity_storage(entity) - storage[self.name][self._local_key] = value + get_entity_storage(entity).set_local(self.name, value) # Record operation. if entity.session.record_operations: @@ -275,8 +266,7 @@ def set_remote_value(self, entity, value): Only set locally stored remote value, do not persist to remote. """ - storage = self.get_entity_storage(entity) - storage[self.name][self._remote_key] = value + get_entity_storage(entity).set_remote(self.name, value) def populate_remote_value(self, entity): """Populate remote value for *entity*.""" @@ -291,18 +281,22 @@ def is_modified(self, entity): are the same on the remote. """ - local_value = self.get_local_value(entity) - remote_value = self.get_remote_value(entity) - return ( - local_value is not ftrack_api.symbol.NOT_SET and local_value != remote_value + local_value, remote_value = get_entity_storage(entity).get_local_remote_pair( + self.name ) + return local_value is not NOT_SET and local_value != remote_value + def is_set(self, entity): """Return whether a value is set for *entity*.""" + local_value, remote_value = get_entity_storage(entity).get_local_remote_pair( + self.name + ) + return any( [ - self.get_local_value(entity) is not ftrack_api.symbol.NOT_SET, - self.get_remote_value(entity) is not ftrack_api.symbol.NOT_SET, + local_value is not NOT_SET, + remote_value is not NOT_SET, ] ) @@ -352,13 +346,14 @@ def is_modified(self, entity): are the same on the remote. """ - local_value = self.get_local_value(entity) - remote_value = self.get_remote_value(entity) + local_value, remote_value = get_entity_storage(entity).get_local_remote_pair( + self.name + ) - if local_value is ftrack_api.symbol.NOT_SET: + if local_value is NOT_SET: return False - if remote_value is ftrack_api.symbol.NOT_SET: + if remote_value is NOT_SET: return True if ftrack_api.inspection.identity( @@ -400,9 +395,7 @@ def get_value(self, entity): # mutated without side effects. local_value = self.get_local_value(entity) remote_value = self.get_remote_value(entity) - if local_value is ftrack_api.symbol.NOT_SET and isinstance( - remote_value, self.collection_class - ): + if local_value is NOT_SET and isinstance(remote_value, self.collection_class): try: with entity.session.operation_recording(False): self.set_local_value(entity, copy.copy(remote_value)) @@ -417,7 +410,7 @@ def get_value(self, entity): # newly created entity for example. It *could* be done as a simple # default value, but that would incur cost for every collection even # when they are not modified before commit. - if value is ftrack_api.symbol.NOT_SET: + if value is NOT_SET: try: with entity.session.operation_recording(False): self.set_local_value( @@ -432,7 +425,7 @@ def get_value(self, entity): def set_local_value(self, entity, value): """Set local *value* for *entity*.""" - if value is not ftrack_api.symbol.NOT_SET: + if value is not NOT_SET: value = self._adapt_to_collection(entity, value) value.mutable = self.mutable @@ -446,7 +439,7 @@ def set_remote_value(self, entity, value): Only set locally stored remote value, do not persist to remote. """ - if value is not ftrack_api.symbol.NOT_SET: + if value is not NOT_SET: value = self._adapt_to_collection(entity, value) value.mutable = False diff --git a/source/ftrack_api/attribute_storage.py b/source/ftrack_api/attribute_storage.py new file mode 100644 index 00000000..d3beee63 --- /dev/null +++ b/source/ftrack_api/attribute_storage.py @@ -0,0 +1,53 @@ +import typing +import collections + +from ftrack_api.symbol import NOT_SET + +if typing.TYPE_CHECKING: + from ftrack_api.entity.base import Entity + +ENTITY_STORAGE_KEY = "ftrack_attribute_storage" + +LOCAL_ENTITY_STORAGE_KEY = "local" +REMOTE_ENTITY_STORAGE_KEY = "remote" + + +class EntityStorage(collections.defaultdict): + """Storage for entity attributes""" + + def get(self, key: str) -> typing.Any: + local, remote = self.get_local_remote_pair(key) + + return local if local is not NOT_SET else remote + + def get_local(self, key: str) -> typing.Any: + return self[key][LOCAL_ENTITY_STORAGE_KEY] + + def get_remote(self, key: str) -> typing.Any: + return self[key][REMOTE_ENTITY_STORAGE_KEY] + + def get_local_remote_pair(self, key: str) -> typing.Tuple[typing.Any, typing.Any]: + """Return local and remote values for *key*.""" + return self[key][LOCAL_ENTITY_STORAGE_KEY], self[key][REMOTE_ENTITY_STORAGE_KEY] + + def set_local(self, key: str, value: typing.Any) -> None: + self[key][LOCAL_ENTITY_STORAGE_KEY] = value + + def set_remote(self, key: str, value: typing.Any) -> None: + self[key][REMOTE_ENTITY_STORAGE_KEY] = value + + +def get_entity_storage(entity: "Entity") -> EntityStorage: + """Return attribute storage on *entity* creating if missing.""" + + storage = getattr(entity, ENTITY_STORAGE_KEY, None) + if storage is None: + storage = EntityStorage( + lambda: { + LOCAL_ENTITY_STORAGE_KEY: NOT_SET, + REMOTE_ENTITY_STORAGE_KEY: NOT_SET, + } + ) + setattr(entity, ENTITY_STORAGE_KEY, storage) + + return storage diff --git a/source/ftrack_api/cache.py b/source/ftrack_api/cache.py index 2ea7ebe4..2c4573c2 100644 --- a/source/ftrack_api/cache.py +++ b/source/ftrack_api/cache.py @@ -48,7 +48,7 @@ import pickle import ftrack_api.inspection -import ftrack_api.symbol +from ftrack_api.symbol import NOT_SET class Cache(with_metaclass(abc.ABCMeta, object)): @@ -184,7 +184,7 @@ def get(self, key): """ target_caches = [] - value = ftrack_api.symbol.NOT_SET + value = NOT_SET for cache in self.caches: try: @@ -195,7 +195,7 @@ def get(self, key): else: break - if value is ftrack_api.symbol.NOT_SET: + if value is NOT_SET: raise KeyError(key) # Set value on all higher level caches. diff --git a/source/ftrack_api/entity/base.py b/source/ftrack_api/entity/base.py index cf8fa4b7..28a38cac 100644 --- a/source/ftrack_api/entity/base.py +++ b/source/ftrack_api/entity/base.py @@ -16,6 +16,8 @@ import ftrack_api.operation from ftrack_api.logging import LazyLogMessage as L from future.utils import with_metaclass +from ftrack_api.attribute import get_entity_storage +from ftrack_api.symbol import NOT_SET class _EntityBase(object): @@ -310,6 +312,9 @@ def merge(self, entity, merged=None): log_message = 'Merged {type} "{name}": {old_value!r} -> {new_value!r}' changes = [] + storage = get_entity_storage(self) + other_storage = get_entity_storage(entity) + # Attributes. # Prioritise by type so that scalar values are set first. This should @@ -317,29 +322,30 @@ def merge(self, entity, merged=None): # are merged before merging any collections that may have references to # this entity. attributes = collections.deque() - for attribute in entity.attributes: - if isinstance(attribute, ftrack_api.attribute.ScalarAttribute): - attributes.appendleft(attribute) + for _attribute in other_storage.keys(): + if isinstance(_attribute, ftrack_api.attribute.ScalarAttribute): + attributes.appendleft(_attribute) else: - attributes.append(attribute) + attributes.append(_attribute) - for other_attribute in attributes: - attribute = self.attributes.get(other_attribute.name) + for attribute_name in attributes: + attribute = self.attributes.get(attribute_name) # Local attributes. - other_local_value = other_attribute.get_local_value(entity) - if other_local_value is not ftrack_api.symbol.NOT_SET: - local_value = attribute.get_local_value(self) + other_local_value = other_storage.get_local(attribute_name) + if other_local_value is not NOT_SET: + local_value = storage.get_local(attribute_name) if local_value != other_local_value: merged_local_value = self.session.merge( other_local_value, merged=merged ) attribute.set_local_value(self, merged_local_value) + changes.append( { "type": "local_attribute", - "name": attribute.name, + "name": attribute_name, "old_value": local_value, "new_value": merged_local_value, } @@ -347,9 +353,9 @@ def merge(self, entity, merged=None): log_debug and self.logger.debug(log_message.format(**changes[-1])) # Remote attributes. - other_remote_value = other_attribute.get_remote_value(entity) - if other_remote_value is not ftrack_api.symbol.NOT_SET: - remote_value = attribute.get_remote_value(self) + other_remote_value = other_storage.get_remote(attribute_name) + if other_remote_value is not NOT_SET: + remote_value = storage.get_remote(attribute_name) if remote_value != other_remote_value: merged_remote_value = self.session.merge( other_remote_value, merged=merged @@ -360,7 +366,7 @@ def merge(self, entity, merged=None): changes.append( { "type": "remote_attribute", - "name": attribute.name, + "name": attribute_name, "old_value": remote_value, "new_value": merged_remote_value, } @@ -404,7 +410,7 @@ def _populate_unset_scalar_attributes(self): projections = [] for attribute in self.attributes: if isinstance(attribute, ftrack_api.attribute.ScalarAttribute): - if attribute.get_remote_value(self) is ftrack_api.symbol.NOT_SET: + if attribute.get_remote_value(self) is NOT_SET: projections.append(attribute.name) if projections: diff --git a/source/ftrack_api/inspection.py b/source/ftrack_api/inspection.py index a9cd8d1e..73f038c4 100644 --- a/source/ftrack_api/inspection.py +++ b/source/ftrack_api/inspection.py @@ -8,6 +8,9 @@ import ftrack_api.symbol import ftrack_api.operation +from ftrack_api.symbol import NOT_SET +from ftrack_api.attribute_storage import get_entity_storage + def identity(entity): """Return unique identity of *entity*.""" @@ -23,9 +26,12 @@ def primary_key(entity): """ primary_key = collections.OrderedDict() + entity_storage = get_entity_storage(entity) + for name in entity.primary_key_attributes: - value = entity[name] - if value is ftrack_api.symbol.NOT_SET: + value = entity_storage.get(name) + + if value is NOT_SET: raise KeyError( 'Missing required value for primary key attribute "{0}" on ' "entity {1!r}.".format(name, entity) @@ -41,13 +47,13 @@ def _state(operation, state): """Return state following *operation* against current *state*.""" if ( isinstance(operation, ftrack_api.operation.CreateEntityOperation) - and state is ftrack_api.symbol.NOT_SET + and state is NOT_SET ): state = ftrack_api.symbol.CREATED elif ( isinstance(operation, ftrack_api.operation.UpdateEntityOperation) - and state is ftrack_api.symbol.NOT_SET + and state is NOT_SET ): state = ftrack_api.symbol.MODIFIED @@ -63,7 +69,7 @@ def state(entity): .. seealso:: :func:`ftrack_api.inspection.states`. """ - value = ftrack_api.symbol.NOT_SET + value = NOT_SET for operation in entity.session.recorded_operations: # Determine if operation refers to an entity and whether that entity @@ -106,7 +112,7 @@ def states(entities): entities_by_identity = collections.OrderedDict() for entity in entities: key = (entity.entity_type, str(list(primary_key(entity).values()))) - entities_by_identity[key] = ftrack_api.symbol.NOT_SET + entities_by_identity[key] = NOT_SET for operation in session.recorded_operations: if isinstance( diff --git a/source/ftrack_api/query.py b/source/ftrack_api/query.py index 62c7dd5b..5b4a3136 100644 --- a/source/ftrack_api/query.py +++ b/source/ftrack_api/query.py @@ -10,8 +10,8 @@ class QueryResult(collections_abc.Sequence): """Results from a query.""" - OFFSET_EXPRESSION = re.compile("(?Poffset (?P\d+))") - LIMIT_EXPRESSION = re.compile("(?Plimit (?P\d+))") + OFFSET_EXPRESSION = re.compile(r"(?Poffset (?P\d+))") + LIMIT_EXPRESSION = re.compile(r"(?Plimit (?P\d+))") def __init__(self, session, expression, page_size=500): """Initialise result set. diff --git a/source/ftrack_api/session.py b/source/ftrack_api/session.py index d8a4f69e..2a37f906 100644 --- a/source/ftrack_api/session.py +++ b/source/ftrack_api/session.py @@ -54,6 +54,11 @@ import ftrack_api._centralized_storage_scenario import ftrack_api.logging from ftrack_api.logging import LazyLogMessage as L +from ftrack_api.symbol import NOT_SET +from ftrack_api.attribute import get_entity_storage + +from ftrack_api.entity.base import Entity +from ftrack_api.collection import Collection, MappedCollectionProxy try: from weakref import WeakMethod @@ -61,6 +66,13 @@ from ftrack_api._weakref import WeakMethod +_MERGABLE_TYPES = ( + Entity, + Collection, + MappedCollectionProxy, +) + + class SessionAuthentication(requests.auth.AuthBase): """Attach ftrack session authentication information to requests.""" @@ -903,14 +915,14 @@ def _merge(self, value, merged): log_debug = self.logger.isEnabledFor(logging.DEBUG) with self.merge_lock: - if isinstance(value, ftrack_api.entity.base.Entity): + if isinstance(value, Entity): log_debug and self.logger.debug( "Merging entity into session: {0} at {1}".format(value, id(value)) ) return self._merge_entity(value, merged=merged) - elif isinstance(value, ftrack_api.collection.Collection): + elif isinstance(value, Collection): log_debug and self.logger.debug( "Merging collection into session: {0!r} at {1}".format( value, id(value) @@ -923,7 +935,7 @@ def _merge(self, value, merged): return merged_collection - elif isinstance(value, ftrack_api.collection.MappedCollectionProxy): + elif isinstance(value, MappedCollectionProxy): log_debug and self.logger.debug( "Merging mapped collection into session: {0!r} at {1}".format( value, id(value) @@ -947,33 +959,25 @@ def _merge_recursive(self, entity, merged=None): merged = {} attached = self.merge(entity, merged) + entity_storage = get_entity_storage(entity) - for attribute in entity.attributes: + for attribute_name in entity_storage.keys(): # Remote attributes. - remote_value = attribute.get_remote_value(entity) - - if isinstance( - remote_value, - ( - ftrack_api.entity.base.Entity, - ftrack_api.collection.Collection, - ftrack_api.collection.MappedCollectionProxy, - ), - ): + remote_value = entity_storage.get_remote(attribute_name) + + if isinstance(remote_value, _MERGABLE_TYPES): log_debug and self.logger.debug( - "Merging remote value for attribute {0}.".format(attribute) + "Merging remote value for attribute {0}.".format(attribute_name) ) - if isinstance(remote_value, ftrack_api.entity.base.Entity): + if isinstance(remote_value, Entity): self._merge_recursive(remote_value, merged=merged) - elif isinstance(remote_value, ftrack_api.collection.Collection): + elif isinstance(remote_value, Collection): for entry in remote_value: self._merge_recursive(entry, merged=merged) - elif isinstance( - remote_value, ftrack_api.collection.MappedCollectionProxy - ): + elif isinstance(remote_value, MappedCollectionProxy): for entry in remote_value.collection: self._merge_recursive(entry, merged=merged) @@ -1262,7 +1266,7 @@ def commit(self): for payload in batch: entity_data = payload.get("entity_data", {}) for key, value in list(entity_data.items()): - if value is ftrack_api.symbol.NOT_SET: + if value is NOT_SET: del entity_data[key] # Remove payloads with redundant entity_data. @@ -1735,7 +1739,7 @@ def _encode(self, item, entity_attribute_strategy="set_only"): with self.auto_populating(True): for attribute in item.attributes: - value = ftrack_api.symbol.NOT_SET + value = NOT_SET if entity_attribute_strategy == "all": value = attribute.get_value(item) @@ -1743,7 +1747,7 @@ def _encode(self, item, entity_attribute_strategy="set_only"): elif entity_attribute_strategy == "set_only": if attribute.is_set(item): value = attribute.get_local_value(item) - if value is ftrack_api.symbol.NOT_SET: + if value is NOT_SET: value = attribute.get_remote_value(item) elif entity_attribute_strategy == "modified_only": @@ -1754,7 +1758,7 @@ def _encode(self, item, entity_attribute_strategy="set_only"): if not attribute.computed: value = attribute.get_remote_value(item) - if value is not ftrack_api.symbol.NOT_SET: + if value is not NOT_SET: if isinstance( attribute, ftrack_api.attribute.ReferenceAttribute ):