Fix attachfile - thanks clumens
[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-rc1'
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
361     # these return Bug objects 
362     def getbug(self,id):
363         '''Return a Bug object with the full complement of bug data
364         already loaded.'''
365         log.debug("getbug(%i)" % id)
366         return Bug(bugzilla=self,dict=self._getbug(id))
367     def getbugsimple(self,id):
368         '''Return a Bug object given bug id, populated with simple info'''
369         return Bug(bugzilla=self,dict=self._getbugsimple(id))
370     def getbugs(self,idlist):
371         '''Return a list of Bug objects with the full complement of bug data
372         already loaded. If there's a problem getting the data for a given id,
373         the corresponding item in the returned list will be None.'''
374         return [(b and Bug(bugzilla=self,dict=b)) or None for b in self._getbugs(idlist)]
375     def getbugssimple(self,idlist):
376         '''Return a list of Bug objects for the given bug ids, populated with
377         simple info. As with getbugs(), if there's a problem getting the data
378         for a given bug ID, the corresponding item in the returned list will
379         be None.'''
380         return [(b and Bug(bugzilla=self,dict=b)) or None for b in self._getbugssimple(idlist)]
381     def query(self,query):
382         '''Query bugzilla and return a list of matching bugs.
383         query must be a dict with fields like those in in querydata['fields'].
384         Returns a list of Bug objects.
385         Also see the _query() method for details about the underlying
386         implementation.
387         '''
388         r = self._query(query)
389         return [Bug(bugzilla=self,dict=b) for b in r['bugs']]
390
391     def simplequery(self,product,version='',component='',string='',matchtype='allwordssubstr'):
392         '''Convenience method - query for bugs filed against the given
393         product, version, and component whose comments match the given string.
394         matchtype specifies the type of match to be done. matchtype may be
395         any of the types listed in querydefaults['long_desc_type_list'], e.g.:
396         ['allwordssubstr','anywordssubstr','substring','casesubstring',
397          'allwords','anywords','regexp','notregexp']
398         Return value is the same as with query().
399         '''
400         q = {'product':product,'version':version,'component':component,
401              'long_desc':string,'long_desc_type':matchtype}
402         return self.query(q)
403
404     #---- Methods for modifying existing bugs.
405
406     # Most of these will probably also be available as Bug methods, e.g.:
407     # Bugzilla.setstatus(id,status) ->
408     #   Bug.setstatus(status): self.bugzilla.setstatus(self.bug_id,status)
409
410     # FIXME inconsistent method signatures
411     # FIXME add more comments on proper implementation
412     def _addcomment(self,id,comment,private=False,
413                    timestamp='',worktime='',bz_gid=''):
414         '''IMPLEMENT ME: add a comment to the given bug ID'''
415         raise NotImplementedError
416     def _setstatus(self,id,status,comment='',private=False,private_in_it=False,nomail=False):
417         '''IMPLEMENT ME: Set the status of the given bug ID'''
418         raise NotImplementedError
419     def _closebug(self,id,resolution,dupeid,fixedin,comment,isprivate,private_in_it,nomail):
420         '''IMPLEMENT ME: close the given bug ID'''
421         raise NotImplementedError
422     def _setassignee(self,id,**data):
423         '''IMPLEMENT ME: set the assignee of the given bug ID'''
424         raise NotImplementedError
425     def _updatedeps(self,id,blocked,dependson,action):
426         '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug.
427         blocked, dependson: list of bug ids/aliases
428         action: 'add' or 'delete'
429         '''
430         raise NotImplementedError
431     def _updatecc(self,id,cclist,action,comment='',nomail=False):
432         '''IMPLEMENT ME: Update the CC list using the action and account list
433         specified.
434         cclist must be a list (not a tuple!) of addresses.
435         action may be 'add', 'delete', or 'overwrite'.
436         comment specifies an optional comment to add to the bug.
437         if mail is True, email will be generated for this change.
438         Note that using 'overwrite' may result in up to three XMLRPC calls
439         (fetch list, remove each element, add new elements). Avoid if possible.
440         '''
441         raise NotImplementedError
442     def _updatewhiteboard(self,id,text,which,action):
443         '''IMPLEMENT ME: Update the whiteboard given by 'which' for the given
444         bug. performs the given action (which may be 'append',' prepend', or 
445         'overwrite') using the given text.'''
446         raise NotImplementedError
447     def _updateflags(self,id,flags):
448         '''Updates the flags associated with a bug report.
449         data should be a hash of {'flagname':'value'} pairs, like so:
450         {'needinfo':'?','fedora-cvs':'+'}
451         You may also add a "nomail":1 item, which will suppress email if set.'''
452         raise NotImplementedError
453
454     #---- Methods for working with attachments
455
456     def _attachment_encode(self,fh):
457         '''Return the contents of the file-like object fh in a form
458         appropriate for attaching to a bug in bugzilla. This is the default
459         encoding method, base64.'''
460         # Read data in chunks so we don't end up with two copies of the file
461         # in RAM.
462         chunksize = 3072 # base64 encoding wants input in multiples of 3
463         data = ''
464         chunk = fh.read(chunksize)
465         while chunk:
466             # we could use chunk.encode('base64') but that throws a newline
467             # at the end of every output chunk, which increases the size of
468             # the output.
469             data = data + base64.b64encode(chunk)
470             chunk = fh.read(chunksize)
471         return data
472
473     def _attachfile(self,id,**attachdata):
474         '''IMPLEMENT ME: attach a file to the given bug.
475         attachdata MUST contain the following keys:
476             data:        File data, encoded in the bugzilla-preferred format.
477                          attachfile() will encode it with _attachment_encode().
478             description: Short description of this attachment.
479             filename:    Filename for the attachment.
480         The following optional keys may also be added:
481             comment:   An optional comment about this attachment.
482             isprivate: Set to True if the attachment should be marked private.
483             ispatch:   Set to True if the attachment is a patch.
484             contenttype: The mime-type of the attached file. Defaults to
485                          application/octet-stream if not set. NOTE that text
486                          files will *not* be viewable in bugzilla unless you 
487                          remember to set this to text/plain. So remember that!
488         Returns (attachment_id,mailresults).
489         '''
490         raise NotImplementedError
491
492     def attachfile(self,id,attachfile,description,**kwargs):
493         '''Attach a file to the given bug ID. Returns the ID of the attachment
494         or raises xmlrpclib.Fault if something goes wrong.
495         attachfile may be a filename (which will be opened) or a file-like
496         object, which must provide a 'read' method. If it's not one of these,
497         this method will raise a TypeError.
498         description is the short description of this attachment.
499         Optional keyword args are as follows:
500             filename:  this will be used as the filename for the attachment.
501                        REQUIRED if attachfile is a file-like object with no
502                        'name' attribute, otherwise the filename or .name
503                        attribute will be used.
504             comment:   An optional comment about this attachment.
505             isprivate: Set to True if the attachment should be marked private.
506             ispatch:   Set to True if the attachment is a patch.
507             contenttype: The mime-type of the attached file. Defaults to
508                          application/octet-stream if not set. NOTE that text
509                          files will *not* be viewable in bugzilla unless you 
510                          remember to set this to text/plain. So remember that!
511         '''
512         if isinstance(attachfile,str):
513             f = open(attachfile)
514         elif hasattr(attachfile,'read'):
515             f = attachfile
516         else:
517             raise TypeError, "attachfile must be filename or file-like object"
518         kwargs['description'] = description
519         if 'filename' not in kwargs:
520             kwargs['filename'] = os.path.basename(f.name)
521         # TODO: guess contenttype?
522         if 'contenttype' not in kwargs:
523             kwargs['contenttype'] = 'application/octet-stream'
524         kwargs['data'] = self._attachment_encode(f)
525         (attachid, mailresults) = self._attachfile(id,**kwargs)
526         return attachid
527
528     def _attachment_uri(self,attachid):
529         '''Returns the URI for the given attachment ID.'''
530         att_uri = self._url.replace('xmlrpc.cgi','attachment.cgi')
531         att_uri = att_uri + '?%i' % attachid
532         return att_uri
533
534     def openattachment(self,attachid):
535         '''Get the contents of the attachment with the given attachment ID.
536         Returns a file-like object.'''
537         att_uri = self._attachment_uri(attachid)
538         att = urllib2.urlopen(att_uri)
539         # RFC 2183 defines the content-disposition header, if you're curious
540         disp = att.headers['content-disposition'].split(';')
541         [filename_parm] = [i for i in disp if i.strip().startswith('filename=')]
542         (dummy,filename) = filename_parm.split('=')
543         # RFC 2045/822 defines the grammar for the filename value, but
544         # I think we just need to remove the quoting. I hope.
545         att.name = filename.strip('"')
546         # Hooray, now we have a file-like object with .read() and .name
547         return att
548
549     #---- createbug - big complicated call to create a new bug
550
551     # Default list of required fields for createbug.
552     # May be overridden by concrete subclasses.
553     createbug_required = ('product','component','version','short_desc','comment',
554                           'rep_platform','bug_severity','op_sys','bug_file_loc')
555
556     # List of field aliases. If a createbug() call lacks a required field, but
557     # a corresponding alias field is present, we'll automatically switch the
558     # field name. This lets us avoid having to change the call to match the 
559     # bugzilla instance quite so much.
560     field_aliases = (('summary','short_desc'),
561                      ('description','comment'),
562                      ('platform','rep_platform'),
563                      ('severity','bug_severity'),
564                      ('status','bug_status'))
565
566     def _createbug(self,**data):
567         '''IMPLEMENT ME: Raw xmlrpc call for createBug() 
568         Doesn't bother guessing defaults or checking argument validity. 
569         Returns bug_id'''
570         raise NotImplementedError
571
572     def createbug(self,check_args=False,**data):
573         '''Create a bug with the given info. Returns a new Bug object.
574         data should be given as keyword args - remember that you can also
575         populate a dict and call createbug(**dict) to fill in keyword args.
576         The arguments are as follows. Note that some are required, some are
577         defaulted, and some are completely optional.
578
579         The Bugzilla 3.2 docs say the following:
580
581         "Clients that want to be able to interact uniformly with multiple
582         Bugzillas should always set both the params marked Required and those 
583         marked Defaulted, because some Bugzillas may not have defaults set for 
584         Defaulted parameters, and then this method will throw an error if you 
585         don't specify them."
586
587         REQUIRED:
588           product: Name of Bugzilla product. 
589             Ex: Red Hat Enterprise Linux
590           component: Name of component in Bugzilla product. 
591             Ex: anaconda
592           version: Version in the list for the Bugzilla product. 
593             Ex: 4.5
594             See querydata['product'][<product>]['versions'] for values.
595           summary: One line summary describing the bug report.
596             ALIAS: short_desc
597
598         DEFAULTED:
599           platform: Hardware type where this bug was experienced.  
600             Ex: i386
601             See querydefaults['rep_platform_list'] for accepted values.
602             ALIAS: rep_platform
603           severity: Bug severity.  
604             Ex: medium
605             See querydefaults['bug_severity_list'] for accepted values.
606             ALIAS: bug_severity
607           priority: Bug priority.
608             Ex: medium
609             See querydefaults['priority_list'] for accepted values.
610           op_sys: Operating system bug occurs on. 
611             Ex: Linux
612             See querydefaults['op_sys_list'] for accepted values.
613           description: A detailed description of the bug report.
614             ALIAS: comment
615
616         OPTIONAL: 
617           alias: Give the bug a (string) alias name.
618             Alias can't be merely numerical.
619             Alias can't contain spaces or commas.
620             Alias can't be more than 20 chars long.
621             Alias has to be unique.
622           assigned_to: Bugzilla username to assign this bug to.
623           qa_contact: Bugzilla username of QA contact for this bug.
624           cc: List of Bugzilla usernames to CC on this bug.
625           status: Status to place the new bug in. Defaults to NEW.
626             ALIAS: bug_status
627
628         Important custom fields (used by RH Bugzilla and maybe others):
629         DEFAULTED: 
630           bug_file_loc: URL pointing to additional information for bug report. 
631             Ex: http://username.fedorapeople.org/logs/crashlog.txt
632           reporter: Bugzilla username to use as reporter. 
633         OPTIONAL: 
634           blocked: List of bug ids this report blocks.
635           dependson: List of bug ids this report depends on.
636         '''
637         # If we're getting a call that uses an old fieldname, convert it to the
638         # new fieldname instead.
639         for newfield, oldfield in self.field_aliases:
640             if newfield in self.createbug_required and newfield not in data \
641                     and oldfield in data:
642                 data[newfield] = data.pop(oldfield)
643
644         # The xmlrpc will raise an error if one of these is missing, but
645         # let's try to save a network roundtrip here if possible..
646         for i in self.createbug_required:
647             if i not in data or not data[i]:
648                 if i == 'bug_file_loc':
649                     data[i] = 'http://'
650                 else:
651                     raise TypeError, "required field missing or empty: '%s'" % i
652
653         # Sort of a chicken-and-egg problem here - check_args will save you a
654         # network roundtrip if your op_sys or rep_platform is bad, but at the
655         # expense of getting querydefaults, which is.. an added network
656         # roundtrip. Basically it's only useful if you're mucking around with
657         # createbug() in ipython and you've already loaded querydefaults.
658         if check_args:
659             if data['op_sys'] not in self.querydefaults['op_sys_list']:
660                 raise ValueError, "invalid value for op_sys: %s" % data['op_sys']
661             if data['rep_platform'] not in self.querydefaults['rep_platform_list']:
662                 raise ValueError, "invalid value for rep_platform: %s" % data['rep_platform']
663         # Actually perform the createbug call.
664         # We return a nearly-empty Bug object, which is kind of a bummer 'cuz
665         # it'll take another network roundtrip to fill it. We *could* fake it
666         # and fill in the blanks with the data given to this method, but the
667         # server might modify/add/drop stuff. Then we'd have a Bug object that
668         # lied about the actual contents of the database. That would be bad.
669         bug_id = self._createbug(**data)
670         return Bug(self,bug_id=bug_id)
671
672 class CookieResponse:
673     '''Fake HTTPResponse object that we can fill with headers we got elsewhere.
674     We can then pass it to CookieJar.extract_cookies() to make it pull out the
675     cookies from the set of headers we have.'''
676     def __init__(self,headers): 
677         self.headers = headers
678         #log.debug("CookieResponse() headers = %s" % headers)
679     def info(self): 
680         return self.headers
681
682 class CookieTransport(xmlrpclib.Transport):
683     '''A subclass of xmlrpclib.Transport that supports cookies.'''
684     cookiejar = None
685     scheme = 'http'
686
687     # Cribbed from xmlrpclib.Transport.send_user_agent 
688     def send_cookies(self, connection, cookie_request):
689         if self.cookiejar is None:
690             log.debug("send_cookies(): creating in-memory cookiejar")
691             self.cookiejar = cookielib.CookieJar()
692         elif self.cookiejar:
693             log.debug("send_cookies(): using existing cookiejar")
694             # Let the cookiejar figure out what cookies are appropriate
695             log.debug("cookie_request headers currently: %s" % cookie_request.header_items())
696             self.cookiejar.add_cookie_header(cookie_request)
697             log.debug("cookie_request headers now: %s" % cookie_request.header_items())
698             # Pull the cookie headers out of the request object...
699             cookielist=list()
700             for h,v in cookie_request.header_items():
701                 if h.startswith('Cookie'):
702                     log.debug("sending cookie: %s=%s" % (h,v))
703                     cookielist.append([h,v])
704             # ...and put them over the connection
705             for h,v in cookielist:
706                 connection.putheader(h,v)
707         else:
708             log.debug("send_cookies(): cookiejar empty. Nothing to send.")
709
710     # This is the same request() method from xmlrpclib.Transport,
711     # with a couple additions noted below
712     def request(self, host, handler, request_body, verbose=0):
713         h = self.make_connection(host)
714         if verbose:
715             h.set_debuglevel(1)
716
717         # ADDED: construct the URL and Request object for proper cookie handling
718         request_url = "%s://%s%s" % (self.scheme,host,handler)
719         log.debug("request_url is %s" % request_url)
720         cookie_request  = urllib2.Request(request_url) 
721
722         self.send_request(h,handler,request_body)
723         self.send_host(h,host) 
724         self.send_cookies(h,cookie_request) # ADDED. creates cookiejar if None.
725         self.send_user_agent(h)
726         self.send_content(h,request_body)
727
728         errcode, errmsg, headers = h.getreply()
729
730         # ADDED: parse headers and get cookies here
731         cookie_response = CookieResponse(headers)
732         # Okay, extract the cookies from the headers
733         self.cookiejar.extract_cookies(cookie_response,cookie_request)
734         log.debug("cookiejar now contains: %s" % self.cookiejar._cookies)
735         # And write back any changes
736         if hasattr(self.cookiejar,'save'):
737             try:
738                 self.cookiejar.save(self.cookiejar.filename)
739             except e:
740                 log.error("Couldn't write cookiefile %s: %s" % \
741                         (self.cookiejar.filename,str(e)))
742
743         if errcode != 200:
744             raise xmlrpclib.ProtocolError(
745                 host + handler,
746                 errcode, errmsg,
747                 headers
748                 )
749
750         self.verbose = verbose
751
752         try:
753             sock = h._conn.sock
754         except AttributeError:
755             sock = None
756
757         return self._parse_response(h.getfile(), sock)
758
759 class SafeCookieTransport(xmlrpclib.SafeTransport,CookieTransport):
760     '''SafeTransport subclass that supports cookies.'''
761     scheme = 'https'
762     request = CookieTransport.request
763
764 class Bug(object):
765     '''A container object for a bug report. Requires a Bugzilla instance - 
766     every Bug is on a Bugzilla, obviously.
767     Optional keyword args:
768         dict=DICT   - populate attributes with the result of a getBug() call
769         bug_id=ID   - if dict does not contain bug_id, this is required before
770                       you can read any attributes or make modifications to this
771                       bug.
772         autorefresh - automatically refresh the data in this bug after calling
773                       a method that modifies the bug. Defaults to True. You can
774                       call refresh() to do this manually.
775     '''
776     def __init__(self,bugzilla,**kwargs):
777         self.bugzilla = bugzilla
778         self.autorefresh = True
779         if 'dict' in kwargs and kwargs['dict']:
780             log.debug("Bug(%s)" % kwargs['dict'].keys())
781             self.__dict__.update(kwargs['dict'])
782         if 'bug_id' in kwargs:
783             log.debug("Bug(%i)" % kwargs['bug_id'])
784             setattr(self,'bug_id',kwargs['bug_id'])
785         if 'autorefresh' in kwargs:
786             self.autorefresh = kwargs['autorefresh']
787         # No bug_id? this bug is invalid!
788         if not hasattr(self,'bug_id'):
789             if hasattr(self,'id'):
790                 self.bug_id = self.id
791             else:
792                 raise TypeError, "Bug object needs a bug_id"
793
794         self.url = bugzilla.url.replace('xmlrpc.cgi',
795                                         'show_bug.cgi?id=%i' % self.bug_id)
796
797         # TODO: set properties for missing bugfields
798         # The problem here is that the property doesn't know its own name,
799         # otherwise we could just do .refresh() and return __dict__[f] after.
800         # basically I need a suicide property that can replace itself after
801         # it's called. Or something.
802         #for f in bugzilla.bugfields:
803         #    if f in self.__dict__: continue
804         #    setattr(self,f,property(fget=lambda self: self.refresh()))
805
806     def __str__(self):
807         '''Return a simple string representation of this bug'''
808         # XXX Not really sure why we get short_desc sometimes and
809         # short_short_desc other times. I feel like I'm working around
810         # a bug here, so keep an eye on this.
811         if 'short_short_desc' in self.__dict__:
812             desc = self.short_short_desc
813         elif 'short_desc' in self.__dict__:
814             desc = self.short_desc
815         elif 'summary' in self.__dict__:
816             desc = self.summary
817         else:
818             log.warn("Weird; this bug has no summary?")
819             desc = "[ERROR: SUMMARY MISSING]"
820             log.debug(self.__dict__)
821         # Some BZ3 implementations give us an ID instead of a name.
822         if 'assigned_to' not in self.__dict__:
823             if 'assigned_to_id' in self.__dict__:
824                 self.assigned_to = self.bugzilla._getuserforid(self.assigned_to_id)
825         return "#%-6s %-10s - %s - %s" % (self.bug_id,self.bug_status,
826                                           self.assigned_to,desc)
827     def __repr__(self):
828         return '<Bug #%i on %s at %#x>' % (self.bug_id,self.bugzilla.url,
829                                            id(self))
830
831     def __getattr__(self,name):
832         if 'bug_id' in self.__dict__:
833             if self.bugzilla.bugfields and name not in self.bugzilla.bugfields:
834                 # We have a list of fields, and you ain't on it. Bail out.
835                 raise AttributeError, "field %s not in bugzilla.bugfields" % name
836             #print "Bug %i missing %s - loading" % (self.bug_id,name)
837             self.refresh()
838             if name in self.__dict__:
839                 return self.__dict__[name]
840         raise AttributeError, "Bug object has no attribute '%s'" % name
841
842     def refresh(self):
843         '''Refresh all the data in this Bug.'''
844         r = self.bugzilla._getbug(self.bug_id)
845         self.__dict__.update(r)
846
847     def reload(self): 
848         '''An alias for reload()'''
849         self.refresh()
850
851     def setstatus(self,status,comment='',private=False,private_in_it=False,nomail=False):
852         '''Update the status for this bug report. 
853         Valid values for status are listed in querydefaults['bug_status_list']
854         Commonly-used values are ASSIGNED, MODIFIED, and NEEDINFO.
855         To change bugs to CLOSED, use .close() instead.
856         See Bugzilla._setstatus() for details.'''
857         self.bugzilla._setstatus(self.bug_id,status,comment,private,private_in_it,nomail)
858         # TODO reload bug data here?
859
860     def setassignee(self,assigned_to='',reporter='',qa_contact='',comment=''):
861         '''Set any of the assigned_to, reporter, or qa_contact fields to a new
862         bugzilla account, with an optional comment, e.g.
863         setassignee(reporter='sadguy@brokencomputer.org',
864                     assigned_to='wwoods@redhat.com')
865         setassignee(qa_contact='wwoods@redhat.com',comment='wwoods QA ftw')
866         You must set at least one of the three assignee fields, or this method
867         will throw a ValueError.
868         Returns [bug_id, mailresults].'''
869         if not (assigned_to or reporter or qa_contact):
870             # XXX is ValueError the right thing to throw here?
871             raise ValueError, "You must set one of assigned_to, reporter, or qa_contact"
872         # empty fields are ignored, so it's OK to send 'em
873         r = self.bugzilla._setassignee(self.bug_id,assigned_to=assigned_to,
874                 reporter=reporter,qa_contact=qa_contact,comment=comment)
875         # TODO reload bug data here?
876         return r
877     def addcomment(self,comment,private=False,timestamp='',worktime='',bz_gid=''):
878         '''Add the given comment to this bug. Set private to True to mark this
879         comment as private. You can also set a timestamp for the comment, in
880         "YYYY-MM-DD HH:MM:SS" form. Worktime is undocumented upstream.
881         If bz_gid is set, and the entire bug is not already private to that
882         group, this comment will be private.'''
883         self.bugzilla._addcomment(self.bug_id,comment,private,timestamp,
884                                   worktime,bz_gid)
885         # TODO reload bug data here?
886     def close(self,resolution,dupeid=0,fixedin='',comment='',isprivate=False,private_in_it=False,nomail=False):
887         '''Close this bug. 
888         Valid values for resolution are in bz.querydefaults['resolution_list']
889         For bugzilla.redhat.com that's:
890         ['NOTABUG','WONTFIX','DEFERRED','WORKSFORME','CURRENTRELEASE',
891          'RAWHIDE','ERRATA','DUPLICATE','UPSTREAM','NEXTRELEASE','CANTFIX',
892          'INSUFFICIENT_DATA']
893         If using DUPLICATE, you need to set dupeid to the ID of the other bug.
894         If using WORKSFORME/CURRENTRELEASE/RAWHIDE/ERRATA/UPSTREAM/NEXTRELEASE
895           you can (and should) set 'new_fixed_in' to a string representing the 
896           version that fixes the bug.
897         You can optionally add a comment while closing the bug. Set 'isprivate'
898           to True if you want that comment to be private.
899         If you want to suppress sending out mail for this bug closing, set
900           nomail=True.
901         '''
902         self.bugzilla._closebug(self.bug_id,resolution,dupeid,fixedin,
903                                 comment,isprivate,private_in_it,nomail)
904         # TODO reload bug data here?
905     def _dowhiteboard(self,text,which,action):
906         '''Actually does the updateWhiteboard call to perform the given action
907         (append,prepend,overwrite) with the given text on the given whiteboard
908         for the given bug.'''
909         self.bugzilla._updatewhiteboard(self.bug_id,text,which,action)
910         # TODO reload bug data here?
911
912     def getwhiteboard(self,which='status'):
913         '''Get the current value of the whiteboard specified by 'which'.
914         Known whiteboard names: 'status','internal','devel','qa'.
915         Defaults to the 'status' whiteboard.'''
916         return getattr(self,"%s_whiteboard" % which)
917     def appendwhiteboard(self,text,which='status'):
918         '''Append the given text (with a space before it) to the given 
919         whiteboard. Defaults to using status_whiteboard.'''
920         self._dowhiteboard(text,which,'append')
921     def prependwhiteboard(self,text,which='status'):
922         '''Prepend the given text (with a space following it) to the given
923         whiteboard. Defaults to using status_whiteboard.'''
924         self._dowhiteboard(text,which,'prepend')
925     def setwhiteboard(self,text,which='status'):
926         '''Overwrites the contents of the given whiteboard with the given text.
927         Defaults to using status_whiteboard.'''
928         self._dowhiteboard(text,which,'overwrite')
929     def addtag(self,tag,which='status'):
930         '''Adds the given tag to the given bug.'''
931         whiteboard = self.getwhiteboard(which)
932         if whiteboard:
933             self.appendwhiteboard(tag,which)
934         else:
935             self.setwhiteboard(tag,which)
936     def gettags(self,which='status'):
937         '''Get a list of tags (basically just whitespace-split the given
938         whiteboard)'''
939         return self.getwhiteboard(which).split()
940     def deltag(self,tag,which='status'):
941         '''Removes the given tag from the given bug.'''
942         tags = self.gettags(which)
943         tags.remove(tag)
944         self.setwhiteboard(' '.join(tags),which)
945     def addcc(self,cclist,comment=''):
946         '''Adds the given email addresses to the CC list for this bug.
947         cclist: list of email addresses (strings)
948         comment: optional comment to add to the bug'''
949         self.bugzilla.updatecc(self.bug_id,cclist,'add',comment)
950     def deletecc(self,cclist,comment=''):
951         '''Removes the given email addresses from the CC list for this bug.'''
952         self.bugzilla.updatecc(self.bug_id,cclist,'delete',comment)
953 # TODO: attach(file), getflag(), setflag()
954 # TODO: add a sync() method that writes the changed data in the Bug object
955 # back to Bugzilla?