save report to file
[opensuse:susereport.git] / susereport / base.py
1 # susereport.py - a main module for suse bug reporting tool
2 #
3 # Copyright (C) 2009 Novell Inc.
4 # Author: Michal Vyskocil <mvyskocil@suse.cz>
5
6 # This program is free software; you can redistribute it and/or modify it
7 # under the terms of the GNU General Public License as published by the
8 # Free Software Foundation; either version 2 of the License, or (at your
9 # option) any later version.  See http://www.gnu.org/copyleft/gpl.html for
10 # the full text of the license.
11
12 __version__ = '0.1'
13
14 # standard Python modules imports
15 import os
16 import re
17 import glob
18 import time
19 import urllib2
20 import logging
21 import threading
22 import copy
23 import sys
24
25 from ConfigParser import SafeConfigParser
26 try:
27     from xml.etree import cElementTree as ET
28 except ImportError:
29     import cElementTree as ET
30
31 # Use rpm rather than satsovler's add_rpmdb, which is very expensive
32 import rpm
33
34 # SUSE specific modules
35 import osc.conf
36 import osc.core
37
38 # susereport modules
39 from util import ThreadedFunc, assignee
40
41 logging.basicConfig()
42 log = logging.getLogger("susereport")
43
44 # a name of a system repository
45 SYSTEM_REPO = '(System packages)'
46 PACKMAN = 'Packman'
47
48 class QueryRPM(object):
49     """Query rpm database"""
50
51     bs_re = re.compile(r"^(?P<bs>.*)://(?P<apiurl>.*?)/(?P<project>.*?)/(?P<repository>.*?)/(?P<md5>.*)-(?P<source>.*)$")
52     srcrep_re = re.compile(r"^srcrep:(?P<md5>.*)-(?P<source>.*)$")
53
54     @classmethod
55     def query(cls, name):
56         """Return a dict with {'name' : (apiurl, project, source, evr, group)}
57         
58         If package is not installed, raises NotInstalledException
59         If it have a srcrep in disturl, returns None apiurl
60         If cannot determine an origins, raises a ValueError
61         """
62         
63         ret = {}
64         
65         packages = cls._query(name)
66         if len(packages.keys()) == 0:
67             return ret
68
69         keys = packages.keys()
70         keys.sort()
71
72
73         for name in keys:
74             evr, disturl, distribution, group = packages[name]
75             if '.pm.' in evr:
76                 # apiurl can be None, as there's no public available access to packman
77                 ret[name] = (None, PACKMAN, name, evr, group)
78
79             if disturl:
80                 m = cls.srcrep_re.match(disturl)
81                 if m:
82                     ret[name] = (None, distribution.split('/')[0].strip(), m.group('source'), evr, group)
83
84                 m = cls.bs_re.match(disturl)
85                 if m:
86                     apiurl = m.group('apiurl')
87                     if apiurl.split('.')[0] != 'api':
88                         apiurl = 'https://api.' + ".".join(apiurl.split('.')[1:])
89                     ret[name] = (apiurl, m.group('project'), m.group('source'), evr, group)
90             else:
91                 ret[name] = (None, distribution.split('/')[0].strip(), name, evr, group)
92
93         return ret
94
95     @classmethod
96     def _evr(cls, hdr):
97         evr = ""
98         for key in ('epoch', 'version', 'release'):
99             if hdr[key] == None:
100                 continue
101             if evr != "":
102                 evr += "-"
103             evr += "%s" % hdr[key]
104         return evr
105
106     @classmethod
107     def _query(cls, name):
108         """Return a dictionary with a packages provides selected symbol. Each package
109         has a tuple (evr, disturl, distribution, source, group)
110         """
111         ts = rpm.ts()
112         hdr = None
113         ret = {}
114         try:
115             for symbol in ("name", "provides"):
116                 mi = ts.dbMatch(symbol, name)
117                 for hdr in mi:
118                     ret[hdr["name"]] = (
119                             cls._evr(hdr),
120                             hdr['disturl'],
121                             hdr['distribution'],
122                             hdr['group'])
123                 if len(ret.keys()) != 0:
124                     break
125             return ret
126         finally:
127             ts.clean()
128             ts.closeDB()
129
130 class AbstractClient(object):
131
132     def get_related_persons(self, rpm_info):
133
134         raise NotImplementedError("The AbstractClient cannot be instanciated, reimplement it in a subclass")
135
136     @classmethod
137     def match(cls, apiurl, project):
138
139         raise NotImplementedError("The AbstractClient.match() cannot be call, reimplement it in a subclass")
140
141 class BSClient(AbstractClient):
142
143     def __init__(self):
144
145         if not os.path.exists(os.path.expanduser("~/.oscrc")):
146             raise RuntimeError("Could not find ~/.oscrc")
147
148         osc.conf.get_config()
149
150     def project_exists(self, apiurl, project):
151         try:
152             data = osc.core.meta_exists(metatype='prj',
153                     path_args=(osc.core.quote_plus(project)),
154                     apiurl=apiurl,
155                     create_new=False)
156         except urllib2.HTTPError:
157             return None
158         return data
159     
160     def package_exists(self, apiurl, project, package):
161         try:
162             data = osc.core.meta_exists(metatype='pkg',
163                     path_args=(osc.core.quote_plus(project), osc.core.quote_plus(package)),
164                     apiurl=apiurl,
165                     create_new=False)
166         except urllib2.HTTPError:
167             return None
168         return data
169
170     def get_mail(self, apiurl, userid):
171         ret = osc.core.get_user_data(apiurl, userid, 'email')
172         return ret[0]
173     
174     def meta_get_persons(self, apiurl, tree):
175         return [(self.get_mail(apiurl, p.get('userid')), p.get('role')) for p in tree.getiterator('person')]
176
177     def _meta_get_devel(self, tree):
178         package = tree.get('name')
179         project = tree.get('project')
180         devel = tree.find('devel')
181
182         if devel != None:
183             if 'package' in devel.keys():
184                 package = devel.get('package')
185             project = devel.get('project')
186         return (project, package)
187
188     def get_related_persons(self, rpm_info):
189     
190         apiurl, project, package = \
191                 rpm_info.apiurl, rpm_info.project, rpm_info.source
192         
193         tmeta = self.project_exists(apiurl, project)
194         if not tmeta:
195             log.debug("Project %s not exists, exiting." % project)
196             return None
197
198         meta =  self.package_exists(apiurl, project, package)
199         if not meta:
200             log.debug("No metadata for %s/%s found, exiting" % (project, package))
201             return None
202
203         tree = ET.fromstring("".join(meta))
204         dprj, dpkg = self._meta_get_devel(tree)
205
206         if (dprj, dpkg) != (project, package):
207             log.debug("(%s, %s) != (%s, %s)" % (dprj, dpkg, project, package))
208             log.debug("Trying to load meta for devel project: %s/%s" % (dprj, dpkg))
209             tmeta = self.project_exists(apiurl, dprj)
210             if not tmeta:
211                 log.debug("No meta for devel project %s/%s found" % (dprj, dpkg))
212             else:
213                 meta = self.package_exists(apiurl, dprj, dpkg)
214                 if meta:
215                     tree = ET.fromstring("".join(meta))
216                     project, package = dprj, dpkg
217                 else:
218                     log.debug("No meta for devel project %s/%s found" % (dprj, dpkg))
219
220         if not tmeta or not meta:
221             log.debug("No metadata for %s/%s found, exiting" % (project, package))
222             return None
223
224         rpm_info.dprj = project
225         rpm_info.dpkg = package
226
227         persons = self.meta_get_persons(apiurl, tree)
228         if not persons:
229             tree = ET.fromstring("".join(tmeta))
230             persons = self.meta_get_persons(apiurl, tree)
231         rpm_info.assigned_to, rpm_info.cc = assignee(persons)
232         return rpm_info
233
234     @classmethod
235     def match(cls, apiurl, project):
236
237         return apiurl != None and project != None
238
239
240 class DefaultClient(BSClient):
241
242     def get_related_persons(self, rpm_info):
243
244         defaults = ['openSUSE:Factory', 'openSUSE:Factory:NonFree']
245         ret = None
246
247         if not rpm_info.apiurl:
248             rpm_info.apiurl = 'https://api.opensuse.org'
249
250         if not rpm_info.project in defaults:
251             defaults.insert(0, rpm_info.project)
252
253         for project in defaults:
254             rpm_info.project = project
255             ret = super(DefaultClient, self).get_related_persons(rpm_info)
256             if ret:
257                 break
258         return ret
259
260     @classmethod
261     def match(cls, apiurl, project):
262
263         return True
264
265 class PackmanClient(AbstractClient):
266
267     def get_related_persons(self, rpm_info):
268
269         raise NotImplementedError("Packman is not yet supported")
270     
271     @classmethod
272     def match(cls, apiurl, project):
273
274         return project == PACKMAN
275
276 class UnknownClient(AbstractClient):
277
278     client_re = re.compile(".*")
279     
280     def get_related_persons(self, rpm_info):
281         
282         apiurl, project, package = \
283                 rpm_info.apiurl, rpm_info.project, rpm_info.package
284         raise ValueError("Cannot found a client for %s, %s, %s" % \
285                 (package, project, apiurl))
286
287     @classmethod
288     def match(cls, apiurl, project):
289
290         return True
291
292
293 class PackageInfoDispatcher(object):
294
295     def __init__(self):
296
297         self._clients = [BSClient, PackmanClient, DefaultClient, UnknownClient]
298         self._used_client = None
299
300     def dispatch(self, apiurl, project):
301         ''' Return list of client for given apiurl, project'''
302
303         return [c for c in self._clients if c.match(apiurl, project)]
304
305     def get_related_persons(self, rpm_info):
306         ''' Call get_related_persons '''
307
308         assert(type(rpm_info) == BugReport)
309
310         apiurl, project, package = \
311                 rpm_info.apiurl, rpm_info.project, rpm_info.source
312         ret = None
313         
314         for client_class in self.dispatch(apiurl, project):
315             client = client_class()
316             log.debug("%s.get_related_persons(%s, %s, %s)" % (client_class, apiurl, project, package))
317             ret = client.get_related_persons(rpm_info)
318             if ret != None:
319                 self._used_client = client
320                 return ret
321
322         return ret
323
324     def __get_client(self):
325         return self._used_client
326     client = property(__get_client)
327
328 class BugReport(object):
329
330     ARGS = (
331             'package',
332             'project',
333             'apiurl',
334             'version',
335             'source',
336             'dprj',
337             'dpkg',
338             'summary',
339             'product',
340             'rep_platform',
341             'version',
342             'op_sys',
343             'component',
344             'assigned_to',
345             'cc',
346             'severity',
347             'description',
348             'attachements')
349
350     FILTER_ARGS = ('package', )
351
352     def __init__(self, **kwargs):
353
354         for arg in self.__class__.ARGS:
355             if arg in kwargs:
356                 self.__dict__[arg] = kwargs[arg]
357             else:
358                 self.__dict__[arg] = None
359
360     def __getitem__(self, name):
361
362         if not name in self.__class__.ARGS:
363             raise KeyError(name)
364
365         return self.__dict__[name]
366     
367     def __setitem__(self, name, value):
368
369         if not name in self.__class__.ARGS:
370             raise KeyError(name)
371
372         self.__dict__[name] = value
373         return self
374
375     def __getattr__(self, name):
376
377         return self.__getitem__(name)
378
379     def __setattr__(self, name, value):
380
381         return self.__setitem__(name, value)
382
383     def header(self, prefix=''):
384         return """%(P)sSummary: %(summary)s
385 %(P)sProduct: %(product)s
386 %(P)sPlatform: %(rep_platform)s
387 %(P)sComponent: %(component)s
388 %(P)sSeverity: %(severity)s
389 %(P)sAssigned_to: %(assigned_to)s
390 %(P)sCC: %(cc)s""" % {
391         'severity' : self.severity,
392         'assigned_to' : self.assigned_to,
393         'cc' : ", ".join(self.cc),
394         'summary' : self.summary,
395         'component' : self.component,
396         'product' : self.product,
397         'rep_platform' : self.rep_platform,
398         'P' : prefix,
399         }
400
401     def description_prefix(self, prefix=''):
402         return """%(P)sPackage: %(package)s
403 %(P)sSource: %(source)s
404 %(P)sVersion: %(version)s
405 %(P)sDevel project: %(dprj)s
406 %(P)sDevel package: %(dpkg)s""" % {
407         'P' : prefix,
408         'package' : self.package,
409         'source' : self.source,
410         'version' : self.version,
411         'dprj' : self.dprj,
412         'dpkg' : self.dpkg,
413         }
414
415     @classmethod
416     def rpminfo(cls, apiurl, project, source, version, group):
417         """Create a new instance from QueryRPM.query result"""
418         return cls(apiurl=apiurl,
419                    project=project,
420                    source=source,
421                    version=version,
422                    group=group)
423
424
425     def merge_rpminfo(self, rpm_info):
426         """Merge things from instance created by rpminfo to BugReport"""
427         
428         assert(type(rpm_info) == BugReport)
429         
430         self.assigned_to = rpm_info.assigned_to
431         self.cc = rpm_info.cc
432         self.source = rpm_info.source
433         self.version = rpm_info.version
434         self.dprj = rpm_info.dprj
435         self.dpkg = rpm_info.dpkg
436
437     def keys(self):
438
439         return copy.copy(self.__class__.ARGS)
440
441     def __iter__(self):
442
443         return iter(self.keys())
444
445     def kwargs(self):
446         ret = dict()
447
448         for key in (k for k in self.__class__.ARGS if not k in self.__class__.FILTER_ARGS):
449             ret[key] = self.__dict__[key]
450
451         return ret
452
453     def add_attachement(self, attachement):
454
455         if self._attachements == None:
456             self._attachements = []
457         self._attachements.append(attachement)
458
459     def del_attachement(self, i):
460         self._attachements.pop(i)
461
462     def __str__(self):
463         return self.header()
464
465     def save(self, file_name=None):
466         import tempfile
467         if not file_name:
468             (fd, file_name) = tempfile.mkstemp(
469                     prefix = 'susereport',
470                     suffix = '.bug',
471                     dir = '/tmp')
472         else:
473             fd = open(file_name, 'w')
474
475         os.write(fd, self.header())
476         os.write(fd, '\n\n')
477         os.write(fd, self.description)
478         os.write(fd, '\n\n')
479         os.close(fd)
480
481         return file_name
482
483         
484
485 class BugAttachement(object):
486
487     def __init__(self, file, description, comment="", isprivate=False, ispatch=False, contenttype=False):
488
489         self.__dict__["_file"] = file
490         self.__dict__["_description"] = description
491         self.__dict__["_comment"] = comment
492         self.__dict__["_isprivate"] = isprivate
493         self.__dict__["_ispatch"] = ispatch
494         self.__dict__["_contenttype"] = contenttype
495
496     def __getattr__(self, name):
497         return getattr(self, "_"+name)
498
499     def __setattr__(self, name, value):
500         self.__dict__["_"+name] = value
501
502     def __getitem__(self, name):
503         return self.__getattr__(name)
504
505     def __setitem__(self, name, value):
506         return self.__setitem__(name, value)
507
508     def args(self):
509         kwargs = {}
510         for k in ("comment", "isprivate", "ispatch", "contenttype"):
511             kwargs[k] = self.__dict__["_"+k]
512         return self._file, self._description, kwargs
513
514 class FSM(object):
515     """Finite State Machine implementation. It could be used for implementing
516     of an interactive workflow, without a mess with complicated conditions.
517     This class is intended to be subclassed and cannot be used directly.
518     
519     States are implemented in do_STATE methods and every method have to
520     return a name of previous STATE, or a tuple (STATE, kwargs), which will be
521     used as a keyword arguments for a next state. The default prefix `do_' is
522     controlled by class variable PREFIX and could be changed. If state method
523     returns None, it will cause a StopIteration error, which stops a
524     transitions. If returns a non existing state, it the do_ERROR with an error
525     wil be called.
526
527     Instances of FSM subclass looks like iterable objects, because it provides
528     a functions __iter__ and next(), so the easiest way how to run over all
529     states is for state in fsm: pass and this is exactly what method main()
530     does. In fact main method takes care about error states too.
531     """
532
533     PREFIX="do_"
534     END_STATES=("ERROR", "EXIT")
535
536     def __init__(self):
537         """Initialize instance variables _kwargs and _state"""
538
539         self._kwargs = {}
540         self._state = "START"
541
542     def dispatch(self, name):
543         """Return a callable for given state name"""
544         log.debug("STATE: %s" % name)
545         return getattr(self, "%s%s" % (self.__class__.PREFIX, name))
546
547     def filter_kwargs(self, callable, kwargs):
548         """Return a dictionary only with arguments, which should be passed to
549         callable"""
550         ret = {}
551         varnames = callable.im_func.func_code.co_varnames
552         for k in [k for k in kwargs if k != 'self' and k in varnames]:
553             ret[k] = kwargs[k]
554         return ret
555
556     def call(self, callable, **kwargs):
557         """Call callable with given keyword arguments and return the (STATE,
558         kwargs) tuple. If callable return None, it raises a StopIteration"""
559         i = callable(**kwargs)
560         if i == None:
561             raise StopIteration
562         if type(i) == str:
563             return (i, {})
564         else:
565             return (i[0], i[1])
566
567     def next(self):
568         """Do a transition - get a callable from dispatch, filter keyword args,
569         so only defined will be passed to a method, call it and check a return
570         state"""
571         callable = self.dispatch(self._state)
572         kwargs = self.filter_kwargs(callable, self._kwargs)
573         self._state, self._kwargs = self.call(callable, **kwargs)
574         return self._state
575
576     def main(self):
577         """A main loop. Iterates for all states and catches errors during
578         transtion. On every error, it will setup the state to ERROR and add an
579         error message.
580
581         1.) If some function returns a non-existing state
582         2.) If loop ends and state is not in END_STATES
583         """
584         try:
585             for state in self:
586                 pass
587         except AttributeError, ae:
588             self._state = "ERROR"
589             self._kwargs = {'message' : "Transition error: %s" % (ae, )}
590             self.next()
591         if self._state not in self.__class__.END_STATES:
592             self._kwargs = {'message' : "Transition error: %s ends in a state `%s'" % (self, self._state)}
593             self._state = "ERROR"
594             self.next()
595
596         return self
597
598     def __iter__(self):
599         return self
600
601     def do_START(self):
602         """A default start method. This one raises a NotImplementedError"""
603         raise NotImplementedError("This has to be reimplemented in a subclass!!")
604     
605     def do_ERROR(self, message="Error", exit=1):
606         """A default error handler. It prints a message to stderr and exits with exit code."""
607         print >>sys.stderr, message
608         sys.exit(exit)
609         return None
610     
611     def do_EXIT(self):
612         """A default exit handler"""
613         return None