Source code for coalaip.entities

"""Entities mirroring COALA IP's entity model.

Requires usage with a persistence layer plugin (see
:class:`~.AbstractPlugin`) for the creation and transfer of entities.
JSON, JSON-LD, and IPLD data formats are supported.

.. note:: This module should not be used directly to generate entities,
          unless you are extending the built-ins for your own
          extensions. Instead, use the high-level functions
          (:mod:`.coalaip`) that return instances of these entities.

.. warning:: The immutability guarantees given in this module are
             best-effort. There is no general way to achieve
             immutability in Python, but we try our hardest to make it
             so.
"""

import attr

from abc import ABC, abstractmethod
from coalaip.data_formats import (
    DataFormat,
    _copy_context_into_mutable,
    _data_format_resolver,
    _extract_ld_data,
)
from coalaip.exceptions import (
    EntityNotYetPersistedError,
    EntityPreviouslyCreatedError,
    ModelNotYetLoadedError,
    PersistenceError,
)
from coalaip.models import (
    Model,
    LazyLoadableModel,
    work_model_factory,
    manifestation_model_factory,
    right_model_factory,
    copyright_model_factory,
    rights_assignment_model_factory,
)
from coalaip.plugin import AbstractPlugin
from coalaip.utils import PostInitImmutable


[docs]@attr.s(repr=False) class Entity(ABC, PostInitImmutable): """Abstract base class of all COALA IP entity models. **Immutable (see :class:`~.PostInitImmutable`)**. Implements base functionality for all COALA IP entities, including entity creation (:meth:`create`) and status queries (:attr:`status`) on the backing persistence layer provided by ``plugin``. Subclasses **must** implement their own :meth:`generate_model`; :meth:`generate_model` determines the semantics behind :attr:`~Entity.model` (its creation and validation). Attributes: model (:class:`~.Model` or :class:`~.LazyLoadableModel`): Model of the entity. Holds the data and Linked Data (JSON-LD) specifics. plugin (subclass of :class:`~.AbstractPlugin`): Persistence layer plugin used by the Entity persist_id (str): Id of this entity on the persistence layer, if saved to one. Initially ``None``. Not initable. Note that this attribute is only immutable after it's been set once after initialization (e.g. after :meth:`create`). """ model = attr.ib(validator=attr.validators.instance_of((Model, LazyLoadableModel))) plugin = attr.ib(validator=attr.validators.instance_of(AbstractPlugin)) persist_id = attr.ib(init=False, default=None) def __repr__(self): persist_str = ', {plugin}@{persist_id}'.format( plugin=self.plugin.type, persist_id=self.persist_id ) if self.persist_id is not None else '' try: data_str = dict(self.model.data) except ModelNotYetLoadedError: data_str = 'Not loaded' return '{name}{persist}: {data}'.format(name=self.__class__.__name__, persist=persist_str, data=data_str) @property def current_owner(self): """any: A user based on the model specified by the persistence layer if a current owner exists, otherwise None. In the case where the user model contains secret information, the returned user may omit this information. Raises: :exc:`~.EntityNotFoundError`: If the entity is persisted, but could not be found on the persistence layer :exc:`~.PersistenceError`: If any other unhandled error in the plugin occurred """ history = self.history return history[-1]['user'] if history else None @property def data(self): """dict: A copy of the basic data held by this entity model. Does not include any JSON-LD or IPLD specific information. If the entity was generated through :meth:`from_persist_id`, the first access of this property may also load the entity's data from the persistence layer (see :meth:`load` for potentially raised exceptions) """ try: data = self.model.data except ModelNotYetLoadedError: self.load() data = self.model.data return dict(data) @property def history(self): """list of dict: A list containing the ownership history of this entity. Each item in the list is a dict containing a user based on the model specified by the persistence layer and a reference id for the event (e.g. transfer). The ownership events are sorted starting from the beginning of the entity's history (i.e. creation). In the case where the user model contains secret information, the returned user may omit this information. Raises: :exc:`~.EntityNotFoundError`: If the entity is persisted, but could not be found on the persistence layer :exc:`~.PersistenceError`: If any other unhandled error in the plugin occurred """ if self.persist_id is None: return [] return self.plugin.get_history(self.persist_id) @property def status(self): """The current status of this entity in the backing persistence layer, as defined by :attr:`Entity.plugin`. Initially ``None``. Raises: :exc:`~.EntityNotFoundError`: If the entity is persisted, but could not be found on the persistence layer :exc:`~.PersistenceError`: If any other unhandled error in the plugin occurred """ if self.persist_id is None: return None return self.plugin.get_status(self.persist_id)
[docs] @classmethod @abstractmethod def generate_model(cls, *, data, ld_type, ld_context, model_cls): """Generate a model instance for use with the current :attr:`cls`. **Must** be implemented by subclasses of :class:`~.Entity`. Args: data (dict, keyword): Model data ld_type (str, keyword): @type of the entity. ld_context (str or dict or [str|dict], keyword): "@context" for the entity as either a string URL or array of string URLs or dictionaries. See the `JSON-LD spec on contexts <https://www.w3.org/TR/json-ld/#the-context>`_ for more information. model_cls (class, keyword): Model class to use the generated model. See :mod:`.models`. Returns: A model instance Raises: :exc:`~.ModelDataError`: if :attr:`data` fails model validation """
[docs] @classmethod def from_data(cls, data, *, data_format=DataFormat.jsonld, plugin): """Generic factory for instantiating :attr:`cls` entities from their model data. Entities instantiated from this factory have yet to be created on the backing persistence layer; see :meth:`create` on persisting an entity. Based on the :attr:`data_format`, the following are considered special keys in :attr:`data` and will have different behaviour depending on the ``data_type`` requested in later methods (e.g. :meth:`create`): - jsonld: - '@type' denotes the Linked Data type of the entity - '@context' denotes the JSON-LD context of the entity - '@id' denotes the JSON-LD identity of the entity - Otherwise: - 'type' denotes the Linked Data type of the entity Args: data (dict): Model data for the entity data_format (:class:`~.DataFormat` or str): Data format of :attr:`data`; must be a member of :class:`~.DataFormat` or a string equivalent. Defaults to jsonld. plugin (subclass of :class:`~.AbstractPlugin`, keyword): Persistence layer plugin used by generated :attr:`cls` Returns: :attr:`cls`: A generated :attr:`cls` entity from :attr:`data` Raises: :exc:`~.ModelDataError`: if :attr:`data` fails model validation """ def bind_get_model_kwargs(data_format): def get_model_kwargs(data): result = _extract_ld_data(data, data_format) model_kwargs = {k: v for (k, v) in result._asdict().items() if v is not None} return model_kwargs return get_model_kwargs def get_model_kwargs_from_ipld(data): raise NotImplementedError(('Creating entities from IPLD has not ' 'been implemented yet.')) get_model_kwargs = _data_format_resolver(data_format, { 'jsonld': bind_get_model_kwargs('jsonld'), 'json': bind_get_model_kwargs('json'), 'ipld': get_model_kwargs_from_ipld, }) model = cls.generate_model(**get_model_kwargs(data)) return cls(model, plugin)
[docs] @classmethod def from_persist_id(cls, persist_id, *, force_load=False, plugin): """Generic factory for creating :attr:`cls` entity instances from their persisted ids. **Note**: by default, instances generated from this factory lazily load their data upon first access (accessing :meth:`data`), which may throw under various conditions. In general, most usages of ``Entity`` and its subclasses do not require access to their data (including internal methods), and thus the data does not usually need to be loaded unless you expect to explicitly use :meth:`data` or one of the transformation methods, e.g. :meth:`to_json`. If you know you will be using the data and want to avoid raising unexpected exceptions upon access, make sure to set :attr:`force_load` or use :meth:`load` on the returned entity before accessing :meth:`data`. Args: persist_id (str): Id of the entity on the persistence layer (see :attr:`Entity.plugin`) force_load (bool, keyword, optional): Whether to load the entity's data immediately from the persistence layer after instantiation. Defaults to false. plugin (subclass of :class:`~.AbstractPlugin`, keyword): Persistence layer plugin used by generated :attr:`cls` Returns: :attr:`cls`: A generated entity based on :attr:`persist_id` Raises: If :attr:`force_load` is ``True``, see :meth:`load` for the list of possible exceptions. """ model = cls.generate_model(model_cls=LazyLoadableModel) entity = cls(model, plugin=plugin) entity.persist_id = persist_id if force_load: entity.load() return entity
[docs] def create(self, user, data_format=DataFormat.jsonld): """Create (i.e. persist) this entity to the backing persistence layer. Args: user (any): A user based on the model specified by the persistence layer data_format (:class:`~.DataFormat` or str): Data format used in persisting the entity; must be a member of :class:`~.DataFormat` or a string equivalent. Defaults to jsonld. Returns: str: Id of this entity on the persistence layer Raises: :exc:`~.EntityCreationError`: If an error occurred during the creation of this entity that caused it to **NOT** be persisted. Contains the original error from the persistence layer, if available. :exc:`~.EntityPreviouslyCreatedError`: If the entity has already been persisted. Contains the existing id of the entity on the persistence layer. :exc:`~.PersistenceError`: If any other unhandled error in the plugin occurred """ if self.persist_id is not None: raise EntityPreviouslyCreatedError(self.persist_id) entity_data = self._to_format(data_format) create_id = self.plugin.save(entity_data, user=user) self.persist_id = create_id return create_id
[docs] def load(self): """Load this entity from the backing persistence layer, if possible. When used by itself, this method is most useful in ensuring that an entity generated from :meth:`from_persist_id` is actually available on the persistence layer to avoid errors later. Raises: :exc:`~.EntityNotYetPersistedError`: If the entity is not associated with an id on the persistence layer (:attr:`~Entity.persist_id`) yet :exc:`~.EntityNotFoundError`: If the entity has a :attr:`~Entity.persist_id` but could not be found on the persistence layer :exc:`~.PersistenceError`: If any other unhandled error in the plugin occurred :exc:`~.ModelDataError`: If the loaded entity's data fails validation or its type or context differs from their expected values """ if hasattr(self.model, 'load'): if self.persist_id is None: raise EntityNotYetPersistedError(('Entities cannot be loaded ' 'until they have been ' 'persisted')) self.model.load(self.persist_id, plugin=self.plugin)
[docs] def to_json(self): """Output this entity as a JSON-serializable dict. The entity's @type is represented as 'type' and the @context is ignored. """ json_model = self.data json_model['type'] = self.model.ld_type return json_model
[docs] def to_jsonld(self): """Output this entity as a JSON-LD-serializable dict. Adds the @type, @context, and @id as-is. If no @id was given, an empty @id is used by default to refer to the current :attr:`~.Entity.persist_id` document. """ ld_model = self.data ld_model['@context'] = _copy_context_into_mutable(self.model.ld_context) ld_model['@type'] = self.model.ld_type ld_model['@id'] = self.model.ld_id return ld_model
[docs] def to_ipld(self): """Output this entity's data as an IPLD-serializable dict. The entity's @type is represented as 'type' and the @context is ignored. """ raise NotImplementedError('to_ipld() has not been implemented yet')
def _to_format(self, data_format): to_format = _data_format_resolver(data_format, { 'jsonld': self.to_jsonld, 'json': self.to_json, 'ipld': self.to_ipld, }) return to_format()
[docs]class TransferrableEntity(Entity): """Base class for transferable COALA IP entity models. Provides functionality for transferrable entities through :meth:`transfer` """
[docs] def transfer(self, transfer_payload=None, *, from_user, to_user): """Transfer this entity to another owner on the backing persistence layer Args: transfer_payload (dict): Payload for the transfer from_user (any): A user based on the model specified by the persistence layer to_user (any): A user based on the model specified by the persistence layer Returns: str: Id of the resulting transfer action on the persistence layer Raises: :exc:`~.EntityNotYetPersistedError`: If the entity being transferred is not associated with an id on the persistence layer (:attr:`~Entity.persist_id`) yet :exc:`~.EntityNotFoundError`: If the entity could not be found on the persistence layer :exc:`~.EntityTransferError`: If the entity fails to be transferred on the persistence layer :exc:`~.PersistenceError`: If any other unhandled error in the plugin occurred """ if self.persist_id is None: raise EntityNotYetPersistedError(('Entities cannot be transferred ' 'until they have been ' 'persisted')) return self.plugin.transfer(self.persist_id, transfer_payload, from_user=from_user, to_user=to_user)
[docs]class Work(Entity): """COALA IP's Work entity. A distinct, abstract Creation whose existence is revealed through one or more :class:`~.Manifestation` entities. :class:`~.Work` entities are always of @type 'AbstractWork'. """
[docs] @classmethod def generate_model(cls, *args, **kwargs): """Generate a Work model. See :meth:`~.Entity.generate_model` for more details. Ignores the given ``ld_type`` as :class:`~.Work` entities always have @type 'AbstractWork'. """ return work_model_factory(*args, **kwargs)
[docs]class Manifestation(Entity): """COALA IP's Manifestation entity. A perceivable manifestation of a :class:`~.Work`. :class:`~.Manifestation` entities are by default of @type 'CreativeWork'. """
[docs] @classmethod def generate_model(cls, *args, **kwargs): """Generate a Manifestation model. See :meth:`~.Entity.generate_model` for more details. """ return manifestation_model_factory(*args, **kwargs)
[docs]class RightsAssignment(Entity): """COALA IP's RightsAssignment entity. The assignment (e.g. transfer) of a :class:`~.Right` to someone. :class:`.RightsAssignment` entities may only be persisted in the underlying persistence layer through transfer operations, and hence cannot be created normally through :meth:`.create`. :class:`~.RightsAssignment` entities are always of @type 'RightsAssignment' and by default only include the COALA IP context, as Copyrights are not dependent on schema.org. """
[docs] def create(self, *args, **kwargs): """Removes the ability to persist a :class:`~.RightsAssignment` normally. Raises :exc:`~.PersistenceError` if called. """ raise PersistenceError(('RightsAssignments can only be created ' ' through transer transactions.'))
[docs] @classmethod def generate_model(cls, *args, **kwargs): """Generate a Work model. See :meth:`~.Entity.generate_model` for more details. Ignores the given ``ld_type`` as :class:`~.RightsAssignment` entities always have @type 'RightsTransferAction's. """ return rights_assignment_model_factory(*args, **kwargs)