# # This file is part of Lodel 2 (https://github.com/OpenEdition) # # Copyright (C) 2015-2017 Cléo UMS-3287 # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program 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 Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # ## #  @package lodel.leapi.datahandlers.base_classes Defines all base/abstract # classes for DataHandlers # # Contains custom exceptions too import copy import importlib import inspect import warnings from lodel.context import LodelContext LodelContext.expose_modules(globals(), { 'lodel.exceptions': [ 'LodelException', 'LodelExceptions', 'LodelFatalError', 'DataNoneValid', 'FieldValidationError' ], 'lodel.mlnamedobject.mlnamedobject': ['MlNamedObject'], 'lodel.leapi.datahandlers.exceptions': [ 'LodelDataHandlerConsistencyException', 'LodelDataHandlerException' ], 'lodel.validator.validator': [ 'ValidationError' ], 'lodel.logger': 'logger', 'lodel.utils.mlstring': ['MlString']}) ## # @brief Base class for all DataHandlers # @ingroup lodel2_datahandlers # # @remarks Some of the methods and properties in this "abstract" class are # bounded to its children. This implies that the parent # is aware of its children, which is an absolute anti-pattern # (Liskov / OC violation), a source of confusion and a decrased # maintainability. Aggregation =/= Inheritance # Concerned methods are: is_reference; is_singlereference. # Concerned properties are __custom_datahandlers; base_handlers. # @remarks What is the purpose of an internal property being set to a # string (namely 'automatic') # @remarks Two sets of methods appears a little strange in regards to their # visibility. # - @ref _construct_data / @ref construct_data # - @ref _check_data_consistency / @ref check_data_consistency class DataHandler(MlNamedObject): base_type = "type" _HANDLERS_MODULES = ('datas_base', 'datas', 'references') ## # @brief Stores the DataHandler child classes indexed by name _base_handlers = None ## # @brief Stores custom DataHandlers classes indexed by name # @todo do it ! (like plugins, register handlers... blablabla) __custom_handlers = dict() help_text = 'Generic Field Data Handler' display_name = "Generic Field" options_spec = dict() options_values = dict() ## # @brief Lists fields that will be exposed to the construct_data method _construct_datas_deps = [] directly_editable = True ## # @brief constructor # # @param internal False | str : define whether or not a field is internal # @param immutable bool : Indicates if the fieldtype has to be defined in child classes of # LeObject or if it is designed globally and immutable # @throw NotImplementedError If it is instantiated directly # @remarks Shouldn't the class be declared abstract? No need to check if it # is instantiated directly, no exception to throw, cleaner code. def __init__(self, **kwargs): if self.__class__ == DataHandler: raise NotImplementedError("Abstract class") self.__arguments = kwargs self.nullable = True self.uniq = False self.immutable = False self.primary_key = False self.internal = False if 'default' in kwargs: self.default, error = self.check_data_value(kwargs['default']) if error: raise error del kwargs['default'] for argname, argval in kwargs.items(): setattr(self, argname, argval) self.check_options() display_name = kwargs.get('display_name', MlString(self.display_name)) help_text = kwargs.get('help_text', MlString(self.help_text)) super().__init__(display_name, help_text) ## # @brief Sets properly cast and checked options for the DataHandler # # @throw LodelDataHandlerNotAllowedOptionException when a passed option # is not in the option specifications of the DataHandler def check_options(self): for option_name, option_datas in self.options_spec.items(): if option_name in self.options_values: # There is a configured option, we check its value try: self.options_values[option_name] = option_datas[1].check_value( self.options_values[option_name]) except ValueError: pass # TODO Deal with the case where the value used for an option is invalid else: # This option was not configured, we get the default value from the specs self.options_values[option_name] = option_datas[0] ## # @return string: Field type name @classmethod def name(cls): return cls.__module__.split('.')[-1] ## # @return bool: True if subclass is of Reference type, False otherwise. @classmethod def is_reference(cls): return issubclass(cls, Reference) ## # @return bool: True if subclass is of SingleRef type, False otherwise. @classmethod def is_singlereference(cls): return issubclass(cls, SingleRef) ## # @return bool: True if the field is a primary_key, False otherwise. def is_primary_key(self): return self.primary_key ## # @brief checks if a field type is internal # @return bool: True if the field is internal, False otherwise. def is_internal(self): return self.internal is not False ## # @brief check if a value can be nullable # # @param value * # @throw DataNoneValid if value is None and nullable. # @throw LodelExceptions if not nullable # @return value (if not None) # @return value # # @remarks why are there an thrown exception if it is allowed? # Exceptions are no message brokers def _check_data_value(self, value): if value is None: if not self.nullable: raise LodelExceptions("None value is forbidden for this data field") raise DataNoneValid("None with a nullable. This exception is allowed") return value ## # @brief calls the data_field (defined in derived class) _check_data_value() method # @param value * # @return tuple (value|None, None|error) value can be cast if NoneError # @remarks Consider renaming this method, such as '_is_data_nullable'. # @remarks Exceptions ARE NOT message brokers! Moreover, those two methods # are more complicated than required. In case 'value' is None, # the returned value is the same as the input value. This is the # same behavior as when the value is not None! # @return What's a "NoneError"? Value can be cast to what? def check_data_value(self, value): try: value = self._check_data_value(value) except DataNoneValid as expt: return value, None except (LodelExceptions, FieldValidationError) as expt: return None, expt return value, None ## # @brief Checks if this class can override the given data handler. # i.e. both class having the same base_type. # @param data_handler DataHandler # @return bool # @remarks Simplify by "return data_handler.__class__.base_type == self.__class__.base_type"? def can_override(self, data_handler): if data_handler.__class__.base_type != self.__class__.base_type: return False return True ## # @brief Build field value # # @ingroup lodel2_dh_checks # @ref _construct_data() and @ref lodel2_dh_check_impl ) # # @param emcomponent EmComponent : An EmComponent child class instance # @param fname str : The field name # @param datas dict : dict storing fields values (from the component) # @param cur_value : the value from the current field (identified by fieldname) # @return the value # @throw RunTimeError if data construction fails # # @warning DO NOT REIMPLEMENT THIS METHOD IN A CUSTOM DATAHANDLER (see # @todo raise something else # # @remarks What the todo up right here means? Raise what? When? # @remarks Nothing is being raised in this method, should it? def construct_data(self, emcomponent, fname, datas, cur_value): emcomponent_fields = emcomponent.fields() data_handler = None if fname in emcomponent_fields: data_handler = emcomponent_fields[fname] new_val = cur_value if fname in datas.keys(): pass elif data_handler is not None and hasattr(data_handler, 'default'): new_val = data_handler.default elif data_handler is not None and data_handler.nullable: new_val = None return self._construct_data(emcomponent, fname, datas, new_val) ## # @brief Designed to be reimplemented by child classes # # @param emcomponent EmComponent : An EmComponent child class instance # @param fname str : The field name # @param datas dict : dict storing fields values (from the component) # @param cur_value : the value from the current field (identified by fieldname) # @return the value # @see construct_data() lodel2_dh_check_impl def _construct_data(self, emcomponent, fname, datas, cur_value): return cur_value ## # @brief Check data consistency # @ingroup lodel2_dh_checks # # @ref lodel2_dh_datas_construction "Data construction section" # @param emcomponent EmComponent : An EmComponent child class instance # @param fname : the field name # @param datas dict : dict storing fields values # @return an Exception instance if fails else True # # @warning DO NOT REIMPLEMENT THIS METHOD IN A CUSTOM DATAHANDLER (see # @ref _construct_data() and @ref lodel2_dh_check_impl ) # @warning the data argument looks like a dict but is not a dict # see @ref base_classes.DatasConstructor "DatasConstructor" and # @todo A implémenter def check_data_consistency(self, emcomponent, fname, datas): return self._check_data_consistency(emcomponent, fname, datas) ## # @brief Designed to be reimplemented by child classes # # @param emcomponent EmComponent : An EmComponent child class instance # @param fname : the field name # @param datas dict : dict storing fields values # @return an Exception instance if fails else True # # @see check_data_consistency() lodel2_dh_check_impl def _check_data_consistency(self, emcomponent, fname, datas): return True ## # @brief Makes consistency after a query # # @param emcomponent EmComponent : An EmComponent child class instance # @param fname : the field name # @param datas dict : dict storing fields values # @return an Exception instance if fails else True # # @todo To be implemented # @remarks It not clear what is the intent of this method... def make_consistency(self, emcomponent, fname, datas): pass ## # @brief Registers a new data handlers # # @note Used by plugins. # @remarks This method is actually never used anywhere. May consider removing it. @classmethod def register_new_handler(cls, name, data_handler): if not inspect.isclass(data_handler): raise ValueError("A class was expected but %s given" % type(data_handler)) if not issubclass(data_handler, DataHandler): raise ValueError("A data handler HAS TO be a child class of DataHandler") cls.__custom_handlers[name] = data_handler ## # @brief Loads all DataHandlers @classmethod def load_base_handlers(cls): if cls._base_handlers is None: cls._base_handlers = dict() for module_name in cls._HANDLERS_MODULES: module = importlib.import_module('lodel.leapi.datahandlers.%s' % module_name) for name, obj in inspect.getmembers(module): if inspect.isclass(obj): logger.debug("Load data handler %s.%s" % (obj.__module__, obj.__name__)) cls._base_handlers[name.lower()] = obj return copy.copy(cls._base_handlers) ## # @brief given a field type name, returns the associated python class # # @param name str : A field type name (not case sensitive) # @return DataField child class # @throw NameError # # @note Would not it be better to prefix the DataHandler name with the # plugin's one so that it is ensured names are unique? # @remarks "do/get what from name?" Consider renaming this method (e.g. # 'get_datafield_from_name') @classmethod def from_name(cls, name): cls.load_base_handlers() all_handlers = dict(cls._base_handlers, **cls.__custom_handlers) name = name.lower() if name not in all_handlers: raise NameError("No data handlers named '%s'" % (name,)) return all_handlers[name] ## # @brief List all DataHandlers # @return a dict with, display_name for keys, and a dict for value # @remarks ATM, solely used by the EditorialModel. # @remarks EditorialModel own class does nothing but calls this class. # Moreover, nothing calls it anyway. # @remarks It also seems like it is an EM related concern, and has # nothing to do with this class. That list appears to be doing # a purely presentational job. Isn't that a serialization instead? @classmethod def list_data_handlers(cls): cls.load_base_handlers() all_handlers = dict(cls._base_handlers, **cls.__custom_handlers) list_dh = dict() for hdl in all_handlers: options = dict({'nullable': hdl.nullable, 'internal': hdl.internal, 'immutable': hdl.immutable, 'primary_key': hdl.primary_key}, hdl.options_spec) list_dh[hdl.display_name] = {'help_text': hdl.help_text, 'options': options} return list_dh ## # @brief Return the module name to import in order to use the DataHandler # @param datahandler_name str : Data handler name # @return str # @remarks consider renaming this (e.g. "datahandler_module_name") @classmethod def module_name(cls, datahandler_name): datahandler_name = datahandler_name.lower() handler_class = cls.from_name(datahandler_name) return '{module_name}.{class_name}'.format( module_name=handler_class.__module__, class_name=handler_class.__name__ ) ## # @brief __hash__ implementation for field types def __hash__(self): hash_dats = [self.__class__.__module__] for kdic in sorted([k for k in self.__dict__.keys() if not k.startswith('_')]): hash_dats.append((kdic, getattr(self, kdic))) return hash(tuple(hash_dats)) ## # @brief Base class for data data handler (by opposition with references) # @ingroup lodel2_datahandlers class DataField(DataHandler): pass ## # @brief Abstract class for all references # @ingroup lodel2_datahandlers # # References are fields that stores a reference to another # editorial object # @todo Construct data implementation : transform the data into a LeObject instance class Reference(DataHandler): base_type = "ref" ## # @brief Instantiation # @param allowed_classes list | None : list of allowed em classes if None no restriction # @param back_reference tuple | None : tuple containing (LeObject child class, field name) # @param internal bool | string: if False, the field is not internal # @param **kwargs : other arguments # @throw ValueError # @remarks internal may hold the string value 'automatic'. So far, nothing # mentions what that means, and nothing seems to be aware # of an 'automatic' value (at least not in leapi package) def __init__(self, allowed_classes=None, back_reference=None, internal=False, **kwargs): self.__allowed_classes = set() if allowed_classes is None else set(allowed_classes) ## # @note what is "useful to Jinja 2"? # For now useful to jinja 2 self.allowed_classes = list() if allowed_classes is None else allowed_classes if back_reference is not None: if len(back_reference) != 2: raise ValueError( "A tuple (classname, fieldname) expected but got '%s'" % back_reference) ## # @note Why is there commented out code? Should it be deleted? Ractivated? # if not issubclass(lodel.leapi.leobject.LeObject, back_reference[0]) # or not isinstance(back_reference[1], str): # raise TypeError("Back reference was expected to be a tuple(, str) # but got : (%s, %s)" % (back_reference[0], back_reference[1])) self.__back_reference = back_reference super().__init__(internal=internal, **kwargs) ## # @brief Method designed to return an empty value for this kind of # multipleref # @remarks purpose!? @classmethod def empty(cls): return None ## # @brief Property that takes value of a copy of the back_reference tuple @property def back_reference(self): return copy.copy(self.__back_reference) ## # @brief Property that takes value of datahandler of the backreference or # None @property def back_ref_datahandler(self): if self.__back_reference is None: return None return self.__back_reference[0].data_handler(self.__back_reference[1]) @property def linked_classes(self): return copy.copy(self.__allowed_classes) ## # @brief Sets a back reference. def _set_back_reference(self, back_reference): self.__back_reference = back_reference ## # @brief Check and cast value in the appropriate type # # @param value # @throw FieldValidationError if value is an appropriate type # @return value # @todo implement the check when we have LeObject uid check value def _check_data_value(self, value): from lodel.leapi.leobject import LeObject value = super()._check_data_value(value) if not (hasattr(value, '__class__') and issubclass(value.__class__, LeObject)): if self.__allowed_classes: rcls = list(self.__allowed_classes)[0] uidname = rcls.uid_fieldname()[0] # TODO multiple uid is broken uiddh = rcls.data_handler(uidname) value = uiddh._check_data_value(value) else: raise FieldValidationError( "Reference datahandler can not check this value %s if any allowed_class is allowed. " % value) return value ## # @brief Check data consistency # # @param emcomponent EmComponent : # @param fname string : the field name # @param datas dict : dict storing fields values # @return bool | Exception : # # @todo check for performance issues and checks logic # @warning composed uid capabilities are broken # @remarks Is that really a legitimate case of retuning an Exception object? def check_data_consistency(self, emcomponent, fname, datas): rep = super().check_data_consistency(emcomponent, fname, datas) if isinstance(rep, Exception): return rep if self.back_reference is None: return True ## # @todo Reimplement instance fetching in construct data # @remarks Set the previous todo as one, looked like it was intended to be. target_class = self.back_reference[0] if target_class not in self.__allowed_classes: logger.warning('Class of the back_reference given is not an allowed class') return False value = datas[fname] ## # @warning multi uid broken here # @remarks Why is that broken? Any clue? Set as a warning. target_uidfield = target_class.uid_fieldname()[0] obj = target_class.get([(target_uidfield, '=', value)]) if len(obj) == 0: logger.warning('Object referenced does not exist') return False return True ## # @brief Utility method designed to fetch referenced objects # # @param value mixed : the field value # @throw NotImplementedError # @remarks Not implemented? Consider renaming? def get_referenced(self, value): raise NotImplementedError ## # @brief DataHandler for single reference to another object # # An instance of this class acts like a "foreign key" to another object class SingleRef(Reference): def __init__(self, allowed_classes=None, **kwargs): super().__init__(allowed_classes=allowed_classes, **kwargs) ## # @brief Checks and casts value to the appropriate type # # @param value: mixed # @throw FieldValidationError if value is inappropriate or can not be cast # @return mixed def _check_data_value(self, value): value = super()._check_data_value(value) return value ## # @brief Utility method to fetch referenced objects # # @param value mixed : the field value # @return A LeObject child class instance # @throw LodelDataHandlerConsistencyException if no referenced object found # @remarks Consider renaming (e.g. get_referenced_object)? def get_referenced(self, value): for leo_cls in self.linked_classes: res = leo_cls.get_from_uid(value) if res is not None: return res raise LodelDataHandlerConsistencyException("Unable to find \ referenced object with uid %s" % value) ## # @brief DataHandler for multiple references to another object # @ingroup lodel2_datahandlers # # The fields using this data handlers are like SingleRef but can store multiple # references in one field. # @note for the moment split on ',' chars class MultipleRef(Reference): ## # @brief Constructor # # @param max_item int | None : indicate the maximum number of item referenced # by this field, None mean no limit def __init__(self, max_item=None, **kwargs): self.max_item = max_item super().__init__(**kwargs) ## # @brief Method designed to return an empty value for this kind of # multipleref # @remarks Purpose!? @classmethod def empty(cls): return [] ## # @brief Check and cast value in appropriate type # @param value mixed # @throw FieldValidationError if value is unappropriate or can not be cast # @return value # @todo Writing test error for errors when stored multiple references in one field def _check_data_value(self, value): value = DataHandler._check_data_value(self, value) if not hasattr(value, '__iter__'): raise FieldValidationError( "MultipleRef has to be an iterable or a string, '%s' found" % value) if self.max_item is not None: if self.max_item < len(value): raise FieldValidationError("Too many items") new_val = list() error_list = list() for i, v in enumerate(value): try: v = super()._check_data_value(v) new_val.append(v) except (FieldValidationError): error_list.append(repr(v)) if len(error_list) > 0: raise FieldValidationError( "MultipleRef have for invalid values [%s] :" % (",".join(error_list))) return new_val ## # @brief Utility method designed to fetch referenced objects # # @param values mixed : the field values # @return A list of LeObject child class instance # @throw LodelDataHandlerConsistencyException if some referenced objects # were not found def get_referenced(self, values): if values is None or len(values) == 0: return list() left = set(values) values = set(values) res = list() for leo_cls in self.linked_classes: uidname = leo_cls.uid_fieldname()[0] # MULTIPLE UID BROKEN HERE tmp_res = leo_cls.get(('%s in (%s)' % (uidname, ','.join( [str(l) for l in left])))) left ^= set((leo.uid() for leo in tmp_res)) res += tmp_res if len(left) == 0: return res raise LodelDataHandlerConsistencyException("Unable to find \ some referenced objects. Following uids were not found : %s" % ','.join(left)) ## # @brief Class designed to handle data access while field types are constructing data # @ingroup lodel2_datahandlers # # This class is designed to allow automatic scheduling of construct_data calls. # # In theory it has the ability to detect circular dependencies # @todo test circular deps detection # @todo test circular deps false positive # @remarks Would not it be better to make sure what the code actually is doing? class DatasConstructor(object): ## # @brief Init a DatasConstructor # # @param leobject LeObject # @param datas dict : dict with field name as key and field values as value # @param fields_handler dict : dict with field name as key and data handler instance as value def __init__(self, leobject, datas, fields_handler): self._leobject = leobject self._datas = copy.copy(datas) # Stores fieldtypes self._fields_handler = fields_handler # Stores list of fieldname for constructed self._constructed = [] # Stores construct calls list self._construct_calls = [] ## # @brief Implements the dict.keys() method on instance # # @return list def keys(self): return self._datas.keys() ## # @brief Allows to access the instance like a dict # # @param fname string: The field name # @return field values # @throw RuntimeError # # @note Determine return type def __getitem__(self, fname): if fname not in self._constructed: if fname in self._construct_calls: raise RuntimeError('Probably circular dependencies in fieldtypes') cur_value = self._datas[fname] if fname in self._datas else None self._datas[fname] = self._fields_handler[fname].construct_data( self._leobject, fname, self, cur_value) self._constructed.append(fname) return self._datas[fname] ## # @brief Allows to set instance values like a dict # # @warning Should not append in theory # # @remarks Why is a warning issued any time we call this method? def __setitem__(self, fname, value): self._datas[fname] = value warnings.warn("Setting value of an DatasConstructor instance") ## # @brief Class designed to handle a DataHandler option class DatahandlerOption(MlNamedObject): ## # @brief instantiates a new DataHandlerOption object # # @param id str # @param display_name MlString # @param help_text MlString # @param validator function def __init__(self, id, display_name, help_text, validator): self.__id = id self.__validator = validator super().__init__(display_name, help_text) ## # @brief Accessor to the id property. @property def id(self): return self.__id ## # @brief checks a value corresponding to this option is valid # # @param value mixed # @return cast value # @throw ValueError def check_value(self, value): try: return self.__validator(value) except ValidationError: raise ValueError()