# copyright 2004-2014 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of yams.
#
# yams is free software: you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# yams is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with yams. If not, see <http://www.gnu.org/licenses/>.
"""Classes used to build a schema."""
__docformat__ = "restructuredtext en"
from warnings import warn
from copy import copy
from six import add_metaclass, string_types
from logilab.common import attrdict
from yams import (BASE_TYPES, MARKER, BadSchemaDefinition, KNOWN_METAATTRIBUTES,
DEFAULT_ETYPEPERMS, DEFAULT_RELPERMS, DEFAULT_ATTRPERMS,
DEFAULT_COMPUTED_ATTRPERMS)
from yams.constraints import (SizeConstraint, UniqueConstraint,
StaticVocabularyConstraint, FORMAT_CONSTRAINT)
from yams.schema import RelationDefinitionSchema
PACKAGE = '<builtin>' # will be modified by the yams'reader when schema is
# beeing read
__all__ = ('EntityType', 'RelationType', 'RelationDefinition',
'SubjectRelation', 'ObjectRelation',
'RichString', ) + tuple(BASE_TYPES)
# EntityType properties
ETYPE_PROPERTIES = ('description', '__permissions__', '__unique_together__')
# RelationType properties. Don't put description inside, handled specifically
RTYPE_PROPERTIES = ('symmetric', 'inlined', 'fulltext_container')
# RelationDefinition properties have to be computed dynamically since new ones
# may be added at runtime
def _RDEF_PROPERTIES():
base = RelationDefinitionSchema.ALL_PROPERTIES()
# infered is an internal property and should not be specified explicitly
base.remove('infered')
# replace permissions by __permissions__ as it's spelled that way in schema
# definition files
base.remove('permissions')
base.add('__permissions__')
return tuple(base)
# regroup all rtype/rdef properties as they may be defined one on each other in
# some cases
def _REL_PROPERTIES():
return RTYPE_PROPERTIES + _RDEF_PROPERTIES()
# pre 0.37 backward compat
RDEF_PROPERTIES = () # stuff added here is also added to underlying dict, nevermind
CREATION_RANK = 0
def _add_constraint(kwargs, constraint):
"""Add constraint to param kwargs."""
constraints = kwargs.setdefault('constraints', [])
for i, existingconstraint in enumerate(constraints):
if existingconstraint.__class__ is constraint.__class__:
constraints[i] = constraint
return
constraints.append(constraint)
def _add_relation(relations, rdef, name=None, insertidx=None):
"""Add relation (param rdef) to list of relations (param relations)."""
if name is not None:
rdef.name = name
if insertidx is None:
insertidx = len(relations)
relations.insert(insertidx, rdef)
if getattr(rdef, 'metadata', {}):
for meta_name, value in rdef.metadata.items():
assert meta_name in KNOWN_METAATTRIBUTES
insertidx += 1 # insert meta after main
meta_rel_name = '_'.join(((name or rdef.name), meta_name))
_add_relation(relations, value, meta_rel_name, insertidx)
def _check_kwargs(kwargs, attributes):
"""Check that all keys of kwargs are actual attributes."""
for key in kwargs:
if not key in attributes:
raise BadSchemaDefinition('no such property %r in %r'
% (key, attributes))
def _copy_attributes(fromobj, toobj, attributes):
for attr in attributes:
value = getattr(fromobj, attr, MARKER)
if value is MARKER:
continue
ovalue = getattr(toobj, attr, MARKER)
if not ovalue is MARKER and value != ovalue:
rname = getattr(toobj, 'name', None) or toobj.__name__
raise BadSchemaDefinition(
'conflicting values %r/%r for property %s of relation %r'
% (ovalue, value, attr, rname))
setattr(toobj, attr, value)
def register_base_types(schema):
"""add base (final) entity types to the given schema"""
for etype in BASE_TYPES:
edef = EntityType(name=etype)
schema.add_entity_type(edef)
# first class schema definition objects #######################################
class autopackage(type):
def __new__(mcs, name, bases, classdict):
classdict['package'] = PACKAGE
return super(autopackage, mcs).__new__(mcs, name, bases, classdict)
@add_metaclass(autopackage)
class Definition(object):
"""Abstract class for entity / relation definition classes."""
meta = MARKER
description = MARKER
__permissions__ = MARKER
def __init__(self, name=None):
self.name = (name or getattr(self, 'name', None)
or self.__class__.__name__)
if self.__doc__:
self.description = ' '.join(self.__doc__.split())
def __repr__(self):
return '<%s %r @%x>' % (self.__class__.__name__, self.name, id(self))
@classmethod
def expand_type_definitions(cls, defined):
"""Schema building step 1: register definition objects by adding them
to the `defined` dictionnary.
"""
raise NotImplementedError()
@classmethod
def expand_relation_definitions(cls, defined, schema):
"""Schema building step 2: register all relations definition,
expanding wildcard if necessary.
"""
raise NotImplementedError()
def get_permissions(self, final=False):
if self.__permissions__ is MARKER:
if final:
return DEFAULT_ATTRPERMS
return DEFAULT_RELPERMS
return self.__permissions__
@classmethod
def set_permissions(cls, perms):
cls.__permissions__ = perms
# classes used to define relationships within entity type classes ##################
# has to be defined before the metadefinition metaclass which "isinstance" this
# class
class ObjectRelation(object):
__permissions__ = MARKER
cardinality = MARKER
constraints = MARKER
def __init__(self, etype, **kwargs):
if self.__class__.__name__ == 'ObjectRelation':
warn('[yams 0.29] ObjectRelation is deprecated, '
'use RelationDefinition subclass', DeprecationWarning,
stacklevel=2)
global CREATION_RANK
CREATION_RANK += 1
self.creation_rank = CREATION_RANK
self.package = PACKAGE
self.name = '<undefined>'
self.etype = etype
if self.constraints:
self.constraints = list(self.constraints)
self.override = kwargs.pop('override', False)
if kwargs.pop('meta', None):
warn('[yams 0.37.0] meta is deprecated',
DeprecationWarning, stacklevel=3)
try:
_check_kwargs(kwargs, _REL_PROPERTIES())
except BadSchemaDefinition as bad:
# XXX (auc) bad field name + required attribute can lead there instead of schema.py ~ 920
bsd_ex = BadSchemaDefinition(('%s in relation to entity %r (also is %r defined ? (check two '
'lines above in the backtrace))') % (bad.args, etype, etype))
bsd_ex.tb_offset = 2
raise bsd_ex
self.__dict__.update(kwargs)
def __repr__(self):
return '%(name)s %(etype)s' % self.__dict__
class SubjectRelation(ObjectRelation):
uid = MARKER
indexed = MARKER
fulltextindexed = MARKER
internationalizable = MARKER
default = MARKER
def __repr__(self):
return '%(etype)s %(name)s' % self.__dict__
class AbstractTypedAttribute(SubjectRelation):
"""AbstractTypedAttribute is not directly instantiable
subclasses must provide a <etype> attribute to be instantiable
"""
def __init__(self, metadata=None, **kwargs):
# Store metadata
if metadata is None:
metadata = {}
self.metadata = metadata
# transform "required" into "cardinality"
required = kwargs.pop('required', False)
if required:
cardinality = '11'
else:
cardinality = '?1'
kwargs['cardinality'] = cardinality
# transform maxsize into SizeConstraint
maxsize = kwargs.pop('maxsize', None)
if maxsize is not None:
_add_constraint(kwargs, SizeConstraint(max=maxsize))
# formula
self.formula = kwargs.pop('formula', MARKER)
# transform vocabulary into StaticVocabularyConstraint
vocabulary = kwargs.pop('vocabulary', None)
if vocabulary is not None:
self.set_vocabulary(vocabulary, kwargs)
# transform unique into UniqueConstraint
unique = kwargs.pop('unique', None)
if unique:
_add_constraint(kwargs, UniqueConstraint())
# use the etype attribute provided by subclasses
super(AbstractTypedAttribute, self).__init__(self.etype, **kwargs)
# reassign creation rank
#
# Main attribute are marked as created before it's metadata.
# order in meta data is preserved.
if self.metadata:
meta = sorted(metadata.values(), key= lambda x: x.creation_rank)
if meta[0].creation_rank < self.creation_rank:
m_iter = iter(meta)
previous = self
for next in meta:
if previous.creation_rank < next.creation_rank:
break
previous.creation_rank, next.creation_rank = next.creation_rank, previous.creation_rank
next = previous
def set_vocabulary(self, vocabulary, kwargs=None):
if kwargs is None:
kwargs = self.__dict__
#constraints = kwargs.setdefault('constraints', [])
_add_constraint(kwargs, StaticVocabularyConstraint(vocabulary))
if self.__class__.__name__ == 'String': # XXX
maxsize = max(len(x) for x in vocabulary)
_add_constraint(kwargs, SizeConstraint(max=maxsize))
def __repr__(self):
return '<%(name)s(%(etype)s)>' % self.__dict__
def make_type(etype):
"""create a python class for a Yams base type.
Notice it is now possible to create a specific type with user-defined
behaviour, e.g.:
Geometry = make_type('Geometry') # (c.f. postgis)
will allow the use of:
Geometry(geom_type='POINT')
in a Yams schema, provided in this example that `geom_type` is specified to
the :func:`yams.register_base_type` function which should be called prior to
make_type.
"""
assert etype in BASE_TYPES
return type(etype, (AbstractTypedAttribute,), {'etype' : etype})
# build a specific class for each base type
String = make_type('String')
Password = make_type('Password')
Bytes = make_type('Bytes')
Int = make_type('Int')
BigInt = make_type('BigInt')
Float = make_type('Float')
Boolean = make_type('Boolean')
Decimal = make_type('Decimal')
Time = make_type('Time')
Date = make_type('Date')
Datetime = make_type('Datetime')
TZTime = make_type('TZTime')
TZDatetime = make_type('TZDatetime')
Interval = make_type('Interval')
# provides a RichString factory for convenience
[docs]def RichString(default_format='text/plain', format_constraints=None, **kwargs):
"""RichString is a convenience attribute type for attribute containing text
in a format that should be specified in another attribute.
The following declaration::
class Card(EntityType):
content = RichString(fulltextindexed=True, default_format='text/rest')
is equivalent to::
class Card(EntityType):
content_format = String(internationalizable=True,
default='text/rest', constraints=[FORMAT_CONSTRAINT])
content = String(fulltextindexed=True)
"""
format_args = {'default': default_format,
'maxsize': 50}
if format_constraints is None:
format_args['constraints'] = [FORMAT_CONSTRAINT]
else:
format_args['constraints'] = format_constraints
meta = {'format':String(internationalizable=True, **format_args)}
return String(metadata=meta, **kwargs)
# other schema definition classes ##############################################
class metadefinition(autopackage):
"""Metaclass that builds the __relations__ attribute of EntityType's
subclasses.
"""
stacklevel = 3
def __new__(mcs, name, bases, classdict):
### Move (any) relation from the class dict to __relations__ attribute
rels = classdict.setdefault('__relations__', [])
relations = dict((rdef.name, rdef) for rdef in rels)
for rname, rdef in list(classdict.items()):
if isinstance(rdef, ObjectRelation):
# relation's name **must** be removed from class namespace
# to avoid conflicts with instance's potential attributes
del classdict[rname]
relations[rname] = rdef
### handle logical inheritance
if '__specializes_schema__' in classdict:
specialized = bases[0]
classdict['__specializes__'] = specialized.__name__
if '__specialized_by__' not in specialized.__dict__:
specialized.__specialized_by__ = []
specialized.__specialized_by__.append(name)
### Initialize processed class
defclass = super(metadefinition, mcs).__new__(mcs, name, bases, classdict)
for rname, rdef in relations.items():
_add_relation(defclass.__relations__, rdef, rname)
### take base classes'relations into account
for base in bases:
for rdef in getattr(base, '__relations__', ()):
if not rdef.name in relations or not relations[rdef.name].override:
if isinstance(rdef, RelationDefinition):
rdef = copy(rdef)
if rdef.subject == base.__name__:
rdef.subject = name
if rdef.object == base.__name__:
rdef.object = name
rels.append(rdef)
else:
relations[rdef.name].creation_rank = rdef.creation_rank
### sort relations by creation rank
defclass.__relations__ = sorted(rels, key=lambda r: r.creation_rank)
return defclass
@add_metaclass(metadefinition)
class EntityType(Definition):
#::FIXME reader magic forbids to define a docstring...
#: an entity has attributes and can be linked to other entities by
#: relations. Both entity attributes and relationships are defined by
#: class attributes.
#:
#: kwargs keys must have values in ETYPE_PROPERTIES
#:
#: Example:
#:
#: >>> class Project(EntityType):
#: ... name = String()
#: >>>
#:
#: After instanciation, EntityType can we altered with dedicated class methods:
#:
#: .. currentmodule:: yams.buildobjs
#:
#: .. automethod:: EntityType.extend
#: .. automethod:: EntityType.add_relation
#: .. automethod:: EntityType.insert_relation_after
#: .. automethod:: EntityType.remove_relation
#: .. automethod:: EntityType.get_relation
#: .. automethod:: EntityType.get_relations
__permissions__ = DEFAULT_ETYPEPERMS
def __init__(self, name=None, **kwargs):
super(EntityType, self).__init__(name)
_check_kwargs(kwargs, ETYPE_PROPERTIES)
self.__dict__.update(kwargs)
self.specialized_type = self.__class__.__dict__.get('__specializes__')
def __str__(self):
return 'entity type %r' % self.name
@property
def specialized_by(self):
return self.__class__.__dict__.get('__specialized_by__', [])
@classmethod
def expand_type_definitions(cls, defined):
"""Schema building step 1: register definition objects by adding
them to the `defined` dictionnary.
"""
name = getattr(cls, 'name', cls.__name__)
assert cls is not defined.get(name), 'duplicate registration: %s' % name
assert name not in defined, \
"type '%s' was already defined here %s, new definition here %s" % \
(name, defined[name].__module__, cls)
cls._defined = defined # XXX may be used later (eg .add_relation())
defined[name] = cls
for relation in cls.__relations__:
cls._ensure_relation_type(relation)
@classmethod
def _ensure_relation_type(cls, relation):
"""Check the type the relation
return False if the class is not yet finalized
(XXX raise excep instead ?)"""
rtype = RelationType(relation.name)
_copy_attributes(relation, rtype, RTYPE_PROPERTIES)
#assert hasattr(cls, '_defined'), "Type definition for %s not yet expanded. you can't register new type through it" % cls
if hasattr(cls, '_defined'):
defined = cls._defined
if relation.name in defined:
_copy_attributes(rtype, defined[relation.name], RTYPE_PROPERTIES)
else:
defined[relation.name] = rtype
return True
else:
return False
@classmethod
def expand_relation_definitions(cls, defined, schema):
"""schema building step 2:
register all relations definition, expanding wildcards if necessary
"""
order = 1
name = getattr(cls, 'name', cls.__name__)
rdefprops = _RDEF_PROPERTIES()
for relation in cls.__relations__:
if isinstance(relation, SubjectRelation):
rdef = RelationDefinition(subject=name, name=relation.name,
object=relation.etype, order=order,
package=relation.package)
_copy_attributes(relation, rdef, rdefprops)
elif isinstance(relation, ObjectRelation):
rdef = RelationDefinition(subject=relation.etype,
name=relation.name,
object=name, order=order,
package=relation.package)
_copy_attributes(relation, rdef, rdefprops)
elif isinstance(relation, RelationDefinition):
rdef = relation
else:
raise BadSchemaDefinition('dunno how to handle %s' % relation)
order += 1
rdef._add_relations(defined, schema)
# methods that can be used to extend an existant schema definition ########
@classmethod
def extend(cls, othermetadefcls):
"""add all relations of ``othermetadefcls`` to the current class"""
for rdef in othermetadefcls.__relations__:
cls.add_relation(rdef)
@classmethod
def add_relation(cls, rdef, name=None):
"""Add ``rdef`` relation to the class"""
if name:
rdef.name = name
if cls._ensure_relation_type(rdef):
_add_relation(cls.__relations__, rdef, name)
if getattr(rdef, 'metadata', {}) and not rdef in cls._defined:
for meta_name in rdef.metadata:
format_attr_name = '_'.join(((name or rdef.name), meta_name))
rdef = next(cls.get_relations(format_attr_name))
cls._ensure_relation_type(rdef)
else:
_add_relation(cls.__relations__, rdef, name=name)
@classmethod
def insert_relation_after(cls, afterrelname, name, rdef):
"""Add ``rdef`` relation to the class right after another"""
# FIXME change order of arguments to rdef, name, afterrelname ?
rdef.name = name
cls._ensure_relation_type(rdef)
for i, rel in enumerate(cls.__relations__):
if rel.name == afterrelname:
break
else:
raise BadSchemaDefinition("can't find %s relation on %s" % (
afterrelname, cls))
_add_relation(cls.__relations__, rdef, name, i+1)
@classmethod
def remove_relation(cls, name):
"""Remove relation from the class"""
for rdef in cls.get_relations(name):
cls.__relations__.remove(rdef)
@classmethod
def get_relations(cls, name):
"""Iterate over relations definitions that match the ``name`` parameters
It may iterate multiple definitions when the class is both object and
sujet of a relation:
"""
for rdef in cls.__relations__[:]:
if rdef.name == name:
yield rdef
@classmethod
def get_relation(cls, name):
"""Return relation definitions by name. Fails if there is multiple one.
"""
relations = tuple(cls.get_relations(name))
assert len(relations) == 1, "can't use get_relation for relation with multiple definitions"
return relations[0]
class RelationType(Definition):
symmetric = MARKER
inlined = MARKER
fulltext_container = MARKER
rule = MARKER
def __init__(self, name=None, **kwargs):
"""kwargs must have values in RTYPE_PROPERTIES"""
super(RelationType, self).__init__(name)
if kwargs.pop('meta', None):
warn('[yams 0.37] meta is deprecated', DeprecationWarning, stacklevel=2)
_check_kwargs(kwargs, RTYPE_PROPERTIES + ('description', '__permissions__'))
self.__dict__.update(kwargs)
def __str__(self):
return 'relation type %r' % self.name
@classmethod
def expand_type_definitions(cls, defined):
"""schema building step 1:
register definition objects by adding them to the `defined` dictionnary
"""
name = getattr(cls, 'name', cls.__name__)
if cls.__doc__ and not cls.description:
cls.description = ' '.join(cls.__doc__.split())
if name in defined:
if defined[name].__class__ is not RelationType:
raise BadSchemaDefinition('duplicated relation type for %s'
% name)
# relation type created from a relation definition, override it
allprops = _REL_PROPERTIES() + ('subject', 'object')
_copy_attributes(defined[name], cls, allprops)
defined[name] = cls
@classmethod
def expand_relation_definitions(cls, defined, schema):
"""schema building step 2:
register all relations definition, expanding wildcard if necessary
"""
name = getattr(cls, 'name', cls.__name__)
if getattr(cls, 'subject', None) and getattr(cls, 'object', None):
rdef = RelationDefinition(subject=cls.subject, name=name,
object=cls.object)
_copy_attributes(cls, rdef, _RDEF_PROPERTIES())
rdef._add_relations(defined, schema)
class ComputedRelation(RelationType):
__permissions__ = MARKER
def __init__(self, name=None, rule=None, **kwargs):
if rule is not None:
self.rule = rule
super(ComputedRelation, self).__init__(name, **kwargs)
class RelationDefinition(Definition):
# FIXME reader magic forbids to define a docstring...
#"""a relation is defined by a name, the entity types that can be
#subject or object the relation, the cardinality, the constraints
#and the symmetric property.
#"""
subject = MARKER
object = MARKER
cardinality = MARKER
constraints = MARKER
symmetric = MARKER
inlined = MARKER
formula = MARKER
def __init__(self, subject=None, name=None, object=None, package=None,
**kwargs):
"""kwargs keys must have values in _RDEF_PROPERTIES()"""
if subject:
self.subject = subject
else:
self.subject = self.__class__.subject
if object:
self.object = object
else:
self.object = self.__class__.object
super(RelationDefinition, self).__init__(name)
global CREATION_RANK
CREATION_RANK += 1
self.creation_rank = CREATION_RANK
if package is not None:
self.package = package
elif self.package == '<builtin>':
self.package = PACKAGE
if kwargs.pop('meta', None):
warn('[yams 0.37] meta is deprecated', DeprecationWarning)
rdefprops = _RDEF_PROPERTIES()
_check_kwargs(kwargs, rdefprops)
_copy_attributes(attrdict(**kwargs), self, rdefprops)
if self.constraints:
self.constraints = list(self.constraints)
def __str__(self):
return 'relation definition (%(subject)s %(name)s %(object)s)' % self.__dict__
@classmethod
def expand_type_definitions(cls, defined):
"""schema building step 1:
register definition objects by adding them to the `defined` dictionnary
"""
name = getattr(cls, 'name', cls.__name__)
rtype = RelationType(name)
_copy_attributes(cls, rtype, RTYPE_PROPERTIES)
if name in defined:
_copy_attributes(rtype, defined[name], RTYPE_PROPERTIES)
else:
defined[name] = rtype
# subject and object in defined's keys are only strings not tuples
if isinstance(cls.subject, tuple):
subjects = cls.subject
else:
subjects = (cls.subject, )
if isinstance(cls.object, tuple):
objects = cls.object
else:
objects = (cls.object, )
for sub in subjects:
for obj in objects:
key = (sub, name, obj)
if key in defined:
raise BadSchemaDefinition(
'duplicated relation definition (%s) %s (%s.%s)'
% (defined[key], key, cls.__module__, cls.__name__))
defined[key] = cls
# XXX keep this for bw compat
defined[(cls.subject, name, cls.object)] = cls
@classmethod
def expand_relation_definitions(cls, defined, schema):
"""schema building step 2:
register all relations definition, expanding wildcard if necessary
"""
assert cls.subject and cls.object, '%s; check the schema (%s, %s)' % (cls, cls.subject, cls.object)
cls()._add_relations(defined, schema)
def _add_relations(self, defined, schema):
name = getattr(self, 'name', self.__class__.__name__)
rtype = defined[name]
rdefprops = _RDEF_PROPERTIES()
# copy relation definition attributes set on the relation type, beside
# description
_copy_attributes(rtype, self, set(rdefprops) - set(('description',)))
# process default cardinality and constraints if not set yet
cardinality = self.cardinality
if cardinality is MARKER:
if self.object in BASE_TYPES:
self.cardinality = '?1'
else:
self.cardinality = '**'
else:
assert len(cardinality) == 2
assert cardinality[0] in '1?+*'
assert cardinality[1] in '1?+*'
if not self.constraints:
self.constraints = ()
rschema = schema.rschema(name)
if rschema.rule:
raise BadSchemaDefinition(
'Cannot add relation definition "{0}" because an '
'homonymous computed relation already exists '
'with rule "{1}"'.format(rschema.type, rschema.rule))
if self.__permissions__ is MARKER:
final = next(iter(_actual_types(schema, self.object))) in BASE_TYPES
if final:
if self.formula is not MARKER:
permissions = DEFAULT_COMPUTED_ATTRPERMS
else:
permissions = DEFAULT_ATTRPERMS
else:
permissions = DEFAULT_RELPERMS
else:
permissions = self.__permissions__
for subj in _actual_types(schema, self.subject):
for obj in _actual_types(schema, self.object):
rdef = RelationDefinition(subj, name, obj,
__permissions__=permissions,
package=self.package)
_copy_attributes(self, rdef, rdefprops)
schema.add_relation_def(rdef)
def _actual_types(schema, etype):
if etype == '*':
return _pow_etypes(schema)
if isinstance(etype, (list, tuple)):
return etype
if not isinstance(etype, string_types):
raise RuntimeError('Entity types must not be instances but strings '
'or list/tuples thereof. Ex. (bad, good) : '
'SubjectRelation(Foo), SubjectRelation("Foo"). '
'Hence, %r is not acceptable.' % etype)
return (etype,)
def _pow_etypes(schema):
for eschema in schema.entities():
if eschema.final:
continue
yield eschema.type