Working with entities
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(...)
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 customise entity classes to alter attribute access or provide your own helper methods.
To see the available attribute names on an entity use the
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)
<ftrack_api.attribute.ScalarAttribute(id) object at 66701296>
<ftrack_api.attribute.ScalarAttribute(name) object at 66702192>
<ftrack_api.attribute.ReferenceAttribute(status) object at 66701240>
<ftrack_api.attribute.CollectionAttribute(timelogs) object at 66701184>
<ftrack_api.attribute.KeyValueMappedCollectionAttribute(metadata) object at 66701632>
...
Notice that there are different types of attribute such as
ScalarAttribute
for plain values or
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'])
<Status(e610b180-4e64-11e1-a500-f23c91df25eb)>
>>> new_status = session.query('Status').first()
>>> task['status'] = new_status
>>> # Collection
>>> print(task['timelogs'])
<ftrack_api.collection.Collection object at 0x00000000040D95C0>
>>> print(task['timelogs'][:])
[<dynamic ftrack Timelog object 72322240>, ...]
>>> new_timelog = session.create('Timelog', {...})
>>> task['timelogs'].append(new_timelog)
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, populate the reverse side attribute manually.
Creating entities
In order to create a new instance of an entity call 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 updating this object
locally until you are ready to persist the changes by calling
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.
Updating entities
Updating an entity is as simple as modifying the values for specific keys on
the dict-like instance and calling 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 auto-population off temporarily:
>>> with session.auto_populating(False):
... task = session.query('Task').first()
... task['bid'] = 28800
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.
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 Session.delete()
on
the entity and 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.
Populating entities
When an entity is retrieved via Session.query()
or 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 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
Session.populate()
which works exactly like 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.
Entity states
Operations on entities are 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 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
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 Session
is constructed it fetches schema details from the
connected server and then calls an 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 construct_entity_type.py
plugin is placed on the
FTRACK_EVENT_PLUGIN_PATH
. This plugin will register a trivial subclass
of 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.
See also
Default projections
When a query is issued without any 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()
>>> 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)),
...
]
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 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__)
(<dynamic ftrack class 'User'>, <dynamic ftrack class 'CustomUser'>, ...)