summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorWill Woods <wwoods@redhat.com>2008-09-04 14:04:44 -0400
committerWill Woods <wwoods@redhat.com>2008-09-04 14:04:44 -0400
commitbcb002af27ee4a224be7578ded886471231e598e (patch)
treeda21e70d6243862417b99a7efff1786340ae64e9
parent041a27dbc66b007f2e4c7132c229f14af5163982 (diff)
parentce753c44a0445b796cd19851cba8dff2e5d455d0 (diff)
downloadpython-bugzilla-bcb002af27ee4a224be7578ded886471231e598e.zip
python-bugzilla-bcb002af27ee4a224be7578ded886471231e598e.tar.gz
python-bugzilla-bcb002af27ee4a224be7578ded886471231e598e.tar.xz
Merge from abstractify branch
-rw-r--r--.gitignore1
-rw-r--r--MANIFEST.in2
-rw-r--r--README11
-rw-r--r--TODO14
-rwxr-xr-xbin/bugzilla (renamed from bugzilla)235
-rw-r--r--bugzilla.162
-rw-r--r--bugzilla/__init__.py74
-rw-r--r--bugzilla/base.py (renamed from bugzilla.py)607
-rw-r--r--bugzilla/bugzilla3.py139
-rw-r--r--bugzilla/rhbugzilla.py503
-rwxr-xr-xselftest.py125
-rw-r--r--setup.py8
12 files changed, 1367 insertions, 414 deletions
diff --git a/.gitignore b/.gitignore
index 7335c53..0eaa88b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
*.swp
MANIFEST
dist
+build
diff --git a/MANIFEST.in b/MANIFEST.in
index 9aca33d..ba370ea 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1 +1 @@
-include COPYING TODO selftest.py MANIFEST.in
+include COPYING TODO README selftest.py MANIFEST.in
diff --git a/README b/README
index 99e399a..202e41c 100644
--- a/README
+++ b/README
@@ -3,18 +3,19 @@ over XMLRPC.
Currently it targets Red Hat's xmlrpc services, because:
a) That's what the Fedora project uses, and
-b) Even if it is ugly, it's got more methods than the Bugzilla 3.0 API
+b) Even if it is ugly, it's got more methods than the Bugzilla 3.x API
-In the near future (see TODO) we may support the Bugzilla 3.0 API, although
-it probably won't not support all the same methods as the RHBugzilla class.
+In the near future (see TODO) we may fully support the Bugzilla 3.x API,
+although it still probably won't not support all the same methods as the
+RHBugzilla class.
In the long-term future, Red Hat is planning on porting their interfaces to
the Bugzilla 3.0 framework and contributing them to upstream Bugzilla, so in
time we may drop the Red Hat implementation in favor of one unified Bugzilla
-interface. Won't that be nice?
+interface. Won't that be nice?
Comments, suggestions, and - most of all - patches are welcomed and encouraged.
Enjoy.
-Will Woods <wwoods@redhat.com>, 7 Sep 2007
+Will Woods <wwoods@redhat.com>, 25 Mar 2008
diff --git a/TODO b/TODO
index f09ae8b..5e342ac 100644
--- a/TODO
+++ b/TODO
@@ -1,4 +1,10 @@
-- Bugzilla class should be renamed RHBugzilla
-- Create a Bugzilla3 class that uses the Bugzilla3.0 web services
-- connect method should move out of Bugzilla class, return one of the two
- above classes (depending on what type of bugzilla instance we're talking to)
+- better documentation for abstract methods in BugzillaBase
+- more consistent calls for abstract methods
+- make the abstract methods return stuff closer to Bugzilla3's return values
+- make Bugzilla methods all take a bug ID list
+ - BZ 3 methods all take idlist
+ - RHBZ can use multicall to emulate that
+- auto-generate the man page
+- actually install the man page
+- Document the 0.x API as it stands
+- Work on a cleaner 1.x API
diff --git a/bugzilla b/bin/bugzilla
index 865095d..c55efc8 100755
--- a/bugzilla
+++ b/bin/bugzilla
@@ -1,7 +1,7 @@
#!/usr/bin/python
# bugzilla - a commandline frontend for the python bugzilla module
#
-# Copyright (C) 2007 Red Hat Inc.
+# Copyright (C) 2007,2008 Red Hat Inc.
# Author: Will Woods <wwoods@redhat.com>
#
# This program is free software; you can redistribute it and/or modify it
@@ -13,8 +13,9 @@
import bugzilla, optparse
import os, sys, glob, re
import logging
+import getpass
-version = '0.2'
+version = '0.4'
default_bz = 'https://bugzilla.redhat.com/xmlrpc.cgi'
# Initial simple logging stuff
@@ -25,18 +26,7 @@ if '--debug' in sys.argv:
elif '--verbose' in sys.argv:
log.setLevel(logging.INFO)
-def findcookie():
- globs = ['~/.mozilla/firefox/*default*/cookies.txt']
- for g in globs:
- log.debug("Looking for cookies.txt in %s", g)
- cookiefiles = glob.glob(os.path.expanduser(g))
- if cookiefiles:
- # return the first one we find.
- # TODO: find all cookiefiles, sort by age, use newest
- return cookiefiles[0]
-cookiefile = None
-
-cmdlist = ('info','query','new','modify')
+cmdlist = ('info','query','new','modify','login')
def setup_parser():
u = "usage: %prog [global options] COMMAND [options]"
u += "\nCommands: %s" % ', '.join(cmdlist)
@@ -46,9 +36,9 @@ def setup_parser():
p.add_option('--bugzilla',default=default_bz,
help="bugzilla XMLRPC URI. default: %s" % default_bz)
p.add_option('--user',
- help="username. Will attempt to use browser cookie if not specified.")
+ help="username")
p.add_option('--password',
- help="password. Will attempt to use browser cookie if not specified.")
+ help="password")
p.add_option('--cookiefile',
help="cookie file to use for bugzilla authentication")
p.add_option('--verbose',action='store_true',
@@ -84,37 +74,76 @@ def setup_action_parser(action):
help="OPTIONAL: URL for further bug info")
p.add_option('--cc',
help="OPTIONAL: add emails to initial CC list")
- p.add_option('--blocked',
- help="OPTIONAL: block bugs with this new bug")
- p.add_option('--dependson',
- help="OPTIONAL: mark this bug as depending on this list of bugs", default=' ')
- # TODO: alias, assigned_to, reporter, qa_contact, dependson, blocked
+
elif action == 'query':
+ # General bug metadata
+ p.add_option('-b','--bug_id',
+ help="specify individual bugs by IDs, separated with commas")
p.add_option('-p','--product',
- help="product name (list with 'bugzilla info -p')")
+ help="product name, comma-separated (list with 'bugzilla info -p')")
p.add_option('-v','--version',
help="product version")
p.add_option('-c','--component',
- help="component name (list with 'bugzilla info -c PRODUCT')")
+ help="component name(s), comma-separated (list with 'bugzilla info -c PRODUCT')")
+ p.add_option('--components_file',
+ help="list of component names from a file, one component per line (list with 'bugzilla info -c PRODUCT')")
p.add_option('-l','--long_desc',
help="search inside bug comments")
p.add_option('-s','--short_desc',
help="search bug summaries")
+ p.add_option('-t','--bug_status',default="NEW",
+ help="comma-separated list of bug statuses to accept [Default:NEW] [Available:NEW,ASSIGNED,NEEDINFO,ON_DEV,MODIFIED,POST,ON_QA,FAILS_QA,PASSES_QA,REOPENED,VERIFIED,RELEASE_PENDING,CLOSED]")
+ p.add_option('-x','--severity',
+ help="search severities, comma-separated")
+ p.add_option('-z','--priority',
+ help="search priorities, comma-separated")
+
+ # Email
+ p.add_option('-E','--emailtype',
+ help="Email: specify searching option for emails, ie. substring,notsubstring,exact,... [Default: substring]",default="substring")
p.add_option('-o','--cc',
- help="search cc lists for given address")
+ help="Email: search cc lists for given address")
p.add_option('-r','--reporter',
- help="search for bugs reported by this address")
+ help="Email: search reporter email for given address")
p.add_option('-a','--assigned_to',
- help="search for bugs assigned to this address")
+ help="Email: search for bugs assigned to this address")
+ p.add_option('-q','--qa_contact',
+ help="Email: search for bugs which have QA Contact assigned to this address")
+
+ # Strings
+ p.add_option('-u','--url',
+ help="search keywords field for given url")
+ p.add_option('-U','--url_type',
+ help="specify searching option for urls, ie. anywords,allwords,nowords")
+ p.add_option('-k','--keywords',
+ help="search keywords field for specified words")
+ p.add_option('-K','--keywords_type',
+ help="specify searching option for keywords, ie. anywords,allwords,nowords")
+ p.add_option('-w','--status_whiteboard',
+ help="search Status Whiteboard field for specified words")
+ p.add_option('-W','--status_whiteboard_type',
+ help="specify searching option for Status Whiteboard, ie. anywords,allwords,nowords")
+
+ # Boolean Charts
+ p.add_option('-B','--booleantype',
+ help="specify searching option for booleans, ie. substring,notsubstring,exact,... [Default: substring]",default="substring")
+ p.add_option('--boolean_query',
+ help="Boolean:Create your own query. Format: BooleanName-Condition-Parameter &/| ... . ie, keywords-substring-Partner & keywords-notsubstring-OtherQA")
p.add_option('--blocked',
- help="search for bugs that block this bug ID")
+ help="Boolean:search for bugs that block this bug ID")
p.add_option('--dependson',
- help="search for bugs that depend on this bug ID")
- p.add_option('-b','--bug_id',
- help="specify individual bugs by IDs, separated with commas")
- p.add_option('-t','--bug_status','--status',
- default="NEW,VERIFIED,ASSIGNED,NEEDINFO,ON_DEV,FAILS_QA,REOPENED",
- help="comma-separated list of bug statuses to accept")
+ help="Boolean:search for bugs that depend on this bug ID")
+ p.add_option('--flag',
+ help="Boolean:search for bugs that have certain flag states present")
+ p.add_option('--qa_whiteboard',
+ help="Boolean:search for bugs that have certain QA Whiteboard text present")
+ p.add_option('--devel_whiteboard',
+ help="Boolean:search for bugs that have certain Devel Whiteboard text present")
+ p.add_option('--alias',
+ help="Boolean:search for bugs that have the provided alias")
+ p.add_option('--fixed_in',
+ help="search Status Whiteboard field for specified words")
+
elif action == 'info':
p.add_option('-p','--products',action='store_true',
help='Get a list of products')
@@ -139,12 +168,14 @@ def setup_action_parser(action):
const='full',default='normal',help="output detailed bug info")
p.add_option('-i','--ids',action='store_const',dest='output',
const='ids',help="output only bug IDs")
+ p.add_option('-e','--extra',action='store_const',dest='output',
+ const='extra',help="output additional bug information (keywords, Whiteboards, etc.)")
p.add_option('--outputformat',
help="Print output in the form given. You can use RPM-style "+
"tags that match bug fields, e.g.: '%{bug_id}: %{short_desc}'")
return p
-if __name__ == '__main__':
+def main():
# Set up parser for global args
parser = setup_parser()
# Parse the commandline, woo
@@ -161,19 +192,40 @@ if __name__ == '__main__':
# Connect to bugzilla
log.info('Connecting to %s',global_opt.bugzilla)
bz=bugzilla.Bugzilla(url=global_opt.bugzilla)
- if global_opt.user and global_opt.password:
+
+ # Handle 'login' action
+ if action == 'login':
+ if not global_opt.user:
+ sys.stdout.write('Username: ')
+ user = sys.stdin.readline()
+ global_opt.user = user.strip()
+ if not global_opt.password:
+ global_opt.password = getpass.getpass()
+ sys.stdout.write('Logging in... ')
+ # XXX NOTE: This will return success if you have a valid login cookie,
+ # even if you give a bad username and password. WEIRD.
+ if bz.login(global_opt.user,global_opt.password):
+ print 'Authorization cookie received.'
+ sys.exit(0)
+ else:
+ print 'failed.'
+ sys.exit(1)
+
+ # Set up authentication
+ if global_opt.user:
+ if not global_opt.password:
+ global_opt.password = getpass.getpass()
log.info('Using username/password for authentication')
bz.login(global_opt.user,global_opt.password)
- elif global_opt.cookiefile:
- log.info('Using cookies in %s for authentication', global_opt.cookiefile)
- bz.readcookiefile(global_opt.cookiefile)
else:
- cookiefile = findcookie()
- if cookiefile:
+ if global_opt.cookiefile:
+ bz.cookiefile = global_opt.cookiefile
+ cookiefile = bz.cookiefile
+ if os.path.exists(cookiefile):
log.info('Using cookies in %s for authentication', cookiefile)
- bz.readcookiefile(cookiefile)
else:
- parser.error("Could not find a Firefox cookie file. Try --user/--password.")
+ # FIXME check to see if .bugzillarc is in use
+ log.info('No authentication info provided.')
# And now we actually execute the given command
buglist = list() # save the results of query/new/modify here
@@ -207,27 +259,85 @@ if __name__ == '__main__':
q = dict()
email_count = 1
chart_id = 0
- for a in ('product','component','version','long_desc','bug_id',
- 'short_desc','cc','assigned_to','reporter','bug_status',
- 'blocked','dependson'):
+ for a in ('product','component','components_file','version','long_desc','bug_id',
+ 'short_desc','cc','assigned_to','reporter','qa_contact','bug_status',
+ 'blocked','dependson','keywords','keywords_type','url','url_type','status_whiteboard',
+ 'status_whiteboard_type','fixed_in','fixed_in_type','flag','alias','qa_whiteboard',
+ 'devel_whiteboard','boolean_query','severity','priority'):
if hasattr(opt,a):
i = getattr(opt,a)
if i:
if a in ('bug_status'): # list args
- q[a] = i.split(',')
- elif a in ('cc','assigned_to','reporter'):
- # the email query fields are kind of weird - thanks
- # to Florian La Roche for figuring this bit out.
+ # FIXME: statuses can differ between bugzilla instances..
+ if i == 'ALL':
+ # Alias for all available bug statuses
+ q[a] = 'NEW,ASSIGNED,NEEDINFO,ON_DEV,MODIFIED,POST,ON_QA,FAILS_QA,PASSES_QA,REOPENED,VERIFIED,RELEASE_PENDING,CLOSED'.split(',')
+ elif i == 'DEV':
+ # Alias for all development bug statuses
+ q[a] = 'NEW,ASSIGNED,NEEDINFO,ON_DEV,MODIFIED,POST,REOPENED'.split(',')
+ elif i == 'QE':
+ # Alias for all QE relevant bug statuses
+ q[a] = 'ASSIGNED,ON_QA,FAILS_QA,PASSES_QA'.split(',')
+ elif i == 'EOL':
+ # Alias for EndOfLife bug statuses
+ q[a] = 'VERIFIED,RELEASE_PENDING,CLOSED'.split(',')
+ else:
+ q[a] = i.split(',')
+ elif a in ('cc','assigned_to','reporter','qa_contact'):
+ # Emails
# ex.: {'email1':'foo@bar.com','emailcc1':True}
q['email%i' % email_count] = i
q['email%s%i' % (a,email_count)] = True
+ q['emailtype%i' % email_count] = opt.emailtype
email_count += 1
- elif a in ('blocked','dependson'):
- # Chart args are weird.
- q['field%i-0-0' % chart_id] = a
- q['type%i-0-0' % chart_id] = 'equals'
+ elif a in ('components_file'):
+ # Components slurped in from file (one component per line)
+ # This can be made more robust
+ arr = []
+ f = open (i, 'r')
+ for line in f.readlines():
+ line = line.rstrip("\n")
+ arr.append(line)
+ q['component'] = ",".join(arr)
+ elif a in ('keywords','keywords_type','url','url_type','status_whiteboard',
+ 'status_whiteboard_type','severity','priority'):
+ if a in ('url'):
+ q['bug_file_loc'] = i
+ elif a in ('url'):
+ q['bug_file_loc_type'] = i
+ else:
+ q['%s' % a] = i
+ elif a in ('fixed_in','blocked','dependson','flag','qa_whiteboard','devel_whiteboard','alias'):
+ # Boolean Charts
+ if a in ('flag'):
+ # Flags have strange parameter name
+ q['field%i-0-0' % chart_id] = 'flagtypes.name'
+ else:
+ q['field%i-0-0' % chart_id] = a
q['value%i-0-0' % chart_id] = i
+ q['type%i-0-0' % chart_id] = opt.booleantype
chart_id += 1
+ elif a in ('boolean_query'):
+ # Custom Boolean Chart query
+ # Format: BooleanName-Condition-Parameter &/| BooleanName-Condition-Parameter &/| ...
+ # ie, keywords-substring-Partner | keywords-notsubstring-PartnerVerified & keywords-notsubstring-OtherQA
+ chart_id = 0
+ and_count = 0
+ or_count = 0
+ # Manually specified boolean query
+ x = i.split(' ')
+ for par in x :
+ if par.find('&') != -1:
+ and_count += 1
+ elif par.find('|') != -1:
+ or_count += 1
+ elif par.find('-') != -1:
+ args = par.split('-')
+ q['field%i-%i-%i' % (chart_id,and_count,or_count)] = args[0]
+ q['type%i-%i-%i' % (chart_id,and_count,or_count)] = args[1]
+ q['value%i-%i-%i' % (chart_id,and_count,or_count)] = args[2]
+ else:
+ parser.error('Malformed boolean query: %s' % i)
else:
q[a] = i
log.debug("bz.query: %s", q)
@@ -237,7 +347,7 @@ if __name__ == '__main__':
data = dict()
required=['product','component','version','short_desc','comment',
'rep_platform','bug_severity','op_sys','bug_file_loc','priority']
- optional=['cc', 'blocked', 'dependson']
+ optional=['cc']
for a in required + optional:
i = getattr(opt,a)
if i:
@@ -302,5 +412,22 @@ if __name__ == '__main__':
elif opt.output == 'normal':
for b in buglist:
print b
+ elif opt.output == 'extra':
+ print "Grabbing 'extra' bug information. This could take a moment."
+ fullbuglist = bz.getbugs([b.bug_id for b in buglist])
+ for b in fullbuglist:
+ print b
+ if b.keywords: print " +Keywords: ",b.keywords
+ if b.qa_whiteboard: print " +QA Whiteboard: ",b.qa_whiteboard
+ if b.status_whiteboard: print " +Status Whiteboard: ",b.status_whiteboard
+ if b.devel_whiteboard: print " +Devel Whiteboard: ",b.devel_whiteboard
+ print "\nBugs listed: ",len(buglist)
else:
parser.error("opt.output was set to something weird.")
+
+if __name__ == '__main__':
+ try:
+ main()
+ except KeyboardInterrupt:
+ print "\ninterrupted."
+ sys.exit(0)
diff --git a/bugzilla.1 b/bugzilla.1
index 91e6b9a..d94e83d 100644
--- a/bugzilla.1
+++ b/bugzilla.1
@@ -1,12 +1,12 @@
-.TH bugzilla 1 "December 12, 2007" "version 0.1" "USER COMMANDS"
+.TH bugzilla 1 "March 25, 2008" "version 0.5" "User Commands"
.SH NAME
-bugzilla - command-line interface to Bugzilla over XML-RPC
+bugzilla \- command-line interface to Bugzilla over XML-RPC
.SH SYNOPSIS
.B bugzilla
-[options] [command] [command-options]
+[\fIoptions\fR] [\fIcommand\fR] [\fIcommand-options\fR]
.SH DESCRIPTION
.PP
-.B bugzilla
+.BR bugzilla
is a command-line utility that allows access to the XML-RPC interface provided
by Bugzilla.
.PP
@@ -19,22 +19,58 @@ by Bugzilla.
.I \fR * modify - modify existing bugs
.br
.I \fR * info - get info about the given bugzilla instance
-.SH "GENERAL OPTIONS"
-These options apply to any command.
+.SH "OPTIONS"
+These options apply to all commands. They must come before the command name.
.PP
-.IP "\fB\-h, \-\-help\fP"
+.IP "\fB\-h\fR, \fB\-\-help\fP"
Displays a help message and then quits. If given after the command,
this will give command-specific help.
-.IP "\fB\-\-bugzilla=BUGZILLA\fP"
-.IP "\fB\-\-user=USER\fP"
-.IP "\fB\-\-password=PASSWORD\fP"
-.IP "\fB\-\-cookiefile=COOKIEFILE\fP"
+.IP "\fB\-\-bugzilla\fP=\fIURL\fP"
+URL for the XML-RPC interface provided by the bugzilla instance. Typically
+something like https://bugzilla.redhat.com/xmlrpc.cgi.
+.IP "\fB\-\-user\fP=\fIUSER\fP"
+Bugzilla username. If \fIuser\fP and \fIpassword\fP are not specified,
+.BR bugzilla
+will try to use cookie authentication instead.
+.IP "\fB\-\-password\fP=\fIPASSWORD\fP"
+Bugzilla password. If \fIuser\fP and \fIpassword\fP are not specified,
+.BR bugzilla
+will try to use cookie authentication instead.
+.IP "\fB\-\-cookiefile\fP=\fICOOKIEFILE\fP"
+Cookie file to use for bugzilla authentication. If not specified,
+.BR bugzilla
+will search the user's home directory for
+.BR firefox (1)
+or
+.BR mozilla (1)
+style cookie files.
.IP "\fB\-\-verbose\fP"
+Give some extra information about what's going on.
.IP "\fB\-\-debug\fP"
+Give lots of noisy debugging info about every step of the process.
+.PP
+These options apply to the \fIinfo\fP command.
+.IP "\fB\-h\fR, \fB\-\-help\fP"
+Show usage information for the \fIinfo\fP command.
+.IP "\fB\-p\fR, \fB\-\-products\fP"
+Show a list of products.
+.IP "\fB\-c\fR, \fB\-\-components\fP=\fIPRODUCT\fP"
+List the components in the given product.
+.IP "\fB\-o\fR, \fB\-\-component_owners\fP=\fIPRODUCT\fP"
+List components and their owners.
+.IP "\fB\-v\fR, \fB\-\-versions\fP=\fIPRODUCT\fP"
+List the versions for the given product.
.SH EXAMPLES
.TP
-TODO.
+bugzilla query --bug_id 62037
.SH EXIT STATUS
-Also TODO.
+.BR bugzilla
+currently returns 0 for all operations. Sorry about that.
+.SH NOTES
+Not everything that's exposed in the Web UI is exposed by XML-RPC, and not
+everything that's exposed by XML-RPC is used by
+.BR bugzilla .
+.SH BUGS
+Bugs? In a sub-1.0 release? Preposterous.
.SH AUTHOR
Will Woods <wwoods@redhat.com>
diff --git a/bugzilla/__init__.py b/bugzilla/__init__.py
new file mode 100644
index 0000000..564f00b
--- /dev/null
+++ b/bugzilla/__init__.py
@@ -0,0 +1,74 @@
+# python-bugzilla - a Python interface to bugzilla using xmlrpclib.
+#
+# Copyright (C) 2007,2008 Red Hat Inc.
+# Author: Will Woods <wwoods@redhat.com>
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the
+# Free Software Foundation; either version 2 of the License, or (at your
+# option) any later version. See http://www.gnu.org/copyleft/gpl.html for
+# the full text of the license.
+
+from bugzilla3 import Bugzilla3, Bugzilla32
+from rhbugzilla import RHBugzilla, RHBugzilla3
+from base import version
+import xmlrpclib
+import logging
+log = logging.getLogger("bugzilla")
+
+def getBugzillaClassForURL(url):
+ log.debug("Choosing subclass for %s" % url)
+ s = xmlrpclib.ServerProxy(url)
+ rhbz = False
+ bzversion = ''
+ c = None
+
+ # Check for a RH-only method
+ try:
+ log.debug("Checking for RH Bugzilla method bugzilla.getProdInfo()")
+ prodinfo = s.bugzilla.getProdInfo()
+ rhbz = True
+ except xmlrpclib.Fault:
+ pass
+ log.debug("rhbz=%s" % str(rhbz))
+
+ # Try to get the bugzilla version string
+ try:
+ log.debug("Checking return value of Buzilla.version()")
+ r = s.Bugzilla.version()
+ bzversion = r['version']
+ except xmlrpclib.Fault:
+ pass
+ log.debug("bzversion='%s'" % str(bzversion))
+
+ # XXX note preference order: RHBugzilla* wins if available
+ # RH BZ 3.2 will have rhbz == True and bzversion == 3.1.x or 3.2.x.
+ if rhbz:
+ if bzversion.startswith('3.'):
+ c = RHBugzilla3
+ else:
+ c = RHBugzilla
+ elif bzversion.startswith('3.'):
+ if bzversion.startswith('3.0'):
+ c = Bugzilla3
+ else: # 3.1 or higher
+ c = Bugzilla32
+
+ return c
+
+class Bugzilla(object):
+ '''Magical Bugzilla class that figures out which Bugzilla implementation
+ to use and uses that. Requires 'url' parameter so we can check available
+ XMLRPC methods to determine the Bugzilla version.'''
+ def __init__(self,**kwargs):
+ log.info("Bugzilla v%s initializing" % base.version)
+ if 'url' in kwargs:
+ c = getBugzillaClassForURL(kwargs['url'])
+ if c:
+ self.__class__ = c
+ c.__init__(self,**kwargs)
+ log.info("Chose subclass %s v%s" % (c.__name__,c.version))
+ else:
+ raise ValueError, "Couldn't determine Bugzilla version for %s" % kwargs['url']
+ else:
+ raise TypeError, "You must pass a valid bugzilla xmlrpc.cgi URL"
diff --git a/bugzilla.py b/bugzilla/base.py
index cf9a709..b316cac 100644
--- a/bugzilla.py
+++ b/bugzilla/base.py
@@ -1,6 +1,6 @@
-# bugzilla.py - a Python interface to bugzilla.redhat.com, using xmlrpclib.
+# base.py - the base classes etc. for a Python interface to bugzilla
#
-# Copyright (C) 2007 Red Hat Inc.
+# Copyright (C) 2007,2008 Red Hat Inc.
# Author: Will Woods <wwoods@redhat.com>
#
# This program is free software; you can redistribute it and/or modify it
@@ -14,11 +14,13 @@ try:
import cookielib
except ImportError:
import ClientCookie as cookielib
-import os.path, base64, copy
+import os.path, base64
+import logging
+log = logging.getLogger('bugzilla')
-version = '0.3'
-user_agent = 'bugzilla.py/%s (Python-urllib2/%s)' % \
- (version,urllib2.__version__)
+version = '0.4'
+user_agent = 'Python-urllib2/%s bugzilla.py/%s' % \
+ (urllib2.__version__,version)
def replace_getbug_errors_with_None(rawlist):
'''r is a raw xmlrpc response.
@@ -35,33 +37,54 @@ def replace_getbug_errors_with_None(rawlist):
result.append(None)
return result
-class Bugzilla(object):
+class BugzillaBase(object):
'''An object which represents the data and methods exported by a Bugzilla
instance. Uses xmlrpclib to do its thing. You'll want to create one thusly:
bz=Bugzilla(url='https://bugzilla.redhat.com/xmlrpc.cgi',user=u,password=p)
- If you so desire, you can use cookie headers for authentication instead.
- So you could do:
- cf=glob(os.path.expanduser('~/.mozilla/firefox/default.*/cookies.txt'))
- bz=Bugzilla(url=url,cookies=cf)
- and, assuming you have previously logged info bugzilla with firefox, your
- pre-existing auth cookie would be used, thus saving you the trouble of
- stuffing your username and password in the bugzilla call.
- On the other hand, this currently munges up the cookie so you'll have to
- log back in when you next use bugzilla in firefox. So this is not
- currently recommended.
-
- The methods which start with a single underscore are thin wrappers around
- xmlrpc calls; those should be safe for multicall usage.
+ You can get authentication cookies by calling the login() method. These
+ cookies will be stored in a MozillaCookieJar-style file specified by the
+ 'cookiefile' attribute (which defaults to ~/.bugzillacookies). Once you
+ get cookies this way, you will be considered logged in until the cookie
+ expires.
+
+ You may also specify 'user' and 'password' in a bugzillarc file, either
+ /etc/bugzillarc or ~/.bugzillarc. The latter will override the former.
+ The format works like this:
+ [bugzilla.yoursite.com]
+ user = username
+ password = password
+ You can also use the [DEFAULT] section to set defaults that apply to
+ any site without a specific section of its own.
+ Be sure to set appropriate permissions on bugzillarc if you choose to
+ store your password in it!
+
+ This is an abstract class; it must be implemented by a concrete subclass
+ which actually connects the methods provided here to the appropriate
+ methods on the bugzilla instance.
'''
def __init__(self,**kwargs):
# Settings the user might want to tweak
self.user = ''
self.password = ''
self.url = ''
+ self.cookiefile = os.path.expanduser('~/.bugzillacookies')
+ self.user_agent = user_agent
+ self.logged_in = False
# Bugzilla object state info that users shouldn't mess with
+ self.init_private_data()
+ if 'url' in kwargs:
+ self.connect(kwargs['url'])
+ if 'user' in kwargs:
+ self.user = kwargs['user']
+ if 'password' in kwargs:
+ self.password = kwargs['password']
+
+ def init_private_data(self):
+ '''initialize private variables used by this bugzilla instance.'''
self._cookiejar = None
self._proxy = None
+ self._transport = None
self._opener = None
self._querydata = None
self._querydefaults = None
@@ -69,99 +92,157 @@ class Bugzilla(object):
self._bugfields = None
self._components = dict()
self._components_details = dict()
- if 'cookies' in kwargs:
- self.readcookiefile(kwargs['cookies'])
- if 'url' in kwargs:
- self.connect(kwargs['url'])
- if 'user' in kwargs:
- self.user = kwargs['user']
- if 'password' in kwargs:
- self.password = kwargs['password']
#---- Methods for establishing bugzilla connection and logging in
- def readcookiefile(self,cookiefile):
- '''Read the given (Mozilla-style) cookie file and fill in the cookiejar,
- allowing us to use the user's saved credentials to access bugzilla.'''
- cj = cookielib.MozillaCookieJar()
- cj.load(cookiefile)
+ def initcookiefile(self,cookiefile=None):
+ '''Read the given (Mozilla-style) cookie file and fill in the
+ cookiejar, allowing us to use saved credentials to access Bugzilla.
+ If no file is given, self.cookiefile will be used.'''
+ if cookiefile:
+ self.cookiefile = cookiefile
+ cj = cookielib.MozillaCookieJar(self.cookiefile)
+ if os.path.exists(self.cookiefile):
+ cj.load()
+ else:
+ # Create an empty file that's only readable by this user
+ old_umask = os.umask(0077)
+ f = open(self.cookiefile,"w")
+ f.close()
+ os.umask(old_umask)
self._cookiejar = cj
- self._cookiejar.filename = cookiefile
+ self._cookiejar.filename = self.cookiefile
+
+ configpath = ['/etc/bugzillarc','~/.bugzillarc']
+ def readconfig(self,configpath=None):
+ '''Read bugzillarc file(s) into memory.'''
+ import ConfigParser
+ if not configpath:
+ configpath = self.configpath
+ configpath = [os.path.expanduser(p) for p in configpath]
+ c = ConfigParser.SafeConfigParser()
+ r = c.read(configpath)
+ if not r:
+ return
+ # See if we have a config section that matches this url.
+ section = ""
+ # Substring match - prefer the longest match found
+ log.debug("Searching for config section matching %s" % self.url)
+ for s in sorted(c.sections(), lambda a,b: cmp(len(a),len(b)) or cmp(a,b)):
+ if s in self.url:
+ log.debug("Found matching section: %s" % s)
+ section = s
+ if not section:
+ return
+ for k,v in c.items(section):
+ if k in ('user','password'):
+ log.debug("Setting '%s' from configfile" % k)
+ setattr(self,k,v)
def connect(self,url):
- '''Connect to the bugzilla instance with the given url.'''
+ '''Connect to the bugzilla instance with the given url.
+
+ This will also read any available config files (see readconfig()),
+ which may set 'user' and 'password'.
+
+ If 'user' and 'password' are both set, we'll run login(). Otherwise
+ you'll have to login() yourself before some methods will work.
+ '''
# Set up the transport
+ self.initcookiefile() # sets _cookiejar
if url.startswith('https'):
self._transport = SafeCookieTransport()
else:
self._transport = CookieTransport()
- self._transport.user_agent = user_agent
- self._transport.cookiejar = self._cookiejar or cookielib.CookieJar()
+ self._transport.user_agent = self.user_agent
+ self._transport.cookiejar = self._cookiejar
# Set up the proxy, using the transport
self._proxy = xmlrpclib.ServerProxy(url,self._transport)
# Set up the urllib2 opener (using the same cookiejar)
handler = urllib2.HTTPCookieProcessor(self._cookiejar)
self._opener = urllib2.build_opener(handler)
- self._opener.addheaders = [('User-agent',user_agent)]
+ self._opener.addheaders = [('User-agent',self.user_agent)]
self.url = url
+ self.readconfig() # we've changed URLs - reload config
+ if (self.user and self.password):
+ log.info("user and password present - doing login()")
+ self.login()
+
+ def disconnect(self):
+ '''Disconnect from the given bugzilla instance.'''
+ self.init_private_data() # clears all the connection state
# Note that the bugzilla methods will ignore an empty user/password if you
# send authentication info as a cookie in the request headers. So it's
# OK if we keep sending empty / bogus login info in other methods.
- def login(self,user,password):
+ def _login(self,user,password):
+ '''IMPLEMENT ME: backend login method'''
+ raise NotImplementedError
+
+ def login(self,user=None,password=None):
'''Attempt to log in using the given username and password. Subsequent
method calls will use this username and password. Returns False if
- login fails, otherwise returns a dict of user info.
-
- Note that it is not required to login before calling other methods;
- you may just set user and password and call whatever methods you like.
+ login fails, otherwise returns some kind of login info - typically
+ either a numeric userid, or a dict of user info. It also sets the
+ logged_in attribute to True, if successful.
+
+ If user is not set, the value of Bugzilla.user will be used. If *that*
+ is not set, ValueError will be raised.
+
+ This method will be called implicitly at the end of connect() if user
+ and password are both set. So under most circumstances you won't need
+ to call this yourself.
'''
- self.user = user
- self.password = password
+ if user:
+ self.user = user
+ if password:
+ self.password = password
+
+ if not self.user:
+ raise ValueError, "missing username"
+ if not self.password:
+ raise ValueError, "missing password"
+
try:
- r = self._proxy.bugzilla.login(self.user,self.password)
+ r = self._login(self.user,self.password)
+ self.logged_in = True
+ log.info("login successful - dropping password from memory")
+ self.password = ''
except xmlrpclib.Fault, f:
r = False
return r
- #---- Methods and properties with basic bugzilla info
+ def _logout(self):
+ '''IMPLEMENT ME: backend login method'''
+ raise NotImplementedError
- def _multicall(self):
- '''This returns kind of a mash-up of the Bugzilla object and the
- xmlrpclib.MultiCall object. Methods you call on this object will be added
- to the MultiCall queue, but they will return None. When you're ready, call
- the run() method and all the methods in the queue will be run and the
- results of each will be returned in a list. So, for example:
-
- mc = bz._multicall()
- mc._getbug(1)
- mc._getbug(1337)
- mc._query({'component':'glibc','product':'Fedora','version':'devel'})
- (bug1, bug1337, queryresult) = mc.run()
-
- Note that you should only use the raw xmlrpc calls (mostly the methods
- starting with an underscore). Normal getbug(), for example, tries to
- return a Bug object, but with the multicall object it'll end up empty
- and, therefore, useless.
-
- Further note that run() returns a list of raw xmlrpc results; you'll
- need to wrap the output in Bug objects yourself if you're doing that
- kind of thing. For example, Bugzilla.getbugs() could be implemented:
-
- mc = self._multicall()
- for id in idlist:
- mc._getbug(id)
- rawlist = mc.run()
- return [Bug(self,dict=b) for b in rawlist]
- '''
- mc = copy.copy(self)
- mc._proxy = xmlrpclib.MultiCall(self._proxy)
- def run(): return mc._proxy().results
- mc.run = run
- return mc
+ def logout(self):
+ '''Log out of bugzilla. Drops server connection and user info, and
+ destroys authentication cookies.'''
+ self._logout()
+ self.disconnect()
+ self.user = ''
+ self.password = ''
+ self.logged_in = False
+
+ #---- Methods and properties with basic bugzilla info
def _getbugfields(self):
- return self._proxy.bugzilla.getBugFields(self.user,self.password)
+ '''IMPLEMENT ME: Get bugfields from Bugzilla.'''
+ raise NotImplementedError
+ def _getqueryinfo(self):
+ '''IMPLEMENT ME: Get queryinfo from Bugzilla.'''
+ raise NotImplementedError
+ def _getproducts(self):
+ '''IMPLEMENT ME: Get product info from Bugzilla.'''
+ raise NotImplementedError
+ def _getcomponentsdetails(self,product):
+ '''IMPLEMENT ME: get component details for a product'''
+ raise NotImplementedError
+ def _getcomponents(self,product):
+ '''IMPLEMENT ME: Get component dict for a product'''
+ raise NotImplementedError
+
def getbugfields(self,force_refresh=False):
'''Calls getBugFields, which returns a list of fields in each bug
for this bugzilla instance. This can be used to set the list of attrs
@@ -181,8 +262,6 @@ class Bugzilla(object):
bugfields = property(fget=lambda self: self.getbugfields(),
fdel=lambda self: setattr(self,'_bugfields',None))
- def _getqueryinfo(self):
- return self._proxy.bugzilla.getQueryInfo(self.user,self.password)
def getqueryinfo(self,force_refresh=False):
'''Calls getQueryInfo, which returns a (quite large!) structure that
contains all of the query data and query defaults for the bugzilla
@@ -203,10 +282,14 @@ class Bugzilla(object):
querydefaults = property(fget=lambda self: self.getqueryinfo()[1],
fdel=lambda self: setattr(self,"_querydefaults",None))
- def _getproducts(self):
- return self._proxy.bugzilla.getProdInfo(self.user, self.password)
def getproducts(self,force_refresh=False):
- '''Return a dict of product names and product descriptions.'''
+ '''Get product data: names, descriptions, etc.
+ The data varies between Bugzilla versions but the basic format is a
+ list of dicts, where the dicts will have at least the following keys:
+ {'id':1,'name':"Some Product",'description':"This is a product"}
+
+ Any method that requires a 'product' can be given either the
+ id or the name.'''
if force_refresh or not self._products:
self._products = self._getproducts()
return self._products
@@ -214,9 +297,18 @@ class Bugzilla(object):
# call and return it for each subsequent call.
products = property(fget=lambda self: self.getproducts(),
fdel=lambda self: setattr(self,'_products',None))
+ def _product_id_to_name(self,productid):
+ '''Convert a product ID (int) to a product name (str).'''
+ # This will auto-create the 'products' list
+ for p in self.products:
+ if p['id'] == productid:
+ return p['name']
+ def _product_name_to_id(self,product):
+ '''Convert a product name (str) to a product ID (int).'''
+ for p in self.products:
+ if p['name'] == product:
+ return p['id']
- def _getcomponents(self,product):
- return self._proxy.bugzilla.getProdCompInfo(product,self.user,self.password)
def getcomponents(self,product,force_refresh=False):
'''Return a dict of components:descriptions for the given product.'''
if force_refresh or product not in self._components:
@@ -224,12 +316,6 @@ class Bugzilla(object):
return self._components[product]
# TODO - add a .components property that acts like a dict?
- def _getcomponentsdetails(self,product):
- '''Returns a list of dicts giving details about the components in the
- given product. Each item has the following keys:
- component, description, initialowner, initialqacontact, initialcclist
- '''
- return self._proxy.bugzilla.getProdCompDetails(product,self.user,self.password)
def getcomponentsdetails(self,product,force_refresh=False):
'''Returns a dict of dicts, containing detailed component information
for the given product. The keys of the dict are component names. For
@@ -252,85 +338,32 @@ class Bugzilla(object):
d = self.getcomponentsdetails(product,force_refresh)
return d[component]
- def _get_info(self,product=None):
- '''This is a convenience method that does getqueryinfo, getproducts,
- and (optionally) getcomponents in one big fat multicall. This is a bit
- faster than calling them all separately.
-
- If you're doing interactive stuff you should call this, with the
- appropriate product name, after connecting to Bugzilla. This will
- cache all the info for you and save you an ugly delay later on.'''
- mc = self._multicall()
- mc._getqueryinfo()
- mc._getproducts()
- mc._getbugfields()
- if product:
- mc._getcomponents(product)
- mc._getcomponentsdetails(product)
- r = mc.run()
- (self._querydata,self._querydefaults) = r.pop(0)
- self._products = r.pop(0)
- self._bugfields = r.pop(0)
- if product:
- self._components[product] = r.pop(0)
- self._components_details[product] = r.pop(0)
-
#---- Methods for reading bugs and bug info
- # Return raw dicts
def _getbug(self,id):
- '''Return a dict of full bug info for the given bug id'''
- return self._proxy.bugzilla.getBug(id, self.user, self.password)
- def _getbugsimple(self,id):
- '''Return a short dict of simple bug info for the given bug id'''
- r = self._proxy.bugzilla.getBugSimple(id, self.user, self.password)
- if r and 'bug_id' not in r:
- # XXX hurr. getBugSimple doesn't fault if the bug is missing.
- # Let's synthesize one ourselves.
- raise xmlrpclib.Fault("Server","Could not load bug %s" % id)
- else:
- return r
+ '''IMPLEMENT ME: Return a dict of full bug info for the given bug id'''
+ raise NotImplementedError
def _getbugs(self,idlist):
- '''Like _getbug, but takes a list of ids and returns a corresponding
- list of bug objects. Uses multicall for awesome speed.'''
- mc = self._multicall()
- for id in idlist:
- mc._getbug(id)
- raw_results = mc.run()
- del mc
- # check results for xmlrpc errors, and replace them with None
- return replace_getbug_errors_with_None(raw_results)
+ '''IMPLEMENT ME: Return a list of full bug dicts, one for each of the
+ given bug ids'''
+ raise NotImplementedError
+ def _getbugsimple(self,id):
+ '''IMPLEMENT ME: Return a short dict of simple bug info for the given
+ bug id'''
+ raise NotImplementedError
def _getbugssimple(self,idlist):
- '''Like _getbugsimple, but takes a list of ids and returns a
- corresponding list of bug objects. Uses multicall for awesome speed.'''
- mc = self._multicall()
- for id in idlist:
- mc._getbugsimple(id)
- raw_results = mc.run()
- del mc
- # check results for xmlrpc errors, and replace them with None
- return replace_getbug_errors_with_None(raw_results)
+ '''IMPLEMENT ME: Return a list of short bug dicts, one for each of the
+ given bug ids'''
+ raise NotImplementedError
def _query(self,query):
- '''Query bugzilla and return a list of matching bugs.
- query must be a dict with fields like those in in querydata['fields'].
-
- Returns a dict like this: {'bugs':buglist,
- 'displaycolumns':columnlist,
- 'sql':querystring}
-
- buglist is a list of dicts describing bugs. You can specify which
- columns/keys will be listed in the bugs by setting 'column_list' in
- the query; otherwise the default columns are used (see the list in
- querydefaults['default_column_list']). The list of columns will be
- in 'displaycolumns', and the SQL query used by this query will be in
- 'sql'.
- '''
- return self._proxy.bugzilla.runQuery(query,self.user,self.password)
+ '''IMPLEMENT ME: Query bugzilla and return a list of matching bugs.'''
+ raise NotImplementedError
# these return Bug objects
def getbug(self,id):
'''Return a Bug object with the full complement of bug data
already loaded.'''
+ log.debug("getbug(%i)" % id)
return Bug(bugzilla=self,dict=self._getbug(id))
def getbugsimple(self,id):
'''Return a Bug object given bug id, populated with simple info'''
@@ -349,8 +382,9 @@ class Bugzilla(object):
def query(self,query):
'''Query bugzilla and return a list of matching bugs.
query must be a dict with fields like those in in querydata['fields'].
-
Returns a list of Bug objects.
+ Also see the _query() method for details about the underlying
+ implementation.
'''
r = self._query(query)
return [Bug(bugzilla=self,dict=b) for b in r['bugs']]
@@ -374,117 +408,56 @@ class Bugzilla(object):
# Bugzilla.setstatus(id,status) ->
# Bug.setstatus(status): self.bugzilla.setstatus(self.bug_id,status)
+ # FIXME inconsistent method signatures
+ # FIXME add more comments on proper implementation
def _addcomment(self,id,comment,private=False,
timestamp='',worktime='',bz_gid=''):
- '''Add a comment to the bug with the given ID. Other optional
- arguments are as follows:
- private: if True, mark this comment as private.
- timestamp: comment timestamp, in the form "YYYY-MM-DD HH:MM:SS"
- worktime: amount of time spent on this comment (undoc in upstream)
- bz_gid: if present, and the entire bug is *not* already private
- to this group ID, this comment will be marked private.
- '''
- return self._proxy.bugzilla.addComment(id,comment,
- self.user,self.password,private,timestamp,worktime,bz_gid)
-
+ '''IMPLEMENT ME: add a comment to the given bug ID'''
+ raise NotImplementedError
def _setstatus(self,id,status,comment='',private=False,private_in_it=False,nomail=False):
- '''Set the status of the bug with the given ID. You may optionally
- include a comment to be added, and may further choose to mark that
- comment as private.
- The status may be anything from querydefaults['bug_status_list'].
- Common statuses: 'NEW','ASSIGNED','MODIFIED','NEEDINFO'
- Less common: 'VERIFIED','ON_DEV','ON_QA','REOPENED'
- 'CLOSED' is not valid with this method; use closebug() instead.
- '''
- return self._proxy.bugzilla.changeStatus(id,status,
- self.user,self.password,comment,private,private_in_it,nomail)
-
- def _setassignee(self,id,**data):
- '''Raw xmlrpc call to set one of the assignee fields on a bug.
- changeAssignment($id, $data, $username, $password)
- data: 'assigned_to','reporter','qa_contact','comment'
- returns: [$id, $mailresults]'''
- return self._proxy.bugzilla.changeAssignment(id,data,self.user,self.password)
-
+ '''IMPLEMENT ME: Set the status of the given bug ID'''
+ raise NotImplementedError
def _closebug(self,id,resolution,dupeid,fixedin,comment,isprivate,private_in_it,nomail):
- '''Raw xmlrpc call for closing bugs. Documentation from Bug.pm is
- below. Note that we drop the username and password fields because the
- Bugzilla object contains them already.
-
- closeBug($bugid, $new_resolution, $username, $password, $dupeid,
- $new_fixed_in, $comment, $isprivate, $private_in_it, $nomail)
-
- Close a current Bugzilla bug report with a specific resolution. This will eventually be done in Bugzilla/Bug.pm
- instead and is meant to only be a quick fix. Please use bugzilla.changesStatus to changed to an opened state.
- This method will change the bug report's status to CLOSED.
-
- $bugid
- # ID of bug report to add comment to.
- $new_resolution
- # Valid Bugzilla resolution to transition the report into.
- # DUPLICATE requires $dupeid to be passed in.
- $dupeid
- # Bugzilla report ID that this bug is being closed as
- # duplicate of.
- # Requires $new_resolution to be DUPLICATE.
- $new_fixed_in
- # OPTIONAL String representing version of product/component
- # that bug is fixed in.
- $comment
- # OPTIONAL Text string containing comment to add.
- $isprivate
- # OPTIONAL Whether the comment will be private to the
- # 'private_comment' Bugzilla group.
- # Default: false
- $private_in_it
- # OPTIONAL if true will make the comment private in
- # Issue Tracker
- # Default: follows $isprivate
- $nomail
- # OPTIONAL Flag that is either 1 or 0 if you want email to be sent or not for this change
+ '''IMPLEMENT ME: close the given bug ID'''
+ raise NotImplementedError
+ def _setassignee(self,id,**data):
+ '''IMPLEMENT ME: set the assignee of the given bug ID'''
+ raise NotImplementedError
+ def _updatedeps(self,id,blocked,dependson,action):
+ '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug.
+ blocked, dependson: list of bug ids/aliases
+ action: 'add' or 'delete'
'''
- return self._proxy.bugzilla.closeBug(id,resolution,self.user,self.password,
- dupeid,fixedin,comment,isprivate,private_in_it,nomail)
-
- def _updatedeps(self,id,deplist):
- #updateDepends($bug_id,$data,$username,$password,$nodependencyemail)
- #data: 'blocked'=>id,'dependson'=>id,'action' => ('add','remove')
raise NotImplementedError
-
def _updatecc(self,id,cclist,action,comment='',nomail=False):
- '''Updates the CC list using the action and account list specified.
+ '''IMPLEMENT ME: Update the CC list using the action and account list
+ specified.
cclist must be a list (not a tuple!) of addresses.
- action may be 'add', 'remove', or 'makeexact'.
+ action may be 'add', 'delete', or 'overwrite'.
comment specifies an optional comment to add to the bug.
if mail is True, email will be generated for this change.
+ Note that using 'overwrite' may result in up to three XMLRPC calls
+ (fetch list, remove each element, add new elements). Avoid if possible.
'''
- data = {'id':id, 'action':action, 'cc':','.join(cclist),
- 'comment':comment, 'nomail':nomail}
- return self._proxy.bugzilla.updateCC(data,self.user,self.password)
-
+ raise NotImplementedError
def _updatewhiteboard(self,id,text,which,action):
- '''Update the whiteboard given by 'which' for the given bug.
- performs the given action (which may be 'append',' prepend', or
+ '''IMPLEMENT ME: Update the whiteboard given by 'which' for the given
+ bug. performs the given action (which may be 'append',' prepend', or
'overwrite') using the given text.'''
- data = {'type':which,'text':text,'action':action}
- return self._proxy.bugzilla.updateWhiteboard(id,data,self.user,self.password)
-
- # TODO: update this when the XMLRPC interface grows requestee support
+ raise NotImplementedError
def _updateflags(self,id,flags):
'''Updates the flags associated with a bug report.
data should be a hash of {'flagname':'value'} pairs, like so:
{'needinfo':'?','fedora-cvs':'+'}
- You may also add a "nomail":1 item, which will suppress email if set.
-
- NOTE: the Red Hat XMLRPC interface does not yet support setting the
- requestee (as in: needinfo from smartguy@answers.com). Alas.'''
- return self._proxy.bugzilla.updateFlags(id,flags,self.user,self.password)
+ You may also add a "nomail":1 item, which will suppress email if set.'''
+ raise NotImplementedError
#---- Methods for working with attachments
- def __attachment_encode(self,fh):
+ def _attachment_encode(self,fh):
'''Return the contents of the file-like object fh in a form
- appropriate for attaching to a bug in bugzilla.'''
+ appropriate for attaching to a bug in bugzilla. This is the default
+ encoding method, base64.'''
# Read data in chunks so we don't end up with two copies of the file
# in RAM.
chunksize = 3072 # base64 encoding wants input in multiples of 3
@@ -498,6 +471,25 @@ class Bugzilla(object):
chunk = fh.read(chunksize)
return data
+ def _attachfile(self,id,**attachdata):
+ '''IMPLEMENT ME: attach a file to the given bug.
+ attachdata MUST contain the following keys:
+ data: File data, encoded in the bugzilla-preferred format.
+ attachfile() will encode it with _attachment_encode().
+ description: Short description of this attachment.
+ filename: Filename for the attachment.
+ The following optional keys may also be added:
+ comment: An optional comment about this attachment.
+ isprivate: Set to True if the attachment should be marked private.
+ ispatch: Set to True if the attachment is a patch.
+ contenttype: The mime-type of the attached file. Defaults to
+ application/octet-stream if not set. NOTE that text
+ files will *not* be viewable in bugzilla unless you
+ remember to set this to text/plain. So remember that!
+ Returns (attachment_id,mailresults).
+ '''
+ raise NotImplementedError
+
def attachfile(self,id,attachfile,description,**kwargs):
'''Attach a file to the given bug ID. Returns the ID of the attachment
or raises xmlrpclib.Fault if something goes wrong.
@@ -530,15 +522,20 @@ class Bugzilla(object):
# TODO: guess contenttype?
if 'contenttype' not in kwargs:
kwargs['contenttype'] = 'application/octet-stream'
- kwargs['data'] = self.__attachment_encode(f)
- (attachid, mailresults) = self._proxy.bugzilla.addAttachment(id,kwargs,self.user,self.password)
+ kwargs['data'] = self._attachment_encode(f)
+ (attachid, mailresults) = self._attachfile(id,kwargs)
return attachid
+ def _attachment_uri(self,attachid):
+ '''Returns the URI for the given attachment ID.'''
+ att_uri = self._url.replace('xmlrpc.cgi','attachment.cgi')
+ att_uri = att_uri + '?%i' % attachid
+ return att_uri
+
def openattachment(self,attachid):
'''Get the contents of the attachment with the given attachment ID.
Returns a file-like object.'''
- att_uri = self._url.replace('xmlrpc.cgi','attachment.cgi')
- att_uri = att_uri + '?%i' % attachid
+ att_uri = self._attachment_uri(attachid)
att = urllib2.urlopen(att_uri)
# RFC 2183 defines the content-disposition header, if you're curious
disp = att.headers['content-disposition'].split(';')
@@ -552,14 +549,18 @@ class Bugzilla(object):
#---- createbug - big complicated call to create a new bug
+ # Default list of required fields for createbug
+ createbug_required = ('product','component','version','short_desc','comment',
+ 'rep_platform','bug_severity','op_sys','bug_file_loc')
+
def _createbug(self,**data):
- '''Raw xmlrpc call for createBug() Doesn't bother guessing defaults
- or checking argument validity. Use with care.
- Returns [bug_id, mailresults]'''
- return self._proxy.bugzilla.createBug(data,self.user,self.password)
+ '''IMPLEMENT ME: Raw xmlrpc call for createBug()
+ Doesn't bother guessing defaults or checking argument validity.
+ Returns bug_id'''
+ raise NotImplementedError
def createbug(self,check_args=False,**data):
- '''Create a bug with the given info. Returns the bug ID.
+ '''Create a bug with the given info. Returns a new Bug object.
data should be given as keyword args - remember that you can also
populate a dict and call createbug(**dict) to fill in keyword args.
The arguments are as follows. Note that some are optional and some
@@ -621,13 +622,14 @@ class Bugzilla(object):
# OPTIONAL Comma or space separate list of bug id's
# this report depends on.
'''
- required = ('product','component','version','short_desc','comment',
- 'rep_platform','bug_severity','op_sys','bug_file_loc')
# The xmlrpc will raise an error if one of these is missing, but
# let's try to save a network roundtrip here if possible..
- for i in required:
+ for i in self.createbug_required:
if i not in data or not data[i]:
- raise TypeError, "required field missing or empty: '%s'" % i
+ if i == 'bug_file_loc':
+ data[i] = 'http://'
+ else:
+ raise TypeError, "required field missing or empty: '%s'" % i
# Sort of a chicken-and-egg problem here - check_args will save you a
# network roundtrip if your op_sys or rep_platform is bad, but at the
# expense of getting querydefaults, which is.. an added network
@@ -644,10 +646,20 @@ class Bugzilla(object):
# and fill in the blanks with the data given to this method, but the
# server might modify/add/drop stuff. Then we'd have a Bug object that
# lied about the actual contents of the database. That would be bad.
- [bug_id, mail_results] = self._createbug(**data)
+ bug_id = self._createbug(**data)
return Bug(self,bug_id=bug_id)
# Trivia: this method has ~5.8 lines of comment per line of code. Yow!
+class CookieResponse:
+ '''Fake HTTPResponse object that we can fill with headers we got elsewhere.
+ We can then pass it to CookieJar.extract_cookies() to make it pull out the
+ cookies from the set of headers we have.'''
+ def __init__(self,headers):
+ self.headers = headers
+ #log.debug("CookieResponse() headers = %s" % headers)
+ def info(self):
+ return self.headers
+
class CookieTransport(xmlrpclib.Transport):
'''A subclass of xmlrpclib.Transport that supports cookies.'''
cookiejar = None
@@ -656,18 +668,25 @@ class CookieTransport(xmlrpclib.Transport):
# Cribbed from xmlrpclib.Transport.send_user_agent
def send_cookies(self, connection, cookie_request):
if self.cookiejar is None:
+ log.debug("send_cookies(): creating in-memory cookiejar")
self.cookiejar = cookielib.CookieJar()
elif self.cookiejar:
+ log.debug("send_cookies(): using existing cookiejar")
# Let the cookiejar figure out what cookies are appropriate
+ log.debug("cookie_request headers currently: %s" % cookie_request.header_items())
self.cookiejar.add_cookie_header(cookie_request)
+ log.debug("cookie_request headers now: %s" % cookie_request.header_items())
# Pull the cookie headers out of the request object...
cookielist=list()
for h,v in cookie_request.header_items():
if h.startswith('Cookie'):
+ log.debug("sending cookie: %s=%s" % (h,v))
cookielist.append([h,v])
# ...and put them over the connection
for h,v in cookielist:
connection.putheader(h,v)
+ else:
+ log.debug("send_cookies(): cookiejar empty. Nothing to send.")
# This is the same request() method from xmlrpclib.Transport,
# with a couple additions noted below
@@ -677,7 +696,8 @@ class CookieTransport(xmlrpclib.Transport):
h.set_debuglevel(1)
# ADDED: construct the URL and Request object for proper cookie handling
- request_url = "%s://%s/" % (self.scheme,host)
+ request_url = "%s://%s%s" % (self.scheme,host,handler)
+ log.debug("request_url is %s" % request_url)
cookie_request = urllib2.Request(request_url)
self.send_request(h,handler,request_body)
@@ -689,16 +709,17 @@ class CookieTransport(xmlrpclib.Transport):
errcode, errmsg, headers = h.getreply()
# ADDED: parse headers and get cookies here
- # fake a response object that we can fill with the headers above
- class CookieResponse:
- def __init__(self,headers): self.headers = headers
- def info(self): return self.headers
cookie_response = CookieResponse(headers)
# Okay, extract the cookies from the headers
self.cookiejar.extract_cookies(cookie_response,cookie_request)
+ log.debug("cookiejar now contains: %s" % self.cookiejar._cookies)
# And write back any changes
if hasattr(self.cookiejar,'save'):
- self.cookiejar.save(self.cookiejar.filename)
+ try:
+ self.cookiejar.save(self.cookiejar.filename)
+ except e:
+ log.error("Couldn't write cookiefile %s: %s" % \
+ (self.cookiejar.filename,str(e)))
if errcode != 200:
raise xmlrpclib.ProtocolError(
@@ -737,14 +758,19 @@ class Bug(object):
self.bugzilla = bugzilla
self.autorefresh = True
if 'dict' in kwargs and kwargs['dict']:
+ log.debug("Bug(%s)" % kwargs['dict'].keys())
self.__dict__.update(kwargs['dict'])
if 'bug_id' in kwargs:
+ log.debug("Bug(%i)" % kwargs['bug_id'])
setattr(self,'bug_id',kwargs['bug_id'])
if 'autorefresh' in kwargs:
self.autorefresh = kwargs['autorefresh']
# No bug_id? this bug is invalid!
if not hasattr(self,'bug_id'):
- raise TypeError, "Bug object needs a bug_id"
+ if hasattr(self,'id'):
+ self.bug_id = self.id
+ else:
+ raise TypeError, "Bug object needs a bug_id"
self.url = bugzilla.url.replace('xmlrpc.cgi',
'show_bug.cgi?id=%i' % self.bug_id)
@@ -765,8 +791,18 @@ class Bug(object):
# a bug here, so keep an eye on this.
if 'short_short_desc' in self.__dict__:
desc = self.short_short_desc
- else:
+ elif 'short_desc' in self.__dict__:
desc = self.short_desc
+ elif 'summary' in self.__dict__:
+ desc = self.summary
+ else:
+ log.warn("Weird; this bug has no summary?")
+ desc = "[ERROR: SUMMARY MISSING]"
+ log.debug(self.__dict__)
+ # Some BZ3 implementations give us an ID instead of a name.
+ if 'assigned_to' not in self.__dict__:
+ if 'assigned_to_id' in self.__dict__:
+ self.assigned_to = self.bugzilla._getuserforid(self.assigned_to_id)
return "#%-6s %-10s - %s - %s" % (self.bug_id,self.bug_status,
self.assigned_to,desc)
def __repr__(self):
@@ -800,7 +836,7 @@ class Bug(object):
To change bugs to CLOSED, use .close() instead.
See Bugzilla._setstatus() for details.'''
self.bugzilla._setstatus(self.bug_id,status,comment,private,private_in_it,nomail)
- # FIXME reload bug data here
+ # TODO reload bug data here?
def setassignee(self,assigned_to='',reporter='',qa_contact='',comment=''):
'''Set any of the assigned_to, reporter, or qa_contact fields to a new
@@ -817,7 +853,7 @@ class Bug(object):
# empty fields are ignored, so it's OK to send 'em
r = self.bugzilla._setassignee(self.bug_id,assigned_to=assigned_to,
reporter=reporter,qa_contact=qa_contact,comment=comment)
- # FIXME reload bug data here
+ # TODO reload bug data here?
return r
def addcomment(self,comment,private=False,timestamp='',worktime='',bz_gid=''):
'''Add the given comment to this bug. Set private to True to mark this
@@ -827,7 +863,7 @@ class Bug(object):
group, this comment will be private.'''
self.bugzilla._addcomment(self.bug_id,comment,private,timestamp,
worktime,bz_gid)
- # FIXME reload bug data here
+ # TODO reload bug data here?
def close(self,resolution,dupeid=0,fixedin='',comment='',isprivate=False,private_in_it=False,nomail=False):
'''Close this bug.
Valid values for resolution are in bz.querydefaults['resolution_list']
@@ -846,13 +882,13 @@ class Bug(object):
'''
self.bugzilla._closebug(self.bug_id,resolution,dupeid,fixedin,
comment,isprivate,private_in_it,nomail)
- # FIXME reload bug data here
+ # TODO reload bug data here?
def _dowhiteboard(self,text,which,action):
'''Actually does the updateWhiteboard call to perform the given action
(append,prepend,overwrite) with the given text on the given whiteboard
for the given bug.'''
self.bugzilla._updatewhiteboard(self.bug_id,text,which,action)
- # FIXME reload bug data here
+ # TODO reload bug data here?
def getwhiteboard(self,which='status'):
'''Get the current value of the whiteboard specified by 'which'.
@@ -887,5 +923,14 @@ class Bug(object):
tags = self.gettags(which)
tags.remove(tag)
self.setwhiteboard(' '.join(tags),which)
+ def addcc(self,cclist,comment=''):
+ '''Adds the given email addresses to the CC list for this bug.
+ cclist: list of email addresses (strings)
+ comment: optional comment to add to the bug'''
+ self.bugzilla.updatecc(self.bug_id,cclist,'add',comment)
+ def deletecc(self,cclist,comment=''):
+ '''Removes the given email addresses from the CC list for this bug.'''
+ self.bugzilla.updatecc(self.bug_id,cclist,'delete',comment)
+# TODO: attach(file), getflag(), setflag()
# TODO: add a sync() method that writes the changed data in the Bug object
-# back to Bugzilla. Someday.
+# back to Bugzilla?
diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py
new file mode 100644
index 0000000..41a3440
--- /dev/null
+++ b/bugzilla/bugzilla3.py
@@ -0,0 +1,139 @@
+# bugzilla3.py - a Python interface to Bugzilla 3.x using xmlrpclib.
+#
+# Copyright (C) 2008 Red Hat Inc.
+# Author: Will Woods <wwoods@redhat.com>
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the
+# Free Software Foundation; either version 2 of the License, or (at your
+# option) any later version. See http://www.gnu.org/copyleft/gpl.html for
+# the full text of the license.
+
+import bugzilla.base
+
+class Bugzilla3(bugzilla.base.BugzillaBase):
+ '''Concrete implementation of the Bugzilla protocol. This one uses the
+ methods provided by standard Bugzilla 3.0.x releases.'''
+
+ version = '0.1'
+ user_agent = bugzilla.base.user_agent + ' Bugzilla3/%s' % version
+
+ def __init__(self,**kwargs):
+ bugzilla.base.BugzillaBase.__init__(self,**kwargs)
+ self.user_agent = self.__class__.user_agent
+
+ def _login(self,user,password):
+ '''Backend login method for Bugzilla3'''
+ return self._proxy.User.login({'login':user,'password':password})
+
+ def _logout(self):
+ '''Backend login method for Bugzilla3'''
+ return self._proxy.User.logout()
+
+ #---- Methods and properties with basic bugzilla info
+
+ def _getuserforid(self,userid):
+ '''Get the username for the given userid'''
+ # STUB FIXME
+ return str(userid)
+
+ # Connect the backend methods to the XMLRPC methods
+ def _getbugfields(self):
+ '''Get a list of valid fields for bugs.'''
+ # XXX BZ3 doesn't currently provide anything like the getbugfields()
+ # method, so we fake it by looking at bug #1. Yuck.
+ keylist = self._getbug(1).keys()
+ if 'assigned_to' not in keylist:
+ keylist.append('assigned_to')
+ return keylist
+ def _getqueryinfo(self):
+ raise NotImplementedError, "Bugzilla 3.0 does not support this method."
+ def _getproducts(self):
+ '''This throws away a bunch of data that RH's getProdInfo
+ didn't return. Ah, abstraction.'''
+ product_ids = self._proxy.Product.get_accessible_products()
+ r = self._proxy.Product.get_products(product_ids)
+ return r['products']
+ def _getcomponents(self,product):
+ if type(product) == str:
+ product = self._product_name_to_id(product)
+ r = self._proxy.Bug.legal_values({'product_id':product,'field':'component'})
+ return r['values']
+ def _getcomponentsdetails(self,product):
+ raise NotImplementedError
+
+ #---- Methods for reading bugs and bug info
+
+ def _getbugs(self,idlist):
+ '''Return a list of dicts of full bug info for each given bug id'''
+ r = self._proxy.Bug.get_bugs({'ids':idlist})
+ return [i['internals'] for i in r['bugs']]
+ def _getbug(self,id):
+ '''Return a dict of full bug info for the given bug id'''
+ return self._getbugs([id])[0]
+ # Bugzilla3 doesn't have getbugsimple - alias to the full method(s)
+ _getbugsimple = _getbug
+ _getbugssimple = _getbugs
+
+ # Bugzilla 3.0 doesn't have a *lot* of things, actually.
+ def _query(self,query):
+ raise NotImplementedError, "Bugzilla 3.0 does not support this method."
+ def _addcomment(self,id,comment,private=False,
+ timestamp='',worktime='',bz_gid=''):
+ raise NotImplementedError, "Bugzilla 3.0 does not support this method."
+ def _setstatus(self,id,status,comment='',private=False,private_in_it=False,nomail=False):
+ raise NotImplementedError, "Bugzilla 3.0 does not support this method."
+ def _closebug(self,id,resolution,dupeid,fixedin,comment,isprivate,private_in_it,nomail):
+ raise NotImplementedError, "Bugzilla 3.0 does not support this method."
+ def _setassignee(self,id,**data):
+ raise NotImplementedError, "Bugzilla 3.0 does not support this method."
+ def _updatedeps(self,id,deplist):
+ raise NotImplementedError, "Bugzilla 3.0 does not support this method."
+ def _updatecc(self,id,cclist,action,comment='',nomail=False):
+ raise NotImplementedError, "Bugzilla 3.0 does not support this method."
+ def _updatewhiteboard(self,id,text,which,action):
+ raise NotImplementedError, "Bugzilla 3.0 does not support this method."
+ # TODO: update this when the XMLRPC interface grows requestee support
+ def _updateflags(self,id,flags):
+ raise NotImplementedError, "Bugzilla 3.0 does not support this method."
+ def _attachfile(self,id,**attachdata):
+ raise NotImplementedError, "Bugzilla 3.0 does not support this method."
+
+ #---- createbug - call to create a new bug
+
+ createbug_required = ('product','component','summary','version',
+ 'op_sys','platform')
+ def _createbug(self,**data):
+ '''Raw xmlrpc call for createBug() Doesn't bother guessing defaults
+ or checking argument validity. Use with care.
+ Returns bug_id'''
+ r = self._proxy.Bug.create(data)
+ return r['id']
+
+# Bugzilla 3.2 adds some new goodies on top of Bugzilla3.
+# Well, okay. It adds one new goodie.
+class Bugzilla32(Bugzilla3):
+ '''Concrete implementation of the Bugzilla protocol. This one uses the
+ methods provided by standard Bugzilla 3.2.x releases.
+
+ For further information on the methods defined here, see the API docs:
+ http://www.bugzilla.org/docs/3.2/en/html/api/
+ '''
+
+ version = '0.1'
+ user_agent = bugzilla.base.user_agent + ' Bugzilla32/%s' % version
+
+ def _addcomment(self,id,comment,private=False,
+ timestamp='',worktime='',bz_gid=''):
+ '''Add a comment to the bug with the given ID. Other optional
+ arguments are as follows:
+ private: if True, mark this comment as private.
+ timestamp: Ignored by BZ32.
+ worktime: amount of time spent on this comment, in hours
+ bz_gid: Ignored by BZ32.
+ '''
+ return self._proxy.Bug.add_comment({'id':id,
+ 'comment':comment,
+ 'private':private,
+ 'work_time':worktime})
+
diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py
new file mode 100644
index 0000000..47a3e22
--- /dev/null
+++ b/bugzilla/rhbugzilla.py
@@ -0,0 +1,503 @@
+# rhbugzilla.py - a Python interface to Red Hat Bugzilla using xmlrpclib.
+#
+# Copyright (C) 2008 Red Hat Inc.
+# Author: Will Woods <wwoods@redhat.com>
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the
+# Free Software Foundation; either version 2 of the License, or (at your
+# option) any later version. See http://www.gnu.org/copyleft/gpl.html for
+# the full text of the license.
+
+import bugzilla.base
+from bugzilla3 import Bugzilla32
+import copy, xmlrpclib
+
+class RHBugzilla(bugzilla.base.BugzillaBase):
+ '''Concrete implementation of the Bugzilla protocol. This one uses the
+ methods provided by Red Hat's Bugzilla 2.18 variant.
+
+ RHBugzilla supports XMLRPC MultiCall. The methods which start with a
+ single underscore are thin wrappers around XMLRPC methods and should thus
+ be safe for multicall use.
+
+ Documentation for most of these methods can be found here:
+ https://bugzilla.redhat.com/docs/en/html/api/extensions/compat_xmlrpc/code/webservice.html
+ '''
+
+ version = '0.2'
+ user_agent = bugzilla.base.user_agent + ' RHBugzilla/%s' % version
+
+ def __init__(self,**kwargs):
+ bugzilla.base.BugzillaBase.__init__(self,**kwargs)
+ self.user_agent = self.__class__.user_agent
+
+ def _login(self,user,password):
+ '''Backend login method for RHBugzilla.'''
+ return self._proxy.bugzilla.login(user,password)
+
+ def _logout(self):
+ '''Backend logout method for RHBugzilla.'''
+ # "Logouts are not implemented due to the non-session nature of
+ # XML-RPC communication."
+ # That's funny, since we get a (session-based) login cookie...
+ return True
+
+ #---- Methods and properties with basic bugzilla info
+
+ def _multicall(self):
+ '''This returns kind of a mash-up of the Bugzilla object and the
+ xmlrpclib.MultiCall object. Methods you call on this object will be
+ added to the MultiCall queue, but they will return None. When you're
+ ready, call the run() method and all the methods in the queue will be
+ run and the results of each will be returned in a list. So, for example:
+
+ mc = bz._multicall()
+ mc._getbug(1)
+ mc._getbug(1337)
+ mc._query({'component':'glibc','product':'Fedora','version':'devel'})
+ (bug1, bug1337, queryresult) = mc.run()
+
+ Note that you should only use the raw xmlrpc calls (mostly the methods
+ starting with an underscore). Normal getbug(), for example, tries to
+ return a Bug object, but with the multicall object it'll end up empty
+ and, therefore, useless.
+
+ Further note that run() returns a list of raw xmlrpc results; you'll
+ need to wrap the output in Bug objects yourself if you're doing that
+ kind of thing. For example, Bugzilla.getbugs() could be implemented:
+
+ mc = self._multicall()
+ for id in idlist:
+ mc._getbug(id)
+ rawlist = mc.run()
+ return [Bug(self,dict=b) for b in rawlist]
+ '''
+ mc = copy.copy(self)
+ mc._proxy = xmlrpclib.MultiCall(self._proxy)
+ def run(): return mc._proxy().results
+ mc.run = run
+ return mc
+
+ # Connect the backend methods to the XMLRPC methods
+ def _getbugfields(self):
+ return self._proxy.bugzilla.getBugFields()
+ def _getqueryinfo(self):
+ return self._proxy.bugzilla.getQueryInfo()
+ def _getproducts(self):
+ '''Backend _getproducts method for RH Bugzilla. This predates the
+ Bugzilla3 Products stuff, so we need to massage this data to make it
+ fit the proper format'''
+ r = self._proxy.bugzilla.getProdInfo()
+ n = 0
+ prod = []
+ for name,desc in r.iteritems():
+ # We're making up a fake id, since RHBugzilla doesn't use them
+ prod.append({'id':n,'name':name,'description':desc})
+ n += 1
+ return prod
+ def _getcomponents(self,product):
+ if type(product) == int:
+ product = self._product_id_to_name(product)
+ return self._proxy.bugzilla.getProdCompInfo(product)
+ def _getcomponentsdetails(self,product):
+ if type(product) == int:
+ product = self._product_id_to_name(product)
+ return self._proxy.bugzilla.getProdCompDetails(product)
+ def _get_info(self,product=None):
+ '''This is a convenience method that does getqueryinfo, getproducts,
+ and (optionally) getcomponents in one big fat multicall. This is a bit
+ faster than calling them all separately.
+
+ If you're doing interactive stuff you should call this, with the
+ appropriate product name, after connecting to Bugzilla. This will
+ cache all the info for you and save you an ugly delay later on.'''
+ mc = self._multicall()
+ mc._getqueryinfo()
+ mc._getproducts()
+ mc._getbugfields()
+ if product:
+ mc._getcomponents(product)
+ mc._getcomponentsdetails(product)
+ r = mc.run()
+ (self._querydata,self._querydefaults) = r.pop(0)
+ self._products = r.pop(0)
+ self._bugfields = r.pop(0)
+ if product:
+ self._components[product] = r.pop(0)
+ self._components_details[product] = r.pop(0)
+
+ #---- Methods for reading bugs and bug info
+
+ def _getbug(self,id):
+ '''Return a dict of full bug info for the given bug id'''
+ return self._proxy.bugzilla.getBug(id)
+ def _getbugsimple(self,id):
+ '''Return a short dict of simple bug info for the given bug id'''
+ r = self._proxy.bugzilla.getBugSimple(id)
+ if r and 'bug_id' not in r:
+ # XXX hurr. getBugSimple doesn't fault if the bug is missing.
+ # Let's synthesize one ourselves.
+ raise xmlrpclib.Fault("Server","Could not load bug %s" % id)
+ else:
+ return r
+ # Multicall methods
+ def _getbugs(self,idlist):
+ '''Like _getbug, but takes a list of ids and returns a corresponding
+ list of bug objects. Uses multicall for awesome speed.'''
+ mc = self._multicall()
+ for id in idlist:
+ mc._getbug(id)
+ raw_results = mc.run()
+ del mc
+ # check results for xmlrpc errors, and replace them with None
+ return bugzilla.base.replace_getbug_errors_with_None(raw_results)
+ def _getbugssimple(self,idlist):
+ '''Like _getbugsimple, but takes a list of ids and returns a
+ corresponding list of bug objects. Uses multicall for awesome speed.'''
+ mc = self._multicall()
+ for id in idlist:
+ mc._getbugsimple(id)
+ raw_results = mc.run()
+ del mc
+ # check results for xmlrpc errors, and replace them with None
+ return bugzilla.base.replace_getbug_errors_with_None(raw_results)
+
+ def _query(self,query):
+ '''Query bugzilla and return a list of matching bugs.
+ query must be a dict with fields like those in in querydata['fields'].
+ Returns a dict like this: {'bugs':buglist,
+ 'displaycolumns':columnlist,
+ 'sql':querystring}
+ buglist is a list of dicts describing bugs. You can specify which
+ columns/keys will be listed in the bugs by setting 'column_list' in
+ the query; otherwise the default columns are used (see the list in
+ querydefaults['default_column_list']). The list of columns will be
+ in 'displaycolumns', and the SQL query used by this query will be in
+ 'sql'.
+ '''
+ return self._proxy.bugzilla.runQuery(query)
+
+ #---- Methods for modifying existing bugs.
+
+ # Most of these will probably also be available as Bug methods, e.g.:
+ # Bugzilla.setstatus(id,status) ->
+ # Bug.setstatus(status): self.bugzilla.setstatus(self.bug_id,status)
+
+ def _addcomment(self,id,comment,private=False,
+ timestamp='',worktime='',bz_gid=''):
+ '''Add a comment to the bug with the given ID. Other optional
+ arguments are as follows:
+ private: if True, mark this comment as private.
+ timestamp: comment timestamp, in the form "YYYY-MM-DD HH:MM:SS"
+ worktime: amount of time spent on this comment (undoc in upstream)
+ bz_gid: if present, and the entire bug is *not* already private
+ to this group ID, this comment will be marked private.
+ '''
+ return self._proxy.bugzilla.addComment(id,comment,self.user,'',
+ private,timestamp,worktime,bz_gid)
+ def _setstatus(self,id,status,comment='',private=False,private_in_it=False,nomail=False):
+ '''Set the status of the bug with the given ID. You may optionally
+ include a comment to be added, and may further choose to mark that
+ comment as private.
+ The status may be anything from querydefaults['bug_status_list'].
+ Common statuses: 'NEW','ASSIGNED','MODIFIED','NEEDINFO'
+ Less common: 'VERIFIED','ON_DEV','ON_QA','REOPENED'
+ 'CLOSED' is not valid with this method; use closebug() instead.
+ '''
+ return self._proxy.bugzilla.changeStatus(id,status,self.user,'',
+ comment,private,private_in_it,nomail)
+ def _closebug(self,id,resolution,dupeid,fixedin,comment,isprivate,private_in_it,nomail):
+ '''Raw xmlrpc call for closing bugs. Documentation from Bug.pm is
+ below. Note that we drop the username and password fields because the
+ Bugzilla object contains them already.
+
+ closeBug($bugid, $new_resolution, $username, $password, $dupeid,
+ $new_fixed_in, $comment, $isprivate, $private_in_it, $nomail)
+
+ Close a current Bugzilla bug report with a specific resolution. This will eventually be done in Bugzilla/Bug.pm
+ instead and is meant to only be a quick fix. Please use bugzilla.changesStatus to changed to an opened state.
+ This method will change the bug report's status to CLOSED.
+
+ $bugid
+ # ID of bug report to add comment to.
+ $new_resolution
+ # Valid Bugzilla resolution to transition the report into.
+ # DUPLICATE requires $dupeid to be passed in.
+ $dupeid
+ # Bugzilla report ID that this bug is being closed as
+ # duplicate of.
+ # Requires $new_resolution to be DUPLICATE.
+ $new_fixed_in
+ # OPTIONAL String representing version of product/component
+ # that bug is fixed in.
+ $comment
+ # OPTIONAL Text string containing comment to add.
+ $isprivate
+ # OPTIONAL Whether the comment will be private to the
+ # 'private_comment' Bugzilla group.
+ # Default: false
+ $private_in_it
+ # OPTIONAL if true will make the comment private in
+ # Issue Tracker
+ # Default: follows $isprivate
+ $nomail
+ # OPTIONAL Flag that is either 1 or 0 if you want email to be sent or not for this change
+ '''
+ return self._proxy.bugzilla.closeBug(id,resolution,self.user,'',
+ dupeid,fixedin,comment,isprivate,private_in_it,nomail)
+ def _setassignee(self,id,**data):
+ '''Raw xmlrpc call to set one of the assignee fields on a bug.
+ changeAssignment($id, $data, $username, $password)
+ data: 'assigned_to','reporter','qa_contact','comment'
+ returns: [$id, $mailresults]'''
+ return self._proxy.bugzilla.changeAssignment(id,data)
+ def _updatedeps(self,id,blocked,dependson,action):
+ '''update the deps (blocked/dependson) for the given bug.
+ blocked/dependson: list of bug ids/aliases
+ action: 'add' or 'delete'
+
+ RHBZ call:
+ updateDepends($bug_id,$data,$username,$password,$nodependencyemail)
+ #data: 'blocked'=>id,'dependson'=>id,'action' => ('add','remove')
+
+ RHBZ only does one bug at a time, so this method will loop through
+ the blocked/dependson lists. This may be slow.
+ '''
+ r = []
+ # Massage input to match what RHBZ expects
+ if action == 'delete':
+ action == 'remove'
+ data = {'id':id, 'action':action, 'blocked':'', 'dependson':''}
+ for b in blocked:
+ data['blocked'] = b
+ self._proxy.bugzilla.updateDepends(id,data)
+ data['blocked'] = ''
+ for d in dependson:
+ data['dependson'] = d
+ self._proxy.bugzilla.updateDepends(id,data)
+ def _updatecc(self,id,cclist,action,comment='',nomail=False):
+ '''Updates the CC list using the action and account list specified.
+ cclist must be a list (not a tuple!) of addresses.
+ action may be 'add', 'delete', or 'overwrite'.
+ comment specifies an optional comment to add to the bug.
+ if mail is True, email will be generated for this change.
+ '''
+ # Massage the 'action' param into what the old updateCC call expects
+ if action == 'delete':
+ action = 'remove'
+ elif action == 'overwrite':
+ action = 'makeexact'
+ data = {'id':id, 'action':action, 'cc':','.join(cclist),
+ 'comment':comment, 'nomail':nomail}
+ return self._proxy.bugzilla.updateCC(data)
+ def _updatewhiteboard(self,id,text,which,action):
+ '''Update the whiteboard given by 'which' for the given bug.
+ performs the given action (which may be 'append',' prepend', or
+ 'overwrite') using the given text.'''
+ data = {'type':which,'text':text,'action':action}
+ return self._proxy.bugzilla.updateWhiteboard(id,data)
+ # TODO: update this when the XMLRPC interface grows requestee support
+ def _updateflags(self,id,flags):
+ '''Updates the flags associated with a bug report.
+ data should be a hash of {'flagname':'value'} pairs, like so:
+ {'needinfo':'?','fedora-cvs':'+'}
+ You may also add a "nomail":1 item, which will suppress email if set.
+
+ NOTE: the Red Hat XMLRPC interface does not yet support setting the
+ requestee (as in: needinfo from smartguy@answers.com). Alas.'''
+ return self._proxy.bugzilla.updateFlags(id,flags)
+
+ #---- Methods for working with attachments
+
+ # If your bugzilla wants attachments in something other than base64, you
+ # should override _attachment_encode here.
+ # If your bugzilla uses non-standard paths for attachment.cgi, you'll
+ # want to override _attachment_uri here.
+
+ def _attachfile(self,id,**attachdata):
+ return self._proxy.bugzilla.addAttachment(id,attachdata)
+
+ #---- createbug - call to create a new bug
+
+ def _createbug(self,**data):
+ '''Raw xmlrpc call for createBug() Doesn't bother guessing defaults
+ or checking argument validity. Use with care.
+ Returns bug_id'''
+ r = self._proxy.bugzilla.createBug(data)
+ return r[0]
+
+class RHBugzilla3(Bugzilla32, RHBugzilla):
+ '''Concrete implementation of the Bugzilla protocol. This one uses the
+ methods provided by Red Hat's Bugzilla 3.2+ instance, which is a superset
+ of the Bugzilla 3.2 methods. The additional methods (Bug.search, Bug.update)
+ should make their way into a later upstream Bugzilla release (probably 4.0).
+
+ Note that RHBZ3 *also* supports most of the old RHBZ methods, under the
+ 'bugzilla' namespace, so we use those when BZ3 methods aren't available.
+
+ This class was written using bugzilla.redhat.com's API docs:
+ https://bugzilla.redhat.com/docs/en/html/api/
+
+ By default, _getbugs will multicall Bug.get(id) multiple times, rather than
+ doing a single Bug.get(idlist) call. You can disable this behavior by
+ setting the 'multicall' property to False. This is faster, but less
+ compatible with RHBugzilla.
+ '''
+
+ version = '0.1'
+ user_agent = bugzilla.base.user_agent + ' RHBugzilla3/%s' % version
+
+ def __init__(self,**kwargs):
+ Bugzilla32.__init__(self,**kwargs)
+ self.user_agent = self.__class__.user_agent
+ self.multicall = kwargs.get('multicall',True)
+
+ # XXX it'd be nice if this wasn't just a copy of RHBugzilla's _getbugs
+ def _getbugs(self,idlist):
+ r = []
+ if self.multicall:
+ mc = self._multicall()
+ for id in idlist:
+ mc._proxy.bugzilla.getBug(id)
+ raw_results = mc.run()
+ del mc
+ # check results for xmlrpc errors, and replace them with None
+ r = bugzilla.base.replace_getbug_errors_with_None(raw_results)
+ else:
+ raw_results = self._proxy.Bug.get({'ids':idlist})
+ r = [i['internals'] for i in raw_results['bugs']]
+ return r
+
+ def _query(self,query):
+ '''Query bugzilla and return a list of matching bugs.
+ query must be a dict with fields like those in in querydata['fields'].
+ You can also pass in keys called 'quicksearch' or 'savedsearch' -
+ 'quicksearch' will do a quick keyword search like the simple search
+ on the Bugzilla home page.
+ 'savedsearch' should be the name of a previously-saved search to
+ execute. You need to be logged in for this to work.
+ Returns a dict like this: {'bugs':buglist,
+ 'sql':querystring}
+ buglist is a list of dicts describing bugs, and 'sql' contains the SQL
+ generated by executing the search.
+ '''
+ return self._proxy.Bug.search(query)
+
+ #---- Methods for updating bugs.
+
+ def _update_bugs(self,ids,updates):
+ '''Update the given fields with the given data in one or more bugs.
+ ids should be a list of integers or strings, representing bug ids or
+ aliases.
+ updates is a dict containing pairs like so: {'fieldname':'newvalue'}
+ '''
+ # TODO document changeable fields & return values
+ # TODO I think we need to catch XMLRPC exceptions to get a useful
+ # return value
+ return self._proxy.Bug.update({'ids':ids,'updates':updates})
+
+ def _update_bug(self,id,updates):
+ '''Update a single bug, specified by integer ID or (string) bug alias.
+ Really just a convenience method for _update_bugs(ids=[id],updates)'''
+ return self._update_bugs(ids=[id],updates=updates)
+
+ # Eventually - when RHBugzilla is well and truly obsolete - we'll delete
+ # all of these methods and refactor the Base Bugzilla object so all the bug
+ # modification calls go through _update_bug.
+ # Until then, all of these methods are basically just wrappers around it.
+
+ # TODO: allow multiple bug IDs
+
+ def _setstatus(self,id,status,comment='',private=False,private_in_it=False,nomail=False):
+ '''Set the status of the bug with the given ID.'''
+ update={'bug_status':status}
+ if comment:
+ update['comment'] = comment
+ return self._update_bug(id,update)
+
+ def _closebug(self,id,resolution,dupeid,fixedin,comment,isprivate,private_in_it,nomail):
+ '''Close the given bug. This is the raw call, and no data checking is
+ done here. That's up to the closebug method.
+ Note that the private_in_it and nomail args are ignored.'''
+ update={'bug_status':'CLOSED','resolution':resolution}
+ if dupeid:
+ update['resolution'] = 'DUPLICATE'
+ update['dupe_id'] = dupeid
+ if fixedin:
+ update['fixed_in'] = fixedin
+ if comment:
+ update['comment'] = comment
+ if isprivate:
+ update['commentprivacy'] = True
+ return self._update_bug(id,update)
+
+ def _setassignee(self,id,**data):
+ '''Raw xmlrpc call to set one of the assignee fields on a bug.
+ changeAssignment($id, $data, $username, $password)
+ data: 'assigned_to','reporter','qa_contact','comment'
+ returns: [$id, $mailresults]'''
+ # drop empty items
+ update = dict([(k,v) for k,v in data.iteritems() if v != ''])
+ return self._update_bug(id,update)
+
+ def _updatedeps(self,id,blocked,dependson,action):
+ '''Update the deps (blocked/dependson) for the given bug.
+ blocked, dependson: list of bug ids/aliases
+ action: 'add' or 'delete'
+ '''
+ if action not in ('add','delete'):
+ raise ValueError, "action must be 'add' or 'delete'"
+ update={'%s_blocked' % action: blocked,
+ '%s_dependson' % action: dependson}
+ self._update_bug(id,update)
+
+ def _updatecc(self,id,cclist,action,comment='',nomail=False):
+ '''Updates the CC list using the action and account list specified.
+ cclist must be a list (not a tuple!) of addresses.
+ action may be 'add', 'delete', or 'overwrite'.
+ comment specifies an optional comment to add to the bug.
+ if mail is True, email will be generated for this change.
+ '''
+ update = {}
+ if comment:
+ update['comment'] = comment
+
+ if action in ('add','delete'):
+ update['%s_cc' % action] = cclist
+ self._update_bug(id,update)
+ elif action == 'overwrite':
+ r = self._getbug(id)
+ if 'cc' not in r:
+ raise AttributeError, "Can't find cc list in bug %s" % str(id)
+ self._updatecc(id,r['cc'],'delete')
+ self._updatecc(id,cclist,'add')
+ # XXX we don't check inputs on other backend methods, maybe this
+ # is more appropriate in the public method(s)
+ else:
+ raise ValueError, "action must be 'add','delete', or 'overwrite'"
+
+ def _updatewhiteboard(self,id,text,which,action):
+ '''Update the whiteboard given by 'which' for the given bug.
+ performs the given action (which may be 'append',' prepend', or
+ 'overwrite') using the given text.
+
+ RHBZ3 Bug.update() only supports overwriting, so append/prepend
+ may cause two server roundtrips - one to fetch, and one to update.
+ '''
+ if not which.endswith('_whiteboard'):
+ which = which + '_whiteboard'
+ update = {}
+ if action == 'overwrite':
+ update[which] = text
+ else:
+ r = self._getbug(id)
+ if which not in r:
+ raise ValueError, "No such whiteboard %s in bug %s" % \
+ (which,str(id))
+ wb = r[which]
+ if action == 'prepend':
+ update[which] = text+' '+wb
+ elif action == 'append':
+ update[which] = wb+' '+text
+ self._update_bug(id,update)
diff --git a/selftest.py b/selftest.py
index 30f212d..e378b21 100755
--- a/selftest.py
+++ b/selftest.py
@@ -14,81 +14,102 @@ from bugzilla import Bugzilla
import os, glob, sys
import xmlrpclib
-def find_firefox_cookiefile():
- cookieglob = os.path.expanduser('~/.mozilla/firefox/*default*/cookies.txt')
- cookiefiles = glob.glob(cookieglob)
- if cookiefiles:
- # TODO return whichever is newest
- return cookiefiles[0]
+bugzillas = {
+ 'Red Hat':{
+ 'url':'https://bugzilla.redhat.com/xmlrpc.cgi',
+ 'public_bug':427301,
+ 'private_bug':250666,
+ 'bugidlist':(1,2,3,1337),
+ 'query':{'product':'Fedora',
+ 'component':'kernel',
+ 'version':'rawhide'}
+ },
+ 'Bugzilla 3.0':{
+ 'url':'https://landfill.bugzilla.org/bugzilla-3.0-branch/xmlrpc.cgi',
+ 'public_bug':4433,
+ 'private_bug':6620, # FIXME - does this instance have groups?
+ 'bugidlist':(1,2,3,4433),
+ 'query':{'product':'WorldControl',
+ 'component':'WeatherControl',
+ 'version':'1.0'}
+ },
+ }
+
+# TODO: add data for these instances
+# 'https://landfill.bugzilla.org/bugzilla-3.2-branch/xmlrpc.cgi' - BZ3.2
+# 'https://partner-bugzilla.redhat.com/xmlrpc.cgi' - BZ3.2/RH hybrid
-def selftest(user='',password=''):
- url = 'https://partner-bugzilla.redhat.com/xmlrpc.cgi'
- public_bug = 1
- private_bug = 250666
- bugidlist = (1,2,3,1337,123456)
- query = {'product':'Fedora',
- 'component':'kernel',
- 'version':'devel',
- 'long_desc':'wireless'}
-
- print "Woo, welcome to the bugzilla.py self-test."
- print "Using bugzilla at " + url
- if user and password:
- print 'Using username "%s", password "%s"' % (user,password)
- bz = Bugzilla(url=url,user=user,password=password)
+def selftest(data,user='',password=''):
+ print "Using bugzilla at " + data['url']
+ bz = Bugzilla(url=data['url'])
+ print "Bugzilla class: %s" % bz.__class__
+ if not bz.logged_in:
+ if user and password:
+ bz.login(user,password)
+ if bz.logged_in:
+ print "Logged in to bugzilla OK."
else:
- cookies = find_firefox_cookiefile()
- if not cookies:
- print "Could not find any cookies for that URL!"
- print "Log in with firefox or give me a username/password."
- sys.exit(1)
- print "Reading cookies from " + cookies
- bz = Bugzilla(url=url,cookies=cookies)
+ print "Not logged in - create a .bugzillarc or provide user/password"
+ # FIXME: only run some tests if .logged_in
+
print "Reading product list"
- print bz.getproducts()
- print
+ prod = bz.getproducts()
+ prodlist = [p['name'] for p in prod]
+ print "Products found: %s, %s, %s...(%i more)" % \
+ (prodlist[0],prodlist[1],prodlist[2],len(prodlist)-3)
- print "Reading public bug (#%i)" % public_bug
- print bz.getbugsimple(public_bug)
+ p = data['query']['product']
+ assert p in prodlist
+ print "Getting component list for %s" % p
+ comp = bz.getcomponents(p)
+ print "%i components found" % len(comp)
+
+
+ print "Reading public bug (#%i)" % data['public_bug']
+ print bz.getbugsimple(data['public_bug'])
print
- print "Reading private bug (#%i)" % private_bug
+ print "Reading private bug (#%i)" % data['private_bug']
try:
- print bz.getbugsimple(private_bug)
+ print bz.getbugsimple(data['private_bug'])
except xmlrpclib.Fault, e:
if 'NotPermitted' in e.faultString:
print "Failed: Not authorized."
else:
print "Failed: Unknown XMLRPC error: %s" % e
- q_msg = "%s %s %s %s" % (query['product'],query['component'],
- query['version'],query['long_desc'])
print
- print "Reading multiple bugs, one-at-a-time: %s" % str(bugidlist)
- for b in bugidlist:
- print bz.getbugsimple(b)
+ print "Reading multiple bugs, one-at-a-time: %s" % str(data['bugidlist'])
+ for b in data['bugidlist']:
+ print bz.getbug(b)
print
- print "Reading multiple bugs, all-at-once: %s" % str(bugidlist)
- for b in bz.getbugssimple(bugidlist):
+ print "Reading multiple bugs, all-at-once: %s" % str(data['bugidlist'])
+ for b in bz.getbugs(data['bugidlist']):
print b
print
- print "Querying %s bugs" % q_msg
- bugs = bz.query(query)
- print "%s bugs found." % len(bugs)
- for bug in bugs:
- print "Bug %s" % bug
+ print "Querying: %s" % str(data['query'])
+ try:
+ bugs = bz.query(data['query'])
+ print "%s bugs found." % len(bugs)
+ for bug in bugs:
+ print "Bug %s" % bug
+ except NotImplementedError:
+ print "This bugzilla class doesn't support query()."
print
- print "Awesome. We're done."
-
if __name__ == '__main__':
user = ''
password = ''
if len(sys.argv) > 2:
(user,password) = sys.argv[1:3]
- try:
- selftest(user,password)
- except KeyboardInterrupt:
- print "Exiting on keyboard interrupt."
+
+ print "Woo, welcome to the bugzilla.py self-test."
+ for name,data in bugzillas.items():
+ try:
+ selftest(data,user,password)
+ except KeyboardInterrupt:
+ print "Exiting on keyboard interrupt."
+ sys.exit(1)
+ print "Awesome. We're done."
diff --git a/setup.py b/setup.py
index c73b0e4..34fd88f 100644
--- a/setup.py
+++ b/setup.py
@@ -1,13 +1,13 @@
from distutils.core import setup
from glob import glob
-import bugzilla
+import bugzilla.base
setup(name='python-bugzilla',
- version=str(bugzilla.version),
+ version=str(bugzilla.base.version),
description='Bugzilla XMLRPC access module',
author='Will Woods',
author_email='wwoods@redhat.com',
url='http://wwoods.fedorapeople.org/python-bugzilla/',
- py_modules=['bugzilla'],
- scripts=['bugzilla'],
+ packages = ['bugzilla'],
+ scripts=['bin/bugzilla'],
)