Update version for rc4.
[opensuse:python-bugzilla.git] / bugzilla / base.py
1 # base.py - the base classes etc. for a Python interface to bugzilla
2 #
3 # Copyright (C) 2007,2008 Red Hat Inc.
4 # Author: Will Woods <wwoods@redhat.com>
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 import xmlrpclib, urllib2
13 try:
14        import cookielib
15 except ImportError:
16        import ClientCookie as cookielib
17 import os.path, base64
18 import logging
19 log = logging.getLogger('bugzilla')
20
21 version = '0.4-rc4'
22 user_agent = 'Python-urllib2/%s bugzilla.py/%s' % \
23         (urllib2.__version__,version)
24
25 def replace_getbug_errors_with_None(rawlist):
26     '''r is a raw xmlrpc response. 
27     If it represents an error, None is returned.
28     Otherwise, r is returned.
29     This is mostly used for XMLRPC Multicall handling.'''
30     # Yes, this is a naive implementation
31     # XXX: return a generator?
32     result = []
33     for r in rawlist:
34         if isinstance(r,dict) and 'bug_id' in r:
35             result.append(r)
36         else:
37             result.append(None)
38     return result
39
40 class BugzillaBase(object):
41     '''An object which represents the data and methods exported by a Bugzilla
42     instance. Uses xmlrpclib to do its thing. You'll want to create one thusly:
43     bz=Bugzilla(url='https://bugzilla.redhat.com/xmlrpc.cgi',user=u,password=p)
44
45     You can get authentication cookies by calling the login() method. These
46     cookies will be stored in a MozillaCookieJar-style file specified by the
47     'cookiefile' attribute (which defaults to ~/.bugzillacookies). Once you
48     get cookies this way, you will be considered logged in until the cookie
49     expires.
50     
51     You may also specify 'user' and 'password' in a bugzillarc file, either
52     /etc/bugzillarc or ~/.bugzillarc. The latter will override the former.
53     The format works like this:
54       [bugzilla.yoursite.com]
55       user = username
56       password = password
57     You can also use the [DEFAULT] section to set defaults that apply to 
58     any site without a specific section of its own.
59     Be sure to set appropriate permissions on bugzillarc if you choose to
60     store your password in it!
61
62     This is an abstract class; it must be implemented by a concrete subclass
63     which actually connects the methods provided here to the appropriate
64     methods on the bugzilla instance.
65     '''
66     def __init__(self,**kwargs):
67         # Settings the user might want to tweak
68         self.user       = ''
69         self.password   = ''
70         self.url        = ''
71         self.cookiefile = os.path.expanduser('~/.bugzillacookies')
72         self.user_agent = user_agent
73         self.logged_in  = False
74         # Bugzilla object state info that users shouldn't mess with
75         self.init_private_data()
76         if 'url' in kwargs:
77             self.connect(kwargs['url'])
78         if 'user' in kwargs:
79             self.user = kwargs['user']
80         if 'password' in kwargs:
81             self.password = kwargs['password']
82
83     def init_private_data(self):
84         '''initialize private variables used by this bugzilla instance.'''
85         self._cookiejar  = None
86         self._proxy      = None
87         self._transport  = None
88         self._opener     = None
89         self._querydata  = None
90         self._querydefaults = None
91         self._products   = None 
92         self._bugfields  = None
93         self._components = dict()
94         self._components_details = dict()
95
96     #---- Methods for establishing bugzilla connection and logging in
97
98     def initcookiefile(self,cookiefile=None):
99         '''Read the given (Mozilla-style) cookie file and fill in the
100         cookiejar, allowing us to use saved credentials to access Bugzilla.
101         If no file is given, self.cookiefile will be used.'''
102         if cookiefile: 
103             self.cookiefile = cookiefile
104         cj = cookielib.MozillaCookieJar(self.cookiefile)
105         if os.path.exists(self.cookiefile):
106             cj.load()
107         else:
108             # Create an empty cookiefile that's only readable by this user
109             old_umask = os.umask(0077)
110             cj.save(self.cookiefile)
111             os.umask(old_umask)
112         self._cookiejar = cj
113         self._cookiejar.filename = self.cookiefile
114
115     configpath = ['/etc/bugzillarc','~/.bugzillarc']
116     def readconfig(self,configpath=None):
117         '''Read bugzillarc file(s) into memory.'''
118         import ConfigParser
119         if not configpath:
120             configpath = self.configpath
121         configpath = [os.path.expanduser(p) for p in configpath]
122         c = ConfigParser.SafeConfigParser()
123         r = c.read(configpath)
124         if not r:
125             return
126         # See if we have a config section that matches this url.
127         section = ""
128         # Substring match - prefer the longest match found
129         log.debug("Searching for config section matching %s" % self.url)
130         for s in sorted(c.sections(), lambda a,b: cmp(len(a),len(b)) or cmp(a,b)):
131             if s in self.url:
132                 log.debug("Found matching section: %s" % s)
133                 section = s
134         if not section:
135             return
136         for k,v in c.items(section):
137             if k in ('user','password'):
138                 log.debug("Setting '%s' from configfile" % k)
139                 setattr(self,k,v)
140
141     def connect(self,url):
142         '''Connect to the bugzilla instance with the given url.
143         
144         This will also read any available config files (see readconfig()),
145         which may set 'user' and 'password'.
146
147         If 'user' and 'password' are both set, we'll run login(). Otherwise
148         you'll have to login() yourself before some methods will work.
149         '''
150         # Set up the transport
151         self.initcookiefile() # sets _cookiejar
152         if url.startswith('https'):
153             self._transport = SafeCookieTransport()
154         else:
155             self._transport = CookieTransport() 
156         self._transport.user_agent = self.user_agent
157         self._transport.cookiejar = self._cookiejar
158         # Set up the proxy, using the transport
159         self._proxy = xmlrpclib.ServerProxy(url,self._transport)
160         # Set up the urllib2 opener (using the same cookiejar)
161         handler = urllib2.HTTPCookieProcessor(self._cookiejar)
162         self._opener = urllib2.build_opener(handler)
163         self._opener.addheaders = [('User-agent',self.user_agent)]
164         self.url = url
165         self.readconfig() # we've changed URLs - reload config
166         if (self.user and self.password):
167             log.info("user and password present - doing login()")
168             self.login()
169
170     def disconnect(self):
171         '''Disconnect from the given bugzilla instance.'''
172         self.init_private_data() # clears all the connection state
173
174     # Note that the bugzilla methods will ignore an empty user/password if you
175     # send authentication info as a cookie in the request headers. So it's
176     # OK if we keep sending empty / bogus login info in other methods.
177     def _login(self,user,password):
178         '''IMPLEMENT ME: backend login method'''
179         raise NotImplementedError
180
181     def login(self,user=None,password=None):
182         '''Attempt to log in using the given username and password. Subsequent
183         method calls will use this username and password. Returns False if 
184         login fails, otherwise returns some kind of login info - typically
185         either a numeric userid, or a dict of user info. It also sets the 
186         logged_in attribute to True, if successful.
187
188         If user is not set, the value of Bugzilla.user will be used. If *that*
189         is not set, ValueError will be raised. 
190
191         This method will be called implicitly at the end of connect() if user
192         and password are both set. So under most circumstances you won't need
193         to call this yourself.
194         '''
195         if user:
196             self.user = user
197         if password:
198             self.password = password
199
200         if not self.user:
201             raise ValueError, "missing username"
202         if not self.password:
203             raise ValueError, "missing password"
204            
205         try: 
206             r = self._login(self.user,self.password)
207             self.logged_in = True
208             log.info("login successful - dropping password from memory")
209             self.password = ''
210         except xmlrpclib.Fault, f:
211             r = False
212         return r
213
214     def _logout(self):
215         '''IMPLEMENT ME: backend login method'''
216         raise NotImplementedError
217
218     def logout(self):
219         '''Log out of bugzilla. Drops server connection and user info, and
220         destroys authentication cookies.'''
221         self._logout()
222         self.disconnect()
223         self.user = ''
224         self.password = ''
225         self.logged_in  = False
226
227     #---- Methods and properties with basic bugzilla info 
228
229     def _getbugfields(self):
230         '''IMPLEMENT ME: Get bugfields from Bugzilla.'''
231         raise NotImplementedError
232     def _getqueryinfo(self):
233         '''IMPLEMENT ME: Get queryinfo from Bugzilla.'''
234         raise NotImplementedError
235     def _getproducts(self):
236         '''IMPLEMENT ME: Get product info from Bugzilla.'''
237         raise NotImplementedError
238     def _getcomponentsdetails(self,product):
239         '''IMPLEMENT ME: get component details for a product'''
240         raise NotImplementedError
241     def _getcomponents(self,product):
242         '''IMPLEMENT ME: Get component dict for a product'''
243         raise NotImplementedError
244
245     def getbugfields(self,force_refresh=False):
246         '''Calls getBugFields, which returns a list of fields in each bug
247         for this bugzilla instance. This can be used to set the list of attrs
248         on the Bug object.'''
249         if force_refresh or not self._bugfields:
250             try:
251                 self._bugfields = self._getbugfields()
252             except xmlrpclib.Fault, f:
253                 if f.faultCode == 'Client':
254                     # okay, this instance doesn't have getbugfields. fine.
255                     self._bugfields = []
256                 else:
257                     # something bad actually happened on the server. blow up.
258                     raise f
259
260         return self._bugfields
261     bugfields = property(fget=lambda self: self.getbugfields(),
262                          fdel=lambda self: setattr(self,'_bugfields',None))
263
264     def getqueryinfo(self,force_refresh=False):
265         '''Calls getQueryInfo, which returns a (quite large!) structure that
266         contains all of the query data and query defaults for the bugzilla
267         instance. Since this is a weighty call - takes a good 5-10sec on
268         bugzilla.redhat.com - we load the info in this private method and the
269         user instead plays with the querydata and querydefaults attributes of
270         the bugzilla object.'''
271         # Only fetch the data if we don't already have it, or are forced to
272         if force_refresh or not (self._querydata and self._querydefaults):
273             (self._querydata, self._querydefaults) = self._getqueryinfo()
274             # TODO: map _querydata to a dict, as with _components_details?
275         return (self._querydata, self._querydefaults)
276     # Set querydata and querydefaults as properties so they auto-create
277     # themselves when touched by a user. This bit was lifted from YumBase,
278     # because skvidal is much smarter than I am.
279     querydata = property(fget=lambda self: self.getqueryinfo()[0],
280                          fdel=lambda self: setattr(self,"_querydata",None))
281     querydefaults = property(fget=lambda self: self.getqueryinfo()[1],
282                          fdel=lambda self: setattr(self,"_querydefaults",None))
283
284     def getproducts(self,force_refresh=False):
285         '''Get product data: names, descriptions, etc.
286         The data varies between Bugzilla versions but the basic format is a 
287         list of dicts, where the dicts will have at least the following keys:
288         {'id':1,'name':"Some Product",'description':"This is a product"}
289
290         Any method that requires a 'product' can be given either the 
291         id or the name.'''
292         if force_refresh or not self._products:
293             self._products = self._getproducts()
294         return self._products
295     # Bugzilla.products is a property - we cache the product list on the first
296     # call and return it for each subsequent call.
297     products = property(fget=lambda self: self.getproducts(),
298                         fdel=lambda self: setattr(self,'_products',None))
299     def _product_id_to_name(self,productid):
300         '''Convert a product ID (int) to a product name (str).'''
301         # This will auto-create the 'products' list
302         for p in self.products:
303             if p['id'] == productid:
304                 return p['name']
305     def _product_name_to_id(self,product):
306         '''Convert a product name (str) to a product ID (int).'''
307         for p in self.products:
308             if p['name'] == product:
309                 return p['id']
310
311     def getcomponents(self,product,force_refresh=False):
312         '''Return a dict of components:descriptions for the given product.'''
313         if force_refresh or product not in self._components:
314             self._components[product] = self._getcomponents(product)
315         return self._components[product]
316     # TODO - add a .components property that acts like a dict?
317
318     def getcomponentsdetails(self,product,force_refresh=False):
319         '''Returns a dict of dicts, containing detailed component information
320         for the given product. The keys of the dict are component names. For 
321         each component, the value is a dict with the following keys: 
322         description, initialowner, initialqacontact, initialcclist'''
323         # XXX inconsistent: we don't do this list->dict mapping with querydata
324         if force_refresh or product not in self._components_details:
325             clist = self._getcomponentsdetails(product)
326             cdict = dict()
327             for item in clist:
328                 name = item['component']
329                 del item['component']
330                 cdict[name] = item
331             self._components_details[product] = cdict
332         return self._components_details[product]
333     def getcomponentdetails(self,product,component,force_refresh=False):
334         '''Get details for a single component. Returns a dict with the
335         following keys: 
336         description, initialowner, initialqacontact, initialcclist'''
337         d = self.getcomponentsdetails(product,force_refresh)
338         return d[component]
339
340     #---- Methods for reading bugs and bug info
341
342     def _getbug(self,id):
343         '''IMPLEMENT ME: Return a dict of full bug info for the given bug id'''
344         raise NotImplementedError
345     def _getbugs(self,idlist):
346         '''IMPLEMENT ME: Return a list of full bug dicts, one for each of the 
347         given bug ids'''
348         raise NotImplementedError
349     def _getbugsimple(self,id):
350         '''IMPLEMENT ME: Return a short dict of simple bug info for the given
351         bug id'''
352         raise NotImplementedError
353     def _getbugssimple(self,idlist):
354         '''IMPLEMENT ME: Return a list of short bug dicts, one for each of the
355         given bug ids'''
356         raise NotImplementedError
357     def _query(self,query):
358         '''IMPLEMENT ME: Query bugzilla and return a list of matching bugs.'''
359         raise NotImplementedError
360     def _updateperms(self,user,action,group):
361         '''IMPLEMEMT ME: Update Bugzilla user permissions'''
362         raise NotImplementedError
363     def _adduser(self,email,name):
364         '''IMPLEMENT ME: Add a bugzilla user'''
365         raise NotImplementedError
366
367     # these return Bug objects 
368     def getbug(self,id):
369         '''Return a Bug object with the full complement of bug data
370         already loaded.'''
371         log.debug("getbug(%s)" % str(id))
372         return Bug(bugzilla=self,dict=self._getbug(id))
373     def getbugsimple(self,id):
374         '''Return a Bug object given bug id, populated with simple info'''
375         return Bug(bugzilla=self,dict=self._getbugsimple(id))
376     def getbugs(self,idlist):
377         '''Return a list of Bug objects with the full complement of bug data
378         already loaded. If there's a problem getting the data for a given id,
379         the corresponding item in the returned list will be None.'''
380         return [(b and Bug(bugzilla=self,dict=b)) or None for b in self._getbugs(idlist)]
381     def getbugssimple(self,idlist):
382         '''Return a list of Bug objects for the given bug ids, populated with
383         simple info. As with getbugs(), if there's a problem getting the data
384         for a given bug ID, the corresponding item in the returned list will
385         be None.'''
386         return [(b and Bug(bugzilla=self,dict=b)) or None for b in self._getbugssimple(idlist)]
387     def query(self,query):
388         '''Query bugzilla and return a list of matching bugs.
389         query must be a dict with fields like those in in querydata['fields'].
390         Returns a list of Bug objects.
391         Also see the _query() method for details about the underlying
392         implementation.
393         '''
394         r = self._query(query)
395         return [Bug(bugzilla=self,dict=b) for b in r['bugs']]
396
397     def simplequery(self,product,version='',component='',string='',matchtype='allwordssubstr'):
398         '''Convenience method - query for bugs filed against the given
399         product, version, and component whose comments match the given string.
400         matchtype specifies the type of match to be done. matchtype may be
401         any of the types listed in querydefaults['long_desc_type_list'], e.g.:
402         ['allwordssubstr','anywordssubstr','substring','casesubstring',
403          'allwords','anywords','regexp','notregexp']
404         Return value is the same as with query().
405         '''
406         q = {'product':product,'version':version,'component':component,
407              'long_desc':string,'long_desc_type':matchtype}
408         return self.query(q)
409
410     #---- Methods for modifying existing bugs.
411
412     # Most of these will probably also be available as Bug methods, e.g.:
413     # Bugzilla.setstatus(id,status) ->
414     #   Bug.setstatus(status): self.bugzilla.setstatus(self.bug_id,status)
415
416     # FIXME inconsistent method signatures
417     # FIXME add more comments on proper implementation
418     def _addcomment(self,id,comment,private=False,
419                    timestamp='',worktime='',bz_gid=''):
420         '''IMPLEMENT ME: add a comment to the given bug ID'''
421         raise NotImplementedError
422     def _setstatus(self,id,status,comment='',private=False,private_in_it=False,nomail=False):
423         '''IMPLEMENT ME: Set the status of the given bug ID'''
424         raise NotImplementedError
425     def _closebug(self,id,resolution,dupeid,fixedin,comment,isprivate,private_in_it,nomail):
426         '''IMPLEMENT ME: close the given bug ID'''
427         raise NotImplementedError
428     def _setassignee(self,id,**data):
429         '''IMPLEMENT ME: set the assignee of the given bug ID'''
430         raise NotImplementedError
431     def _updatedeps(self,id,blocked,dependson,action):
432         '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug.
433         blocked, dependson: list of bug ids/aliases
434         action: 'add' or 'delete'
435         '''
436         raise NotImplementedError
437     def _updatecc(self,id,cclist,action,comment='',nomail=False):
438         '''IMPLEMENT ME: Update the CC list using the action and account list
439         specified.
440         cclist must be a list (not a tuple!) of addresses.
441         action may be 'add', 'delete', or 'overwrite'.
442         comment specifies an optional comment to add to the bug.
443         if mail is True, email will be generated for this change.
444         Note that using 'overwrite' may result in up to three XMLRPC calls
445         (fetch list, remove each element, add new elements). Avoid if possible.
446         '''
447         raise NotImplementedError
448     def _updatewhiteboard(self,id,text,which,action):
449         '''IMPLEMENT ME: Update the whiteboard given by 'which' for the given
450         bug. performs the given action (which may be 'append',' prepend', or 
451         'overwrite') using the given text.'''
452         raise NotImplementedError
453     def _updateflags(self,id,flags):
454         '''Updates the flags associated with a bug report.
455         data should be a hash of {'flagname':'value'} pairs, like so:
456         {'needinfo':'?','fedora-cvs':'+'}
457         You may also add a "nomail":1 item, which will suppress email if set.'''
458         raise NotImplementedError
459
460     #---- Methods for working with attachments
461
462     def _attachment_encode(self,fh):
463         '''Return the contents of the file-like object fh in a form
464         appropriate for attaching to a bug in bugzilla. This is the default
465         encoding method, base64.'''
466         # Read data in chunks so we don't end up with two copies of the file
467         # in RAM.
468         chunksize = 3072 # base64 encoding wants input in multiples of 3
469         data = ''
470         chunk = fh.read(chunksize)
471         while chunk:
472             # we could use chunk.encode('base64') but that throws a newline
473             # at the end of every output chunk, which increases the size of
474             # the output.
475             data = data + base64.b64encode(chunk)
476             chunk = fh.read(chunksize)
477         return data
478
479     def _attachfile(self,id,**attachdata):
480         '''IMPLEMENT ME: attach a file to the given bug.
481         attachdata MUST contain the following keys:
482             data:        File data, encoded in the bugzilla-preferred format.
483                          attachfile() will encode it with _attachment_encode().
484             description: Short description of this attachment.
485             filename:    Filename for the attachment.
486         The following optional keys may also be added:
487             comment:   An optional comment about this attachment.
488             isprivate: Set to True if the attachment should be marked private.
489             ispatch:   Set to True if the attachment is a patch.
490             contenttype: The mime-type of the attached file. Defaults to
491                          application/octet-stream if not set. NOTE that text
492                          files will *not* be viewable in bugzilla unless you 
493                          remember to set this to text/plain. So remember that!
494         Returns (attachment_id,mailresults).
495         '''
496         raise NotImplementedError
497
498     def attachfile(self,id,attachfile,description,**kwargs):
499         '''Attach a file to the given bug ID. Returns the ID of the attachment
500         or raises xmlrpclib.Fault if something goes wrong.
501         attachfile may be a filename (which will be opened) or a file-like
502         object, which must provide a 'read' method. If it's not one of these,
503         this method will raise a TypeError.
504         description is the short description of this attachment.
505         Optional keyword args are as follows:
506             filename:  this will be used as the filename for the attachment.
507                        REQUIRED if attachfile is a file-like object with no
508                        'name' attribute, otherwise the filename or .name
509                        attribute will be used.
510             comment:   An optional comment about this attachment.
511             isprivate: Set to True if the attachment should be marked private.
512             ispatch:   Set to True if the attachment is a patch.
513             contenttype: The mime-type of the attached file. Defaults to
514                          application/octet-stream if not set. NOTE that text
515                          files will *not* be viewable in bugzilla unless you 
516                          remember to set this to text/plain. So remember that!
517         '''
518         if isinstance(attachfile,str):
519             f = open(attachfile)
520         elif hasattr(attachfile,'read'):
521             f = attachfile
522         else:
523             raise TypeError, "attachfile must be filename or file-like object"
524         kwargs['description'] = description
525         if 'filename' not in kwargs:
526             kwargs['filename'] = os.path.basename(f.name)
527         # TODO: guess contenttype?
528         if 'contenttype' not in kwargs:
529             kwargs['contenttype'] = 'application/octet-stream'
530         kwargs['data'] = self._attachment_encode(f)
531         (attachid, mailresults) = self._attachfile(id,**kwargs)
532         return attachid
533
534     def _attachment_uri(self,attachid):
535         '''Returns the URI for the given attachment ID.'''
536         att_uri = self._url.replace('xmlrpc.cgi','attachment.cgi')
537         att_uri = att_uri + '?%i' % attachid
538         return att_uri
539
540     def openattachment(self,attachid):
541         '''Get the contents of the attachment with the given attachment ID.
542         Returns a file-like object.'''
543         att_uri = self._attachment_uri(attachid)
544         att = urllib2.urlopen(att_uri)
545         # RFC 2183 defines the content-disposition header, if you're curious
546         disp = att.headers['content-disposition'].split(';')
547         [filename_parm] = [i for i in disp if i.strip().startswith('filename=')]
548         (dummy,filename) = filename_parm.split('=')
549         # RFC 2045/822 defines the grammar for the filename value, but
550         # I think we just need to remove the quoting. I hope.
551         att.name = filename.strip('"')
552         # Hooray, now we have a file-like object with .read() and .name
553         return att
554
555     #---- createbug - big complicated call to create a new bug
556
557     # Default list of required fields for createbug.
558     # May be overridden by concrete subclasses.
559     createbug_required = ('product','component','version','short_desc','comment',
560                           'rep_platform','bug_severity','op_sys','bug_file_loc')
561
562     # List of field aliases. If a createbug() call lacks a required field, but
563     # a corresponding alias field is present, we'll automatically switch the
564     # field name. This lets us avoid having to change the call to match the 
565     # bugzilla instance quite so much.
566     field_aliases = (('summary','short_desc'),
567                      ('description','comment'),
568                      ('platform','rep_platform'),
569                      ('severity','bug_severity'),
570                      ('status','bug_status'))
571
572     def _createbug(self,**data):
573         '''IMPLEMENT ME: Raw xmlrpc call for createBug() 
574         Doesn't bother guessing defaults or checking argument validity. 
575         Returns bug_id'''
576         raise NotImplementedError
577
578     def createbug(self,check_args=False,**data):
579         '''Create a bug with the given info. Returns a new Bug object.
580         data should be given as keyword args - remember that you can also
581         populate a dict and call createbug(**dict) to fill in keyword args.
582         The arguments are as follows. Note that some are required, some are
583         defaulted, and some are completely optional.
584
585         The Bugzilla 3.2 docs say the following:
586
587         "Clients that want to be able to interact uniformly with multiple
588         Bugzillas should always set both the params marked Required and those 
589         marked Defaulted, because some Bugzillas may not have defaults set for 
590         Defaulted parameters, and then this method will throw an error if you 
591         don't specify them."
592
593         REQUIRED:
594           product: Name of Bugzilla product. 
595             Ex: Red Hat Enterprise Linux
596           component: Name of component in Bugzilla product. 
597             Ex: anaconda
598           version: Version in the list for the Bugzilla product. 
599             Ex: 4.5
600             See querydata['product'][<product>]['versions'] for values.
601           summary: One line summary describing the bug report.
602
603         DEFAULTED:
604           platform: Hardware type where this bug was experienced.  
605             Ex: i386
606             See querydefaults['rep_platform_list'] for accepted values.
607           severity: Bug severity.  
608             Ex: medium
609             See querydefaults['bug_severity_list'] for accepted values.
610           priority: Bug priority.
611             Ex: medium
612             See querydefaults['priority_list'] for accepted values.
613           op_sys: Operating system bug occurs on. 
614             Ex: Linux
615             See querydefaults['op_sys_list'] for accepted values.
616           description: A detailed description of the bug report.
617
618         OPTIONAL: 
619           alias: Give the bug a (string) alias name.
620             Alias can't be merely numerical.
621             Alias can't contain spaces or commas.
622             Alias can't be more than 20 chars long.
623             Alias has to be unique.
624           assigned_to: Bugzilla username to assign this bug to.
625           qa_contact: Bugzilla username of QA contact for this bug.
626           cc: List of Bugzilla usernames to CC on this bug.
627           status: Status to place the new bug in. Defaults to NEW.
628
629         Important custom fields (used by RH Bugzilla and maybe others):
630         DEFAULTED: 
631           bug_file_loc: URL pointing to additional information for bug report. 
632             Ex: http://username.fedorapeople.org/logs/crashlog.txt
633           reporter: Bugzilla username to use as reporter. 
634         OPTIONAL: 
635           blocked: List of bug ids this report blocks.
636           dependson: List of bug ids this report depends on.
637         '''
638         # If we're getting a call that uses an old fieldname, convert it to the
639         # new fieldname instead.
640         # XXX - emit deprecation warnings here
641         for newfield, oldfield in self.field_aliases:
642             if newfield in self.createbug_required and newfield not in data \
643                     and oldfield in data:
644                 data[newfield] = data.pop(oldfield)
645
646         # The xmlrpc will raise an error if one of these is missing, but
647         # let's try to save a network roundtrip here if possible..
648         for i in self.createbug_required:
649             if i not in data or not data[i]:
650                 if i == 'bug_file_loc':
651                     data[i] = 'http://'
652                 else:
653                     raise TypeError, "required field missing or empty: '%s'" % i
654
655         # Sort of a chicken-and-egg problem here - check_args will save you a
656         # network roundtrip if your op_sys or rep_platform is bad, but at the
657         # expense of getting querydefaults, which is.. an added network
658         # roundtrip. Basically it's only useful if you're mucking around with
659         # createbug() in ipython and you've already loaded querydefaults.
660         if check_args:
661             if data['op_sys'] not in self.querydefaults['op_sys_list']:
662                 raise ValueError, "invalid value for op_sys: %s" % data['op_sys']
663             if data['rep_platform'] not in self.querydefaults['rep_platform_list']:
664                 raise ValueError, "invalid value for rep_platform: %s" % data['rep_platform']
665         # Actually perform the createbug call.
666         # We return a nearly-empty Bug object, which is kind of a bummer 'cuz
667         # it'll take another network roundtrip to fill it. We *could* fake it
668         # and fill in the blanks with the data given to this method, but the
669         # server might modify/add/drop stuff. Then we'd have a Bug object that
670         # lied about the actual contents of the database. That would be bad.
671         bug_id = self._createbug(**data)
672         return Bug(self,bug_id=bug_id)
673
674     def updateperms(self,user,action,groups):
675         '''A method to update  the permissions (group membership) of a bugzilla
676         user. Takes the following:
677
678         user: The e-mail address of the user to be acted upon
679         action: either add or rem
680         groups: list of groups to be added to (i.e. ['fedora_contrib'])
681         '''
682         self._updateperms(user,action,groups)
683     def adduser(self,user,name):
684         '''A method to create a user in Bugzilla. Takes the following:
685
686         email: The email address of the user to create
687         name: The full name of the user to create
688         '''
689         self._adduser(user,name)
690
691 class CookieResponse:
692     '''Fake HTTPResponse object that we can fill with headers we got elsewhere.
693     We can then pass it to CookieJar.extract_cookies() to make it pull out the
694     cookies from the set of headers we have.'''
695     def __init__(self,headers): 
696         self.headers = headers
697         #log.debug("CookieResponse() headers = %s" % headers)
698     def info(self): 
699         return self.headers
700
701 class CookieTransport(xmlrpclib.Transport):
702     '''A subclass of xmlrpclib.Transport that supports cookies.'''
703     cookiejar = None
704     scheme = 'http'
705
706     # Cribbed from xmlrpclib.Transport.send_user_agent 
707     def send_cookies(self, connection, cookie_request):
708         if self.cookiejar is None:
709             log.debug("send_cookies(): creating in-memory cookiejar")
710             self.cookiejar = cookielib.CookieJar()
711         elif self.cookiejar:
712             log.debug("send_cookies(): using existing cookiejar")
713             # Let the cookiejar figure out what cookies are appropriate
714             log.debug("cookie_request headers currently: %s" % cookie_request.header_items())
715             self.cookiejar.add_cookie_header(cookie_request)
716             log.debug("cookie_request headers now: %s" % cookie_request.header_items())
717             # Pull the cookie headers out of the request object...
718             cookielist=list()
719             for h,v in cookie_request.header_items():
720                 if h.startswith('Cookie'):
721                     log.debug("sending cookie: %s=%s" % (h,v))
722                     cookielist.append([h,v])
723             # ...and put them over the connection
724             for h,v in cookielist:
725                 connection.putheader(h,v)
726         else:
727             log.debug("send_cookies(): cookiejar empty. Nothing to send.")
728
729     # This is the same request() method from xmlrpclib.Transport,
730     # with a couple additions noted below
731     def request(self, host, handler, request_body, verbose=0):
732         h = self.make_connection(host)
733         if verbose:
734             h.set_debuglevel(1)
735
736         # ADDED: construct the URL and Request object for proper cookie handling
737         request_url = "%s://%s%s" % (self.scheme,host,handler)
738         log.debug("request_url is %s" % request_url)
739         cookie_request  = urllib2.Request(request_url) 
740
741         self.send_request(h,handler,request_body)
742         self.send_host(h,host) 
743         self.send_cookies(h,cookie_request) # ADDED. creates cookiejar if None.
744         self.send_user_agent(h)
745         self.send_content(h,request_body)
746
747         errcode, errmsg, headers = h.getreply()
748
749         # ADDED: parse headers and get cookies here
750         cookie_response = CookieResponse(headers)
751         # Okay, extract the cookies from the headers
752         self.cookiejar.extract_cookies(cookie_response,cookie_request)
753         log.debug("cookiejar now contains: %s" % self.cookiejar._cookies)
754         # And write back any changes
755         if hasattr(self.cookiejar,'save'):
756             try:
757                 self.cookiejar.save(self.cookiejar.filename)
758             except e:
759                 log.error("Couldn't write cookiefile %s: %s" % \
760                         (self.cookiejar.filename,str(e)))
761
762         if errcode != 200:
763             raise xmlrpclib.ProtocolError(
764                 host + handler,
765                 errcode, errmsg,
766                 headers
767                 )
768
769         self.verbose = verbose
770
771         try:
772             sock = h._conn.sock
773         except AttributeError:
774             sock = None
775
776         return self._parse_response(h.getfile(), sock)
777
778 class SafeCookieTransport(xmlrpclib.SafeTransport,CookieTransport):
779     '''SafeTransport subclass that supports cookies.'''
780     scheme = 'https'
781     request = CookieTransport.request
782
783 class Bug(object):
784     '''A container object for a bug report. Requires a Bugzilla instance - 
785     every Bug is on a Bugzilla, obviously.
786     Optional keyword args:
787         dict=DICT   - populate attributes with the result of a getBug() call
788         bug_id=ID   - if dict does not contain bug_id, this is required before
789                       you can read any attributes or make modifications to this
790                       bug.
791         autorefresh - automatically refresh the data in this bug after calling
792                       a method that modifies the bug. Defaults to True. You can
793                       call refresh() to do this manually.
794     '''
795     def __init__(self,bugzilla,**kwargs):
796         self.bugzilla = bugzilla
797         self.autorefresh = True
798         if 'dict' in kwargs and kwargs['dict']:
799             log.debug("Bug(%s)" % kwargs['dict'].keys())
800             self.__dict__.update(kwargs['dict'])
801         if 'bug_id' in kwargs:
802             log.debug("Bug(%i)" % kwargs['bug_id'])
803             setattr(self,'bug_id',kwargs['bug_id'])
804         if 'autorefresh' in kwargs:
805             self.autorefresh = kwargs['autorefresh']
806         # No bug_id? this bug is invalid!
807         if not hasattr(self,'bug_id'):
808             if hasattr(self,'id'):
809                 self.bug_id = self.id
810             else:
811                 raise TypeError, "Bug object needs a bug_id"
812
813         self.url = bugzilla.url.replace('xmlrpc.cgi',
814                                         'show_bug.cgi?id=%i' % self.bug_id)
815
816         # TODO: set properties for missing bugfields
817         # The problem here is that the property doesn't know its own name,
818         # otherwise we could just do .refresh() and return __dict__[f] after.
819         # basically I need a suicide property that can replace itself after
820         # it's called. Or something.
821         #for f in bugzilla.bugfields:
822         #    if f in self.__dict__: continue
823         #    setattr(self,f,property(fget=lambda self: self.refresh()))
824
825     def __str__(self):
826         '''Return a simple string representation of this bug'''
827         # XXX Not really sure why we get short_desc sometimes and
828         # short_short_desc other times. I feel like I'm working around
829         # a bug here, so keep an eye on this.
830         if 'short_short_desc' in self.__dict__:
831             desc = self.short_short_desc
832         elif 'short_desc' in self.__dict__:
833             desc = self.short_desc
834         elif 'summary' in self.__dict__:
835             desc = self.summary
836         else:
837             log.warn("Weird; this bug has no summary?")
838             desc = "[ERROR: SUMMARY MISSING]"
839             log.debug(self.__dict__)
840         # Some BZ3 implementations give us an ID instead of a name.
841         if 'assigned_to' not in self.__dict__:
842             if 'assigned_to_id' in self.__dict__:
843                 self.assigned_to = self.bugzilla._getuserforid(self.assigned_to_id)
844         return "#%-6s %-10s - %s - %s" % (self.bug_id,self.bug_status,
845                                           self.assigned_to,desc)
846     def __repr__(self):
847         return '<Bug #%i on %s at %#x>' % (self.bug_id,self.bugzilla.url,
848                                            id(self))
849
850     def __getattr__(self,name):
851         if 'bug_id' in self.__dict__:
852             if self.bugzilla.bugfields and name not in self.bugzilla.bugfields:
853                 # We have a list of fields, and you ain't on it. Bail out.
854                 raise AttributeError, "field %s not in bugzilla.bugfields" % name
855             #print "Bug %i missing %s - loading" % (self.bug_id,name)
856             self.refresh()
857             if name in self.__dict__:
858                 return self.__dict__[name]
859         raise AttributeError, "Bug object has no attribute '%s'" % name
860
861     def refresh(self):
862         '''Refresh all the data in this Bug.'''
863         r = self.bugzilla._getbug(self.bug_id)
864         self.__dict__.update(r)
865
866     def reload(self): 
867         '''An alias for refresh()'''
868         self.refresh()
869
870     def setstatus(self,status,comment='',private=False,private_in_it=False,nomail=False):
871         '''Update the status for this bug report. 
872         Valid values for status are listed in querydefaults['bug_status_list']
873         Commonly-used values are ASSIGNED, MODIFIED, and NEEDINFO.
874         To change bugs to CLOSED, use .close() instead.
875         See Bugzilla._setstatus() for details.'''
876         self.bugzilla._setstatus(self.bug_id,status,comment,private,private_in_it,nomail)
877         # TODO reload bug data here?
878
879     def setassignee(self,assigned_to='',reporter='',qa_contact='',comment=''):
880         '''Set any of the assigned_to, reporter, or qa_contact fields to a new
881         bugzilla account, with an optional comment, e.g.
882         setassignee(reporter='sadguy@brokencomputer.org',
883                     assigned_to='wwoods@redhat.com')
884         setassignee(qa_contact='wwoods@redhat.com',comment='wwoods QA ftw')
885         You must set at least one of the three assignee fields, or this method
886         will throw a ValueError.
887         Returns [bug_id, mailresults].'''
888         if not (assigned_to or reporter or qa_contact):
889             # XXX is ValueError the right thing to throw here?
890             raise ValueError, "You must set one of assigned_to, reporter, or qa_contact"
891         # empty fields are ignored, so it's OK to send 'em
892         r = self.bugzilla._setassignee(self.bug_id,assigned_to=assigned_to,
893                 reporter=reporter,qa_contact=qa_contact,comment=comment)
894         # TODO reload bug data here?
895         return r
896     def addcomment(self,comment,private=False,timestamp='',worktime='',bz_gid=''):
897         '''Add the given comment to this bug. Set private to True to mark this
898         comment as private. You can also set a timestamp for the comment, in
899         "YYYY-MM-DD HH:MM:SS" form. Worktime is undocumented upstream.
900         If bz_gid is set, and the entire bug is not already private to that
901         group, this comment will be private.'''
902         self.bugzilla._addcomment(self.bug_id,comment,private,timestamp,
903                                   worktime,bz_gid)
904         # TODO reload bug data here?
905     def close(self,resolution,dupeid=0,fixedin='',comment='',isprivate=False,private_in_it=False,nomail=False):
906         '''Close this bug. 
907         Valid values for resolution are in bz.querydefaults['resolution_list']
908         For bugzilla.redhat.com that's:
909         ['NOTABUG','WONTFIX','DEFERRED','WORKSFORME','CURRENTRELEASE',
910          'RAWHIDE','ERRATA','DUPLICATE','UPSTREAM','NEXTRELEASE','CANTFIX',
911          'INSUFFICIENT_DATA']
912         If using DUPLICATE, you need to set dupeid to the ID of the other bug.
913         If using WORKSFORME/CURRENTRELEASE/RAWHIDE/ERRATA/UPSTREAM/NEXTRELEASE
914           you can (and should) set 'new_fixed_in' to a string representing the 
915           version that fixes the bug.
916         You can optionally add a comment while closing the bug. Set 'isprivate'
917           to True if you want that comment to be private.
918         If you want to suppress sending out mail for this bug closing, set
919           nomail=True.
920         '''
921         self.bugzilla._closebug(self.bug_id,resolution,dupeid,fixedin,
922                                 comment,isprivate,private_in_it,nomail)
923         # TODO reload bug data here?
924     def _dowhiteboard(self,text,which,action):
925         '''Actually does the updateWhiteboard call to perform the given action
926         (append,prepend,overwrite) with the given text on the given whiteboard
927         for the given bug.'''
928         self.bugzilla._updatewhiteboard(self.bug_id,text,which,action)
929         # TODO reload bug data here?
930
931     def getwhiteboard(self,which='status'):
932         '''Get the current value of the whiteboard specified by 'which'.
933         Known whiteboard names: 'status','internal','devel','qa'.
934         Defaults to the 'status' whiteboard.'''
935         return getattr(self,"%s_whiteboard" % which)
936     def appendwhiteboard(self,text,which='status'):
937         '''Append the given text (with a space before it) to the given 
938         whiteboard. Defaults to using status_whiteboard.'''
939         self._dowhiteboard(text,which,'append')
940     def prependwhiteboard(self,text,which='status'):
941         '''Prepend the given text (with a space following it) to the given
942         whiteboard. Defaults to using status_whiteboard.'''
943         self._dowhiteboard(text,which,'prepend')
944     def setwhiteboard(self,text,which='status'):
945         '''Overwrites the contents of the given whiteboard with the given text.
946         Defaults to using status_whiteboard.'''
947         self._dowhiteboard(text,which,'overwrite')
948     def addtag(self,tag,which='status'):
949         '''Adds the given tag to the given bug.'''
950         whiteboard = self.getwhiteboard(which)
951         if whiteboard:
952             self.appendwhiteboard(tag,which)
953         else:
954             self.setwhiteboard(tag,which)
955     def gettags(self,which='status'):
956         '''Get a list of tags (basically just whitespace-split the given
957         whiteboard)'''
958         return self.getwhiteboard(which).split()
959     def deltag(self,tag,which='status'):
960         '''Removes the given tag from the given bug.'''
961         tags = self.gettags(which)
962         tags.remove(tag)
963         self.setwhiteboard(' '.join(tags),which)
964     def addcc(self,cclist,comment=''):
965         '''Adds the given email addresses to the CC list for this bug.
966         cclist: list of email addresses (strings)
967         comment: optional comment to add to the bug'''
968         self.bugzilla._updatecc(self.bug_id,cclist,'add',comment)
969     def deletecc(self,cclist,comment=''):
970         '''Removes the given email addresses from the CC list for this bug.'''
971         self.bugzilla._updatecc(self.bug_id,cclist,'delete',comment)
972 # TODO: attach(file), getflag(), setflag()
973 # TODO: add a sync() method that writes the changed data in the Bug object
974 # back to Bugzilla?