source: ndgCommon/trunk/ndg/common/src/lib/atomvalidator.py @ 4964

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/ndgCommon/trunk/ndg/common/src/lib/atomvalidator.py@4991
Revision 4964, 15.5 KB checked in by cbyrom, 11 years ago (diff)

Adjust granulite to ensure that granule atoms are validated before
they are created - and add code to properly deal with validation errors.

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