source: exist/trunk/python/ndgUtils/lib/atomvalidator.py @ 4636

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/exist/trunk/python/ndgUtils/lib/atomvalidator.py@4636
Revision 4636, 14.1 KB checked in by cbyrom, 12 years ago (diff)

Add range validation - i.e. checking max/min values correct way around
+ separate out error messages as class constants to allow checking with
tests.

Line 
1#!/usr/bin/env python
2'''
3 Helper class to use with the Atom data model - for data validation
4 Validates:
5 i) External links
6 ii) Vocab data
7 iii) Schema compliance
8 iv) unicode compliance - with utf-8 encoding
9 v) data consistency within the atom data model
10 
11 @author: C Byrom, Tessella Nov 2008
12'''
13import logging, traceback, datetime, xmlrpclib, socket
14import ndgUtils.lib.existdbclient as edc
15from ndgUtils.vocabtermdata import isValidTermURI
16from ndgUtils.models.Atom import Atom
17from ndgUtils.ndgXqueries import ndgXqueries
18from ndgUtils.lib.utilities import isValidUnicode, simpleURLCheck, strftime
19
20
21class ValidationError(Exception):
22    """
23    Exception handling for validation.
24    """
25    BASE_MESSAGE = 'Data validation error'
26    def __init__(self, errorDict):
27        logging.error(self.BASE_MESSAGE)
28        Exception.__init__(self, self.BASE_MESSAGE)
29        for val in errorDict.itervalues():
30            logging.error(val)
31        self._errorDict = errorDict
32           
33    def unpack_errors(self):
34        return self._errorDict
35   
36   
37class AtomValidator(object):
38    '''
39    Helper class for validating atom data
40    '''
41    # eXist DB client
42    _eXist = None
43   
44    # standard output delimiter
45    LINE_SEPARATOR = "-----------------------------"
46   
47    # constants to use as error dict keys
48    BROKEN_LINKS = 1
49    INVALID_VOCAB_TERM = 2
50    SCHEMA_VALIDATION_FAILURE = 4
51
52    VALID_RELS = ["self", "related"]
53   
54    NEW_LINE = "\n"
55    ILLEGAL_UNICODE_MESSAGE = 'Illegal unicode found in string'
56
57       
58    def __init__(self, atom, dbConfigFile, raiseException = True, \
59                 newLineChar= NEW_LINE, loadAllCollections = False, \
60                 isDebug = False):
61        '''
62        Set up validator object - with atom to validate
63        @param atom: Atom object to validate
64        @keyword dbConfigFile: config file to use with eXist DB connection
65        @keyword raiseException: if True, raise a ValidationException following a failed validation
66        - NB, if not used, errors can be retrieved from the self.errors field
67        @keyword loadAllCollections: loads all collections info when initialising eXist
68        connection, if True
69        @keyword isDebug: if True, provide more detailed output
70        '''
71        logging.info("Setting up atomValidation object")
72        self._atom = atom
73        self._nl = newLineChar
74        self._isDebug = isDebug
75       
76        # collections to effectively cache positive results - to avoid multiple
77        # (time consuming) lookups of the same data
78        self._validLinks = []
79        self._validVocabTerms = []
80       
81        # set up connection to eXist
82        self.__setUpEXistDBConnection(dbConfigFile, loadAllCollections = loadAllCollections)
83
84        # setup the dictionary to store errors
85        self.raiseException = raiseException
86        self.errors = {}
87        logging.info("atomValidator initialised")
88
89   
90    def __setUpEXistDBConnection(self, dbConfFile, loadAllCollections = False):
91        '''
92        Get the default eXist DB connection - by reading in data from the db config file
93        @keyword dbConfigFile: config file to use with eXist DB connection
94        @keyword loadAllCollections: loads all collections info when initialising eXist
95        '''
96        logging.info("Setting up connection to eXist DB")
97        self._eXist = edc.eXistDBClient(configFile = dbConfFile, 
98                                        loadCollectionData=loadAllCollections)
99        logging.info("eXist DB connection now set up")
100
101
102    def setAtom(self, atom):
103        '''
104        Set the atom to use the validator with
105        @param atom: an Atom object to validate
106        '''
107        if not isinstance(atom, Atom):
108            raise ValueError("Input object is not an Atom object")
109        logging.info("Setting new atom with validator (id=%s)" %atom.atomID)
110        self.errors = {} # clear out any existing errors
111        self._atom = atom
112       
113
114    def validateAtom(self):
115        '''
116        Retrieve an atom from the specified path and validate the contents
117        @param atomPath: path to the atom in the eXist DB
118        '''
119        logging.info("Validating atom, '%s'" %self._atom.atomID)
120        # firstly, check the links point to valid uris
121        self.__validateLinks()
122       
123        # now check the vocab terms
124        self.__validateVocabData()
125       
126        # check the atom conforms to the schema
127        self.__validateSchemaCompliance()
128       
129        # validate the actual atom content - for more specific checks on data
130        self.__validateAtomContent()
131       
132        # lastly check for non-unicode compliant characters
133        self.__validateUnicode()
134           
135        logging.info("Atom validation completed")
136       
137        # remove the error dict entry if no errors receieved
138        if self.errors:
139            logging.info("- atom is invalid")
140           
141            if self.raiseException:
142                logging.warning("Errors found in atom data: %s" %self.errors)
143                raise ValidationError(self.errors)
144        else:
145            logging.info("- atom is valid")
146       
147
148    def __validateAtomContent(self):
149        '''
150        Check the data content of the atom is consistent; if an error with any of
151        these is found, raise a ValueError
152        @raise ValueError: if any atom attributes have a problem
153        '''
154        logging.info("Validating the atom data model consistency")
155        if not self._atom.title:
156            self.__addError('title', "Title attribute cannot be empty")
157           
158        if not self._atom.author.hasValue():
159            self.__addError('Author.0.name', "Author name cannot be empty")
160           
161        if self._atom.minX or self._atom.maxX or self._atom.minY or self._atom.maxY:
162            missingVals = False
163            incorrectFormat = False 
164            for val in [self._atom.minX, self._atom.maxX, self._atom.minY, self._atom.maxY]:
165                if val == '':
166                    missingVals = True
167                else:
168                    try:
169                        float(val)
170                    except:
171                        incorrectFormat = True
172
173            spatialError = ""
174            if missingVals:
175                spatialError += "Incomplete spatial coverage data.%s"  %self._nl
176            if incorrectFormat:
177                spatialError += "Spatial coverage data not in numerical format.%s"  %self._nl
178
179            if not self.__isRangeValid(self._atom.minX ,self._atom.maxX):
180                spatialError += "Max longitude is less than min longitude.%s"  %self._nl
181               
182            if not self.__isRangeValid(self._atom.minY ,self._atom.maxY):
183                spatialError += "Max latitude is less than min latitude.%s"  %self._nl
184               
185               
186            if spatialError:
187                self.__addError('spatialcoverage', spatialError)
188
189        if self._atom.t1 or self._atom.t2:
190            timeErrors = ''
191            d1 = None
192            d2 = None
193            if self._atom.t1:
194                try:
195                    d1 = datetime.datetime.strptime(self._atom.t1, self._atom.YEAR_FORMAT)
196                except:
197                    timeErrors += "Incorrect start date format - '%s' - c.f. '2008-04-12'. %s" \
198                        %(self._atom.t1, self._nl)
199            if self._atom.t2:
200                try:
201                    d2 = datetime.datetime.strptime(self._atom.t2, self._atom.YEAR_FORMAT)
202                except:
203                    timeErrors += "Incorrect end date format - '%s' - c.f. '2008-04-12'. %s" \
204                        %(self._atom.t2, self._nl)
205
206            if d1 and d2:
207                if d1 > d2 or d2 < d1:
208                    timeErrors += "Inconsistent date range - '%s' is not before '%s'" \
209                        %(strftime(d1, self._atom.YEAR_FORMAT), strftime(d2, self._atom.YEAR_FORMAT))
210
211            if timeErrors:
212                self.__addError('temporalrange', timeErrors)
213
214        logging.info("Atom model consistency validation completed")
215
216
217    def __isRangeValid(self, minVal, maxVal):
218        '''
219        Check if two values adhere to being min/max of a range
220        @param minVal: start of the range
221        @param maxVal: end of the range
222        @return: True, if range is valid, or unknown (inputs may be badly defined), or False otherwise
223        '''
224        try:
225            if float(minVal) <= float(maxVal):
226                return True
227            else:
228                return False
229        except:
230            logging.debug("Comparing invalid data: '%s' vs '%s'" %(minVal, maxVal))
231       
232        return False
233
234    def __validateUnicode(self):
235        '''
236        Do a quick recursion over all the attributes to look for non
237        utf-8 compliant characters
238        '''
239        logging.info("Validating unicode UTF-8 compliance")
240        for key, val in self._atom.__dict__.items():
241            if val:
242                if isinstance(val, basestring):
243                    if not isValidUnicode(val):
244                        if not self.errors.has_key(key):
245                            self.errors[key] = ''
246                        self.errors[key] += "%s: '%s'.'%s'" %(self.ILLEGAL_UNICODE_MESSAGE, \
247                                                              val, self._nl)
248        logging.info("Completed validating unicode UTF-8 compliance")
249       
250
251    def __validateLinks(self):
252        '''
253        Check the external links contained in the atom and ensure they are valid
254        '''
255        logging.info("Validating atom links")
256        for link in self._atom.relatedLinks:
257            if link.hasValue():
258                try:
259                    # don't lookup link, if it has already been validated before
260                    if link.href in self._validLinks:
261                        continue
262                   
263                    if not simpleURLCheck(link.href):
264                        self.__addError(self.BROKEN_LINKS, "Broken link: '%s'" %link.href)
265                    else:
266                        self._validLinks.append(link.href)
267                       
268                except Exception, e:
269                    errorMessage = e.message
270                    if errorMessage.startswith('unknown url type'):
271                        errorMessage += " - NB, url must be of format, 'http://blah.co.uk'"
272                    self.__addError(self.BROKEN_LINKS, errorMessage)
273
274        logging.info("Completed link validation")
275
276
277    def __validateVocabData(self):
278        '''
279        Check the vocab data contained in the atom and ensure they are valid
280        '''
281        logging.info("Validating atom vocab data")
282        for category in self._atom.parameters:
283            if category.hasValue():
284                self.__validateTermURL(category.scheme)
285
286        # also check the terms used in the links
287        for link in self._atom.relatedLinks:
288            if link.hasValue():
289                self.__validateTermURL(link.rel)
290        logging.info("Completed link validation")
291
292
293    def __validateTermURL(self, url):
294        '''
295        Check the specified vocab url - and add any encountered errors
296        to the global error collection.  Also add any validated urls
297        to the global valid term collection.
298        @param url: url string representing a vocab term
299        '''
300        # don't lookup link, if it has already been validated before
301        if url in self._validVocabTerms or url in self.VALID_RELS:
302            logging.info("- term is valid")
303            return
304       
305        if not isValidTermURI(url):
306            logging.info("- term is invalid")
307            self.__addError(self.INVALID_VOCAB_TERM, \
308                            "Invalid vocab term: '%s'" %url)
309        else:
310            logging.info("- term is valid")
311            self._validVocabTerms.append(url)
312       
313
314    def __validateSchemaCompliance(self):
315        '''
316        Validate the atom, against the atom xsd, using eXist validation facilities
317        @param atomPath: collection path to atom in eXist
318        @param atomID: atom ID
319        '''
320        logging.info("Validating schema compliance")
321        atomPath = self._atom.getDefaultCollectionPath() + self._atom.atomName
322        try:
323            errors = self._eXist.checkAtomSchemaCompliance(atomPath, atom = self._atom, \
324                                                           isDebug = self._isDebug)
325            for error in errors:
326                self.__addError(self.SCHEMA_VALIDATION_FAILURE, error)
327           
328        except Exception, e:
329            # check for a meaningful error message
330            error = e.message
331            if isinstance(e, xmlrpclib.Fault):
332                # strip out the exception type - NB, this is usually native library code
333                # and is of no real interest - and will just confuse viewers
334                error = e.faultString.split(':')[-1] 
335            elif isinstance(e, socket.error):
336                error = e.args[1]
337               
338            errorMessage = "Problem experienced when validating against schema:%s'%s'" \
339                %(self._nl, error)
340
341            logging.error(errorMessage)
342            self.__addError(self.SCHEMA_VALIDATION_FAILURE, errorMessage)
343        logging.info("Completed validating schema compliance")
344           
345   
346    def __addError(self, errorLabel, errorMessage):
347        '''
348        Add an error with the specified label and message to the error dict for the
349        specified atom ID
350        @param errorLabel: type of error to add
351        @param errorMessage: error message to add
352        '''
353        logging.debug("Adding error to error list")
354        logging.debug(errorMessage)
355       
356        if not self.errors.has_key(errorLabel):
357            self.errors[errorLabel] = []
358           
359        self.errors[errorLabel].append(errorMessage)
360        logging.debug("Error added")
361
362       
363    def logErrors(self):
364        '''
365        Outputs any errors caught during validation to log
366        '''
367        logging.info("The following errors were encountered when validating the atoms:")
368        logging.info('')
369        logging.info("- atom ID '%s'" %self._atom.atomID)
370        logging.info("--------------------------------------")
371        for errors in self.errors.values():
372            for error in errors:
373                logging.info(error)
374        logging.info("--------------------------------------")
Note: See TracBrowser for help on using the repository browser.