source: exist/trunk/python/ndgUtils/models/Atom.py @ 4229

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/exist/trunk/python/ndgUtils/models/Atom.py@4229
Revision 4229, 26.0 KB checked in by cbyrom, 11 years ago (diff)

Add support for doing lists and summaries of atom docs via ndgDirectory and the existInterface class + add necessary xquery files for this + add new method to
allow retrieval of the collection to which an atom with a specified ID belongs - including the associated xquery file + extend tests to exercise these new features
+ add custom Atom error + improve preservation of key atom attributes when loading existing atoms into an Atom object + improve determining default atom collection

Line 
1'''
2 Class representing data in  atom format - allowing moles data to be stored and accessed in a web feed compatible way
3 
4 @author: C Byrom, Tessella Jun 2008
5'''
6try: #python 2.5
7    from xml.etree import cElementTree as ET
8except ImportError:
9    try:
10        # if you've installed it yourself it comes this way
11        import cElementTree as ET
12    except ImportError:
13        # if you've egged it this is the way it comes
14        from ndgUtils.elementtree import cElementTree as ET
15import sys, logging, re, datetime
16from ndgUtils.eXistConnector import eXistConnector
17from ndgUtils.ETxmlView import et2text
18from utilities import getTripleData, escapeSpecialCharacters, \
19    tidyUpParameters, getISO8601Date
20from ndgUtils.vocabtermdata import VocabTermData as VTD
21from ndgUtils.models import MolesEntity as ME
22
23
24class AtomError(Exception):
25    """
26    Exception handling for Atom class.
27    """
28    def __init__(self, msg):
29        logging.error(msg)
30        Exception.__init__(self, msg)
31
32
33class Person():
34    '''
35    Class representing atom author type data - with name, uri and role attributes
36    '''
37    def __init__(self, tagName = "author", namespace = None):
38        self.tagName = tagName
39        self.ns = namespace
40        self.name = ""
41        self.uri = ""
42        self.role = ""
43
44    def fromString(self, personString):
45        (self.name, self.uri, self.role) = getTripleData(personString)
46       
47    def fromETElement(self, personTag):
48        self.name = personTag.findtext('name') or ""
49        self.role = personTag.findtext('role') or ""
50        self.uri = personTag.findtext('uri') or ""
51        logging.debug("Added name: '%s', role: '%s', uri: '%s'" \
52                      %(self.name, self.role, self.uri))
53
54    def toXML(self):
55        prefix = ""
56        if self.ns:
57            prefix = self.ns + ':'
58
59        author = ET.Element(prefix + self.tagName)
60
61        if self.name:
62            name = ET.SubElement(author, prefix + "name")
63            name.text = self.name
64       
65        if self.uri:
66            uri = ET.SubElement(author, prefix + "uri")
67            uri.text = self.uri
68       
69        if self.role:
70            role = ET.SubElement(author, prefix + "role")
71            role.text = self.role
72
73        return author
74   
75
76class Link():
77    '''
78    Class representing an atom link - with href, title and rel attributes
79    '''
80    def __init__(self):
81        self.href = ""
82        self.title = ""
83        self.rel = ""
84
85    def fromString(self, linkString):
86        (self.href, self.title, self.ref) = getTripleData(linkString)
87       
88    def fromETElement(self, linkTag):
89        self.href = linkTag.attrib.get('href') or ""
90        self.rel = linkTag.attrib.get('rel') or ""
91        self.title = linkTag.attrib.get('title') or ""
92
93    def toXML(self):
94        link = ET.Element("link")
95        link.attrib["href"] = self.href
96        link.attrib["title"] = self.title
97        link.attrib["rel"] = self.rel
98        return link
99
100
101class Category():
102    '''
103    Class representing an atom category - with term, scheme and label attributes
104    '''
105    def __init__(self):
106        self.term = ""
107        self.scheme = ""
108        self.label = ""
109
110    def fromString(self, linkString):
111        (self.label, self.scheme, self.term) = getTripleData(linkString)
112       
113    def fromETElement(self, linkTag):
114        self.term = linkTag.attrib.get('term') or ""
115        self.label = linkTag.attrib.get('label') or ""
116        self.scheme = linkTag.attrib.get('scheme') or ""
117
118    def toXML(self):
119        link = ET.Element("category")
120        link.attrib["term"] = self.term
121        link.attrib["scheme"] = self.scheme
122        link.attrib["label"] = self.label
123        return link
124
125
126class Atom(object):
127
128    ATOM_TYPE = "ATOM_TYPE"
129    ATOM_SUBTYPE = "ATOM_SUBTYPE"
130
131    def __init__(self, atomType = None, vocabTermData = None, ndgObject = None, \
132                 xmlString = None, state = eXistConnector.WORKING_COLLECTION_PATH, **inputs):
133        '''
134        Constructor - initialise the atom variables
135        '''
136        logging.info("Initialising atom")
137        if atomType:
138            logging.info(" - of type '%s'" %atomType)
139        self.atomTypeID = atomType
140
141        # some data have further subtypes specified
142        self.subtype = None
143       
144        self.ndgObject = ndgObject
145
146        self.atomName = None
147        self.files = []
148        self.author = None
149        self.contributors = []
150        self.atomAuthors = []
151        self.parameters = []
152        self.spatialData = []
153        self.temporalData = []
154        self.relatedLinks = []
155        self.summary = []
156        self.csmlFile = None
157        self.cdmlFile = None
158        # general variable to use for setting the atom content - NB, if a csmlFile is specified
159        # (either directly or via a cdmlFile specification), this will be the content by default
160        # for this purpose
161        self.contentFile = None     
162        self.logos = []
163        self.title = None
164        self.datasetID = None        # NB, the dataset id ends up in the atomName - <path><datasetID>.atom
165        self.atomID = None
166   
167        # boundary box info - to replace spatial/temporalData?
168        self.minX = None
169        self.minY = None
170        self.maxX = None
171        self.maxY = None
172        self.t1 = None
173        self.t2 = None
174
175        self.ME = ME.MolesEntity()
176       
177        # date when the atom was first ingested
178        self.publishedDate = None
179
180        # last update date
181        self.updateDate = None
182
183        # assume atom in working state by default - this is used to define what collection
184        # in eXist the atom is stored in
185        self.state = state
186       
187        # additional, non standard atom data can be included in the molesExtra element
188        if vocabTermData:
189            self.VTD = vocabTermData
190        else:
191            self.VTD = VTD()
192       
193        if xmlString:
194            self.fromString(xmlString)
195
196        # if inputs passed in as dict, add these now
197        self.__dict__.update(inputs)
198
199        if self.atomTypeID:
200            self.atomTypeName = self.VTD.TERM_DATA[self.atomTypeID].title
201
202        logging.info("Atom initialised")
203
204
205    def getDefaultCollectionPath(self):
206        '''
207        Determine the correct collection to use for the atom in eXist
208        '''
209        collectionPath = eXistConnector.BASE_COLLECTION_PATH + self.state
210       
211        if self.atomTypeID == VTD.DE_TERM:
212            collectionPath += eXistConnector.DE_COLLECTION_PATH
213        elif self.atomTypeID == VTD.GRANULE_TERM:
214            collectionPath += eXistConnector.GRANULE_COLLECTION_PATH
215        else:
216            collectionPath += eXistConnector.DEPLOYMENT_COLLECTION_PATH
217       
218        if not self.ME.providerID:
219            raise AtomError("Error: cannot determine atom collection path because " + \
220                            "the provider ID is not defined")
221           
222        collectionPath += self.ME.providerID + "/"
223        return collectionPath
224
225
226
227    def getValidSubTypes(self):
228        '''
229        Get list of subtypes that are valid wrt this atom type
230        '''
231        logging.debug("Lookup up subtypes for type, '%s'" %self.atomTypeID)
232        subTypes = self.VTD.SUBTYPE_TERMS.get(self.atomTypeID) or []
233        types = []
234        for st in subTypes:
235            types.append(self.VTD.TERM_DATA[st])
236        logging.debug("Found subtypes: %s" %subTypes)
237        return types
238       
239           
240
241    def __addAtomTypeDataXML(self, root):
242        '''
243        Add the atom type, and subtype data, if available, to atom categories
244        - and lookup and add the appropriate vocab term data
245        '''
246        if self.atomTypeID:
247            logging.info("Adding atom type info to XML output")
248            category = Category()
249            category.label = self.atomTypeID
250            # look up the appropriate vocab term data
251            category.scheme = self.VTD.getTermCurrentVocabURL(self.atomTypeID)
252            category.term = self.ATOM_TYPE
253            root.append(category.toXML())
254
255        if self.subtype:
256            logging.info("Adding atom subtype info to XML output")
257            # NB subtypes not all defined, so leave this out for the moment
258            category.label = self.subtype
259            # look up the appropriate vocab term data
260            category.scheme = self.VTD.getTermCurrentVocabURL(self.subtype)
261            category.term = self.ATOM_SUBTYPE
262            root.append(category.toXML())
263
264
265    def addMolesEntityData(self, abbreviation, provider_id, object_creation_time):
266        '''
267        Add data to include in the moles entity element
268        '''
269        logging.debug('Adding moles entity information')
270        self.ME.abbreviation = abbreviation
271        self.ME.providerID = provider_id
272        self.ME.createdDate = getISO8601Date(object_creation_time)
273        logging.debug('Moles entity information added')
274
275
276    def _isNewParameter(self, param):
277        '''
278        Check if a parameter is already specified in the atom, return False if
279        so, otherwise return True
280        '''
281        for p in self.parameters:
282            if p.term == param.term and \
283                p.scheme == param.scheme and \
284                p.label == param.label:
285                return False
286        return True
287
288
289    def addRelatedLinks(self, linkVals):
290        '''
291        Add related links in string format - converting to Link objects
292        @param linkVals: string of format, 'uri | title | vocabServerURL'
293        '''
294        self.relatedLinks.append(self.objectify(linkVals, 'relatedLinks'))
295
296
297    def addLogos(self, logoVals):
298        '''
299        Add related logos in string format - converting to Link objects
300        @param linkVals: string of format, 'uri | title | vocabServerURL'
301        '''
302        self.relatedLinks.append(self.objectify(logoVals, 'logo'))
303
304
305    def addParameters(self, params):
306        '''
307        Add a parameter to list - ensuring it is unique and has been formatted and tidied appropriately
308        @params param: parameter, as string array, to add to atom parameters collection
309        '''
310        # avoid strings being parsed character by character
311        if type(params) is str:
312            params = [params]
313           
314        for param in params:
315            # firstly tidy parameter
316            param = tidyUpParameters(param)
317            category = Category()
318            category.fromString(param)
319
320            # now check for uniqueness
321            if self._isNewParameter(category):
322                logging.debug("Adding new parameter: %s" %param)
323                self.parameters.append(category)
324   
325   
326    def _linksToXML(self, root):
327        '''
328        Add required links to the input element
329        @param root: element to add links to - NB, should be the root element of the atom
330        '''
331        selfLink = ET.SubElement(root, "link")
332        selfLink.attrib["href"] = self.atomBrowseURL
333        selfLink.attrib["rel"] = "self"
334        molesLink = ET.SubElement(root, "link")
335        molesDoc = re.sub('ATOM','NDG-B1', self.atomBrowseURL)
336        molesLink.attrib["href"] = molesDoc
337        molesLink.attrib["rel"] = "related"
338       
339        for relatedLink in self.relatedLinks:
340            root.append(relatedLink.toXML())
341       
342        for logo in self.logos:
343            root.append(logo.toXML())
344   
345    def toXML(self):
346        '''
347        Convert the atom into XML representation and return this
348        @return: xml version of atom
349        '''
350        logging.info("Creating formatted XML version of Atom")
351        root = ET.Element("entry")
352        root.attrib["xmlns"] = "http://www.w3.org/2005/Atom"
353        root.attrib["xmlns:moles"] = "http://ndg.nerc.ac.uk/schema/moles2alpha"
354        root.attrib["xmlns:georss"] = "http://www.georss.org/georss"
355        root.attrib["xmlns:gml"] = "http://www.opengis.net/gml"
356        id = ET.SubElement(root, "id")
357        id.text = self.atomID
358        title = ET.SubElement(root, "title")
359        title.text = self.title
360        self._linksToXML(root)
361
362        # NB, the author tag is mandatory for atoms - so if an explicit
363        # author has not been set, just take the author to be the provider
364        if not self.author:
365            author = Person()
366            author.name = self.ME.providerID
367            author.uri = self.ME.providerID
368            self.author = author
369
370        root.append(self.author.toXML())
371           
372        # NB, only the first author in the list is the author; the rest are contrinbutors
373        for contributor in self.contributors:
374            root.append(contributor.toXML())
375
376        # add the moles entity section, if it is required
377        if self.ME:
378            # add any authors info
379            for author in self.atomAuthors:
380                if author not in self.ME.responsibleParties:
381                    self.ME.responsibleParties.append(author)
382            root.append(self.ME.toXML())
383
384        # add parameters data
385        for param in self.parameters:
386            root.append(param.toXML())
387
388        # add the type and subtype data
389        self.__addAtomTypeDataXML(root)
390                   
391        summary = ET.SubElement(root, "summary")
392        summary.text = self.Summary
393
394        # add link to content, if required - NB, can only have one content element in atom
395        # - and this is mandatory
396        content = ET.SubElement(root, "content")
397        if self.contentFile:
398            content.attrib["type"] = "application/xml"
399            content.attrib["src"] = self.contentFile
400        else:
401            content.text = "Metadata document"
402       
403        # if there's a published date already defined, assume we're doing an update now
404        # NB, update element is mandatory
405        currentDate = datetime.datetime.today().strftime("%Y-%m-%dT%H:%M:%SZ")
406        if not self.publishedDate:
407            self.publishedDate = currentDate
408
409        updated = ET.SubElement(root, "updated")
410        if not self.updateDate:
411            self.updateDate = currentDate
412        updated.text = self.updateDate
413
414        published = ET.SubElement(root, "published")
415        published.text = self.publishedDate
416
417        # add temporal range data, if available
418        temporalRange = ET.SubElement(root, "moles:temporalRange")
419        if self.t1:
420            temporalRange.text = self.t1
421            if self.t2:
422                temporalRange.text += "/" + self.t2
423
424        # add spatial range data, if available
425        self._addSpatialData(root)
426
427        tree = ET.ElementTree(root)
428        logging.info("XML version of Atom created")
429        return tree
430
431
432    def __getSummary(self):
433        logging.debug("Getting summary data")
434        summaryString = ""
435        for summary_line in self.summary:
436            summaryString += summary_line + "\n"
437
438        return summaryString
439
440    def __setSummary(self, summary):
441        logging.debug("Adding summary data")
442        self.summary = []
443        for summary_line in summary.split('\n'):
444            self.summary.append(summary_line)
445           
446    Summary = property(fset=__setSummary, fget=__getSummary, doc="Atom summary")
447
448           
449    def fromString(self, xmlString):
450        '''
451        Initialise Atom object using an xmlString
452        @param xmlString: representation of atom as an XML string
453        '''
454        logging.info("Ingesting data from XML string")
455       
456        # firstly, remove any namespaces used - to avoid problems with elementtree
457        logging.debug("Stripping moles namespace from string to allow easy handling with elementtree")
458        xmlString = xmlString.replace('moles:', '')
459        xmlString = xmlString.replace('georss:', '')
460        xmlString = xmlString.replace('gml:', '')
461        xmlString = xmlString.replace('xmlns="http://www.w3.org/2005/Atom"', '')
462
463        # now create elementtree with the XML string
464        logging.debug("Create elementtree instance with XML string")
465        tree = ET.fromstring(xmlString)
466       
467        title = tree.findtext('title')
468        if title:
469            logging.debug("Adding title data")
470            self.title = title
471
472        summary = tree.findtext('summary')
473        if summary:
474            self.Summary = summary
475
476        authorElement = tree.find('author')
477        logging.debug("Adding author data")
478        author = Person()
479        author.fromETElement(authorElement)
480        self.author = author
481
482        contributorElements = tree.findall('contributor')
483        for contributorElement in contributorElements:
484            logging.debug("Adding contributor data")
485            contributor = Person(tagName = 'contributor')
486            contributor.fromETElement(contributorElement)
487            self.contributors.append(contributor)
488
489        molesElement = tree.find('entity')
490        if molesElement:
491            self.ME.fromET(molesElement)
492            for author in self.ME.responsibleParties:
493                self.atomAuthors.append(author)
494               
495        self.atomID = tree.findtext('id')
496
497        self._parseCategoryData(tree.findall('category'))
498
499        self._parseLinksData(tree.findall('link'))
500           
501        contentTag = tree.find('content')
502        if contentTag != None:
503            logging.debug("Found content tag - checking for CSML/CDML file data")
504            file = contentTag.attrib.get('src')
505            if file:
506                # NB, the path will reveal more reliably whether we're dealing with CSML and CDML files
507                if file.upper().find('CSML') > -1:
508                    logging.debug("Adding CSML file data")
509                    self.csmlFile = file
510                elif file.upper().find('CDML') > -1:
511                    logging.debug("Adding CDML file data")
512                    self.cdmlFile = file
513                self.contentFile = file
514       
515        range = tree.findtext('temporalRange')
516        if range:
517            logging.debug("Adding temporal range data")
518            timeData = range.split('/')
519            self.t1 = timeData[0]
520            if len(timeData) > 1:
521                self.t2 = timeData[1]
522       
523        # NB, this parser won't mind if we're dealing with Envelope or EnvelopeWithTimePeriod
524        minBBox = tree.findall('.//lowerCorner')
525        if minBBox:
526            logging.debug("Adding min spatial range data")
527            minBBox = minBBox[0]
528            spatialData = minBBox.text.split()
529            self.minX = spatialData[0]
530            if len(spatialData) > 1:
531                self.minY = spatialData[1]
532       
533        maxBBox = tree.findall('.//upperCorner')
534        if maxBBox:
535            maxBBox = maxBBox[0]
536            logging.debug("Adding max spatial range data")
537            spatialData = maxBBox.text.split()
538            self.maxX = spatialData[0]
539            if len(spatialData) > 1:
540                self.maxY = spatialData[1]
541               
542        publishedDate = tree.findtext('published')
543        if publishedDate:
544            logging.debug("Adding published date")
545            self.publishedDate = publishedDate
546           
547        logging.info("Completed data ingest")
548   
549   
550    def _parseCategoryData(self, categories):
551        logging.debug("Adding category/parameters data")
552        for category in categories:
553            cat = Category()
554            cat.fromETElement(category)
555           
556            if cat.term == self.ATOM_TYPE:
557                logging.debug("Found atom type data")
558                self.atomTypeID = cat.label
559                self.atomTypeName = self.VTD.TERM_DATA[cat.label].title
560                continue
561            elif cat.term == self.ATOM_SUBTYPE:
562                logging.debug("Found atom subtype data")
563                self.subtype = cat.label
564                continue
565
566            self.parameters.append(cat)
567   
568
569    def setDatasetID(self, datasetID):
570        '''
571        Set the dataset ID for the atom - and generate an appropriate atom name using this
572        @param datasetID: ID to set for the atom
573        '''
574        self.datasetID = datasetID
575        self._generateAtomName(datasetID) 
576        self.atomID = self.createAtomID(datasetID)
577
578
579    def createAtomID(self, datasetID):
580        '''
581        Create a unique ID, conforming to atom standards, for atom
582        NB, see http://diveintomark.org/archives/2004/05/28/howto-atom-id
583        @param datasetID: ID of atom's dataset
584        @return: unique ID
585        '''
586        logging.info("Creating unique ID for atom")
587        if not self.atomBrowseURL:
588            self._generateAtomName(datasetID)
589        urlBit = self.atomBrowseURL.split('://')[1]
590        urlBit = urlBit.replace('#', '')
591        urlBits = urlBit.split('/')
592        dateBit = datetime.datetime.today().strftime("%Y-%m-%d")
593       
594        id = "tag:" + urlBits[0] + "," + dateBit + ":/" + "/".join(urlBits[1:])
595        logging.info("- unique ID created for atom")
596        logging.debug(" - '%s'" %id)
597        return id
598       
599       
600    def _generateAtomName(self, datasetID):
601        '''
602        Generate a consistent name for the atom - with full eXist doc path
603        @param datasetID: ID of atom's dataset
604        '''
605        self.atomName = datasetID + ".atom"
606        self.atomBrowseURL = VTD.BROWSE_ROOT_URL + \
607            self.ME.providerID + "__ATOM__" + datasetID
608
609
610    def _parseLinksData(self, links):
611        '''
612        Extract links and atom data from array of link elements in the XML representation of the atom
613        @param links: an array of <link> elements
614        '''
615        # firstly, get all data to start with, so we can properly process it afterwards
616        linkData = {}
617        logging.debug("Getting link data")
618        for linkTag in links:
619            link = Link()
620            link.fromETElement(linkTag)
621
622            if not linkData.has_key(link.rel):
623                linkData[link.rel] = []
624            if link.title == VTD.TERM_DATA[VTD.LOGO_TERM].title:
625                self.logos.append(link)
626            else:
627                linkData[link.rel].append(link)
628
629        # there should be one self referencing link - which will provide info on the atom itself
630        if not linkData.has_key('self'):
631            errorMessage = "Atom does not have self referencing link - " + \
632                "cannot ascertain datasetID without this - please fix"
633            logging.error(errorMessage)
634            raise ValueError(errorMessage)
635       
636        # this is the link describing the atom itself
637        self.atomBrowseURL = linkData['self'][0].href
638       
639        self.datasetID = self.atomBrowseURL.split("__ATOM__")[-1]
640        self.atomName = self.datasetID + ".atom"
641       
642        # now remove this value and the associated moles doc link
643        del linkData['self']
644        molesDoc = self.atomBrowseURL.replace('ATOM', 'NDG-B1')
645        if linkData.has_key('related'):
646            relatedLinks = []
647            for link in linkData['related']:
648                if link.href != molesDoc:
649                    relatedLinks.append(link)
650           
651            linkData['related'] = relatedLinks
652               
653        # now add the remaining links to the atom
654        for key in linkData:
655            for link in linkData[key]:
656                logging.debug("Adding link data")
657                self.relatedLinks.append(link)
658       
659
660    def _addSpatialData(self, element):
661        '''
662        Add spatial coverage element to an input element
663        @param element: element to add coverage data to
664        '''
665        logging.info("Adding spatial data to Atom")
666        bbox = ET.SubElement(element, "georss:where")
667        if not self.minX:
668            logging.info("No spatial data specified")
669            return
670       
671        envelope = ET.SubElement(bbox, "gml:Envelope")
672        lc = ET.SubElement(envelope, "gml:lowerCorner")
673        lc.text = self.minX + " " + self.minY
674        uc = ET.SubElement(envelope, "gml:upperCorner")
675        uc.text = self.maxX + " " + self.maxY
676
677       
678    def setAttribute(self, attributeName, attributeValue):
679        '''
680        Set the value of an atom attribute - and do some basic tidying up of the string content
681        - to escape any XML unfriendly characters
682        @param attributeName: name of the attribute whose value to set
683        @param attributeValue: value to set the attribute to 
684        '''
685        logging.debug("Setting attribute, %s, to %s" %(attributeName, attributeValue))
686        origValue = attributeValue
687       
688        # escape any special characters if a value has been specified
689        # NB, need to cope with both single values and arrays
690        if attributeValue:
691            if type(attributeValue) is list:
692                newVals = []
693                for val in attributeValue:
694                    newVals.append(objectify(escapeSpecialCharacters(val)), attributeName)
695                attributeValue = newVals
696                   
697            else:
698                attributeValue = objectify(escapeSpecialCharacters(attributeValue), attributeName)
699
700        # handle the special case of authors; only one author is allowed per atom
701        # - the others should be treated as contributors
702        if attributeName == "authors":
703            setattr(self, "author", attributeValue[0])
704            if len(attributeValue) > 1:
705                setattr(self, "contributors", attributeValue[1:])
706        else:
707            setattr(self, attributeName, attributeValue)
708
709
710    def objectify(self, objectVals, attributeName):
711        '''
712        Some inputs are specified as strings but need to be converted into
713        objects - do this here
714        @param objectVals: a '|' delimited string of values
715        @param attributeName: name of attribute the values belong to
716        '''
717        obj = None
718        if type(objectVals) != str:
719            return objectVals
720       
721        if attributeName == "relatedLinks" or attributeName == "logo":
722            obj = Link()
723        elif attributeName == "atomAuthors" or attributeName == "authors":
724            obj = Person()
725
726        if obj:
727            obj.fromString(objectVals)
728            return obj
729       
730        return objectVals
731
732
733    def toPrettyXML(self):
734        '''
735        Returns nicely formatted XML as string
736        '''
737        atomXML = self.toXML()
738
739        # create the string
740        logging.debug("Converting the elementtree object into a string")
741        prettyXML = et2text(atomXML.getroot())
742
743        # add XML version tag
744        prettyXML = "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" + prettyXML
745        logging.info("Created formatted version of XML object")
746        return prettyXML
Note: See TracBrowser for help on using the repository browser.