Working with entities
Entity <ftrack_api.entity.base.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 ~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)
<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 ~ftrack_api.attribute.ScalarAttribute
for plain values or ~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"])
<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': 'jane'})
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()
)
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()
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: Jane
Email: NOT_SET
...
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'] = 'jane@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 <ftrack_api.entity.factory.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.
Read the article on configuring plugins
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)),
...
]
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:
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'jane'),
(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())
Jane Doe
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 ~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())
Jane Doe
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'>, ...)