from functools import partial
import logging
import json
from types import GeneratorType
from rdflib import URIRef, Graph, RDF
from oldman.core.exception import OMUnauthorizedTypeChangeError
from oldman.core.exception import OMAttributeAccessError, OMWrongResourceError, OMEditError
[docs]class Resource(object):
"""A :class:`~oldman.resource.resource.Resource` object is a subject-centric representation of a Web resource.
A set of :class:`~oldman.resource.resource.Resource` objects is equivalent to a RDF graph.
In RDF, a resource is identified by an IRI (globally) or a blank node (locally).
Because blank node support is complex and limited (:class:`rdflib.plugins.stores.sparqlstore.SPARQLStore`
stores do not support them), **every** :class:`~oldman.resource.Resource` **object has an IRI**.
This IRI is either given or generated by a :class:`~oldman.iri.IriGenerator` object.
Some generators generate recognizable `skolem IRIs
<http://www.w3.org/TR/2014/REC-rdf11-concepts-20140225/#section-skolemization>`_
that are treated as blank nodes when the resource is serialized into JSON, JSON-LD
or another RDF format (for external consumption).
A resource is usually instance of some RDFS classes. These classes are grouped in its attribute `types`.
:class:`~oldman.model.Model` objects are found from these classes, by calling the method
:func:`oldman.resource.manager.ResourceManager.find_models_and_types`.
Models give access to Python methods and to :class:`~oldman.attribute.OMAttribute` objects.
Their ordering determines inheritance priorities.
The main model is the first one of this list.
Values of :class:`~oldman.attribute.OMAttribute` objects are accessible and modifiable
like ordinary Python attribute values.
However, these values are checked so some :class:`~oldman.exception.OMAccessError`
or :class:`~oldman.exception.OMEditError` errors may be raised.
This abstract class accepts two concrete classes: :class:`~oldman.resource.resource.StoreResource` and
:class:`~oldman.resource.resource.ClientResource`. The former is serializable and can be saved directly
by the datastore while the latter has to be converted into a :class:`~oldman.resource.resource.StoreResource`
so as to be saved.
Example::
>>> alice = StoreResource(model_manager, data_store, types=["http://schema.org/Person"], name=u"Alice")
>>> alice.id
u'http://localhost/persons/1'
>>> alice.name
u'Alice'
>>> alice.save()
>>> alice.name = "Alice A."
>>> print alice.to_jsonld()
{
"@context": "http://localhost/person.jsonld",
"id": "http://localhost/persons/1",
"types": [
"http://schema.org/Person"
],
"name": "Alice A."
}
>>> alice.name = 5
oldman.exception.OMAttributeTypeCheckError: 5 is not a (<type 'str'>, <type 'unicode'>)
.. admonition:: Resource creation
:class:`~oldman.resource.resource.Resource` objects are normally created by a
:class:`~oldman.model.model.Model` or a
:class:`~oldman.resource.manager.ResourceManager` object. Please use the
methods :func:`oldman.model.model.Model.create`, :func:`oldman.model.Model.new`,
:func:`oldman.resource.manager.ResourceManager.create` or
:func:`oldman.resource.manager.ResourceManager.new` for creating new
:class:`~oldman.resource.Resource` objects.
:param id: TODO:describe.
:param model_manager: :class:`~oldman.model.manager.ModelManager` object. Gives access to its models.
:param types: IRI list or set of the RDFS classes the resource is instance of. Defaults to `set()`.
:param is_new: When is `True` and `id` given, checks that the IRI is not already existing in the
`data_store`. Defaults to `True`.
:param former_types: IRI list or set of the RDFS classes the resource was instance of. Defaults to `set()`.
:param kwargs: values indexed by their attribute names.
TODO: update this comment!!!!!
"""
_special_attribute_names = ["_models", "_id", "_types", "_is_blank_node", "_model_manager",
"_store", "_former_types", "_logger", "_session", "_is_new", "_tmp_attribute_values"]
_pickle_attribute_names = ["_id", '_types', '_is_new']
def __init__(self, id, model_manager, session, types=None, is_new=True, former_types=None, **kwargs):
"""Inits but does not save it (in the `data_graph`)."""
self._models, self._types = model_manager.find_models_and_types(types)
if former_types is not None:
self._former_types = set(former_types)
else:
self._former_types = set(self._types) if not is_new else set()
self._model_manager = model_manager
self._is_new = is_new
self._id = id
self._session = session
self._init_non_persistent_attributes(self._id)
for k, v in kwargs.iteritems():
if k in self._special_attribute_names:
raise AttributeError(u"Special attribute %s should not appear in **kwargs" % k)
setattr(self, k, v)
def _init_non_persistent_attributes(self, id):
"""Used at init and unpickling times."""
self._logger = logging.getLogger(__name__)
#TODO: should we remove it?
self._is_blank_node = id.is_blank_node
@property
def types(self):
"""IRI list of the RDFS classes the resource is instance of."""
return list(self._types)
@property
def models(self):
"""TODO: describe"""
return list(self._models)
@property
def id(self):
"""IRI that identifies the resource."""
return self._id
@property
def context(self):
""" An IRI, a `list` or a `dict` that describes the JSON-LD context.
Derived from :attr:`oldman.model.Model.context` attributes.
"""
if len(self._models) > 1:
raise NotImplementedError(u"TODO: merge contexts when a Resource has multiple models")
return list(self._models)[0].context
@property
def local_context(self):
"""Context that is locally accessible but that may not be advertised in the JSON-LD serialization."""
if len(self._models) > 1:
raise NotImplementedError(u"TODO: merge local contexts when a Resource has multiple models")
return list(self._models)[0].local_context
@property
def model_manager(self):
""":class:`~oldman.model.manager.ModelManager` object. Gives access to the
:class:`~oldman.model.model.Model` objects. """
return self._model_manager
@property
def is_new(self):
"""True if the resource has never been saved."""
return self._is_new
@property
def former_types(self):
"""Not for end-users"""
return list(self._former_types)
@property
def non_model_types(self):
"""RDFS classes that are not associated to a `Model`."""
return set(self._types).difference({m.class_iri for m in self._models})
@property
def former_non_model_types(self):
"""RDFS classes that were not associated to a `Model`."""
if len(self._former_types) == 0:
return {}
possible_non_model_types = set(self._former_types).difference({m.class_iri
for m in self._models})
if len(possible_non_model_types) == 0:
return {}
corresponding_models, _ = self._model_manager.find_models_and_types(possible_non_model_types)
return possible_non_model_types.difference({m.class_iri for m in corresponding_models})
@property
def attributes(self):
""":return: An ordered list of list of :class:`~oldman.attribute.OMAttribute` objects."""
attributes = []
for model in self._models:
attributes += model.om_attributes.values()
return attributes
[docs] def is_valid(self):
"""Tests if the resource is valid.
:return: `False` if the resource is invalid, `True` otherwise.
"""
for model in self._models:
for attr in model.om_attributes.values():
if not attr.is_valid(self):
return False
return True
[docs] def is_blank_node(self):
"""Tests if `id.iri` is a skolem IRI and should thus be considered as a blank node.
See :func:`~oldman.resource.is_blank_node` for further details.
:return: `True` if `id.iri` is a locally skolemized IRI.
"""
return self._id.is_blank_node
[docs] def is_instance_of(self, model):
""" Tests if the resource is instance of the RDFS class of the model.
:param model: :class:`~oldman.model.Model` object.
:return: `True` if the resource is instance of the RDFS class.
"""
return model.class_iri in self._types
[docs] def in_same_document(self, other_resource):
"""Tests if two resources have the same hash-less IRI.
:return: `True` if these resources are in the same document.
"""
return self._id.hashless_iri == other_resource.id.hashless_iri
[docs] def get_operation(self, http_method):
"""TODO: describe """
for model in self._models:
operation = model.get_operation(http_method)
if operation is not None:
return operation
return None
[docs] def get_lightly(self, attribute_name):
"""If the attribute corresponds to an `owl:ObjectProperty`, returns a IRI or None.
Otherwise (if is a datatype), returns the value.
"""
return self.get_attribute(attribute_name).get_lightly(self)
[docs] def get_attribute(self, attribute_name):
"""Not for the end-user!"""
for model in self._models:
if attribute_name in model.om_attributes:
return model.access_attribute(attribute_name)
raise AttributeError("%s has no regular attribute %s" % (self, attribute_name))
def __getattr__(self, name):
"""Gets:
* A declared Python method ;
* A declared operation ;
* Or the value of a given :class:`~oldman.attribute.OMAttribute` object.
Note that attributes stored in the `__dict__` attribute are not concerned
by this method.
:class:`~oldman.attribute.OMAttribute` objects are made accessible
by :class:`~oldman.model.Model` objects.
The first method or :class:`~oldman.attribute.OMAttribute` object matching the requested
`name` is returned. This is why the ordering of models is so important.
:param name: attribute name.
:return: Its value.
"""
for model in self._models:
if name in model.om_attributes:
return model.access_attribute(name).get(self)
method = model.methods.get(name)
if method is not None:
# Make this function be a method (taking self as first parameter)
return partial(method, self)
operation = model.get_operation_by_name(name)
if operation is not None:
return partial(operation, self)
raise AttributeError("%s has no attribute %s" % (self, name))
def __setattr__(self, name, value):
"""Sets the value of one or multiple :class:`~oldman.attribute.OMAttribute` objects.
If multiple :class:`~oldman.attribute.OMAttribute` objects have the same
name, they will all receive the same value.
:param name: attribute name.
:param value: value to assign.
"""
if name in self._special_attribute_names:
self.__dict__[name] = value
return
found = False
for model in self._models:
if name in model.om_attributes:
model.access_attribute(name).set(self, value)
found = True
if not found:
raise AttributeError("%s has not attribute %s" % (self, name))
[docs] def add_type(self, additional_type):
"""Declares that the resource is instance of another RDFS class.
Note that it may introduce a new model to the list
and change its ordering.
:param additional_type: IRI or JSON-LD term identifying a RDFS class.
"""
if additional_type not in self._types:
new_types = set(self._types)
new_types.add(additional_type)
self._change_types(new_types)
[docs] def check_validity(self, is_end_user=True):
"""Checks its validity.
Raises an :class:`oldman.exception.OMEditError` exception if invalid.
"""
for model in self._models:
for attr in model.om_attributes.values():
attr.check_validity(self, is_end_user=is_end_user)
[docs] def notify_reference(self, reference, object_resource=None, object_iri=None):
""" Not for end-users!
TODO: describe
"""
self._session.receive_reference(reference, object_resource=object_resource, object_iri=object_iri)
[docs] def notify_reference_removal(self, reference):
""" Not for end-users!
TODO: describe
"""
self._session.receive_reference_removal_notification(reference)
[docs] def receive_storage_ack(self, id):
"""Receives the permanent ID assigned by the store.
Useful when the permanent ID is given by an external server.
Replaces the temporary ID of the resource.
"""
# TODO: make sure the previous id was a temporary one
self._id = id
self._is_new = False
# Clears former values
self._former_types = self._types
for attr in self.attributes:
attr.receive_storage_ack(self)
[docs] def to_dict(self, remove_none_values=True, include_different_contexts=False,
ignored_iris=None):
"""Serializes the resource into a JSON-like `dict`.
:param remove_none_values: If `True`, `None` values are not inserted into the dict.
Defaults to `True`.
:param include_different_contexts: If `True` local contexts are given to sub-resources.
Defaults to `False`.
:param ignored_iris: List of IRI of resources that should not be included in the `dict`.
Defaults to `set()`.
:return: A `dict` describing the resource.
"""
if ignored_iris is None:
ignored_iris = set()
ignored_iris.add(self._id.iri)
dct = {attr.name: self._convert_value(getattr(self, attr.name), ignored_iris, remove_none_values,
include_different_contexts)
for attr in self.attributes
if not attr.is_write_only}
# filter None values
if remove_none_values:
dct = {k: v for k, v in dct.iteritems() if v is not None}
if not self.is_blank_node():
dct["id"] = self._id.iri
if self._types and len(self._types) > 0:
dct["types"] = list(self._types)
return dct
[docs] def to_json(self, remove_none_values=True, ignored_iris=None):
"""Serializes the resource into pure JSON (not JSON-LD).
:param remove_none_values: If `True`, `None` values are not inserted into the dict.
Defaults to `True`.
:param ignored_iris: List of IRI of resources that should not be included in the `dict`.
Defaults to `set()`.
:return: A JSON-encoded string.
"""
return json.dumps(self.to_dict(remove_none_values=remove_none_values,
include_different_contexts=False,
ignored_iris=ignored_iris), sort_keys=True, indent=2)
[docs] def to_jsonld(self, remove_none_values=True, include_different_contexts=False,
ignored_iris=None):
"""Serializes the resource into JSON-LD.
:param remove_none_values: If `True`, `None` values are not inserted into the dict.
Defaults to `True`.
:param include_different_contexts: If `True` local contexts are given to sub-resources.
Defaults to `False`.
:param ignored_iris: List of IRI of resources that should not be included in the `dict`.
Defaults to `set()`.
:return: A JSON-LD encoded string.
"""
dct = self.to_dict(remove_none_values=remove_none_values,
include_different_contexts=include_different_contexts,
ignored_iris=ignored_iris)
dct['@context'] = self.context
return json.dumps(dct, sort_keys=True, indent=2)
[docs] def to_rdf(self, rdf_format="turtle"):
"""Serializes the resource into RDF.
:param rdf_format: content-type or keyword supported by RDFlib.
Defaults to `"turtle"`.
:return: A string in the chosen RDF format.
"""
g = Graph()
g.parse(data=self.to_json(), context=self.local_context, format="json-ld")
return g.serialize(format=rdf_format)
def __str__(self):
return self._id.iri
def __repr__(self):
return u"%s(<%s>)" % (self.__class__.__name__, self._id.iri)
def _convert_value(self, value, ignored_iris, remove_none_values, include_different_contexts=False):
"""Recursive method. Internals of :func:`~oldman.resource.Resource.to_dict`.
:return: JSON-compatible value or list of JSON-compatible values.
"""
# Containers
if isinstance(value, (list, set, GeneratorType)):
return [self._convert_value(v, ignored_iris, remove_none_values, include_different_contexts)
for v in value]
# Object
if isinstance(value, Resource):
# If non-blank or in the same document
if value.id.iri not in ignored_iris and \
(value.is_blank_node() or self.in_same_document(value)):
value_dict = dict(value.to_dict(remove_none_values, include_different_contexts, ignored_iris))
# TODO: should we improve this test?
if include_different_contexts and value._context != self._context:
value_dict["@context"] = value._context
return value_dict
else:
# URI
return value.id.iri
# Literal
return value
[docs] def update(self, full_dict, allow_new_type=False, allow_type_removal=False):
"""Updates the resource from a flat `dict`.
By flat, we mean that sub-resources are only represented by their IRIs:
there is no nested sub-object structure.
This dict is supposed to be exhaustive, so absent value is removed.
Some sub-resources may thus be deleted like if there were a cascade
deletion.
:param full_dict: Flat `dict` containing the attribute values to update.
:param allow_new_type: If `True`, new types can be added.
Please keep in mind that type change can:
- Modify the behavior of the resource by changing its model list.
- Interfere with the SPARQL requests using instance tests.
If enabled, this may represent a major **security concern**.
Defaults to `False`.
:param allow_type_removal: If `True`, new types can be removed. Same security concerns than above.
Defaults to `False`.
:return: The :class:`~oldman.resource.Resource` object itself.
"""
if "id" not in full_dict:
raise OMWrongResourceError(u"Cannot update an object without IRI")
elif full_dict["id"] != self._id.iri:
raise OMWrongResourceError(u"Wrong IRI %s (%s was expected)" % (full_dict["id"], self._id.iri))
attributes = self.attributes
attr_names = [a.name for a in attributes]
for key in full_dict:
if key not in attr_names and key not in ["@context", "id", "types"]:
raise OMAttributeAccessError(u"%s is not an attribute of %s" % (key, self._id))
# Type change resource
if "types" in full_dict:
try:
new_types = set(full_dict["types"])
except TypeError:
raise OMEditError(u"'types' attribute is not a list, a set or a string but is %s " % new_types)
self._check_and_update_types(new_types, allow_new_type, allow_type_removal)
for attr in attributes:
value = full_dict.get(attr.name)
# set is not a JSON structure (but a JSON-LD one)
if value is not None and attr.container == "@set":
value = set(value)
attr.set(self, value)
return self
[docs] def update_from_graph(self, subgraph, initial=False, allow_new_type=False, allow_type_removal=False):
"""Similar to :func:`~oldman.resource.Resource.full_update` but with
a RDF graph instead of a Python `dict`.
:param subgraph: :class:`rdflib.Graph` object containing the full description of the resource.
:param initial: `True` when the subgraph comes from the `data_graph` and is thus used
to load :class:`~oldman.resource.Resource` object from the triple store.
Defaults to `False`.
:param allow_new_type: If `True`, new types can be added. Defaults to `False`. See
:func:`~oldman.resource.Resource.full_update` for explanations about the
security concerns.
:param allow_type_removal: If `True`, new types can be removed. Same security concerns than above.
Defaults to `False`.
:return: The :class:`~oldman.resource.Resource` object itself.
"""
for attr in self.attributes:
attr.update_from_graph(self, subgraph, initial=initial)
#Types
if not initial:
new_types = {unicode(t) for t in subgraph.objects(URIRef(self._id.iri), RDF.type)}
self._check_and_update_types(new_types, allow_new_type, allow_type_removal)
return self
def _check_and_update_types(self, new_types, allow_new_type, allow_type_removal):
current_types = set(self._types)
if new_types == current_types:
return
change = False
# Appending new types
additional_types = new_types.difference(current_types)
if len(additional_types) > 0:
if not allow_new_type:
raise OMUnauthorizedTypeChangeError(u"Adding %s to %s has not been allowed"
% (additional_types, self._id))
change = True
# Removal
missing_types = current_types.difference(new_types)
if len(missing_types) > 0:
implicit_types = {t for m in self._models for t in m.ancestry_iris}.difference(
{m.class_iri for m in self._models})
removed_types = missing_types.difference(implicit_types)
if len(removed_types) > 0:
if not allow_type_removal:
raise OMUnauthorizedTypeChangeError(u"Removing %s to %s has not been allowed"
% (removed_types, self._id))
change = True
if change:
self._models, types = self._model_manager.find_models_and_types(new_types)
self._change_types(types)
def _change_types(self, new_types):
self._types = new_types
def _get_om_attribute(self, name):
for model in self._models:
if name in model.om_attributes:
return model.access_attribute(name)
self._logger.debug(u"Models: %s, types: %s" % ([m.name for m in self._models], self._types))
#self._logger.debug(u"%s" % self._manager._registry.model_names)
raise AttributeError(u"%s has not attribute %s" % (self, name))