.. :copyright: Copyright (c) 2014 ftrack .. _working_with_entities: ********************* Working with entities ********************* .. currentmodule:: ftrack_api.session :class:`Entity ` instances are Python dict-like objects whose keys correspond to attributes for that type in the system. They may also provide helper methods to perform common operations such as replying to a note:: note = session.query('Note').first() print(note.keys()) print(note['content']) note['content'] = 'A different message!' reply = note.create_reply(...) .. _working_with_entities/attributes: Attributes ========== Each entity instance is typed according to its underlying entity type on the server and configured with appropriate attributes. For example, a *task* will be represented by a *Task* class and have corresponding attributes. You can :ref:`customise entity classes ` to alter attribute access or provide your own helper methods. To see the available attribute names on an entity use the :meth:`~ftrack_api.entity.base.Entity.keys` method on the instance:: >>> task = session.query('Task').first() >>> print(task.keys()) ['id', 'name', ...] If you need more information about the type of attribute, examine the ``attributes`` property on the corresponding class:: >>> for attribute in type(task).attributes: ... print(attribute) ... Notice that there are different types of attribute such as :class:`~ftrack_api.attribute.ScalarAttribute` for plain values or :class:`~ftrack_api.attribute.ReferenceAttribute` for relationships. These different types are reflected in the behaviour on the entity instance when accessing a particular attribute by key: >>> # Scalar >>> print(task['name']) 'model' >>> task['name'] = 'comp' >>> # Single reference >>> print(task['status']) >>> new_status = session.query('Status').first() >>> task['status'] = new_status >>> # Collection >>> print(task['timelogs']) >>> print(task['timelogs'][:]) [, ...] >>> new_timelog = session.create('Timelog', {...}) >>> task['timelogs'].append(new_timelog) .. _working_with_entities/attributes/bidirectional: Bi-directional relationships ---------------------------- Some attributes refer to different sides of a bi-directional relationship. In the current version of the API bi-directional updates are not propagated automatically to the other side of the relationship. For example, setting a *parent* will not update the parent entity's *children* collection locally. There are plans to support this behaviour better in the future. For now, after commit, :ref:`populate ` the reverse side attribute manually. .. _working_with_entities/creating: Creating entities ================= In order to create a new instance of an entity call :meth:`Session.create` passing in the entity type to create and any initial attribute values:: new_user = session.create('User', {'username': 'martin'}) If there are any default values that can be set client side then they will be applied at this point. Typically this will be the unique entity key:: >>> print(new_user['id']) 170f02a4-6656-4f15-a5cb-c4dd77ce0540 At this point no information has been sent to the server. However, you are free to continue :ref:`updating ` this object locally until you are ready to persist the changes by calling :meth:`Session.commit`. If you are wondering about what would happen if you accessed an unset attribute on a newly created entity, go ahead and give it a go:: >>> print(new_user['first_name']) NOT_SET The session knows that it is a newly created entity that has not yet been persisted so it doesn't try to fetch any attributes on access even when ``session.auto_populate`` is turned on. .. _working_with_entities/updating: Updating entities ================= Updating an entity is as simple as modifying the values for specific keys on the dict-like instance and calling :meth:`Session.commit` when ready. The entity to update can either be a new entity or a retrieved entity:: task = session.query('Task').first() task['bid'] = 28800 Remember that, for existing entities, accessing an attribute will load it from the server automatically. If you are interested in just setting values without first fetching them from the server, turn :ref:`auto-population ` off temporarily:: >>> with session.auto_populating(False): ... task = session.query('Task').first() ... task['bid'] = 28800 .. _working_with_entities/resetting: Server side reset of entity attributes or settings. =================================================== Some entities support resetting of attributes, for example to reset a users api key:: session.reset_remote( 'api_key', entity=session.query('User where username is "test_user"').one() ) .. note:: Currently the only attribute possible to reset is 'api_key' on the user entity type. .. _working_with_entities/deleting: Deleting entities ================= To delete an entity you need an instance of the entity in your session (either from having created one or retrieving one). Then call :meth:`Session.delete` on the entity and :meth:`Session.commit` when ready:: task_to_delete = session.query('Task').first() session.delete(task_to_delete) ... session.commit() .. note:: Even though the entity is deleted, you will still have access to the local instance and any local data stored on that instance whilst that instance remains in memory. Keep in mind that some deletions, when propagated to the server, will cause other entities to be deleted also, so you don't have to worry about deleting an entire hierarchy manually. For example, deleting a *Task* will also delete all *Notes* on that task. .. _working_with_entities/populating: Populating entities =================== When an entity is retrieved via :meth:`Session.query` or :meth:`Session.get` it will have some attributes prepopulated. The rest are dynamically loaded when they are accessed. If you need to access many attributes it can be more efficient to request all those attributes be loaded in one go. One way to do this is to use a :ref:`projections ` in queries. However, if you have entities that have been passed to you from elsewhere you don't have control over the query that was issued to get those entities. In this case you can you can populate those entities in one go using :meth:`Session.populate` which works exactly like :ref:`projections ` in queries do, but operating against known entities:: >>> users = session.query('User') >>> session.populate(users, 'first_name, last_name') >>> with session.auto_populating(False): # Turn off for example purpose. ... for user in users: ... print('Name: {0}'.format(user['first_name'])) ... print('Email: {0}'.format(user['email'])) Name: Martin Email: NOT_SET ... .. note:: You can populate a single or many entities in one call so long as they are all the same entity type. .. _working_with_entities/entity_states: Entity states ============= Operations on entities are :ref:`recorded in the session ` as they happen. At any time you can inspect an entity to determine its current state from those pending operations. To do this, use :func:`ftrack_api.inspection.state`:: >>> import ftrack_api.inspection >>> new_user = session.create('User', {}) >>> print(ftrack_api.inspection.state(new_user)) CREATED >>> existing_user = session.query('User').first() >>> print(ftrack_api.inspection.state(existing_user)) NOT_SET >>> existing_user['email'] = 'martin@example.com' >>> print(ftrack_api.inspection.state(existing_user)) MODIFIED >>> session.delete(new_user) >>> print(ftrack_api.inspection.state(new_user)) DELETED .. _working_with_entities/entity_types: Customising entity types ======================== Each type of entity in the system is represented in the Python client by a dedicated class. However, because the types of entities can vary these classes are built on demand using schema information retrieved from the server. Many of the default classes provide additional helper methods which are mixed into the generated class at runtime when a session is started. In some cases it can be useful to tailor the custom classes to your own pipeline workflows. Perhaps you want to add more helper functions, change attribute access rules or even providing a layer of backwards compatibility for existing code. The Python client was built with this in mind and makes such customisations as easy as possible. When a :class:`Session` is constructed it fetches schema details from the connected server and then calls an :class:`Entity factory ` to create classes from those schemas. It does this by emitting a synchronous event, *ftrack.api.session.construct-entity-type*, for each schema and expecting a *class* object to be returned. In the default setup, a :download:`construct_entity_type.py <../resource/plugin/construct_entity_type.py>` plugin is placed on the :envvar:`FTRACK_EVENT_PLUGIN_PATH`. This plugin will register a trivial subclass of :class:`ftrack_api.entity.factory.StandardFactory` to create the classes in response to the construct event. The simplest way to get started is to edit this default plugin as required. .. seealso:: :ref:`understanding_sessions/plugins` .. _working_with_entities/entity_types/default_projections: Default projections ------------------- When a :ref:`query ` is issued without any :ref:`projections `, the session will automatically add default projections according to the type of the entity. For example, the following shows that for a *User*, only *id* is fetched by default when no projections added to the query:: >>> user = session.query('User').first() >>> with session.auto_populating(False): # For demonstration purpose only. ... print(user.items()) [ (u'id', u'59f0963a-15e2-11e1-a5f1-0019bb4983d8') (u'username', Symbol(NOT_SET)), (u'first_name', Symbol(NOT_SET)), ... ] .. note:: These default projections are also used when you access a relationship attribute using the dictionary key syntax. If you want to default to fetching *username* for a *Task* as well then you can change the default_projections* in your class factory plugin:: class Factory(ftrack_api.entity.factory.StandardFactory): '''Entity class factory.''' def create(self, schema, bases=None): '''Create and return entity class from *schema*.''' cls = super(Factory, self).create(schema, bases=bases) # Further customise cls before returning. if schema['id'] == 'User': cls.default_projections = ['id', 'username'] return cls Now a projection-less query will also query *username* by default: .. note:: You will need to start a new session to pick up the change you made:: session = ftrack_api.Session() .. code-block:: python >>> user = session.query('User').first() >>> with session.auto_populating(False): # For demonstration purpose only. ... print(user.items()) [ (u'id', u'59f0963a-15e2-11e1-a5f1-0019bb4983d8') (u'username', u'martin'), (u'first_name', Symbol(NOT_SET)), ... ] Note that if any specific projections are applied in a query, those override the default projections entirely. This allows you to also *reduce* the data loaded on demand:: >>> session = ftrack_api.Session() # Start new session to avoid cache. >>> user = session.query('select id from User').first() >>> with session.auto_populating(False): # For demonstration purpose only. ... print(user.items()) [ (u'id', u'59f0963a-15e2-11e1-a5f1-0019bb4983d8') (u'username', Symbol(NOT_SET)), (u'first_name', Symbol(NOT_SET)), ... ] .. _working_with_entities/entity_types/helper_methods: Helper methods -------------- If you want to add additional helper methods to the constructed classes to better support your pipeline logic, then you can simply patch the created classes in your factory, much like with changing the default projections:: def get_full_name(self): '''Return full name for user.''' return '{0} {1}'.format(self['first_name'], self['last_name']).strip() class Factory(ftrack_api.entity.factory.StandardFactory): '''Entity class factory.''' def create(self, schema, bases=None): '''Create and return entity class from *schema*.''' cls = super(Factory, self).create(schema, bases=bases) # Further customise cls before returning. if schema['id'] == 'User': cls.get_full_name = get_full_name return cls Now you have a new helper method *get_full_name* on your *User* entities:: >>> session = ftrack_api.Session() # New session to pick up changes. >>> user = session.query('User').first() >>> print(user.get_full_name()) Martin Pengelly-Phillips If you'd rather not patch the existing classes, or perhaps have a lot of helpers to mixin, you can instead inject your own class as the base class. The only requirement is that it has the base :class:`~ftrack_api.entity.base.Entity` class in its ancestor classes:: import ftrack_api.entity.base class CustomUser(ftrack_api.entity.base.Entity): '''Represent user.''' def get_full_name(self): '''Return full name for user.''' return '{0} {1}'.format(self['first_name'], self['last_name']).strip() class Factory(ftrack_api.entity.factory.StandardFactory): '''Entity class factory.''' def create(self, schema, bases=None): '''Create and return entity class from *schema*.''' # Alter base class for constructed class. if bases is None: bases = [ftrack_api.entity.base.Entity] if schema['id'] == 'User': bases = [CustomUser] cls = super(Factory, self).create(schema, bases=bases) return cls The resulting effect is the same:: >>> session = ftrack_api.Session() # New session to pick up changes. >>> user = session.query('User').first() >>> print(user.get_full_name()) Martin Pengelly-Phillips .. note:: Your custom class is not the leaf class which will still be a dynamically generated class. Instead your custom class becomes the base for the leaf class:: >>> print(type(user).__mro__) (, , ...)