[gnomedoc/sweep] Hackishly fix a segfault in XML parsing
[blip:blip-gnome.git] / gnomedoc / sweep.py
1 # Copyright (c) 2006-2009  Shaun McCance  <shaunm@gnome.org>
2 #
3 # This file is part of Pulse, a program for displaying various statistics
4 # of questionable relevance about software and the people who make it.
5 #
6 # Pulse is free software; you can redistribute it and/or modify it under the
7 # terms of the GNU General Public License as published by the Free Software
8 # Foundation; either version 2 of the License, or (at your option) any later
9 # version.
10 #
11 # Pulse is distributed in the hope that it will be useful, but WITHOUT ANY
12 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
13 # FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
14 # details.
15 #
16 # You should have received a copy of the GNU General Public License along
17 # with Pulse; if not, write to the Free Software Foundation, 59 Temple Place,
18 # Suite 330, Boston, MA  0211-1307  USA.
19 #
20
21 import commands
22 import ConfigParser
23 import datetime
24 import re
25 import os
26 import urllib
27
28 try:
29     from hashlib import md5
30 except:
31     from md5 import new as md5
32
33 import libxml2
34 # Raptor installs global error hooks in libxml2, possibly when it gets
35 # imported by another plugin. And for some reason, if we don't import
36 # RDF in this module, we get a segfault (!) whenever libxml2 has an
37 # otherwise-recoverable error.
38 try:
39     import RDF
40 except:
41     pass
42
43 import blinq.config
44
45 import blip.data
46 import blip.db
47 import blip.utils
48
49 import blip.parsers
50 import blip.parsers.automake
51 import blip.parsers.po
52
53 import blip.plugins.modules.sweep
54
55 _STATUSES = {'none':       '00',
56              'stub':       '10',
57              'incomplete': '20',
58              'draft':      '30',
59              'outdated':   '40',
60              'review':     '50',
61              'candidate':  '60',
62              'final':      '70'}
63 def get_status (status):
64     if _STATUSES.has_key (status):
65         return _STATUSES[status] + status
66     else:
67         return '00none'
68
69
70 class GnomeDocScanner (blip.plugins.modules.sweep.ModuleFileScanner):
71     def __init__ (self, scanner):
72         self.documents = []
73         self.translations = []
74         blip.plugins.modules.sweep.ModuleFileScanner.__init__ (self, scanner)
75     
76     def process_file (self, dirname, basename):
77         if basename != 'Makefile.am':
78             return
79
80         filename = os.path.join (dirname, basename)
81         rel_ch = blip.utils.relative_path (os.path.join (dirname, filename),
82                                            self.scanner.repository.directory)
83         branch = self.scanner.branch
84
85         bserver, bmodule, bbranch = branch.ident.split('/')[2:]
86
87         with blip.db.Timestamp.stamped (filename, self.scanner.repository) as stamp:
88             try:
89                 stamp.check (self.scanner.request.get_tool_option ('timestamps'))
90             except:
91                 data = {'parent' : branch}
92                 scm_dir, scm_file = os.path.split (rel_ch)
93                 data['scm_dir'] = os.path.join (scm_dir, 'C')
94                 doc = blip.db.Branch.select_one (type=u'Document', **data)
95                 if doc is not None:
96                     self.scanner.add_child (doc)
97                     self.documents.append (doc)
98                     for translation in doc.select_children (u'Translation'):
99                         self.translations.append (translation)
100                 raise
101
102             makefile = blip.parsers.get_parsed_file (blip.parsers.automake.Automake,
103                                                      self.scanner.branch, filename)
104
105             is_gdu_doc = False
106             for line in makefile.get_lines ():
107                 if line.startswith ('include $(top_srcdir)/'):
108                     if line.endswith ('gnome-doc-utils.make'):
109                         is_gdu_doc = True
110                         break
111             if not is_gdu_doc:
112                 return
113
114             stamp.log ()
115
116             if 'DOC_MODULE' in makefile:
117                 doc_id = makefile['DOC_MODULE']
118                 doc_type = u'docbook'
119             elif 'DOC_ID' in makefile:
120                 doc_id = makefile['DOC_ID']
121                 doc_type = u'mallard'
122             else:
123                 return
124             if doc_id == '@PACKAGE_NAME@':
125                 doc_id = branch.data.get ('pkgname')
126             if doc_id is None:
127                 return
128             ident = u'/'.join(['/doc', bserver, bmodule, doc_id, bbranch])
129             document = blip.db.Branch.get_or_create (ident, u'Document')
130             document.parent = branch
131
132             for key in ('scm_type', 'scm_server', 'scm_module', 'scm_branch', 'scm_path'):
133                 setattr (document, key, getattr (branch, key))
134             document.subtype = u'gdu-' + doc_type
135             document.scm_dir = blip.utils.relative_path (os.path.join (dirname, 'C'),
136                                                          self.scanner.repository.directory)
137             if doc_type == 'docbook':
138                 document.scm_file = blip.utils.utf8dec (doc_id) + u'.xml'
139             else:
140                 # FIXME: plugin sets won't have this
141                 document.scm_file = u'index.page'
142
143             fnames = (makefile.get('DOC_PAGES', '').split() +
144                       makefile.get('DOC_INCLUDES', '').split())
145             if doc_type == u'docbook':
146                 fnames.append (doc_id + '.xml')
147             document.data['xml2po_files'] = [os.path.join ('C', fname) for fname in fnames]
148             fnames += makefile.get('DOC_ENTITIES', '').split()
149             xmlfiles = sorted (fnames)
150             document.data['scm_files'] = xmlfiles
151
152             files = [os.path.join (document.scm_dir, f) for f in xmlfiles]
153             if len(files) == 0:
154                 document.mod_score = 0
155             else:
156                 #FIXME
157                 pass
158
159             translations = []
160             if makefile.has_key ('DOC_LINGUAS'):
161                 for lang in makefile['DOC_LINGUAS'].split():
162                     lident = u'/l10n/' + lang + document.ident
163                     translation = blip.db.Branch.get_or_create (lident, u'Translation')
164                     translations.append (translation)
165                     for key in ('scm_type', 'scm_server', 'scm_module', 'scm_branch', 'scm_path'):
166                         setattr (translation, key, getattr (document, key))
167                     translation.subtype = u'xml2po'
168                     translation.scm_dir = blip.utils.relative_path (os.path.join (dirname, lang),
169                                                                     self.scanner.repository.directory)
170                     translation.scm_file = lang + '.po'
171                     translation.parent = document
172                 document.set_children (u'Translation', translations)
173
174             self.documents.append (document)
175             for translation in translations:
176                 self.translations.append (translation)
177
178     def post_process (self):
179         for document in self.documents:
180             if document.subtype == u'gdu-docbook':
181                 GnomeDocScanner.process_docbook (document, self.scanner)
182             elif document.subtype == u'gdu-mallard':
183                 GnomeDocScanner.process_mallard (document, self.scanner)
184             rev = blip.db.Revision.get_last_revision (branch=document.parent,
185                                                       files=[os.path.join (document.scm_dir, fname)
186                                                              for fname in document.data.get ('scm_files', [])])
187             if rev is not None:
188                 document.mod_datetime = rev.datetime
189                 document.mod_person = rev.person
190             document.updated = datetime.datetime.utcnow ()
191         for translation in self.translations:
192             if translation.subtype == u'xml2po':
193                 GnomeDocScanner.process_xml2po (translation, self.scanner)
194
195     @classmethod
196     def process_docbook (cls, document, scanner):
197         filename = os.path.join (scanner.repository.directory,
198                                  document.scm_dir, document.scm_file)
199         rel_scm = blip.utils.relative_path (filename, blinq.config.scm_dir)
200         blip.utils.log ('Processing %s' % rel_scm)
201
202         title = None
203         abstract = None
204         credits = []
205         try:
206             ctxt = libxml2.newParserCtxt ()
207             xmldoc = ctxt.ctxtReadFile (filename, None, 0)
208             xmldoc.xincludeProcess ()
209             root = xmldoc.getRootElement ()
210         except Exception, e:
211             blip.db.Error.set_error (document.ident, unicode (e))
212             return
213         blip.db.Error.clear_error (document.ident)
214         seen = 0
215         document.data['status'] = '00none'
216         for node in xmliter (root):
217             if node.type != 'element':
218                 continue
219             if node.name[-4:] == 'info':
220                 seen += 1
221                 infonodes = list (xmliter (node))
222                 i = 0
223                 while i < len(infonodes):
224                     infonode = infonodes[i]
225                     if infonode.type != 'element':
226                         i += 1
227                         continue
228                     if infonode.name == 'title':
229                         if title is None:
230                             title = infonode.getContent ()
231                     elif infonode.name == 'abstract' and infonode.prop('role') == 'description':
232                         abstract = infonode.getContent ()
233                     elif infonode.name == 'releaseinfo':
234                         if infonode.prop ('revision') == document.parent.data.get ('pkgseries'):
235                             document.data['docstatus'] = get_status (infonode.prop ('role'))
236                     elif infonode.name == 'authorgroup':
237                         infonodes.extend (list (xmliter (infonode)))
238                     elif infonode.name in ('author', 'editor', 'othercredit'):
239                         cr_name, cr_email = personname (infonode)
240                         maint = (infonode.prop ('role') == 'maintainer')
241                         credits.append ((cr_name, cr_email, infonode.name, maint))
242                     elif infonode.name == 'collab':
243                         cr_name = None
244                         for ch in xmliter (infonode):
245                             if ch.type == 'element' and ch.name == 'collabname':
246                                 cr_name = normalize (ch.getContent ())
247                         if cr_name is not None:
248                             maint = (infonode.prop ('role') == 'maintainer')
249                             credits.append ((cr_name, None, 'collab', maint))
250                     elif infonode.name in ('corpauthor', 'corpcredit'):
251                         maint = (infonode.prop ('role') == 'maintainer')
252                         credits.append ((normalize (infonode.getContent ()),
253                                               None, infonode.name, maint))
254                     elif infonode.name == 'publisher':
255                         cr_name = None
256                         for ch in xmliter (infonode):
257                             if ch.type == 'element' and ch.name == 'publishername':
258                                 cr_name = normalize (ch.getContent ())
259                         if cr_name is not None:
260                             maint = (infonode.prop ('role') == 'maintainer')
261                             credits.append ((cr_name, None, 'publisher', maint))
262                     i += 1
263             elif node.name == 'title':
264                 seen += 1
265                 title = node.getContent ()
266             if seen > 1:
267                 break
268
269         if title is not None:
270             document.name = unicode (normalize (title))
271         if abstract is not None:
272             document.desc = unicode (normalize (abstract))
273
274         rels = []
275         for cr_name, cr_email, cr_type, cr_maint in credits:
276             ent = None
277             if cr_email is not None:
278                 ent = blip.db.Entity.get_or_create_email (cr_email)
279             if ent is None:
280                 ident = u'/ghost/' + urllib.quote (cr_name)
281                 ent = blip.db.Entity.get_or_create (ident, u'Ghost')
282                 if ent.ident == ident:
283                     ent.name = blip.utils.utf8dec (cr_name)
284             if ent is not None:
285                 ent.extend (name=blip.utils.utf8dec (cr_name))
286                 ent.extend (email=blip.utils.utf8dec (cr_email))
287                 rel = blip.db.DocumentEntity.set_related (document, ent)
288                 rel.author = (cr_type in ('author', 'corpauthor'))
289                 rel.editor = (cr_type == 'editor')
290                 rel.publisher = (cr_type == 'publisher')
291                 rel.maintainer = (cr_maint == True)
292                 rels.append (rel)
293         document.set_relations (blip.db.DocumentEntity, rels)
294
295     @classmethod
296     def process_mallard (cls, document, scanner):
297         MALLARD_NS = 'http://projectmallard.org/1.0/'
298
299         for basename in document.data.get ('scm_files', []):
300             filename = os.path.join (scanner.repository.directory,
301                                      document.scm_dir, basename)
302             with blip.db.Timestamp.stamped (filename, scanner.repository) as stamp:
303                 stamp.check (scanner.request.get_tool_option ('timestamps'))
304                 stamp.log ()
305
306                 title = None
307                 desc = None
308                 credits = []
309                 try:
310                     ctxt = libxml2.newParserCtxt ()
311                     xmldoc = ctxt.ctxtReadFile (filename, None, 0)
312                     xmldoc.xincludeProcess ()
313                     root = xmldoc.getRootElement ()
314                 except Exception, e:
315                     blip.db.Error.set_error (document.ident, unicode (e),
316                                              ctxt=document.scm_file)
317                     return
318                 blip.db.Error.clear_error (document.ident,
319                                            ctxt=document.scm_file)
320
321                 if not _is_ns_name (root, MALLARD_NS, 'page'):
322                     continue
323                 pageid = root.prop ('id')
324                 pkgseries = document.parent.data.get ('pkgseries', None)
325                 revision = {}
326                 for node in xmliter (root):
327                     if node.type != 'element':
328                         continue
329                     if _is_ns_name (node, MALLARD_NS, 'info'):
330                         for infonode in xmliter (node):
331                             if infonode.type != 'element':
332                                 continue
333                             if _is_ns_name (infonode, MALLARD_NS, 'title'):
334                                 if infonode.prop ('type') == 'text':
335                                     title = normalize (infonode.getContent ())
336                             elif _is_ns_name (infonode, MALLARD_NS, 'desc'):
337                                 desc = normalize (infonode.getContent ())
338                             elif _is_ns_name (infonode, MALLARD_NS, 'revision'):
339                                 if pkgseries is not None:
340                                     for prop in ('version', 'docversion', 'pkgversion'):
341                                         if infonode.prop (prop) == pkgseries:
342                                             revdate = infonode.prop ('date')
343                                             revstatus = infonode.prop ('status')
344                                             if (not revision.has_key (prop)) or (revdate > revision[prop][0]):
345                                                 revision[prop] = (revdate, revstatus)
346                             elif _is_ns_name (infonode, MALLARD_NS, 'credit'):
347                                 types = infonode.prop ('type')
348                                 if isinstance (types, basestring):
349                                     types = types.split ()
350                                 else:
351                                     types = []
352                                 crname = cremail = None
353                                 for crnode in xmliter (infonode):
354                                     if _is_ns_name (crnode, MALLARD_NS, 'name'):
355                                         crname = normalize (crnode.getContent ())
356                                     elif _is_ns_name (crnode, MALLARD_NS, 'email'):
357                                         cremail = normalize (crnode.getContent ())
358                                 if crname is not None or cremail is not None:
359                                     credits.append ((crname, cremail, types))
360                     elif _is_ns_name (node, MALLARD_NS, 'title'):
361                         if title is None:
362                             title = normalize (node.getContent ())
363
364                 docstatus = None
365                 docdate = None
366                 if pageid is not None:
367                     ident = u'/page/' + pageid + document.ident
368                     page = blip.db.Branch.get_or_create (ident, u'DocumentPage')
369                     page.parent = document
370                     for key in ('scm_type', 'scm_server', 'scm_module', 'scm_branch', 'scm_path', 'scm_dir'):
371                         setattr (page, key, getattr (document, key))
372                     page.scm_file = basename
373                     if title is not None:
374                         page.name = blip.utils.utf8dec (title)
375                     if desc is not None:
376                         page.desc = blip.utils.utf8dec (desc)
377                     for prop in ('pkgversion', 'docversion', 'version'):
378                         if revision.has_key (prop):
379                             (docdate, docstatus) = revision[prop]
380                             docstatus = get_status (docstatus)
381                             page.data['docstatus'] = docstatus
382                             page.data['docdate'] = docdate
383                     rels = []
384                     for cr_name, cr_email, cr_types in credits:
385                         ent = None
386                         if cr_email is not None:
387                             ent = blip.db.Entity.get_or_create_email (cr_email)
388                         if ent is None:
389                             ident = u'/ghost/' + urllib.quote (cr_name)
390                             ent = blip.db.Entity.get_or_create (ident, u'Ghost')
391                             if ent.ident == ident:
392                                 ent.name = blip.utils.utf8dec (cr_name)
393                         if ent is not None:
394                             ent.extend (name=blip.utils.utf8dec (cr_name))
395                             ent.extend (email=blip.utils.utf8dec (cr_email))
396                             rel = blip.db.DocumentEntity.set_related (page, ent)
397                             for badge in ('maintainer', 'author', 'editor', 'pulisher'):
398                                 setattr (rel, badge, badge in cr_types)
399                             rels.append (rel)
400                     page.set_relations (blip.db.DocumentEntity, rels)
401
402                 if pageid == 'index':
403                     if title is not None:
404                         document.name = blip.utils.utf8dec (title)
405                     if desc is not None:
406                         document.desc = blip.utils.utf8dec (desc)
407                     document.data['docstatus'] = docstatus
408                     document.data['docdate'] = docdate
409                     rels = []
410                     for cr_name, cr_email, cr_types in credits:
411                         ent = None
412                         if cr_email is not None:
413                             ent = blip.db.Entity.get_or_create_email (cr_email)
414                         if ent is None:
415                             ident = u'/ghost/' + urllib.quote (cr_name)
416                             ent = blip.db.Entity.get_or_create (ident, u'Ghost')
417                             if ent.ident == ident:
418                                 ent.name = blip.utils.utf8dec (cr_name)
419                         if ent is not None:
420                             ent.extend (name=blip.utils.utf8dec (cr_name))
421                             ent.extend (email=blip.utils.utf8dec (cr_email))
422                             rel = blip.db.DocumentEntity.set_related (document, ent)
423                             for badge in ('maintainer', 'author', 'editor', 'pulisher'):
424                                 setattr (rel, badge, badge in cr_types)
425                             rels.append (rel)
426                     document.set_relations (blip.db.DocumentEntity, rels)
427
428     @classmethod
429     def process_xml2po (cls, translation, scanner):
430         filename = os.path.join (scanner.repository.directory,
431                                  translation.scm_dir, translation.scm_file)
432         potfile = cls.get_potfile (translation, scanner)
433         if potfile is None:
434             return None
435
436         filepath = os.path.join (scanner.repository.directory,
437                                  translation.scm_dir,
438                                  translation.scm_file)
439         if not os.path.exists (filepath):
440             # FIXME: set_error
441             blip.utils.warn ('Could not location file %s for %s' %
442                              (translation.scm_file, translation.parent.ident))
443             return
444         with blip.db.Timestamp.stamped (filepath, scanner.repository) as stamp:
445             try:
446                 stamp.check (scanner.request.get_tool_option ('timestamps'))
447             except:
448                 # If the checksums differ, ignore the timestamp
449                 pomd5 = translation.data.get ('md5', None)
450                 potmd5 = potfile.data.get ('md5', None)
451                 if pomd5 is not None and pomd5 == potmd5:
452                     raise
453
454             stamp.log ()
455
456             makedir = os.path.join (scanner.repository.directory,
457                                     os.path.dirname (translation.scm_dir))
458             cmd = 'msgmerge "%s" "%s" 2>&1' % (
459                 os.path.join (os.path.basename (translation.scm_dir), translation.scm_file),
460                 potfile.get_file_path ())
461             owd = os.getcwd ()
462             try:
463                 os.chdir (makedir)
464                 pofile = blip.parsers.po.Po (scanner.branch, os.popen (cmd))
465                 stats = pofile.get_stats ()
466                 total = stats[0] + stats[1] + stats[2]
467                 blip.db.Statistic.set_statistic (translation,
468                                                  blip.utils.daynum (),
469                                                  u'Messages',
470                                                  stats[0], stats[1], total)
471                 stats = pofile.get_image_stats ()
472                 total = stats[0] + stats[1] + stats[2]
473                 blip.db.Statistic.set_statistic (translation,
474                                                  blip.utils.daynum (),
475                                                  u'ImageMessages',
476                                                  stats[0], stats[1], stats[2])
477             finally:
478                 os.chdir (owd)
479
480     potfiles = {}
481     @classmethod
482     def get_potfile (cls, translation, scanner):
483         domain = translation.parent
484         indir = os.path.dirname (os.path.join (scanner.repository.directory,
485                                                domain.scm_dir))
486         if cls.potfiles.has_key (indir):
487             return cls.potfiles[indir]
488
489         doc_id = translation.ident.split('/')[-2]
490         doc_files = translation.parent.data.get ('xml2po_files', [])
491         potfile = doc_id + u'.pot'
492         of = blip.db.OutputFile.select_one (type=u'l10n', ident=domain.ident, filename=potfile)
493         if of is None:
494             of = blip.db.OutputFile (type=u'l10n', ident=domain.ident, filename=potfile,
495                                      datetime=datetime.datetime.now())
496         potfile_abs = of.get_file_path ()
497         potfile_rel = blip.utils.relative_path (potfile_abs,
498                                                 os.path.join (blinq.config.web_files_dir, 'l10n'))
499
500         if not scanner.request.get_tool_option ('timestamps'):
501             dt = of.data.get ('mod_datetime')
502             if dt is not None and dt == domain.parent.mod_datetime:
503                 cls.potfiles[indir] = of
504                 return of
505
506         potdir = os.path.dirname (potfile_abs)
507         if not os.path.exists (potdir):
508             os.makedirs (potdir)
509
510         cmd = 'xml2po -e -o "' + potfile_abs + '" "' + '" "'.join(doc_files) + '"'
511         owd = os.getcwd ()
512         try:
513             os.chdir (indir)
514             blip.utils.log ('Creating POT file %s' % potfile_rel)
515             (status, output) = commands.getstatusoutput (cmd)
516         finally:
517             os.chdir (owd)
518         if status == 0:
519             potmd5 = md5 ()
520             # We don't start feeding potmd5 until we've hit a blank line.
521             # This keeps inconsequential differences in the header from
522             # affecting the MD5.
523             blanklink = False
524             popo = blip.parsers.po.Po (scanner.branch)
525             for line in open (potfile_abs):
526                 if blanklink:
527                     potmd5.update (line)
528                 elif line.strip() == '':
529                     blankline = True
530                 popo.feed (line)
531             popo.finish ()
532             num = popo.get_num_messages ()
533             of.datetime = datetime.datetime.utcnow ()
534             of.data['mod_datetime'] = domain.parent.mod_datetime
535             of.statistic = num
536             of.data['md5'] = potmd5.hexdigest ()
537             cls.potfiles[indir] = of
538             return of
539         else:
540             # FIXME: set_error
541             blip.utils.warn ('Failed to create POT file %s' % potfile_rel)
542             cls.potfiles[indir] = None
543             return None
544
545 def normalize (string):
546     if string is None:
547         return None
548     return re.sub ('\s+', ' ', string).strip()
549
550 def personname (node):
551     """
552     Get the name of a person from a DocBook node.
553     """
554     name = [None, None, None, None, None]
555     namestr = None
556     email = None
557     for child in xmliter (node):
558         if child.type != 'element':
559             continue
560         if child.name == 'personname':
561             namestr = personname(child)[0]
562         elif child.name == 'email':
563             email = child.getContent()
564         elif namestr == None:
565             try:
566                 i = ['honorific', 'firstname', 'othername', 'surname', 'lineage'].index(child.name)
567                 if name[i] == None:
568                     name[i] = child.getContent()
569             except ValueError:
570                 pass
571     if namestr == None:
572         while None in name:
573             name.remove(None)
574         namestr = ' '.join (name)
575     return (normalize (namestr), normalize (email))
576
577 def _get_ns (node):
578     ns = node.ns()
579     if ns is not None:
580         return ns.getContent ()
581     return None
582
583 def _is_ns_name (node, ns, name):
584     return (_get_ns (node) == ns and node.name == name)
585
586 def xmliter (node):
587     child = node.children
588     while child:
589         yield child
590         child = child.next