From 091e7386f730c17961e8c3b4d80fcf667d29d718 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 25 Mar 2008 17:30:30 -0400 Subject: Woo doggies a big chunk of abstractification. --- MANIFEST.in | 2 +- README | 11 +- TODO | 8 +- bin/bugzilla | 302 +++++++++++++++++ bugzilla | 302 ----------------- bugzilla.py | 887 ------------------------------------------------- bugzilla/__init__.py | 45 +++ bugzilla/base.py | 843 ++++++++++++++++++++++++++++++++++++++++++++++ bugzilla/bugzilla3.py | 189 +++++++++++ bugzilla/rhbugzilla.py | 189 +++++++++++ setup.py | 8 +- 11 files changed, 1583 insertions(+), 1203 deletions(-) create mode 100755 bin/bugzilla delete mode 100755 bugzilla delete mode 100644 bugzilla.py create mode 100644 bugzilla/__init__.py create mode 100644 bugzilla/base.py create mode 100644 bugzilla/bugzilla3.py create mode 100644 bugzilla/rhbugzilla.py 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 , 7 Sep 2007 +Will Woods , 25 Mar 2008 diff --git a/TODO b/TODO index f09ae8b..4d1b6a9 100644 --- a/TODO +++ b/TODO @@ -1,4 +1,4 @@ -- 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) +- flesh out Bugzilla3 class +- 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 diff --git a/bin/bugzilla b/bin/bugzilla new file mode 100755 index 0000000..caeda15 --- /dev/null +++ b/bin/bugzilla @@ -0,0 +1,302 @@ +#!/usr/bin/python +# bugzilla - a commandline frontend for the python bugzilla module +# +# Copyright (C) 2007 Red Hat Inc. +# Author: Will Woods +# +# 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, optparse +import os, sys, glob, re +import logging + +version = '0.2' +default_bz = 'https://bugzilla.redhat.com/xmlrpc.cgi' + +# Initial simple logging stuff +logging.basicConfig() +log = logging.getLogger("bugzilla") +if '--debug' in sys.argv: + log.setLevel(logging.DEBUG) +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') +def setup_parser(): + u = "usage: %prog [global options] COMMAND [options]" + u += "\nCommands: %s" % ', '.join(cmdlist) + p = optparse.OptionParser(usage=u) + p.disable_interspersed_args() + # General bugzilla connection options + 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.") + p.add_option('--password', + help="password. Will attempt to use browser cookie if not specified.") + p.add_option('--cookiefile', + help="cookie file to use for bugzilla authentication") + p.add_option('--verbose',action='store_true', + help="give more info about what's going on") + p.add_option('--debug',action='store_true', + help="output bunches of debugging info") + return p + +def setup_action_parser(action): + p = optparse.OptionParser(usage="usage: %%prog %s [options]" % action) + # TODO: product and version could default to current system + # info (read from /etc/redhat-release?) + if action == 'new': + p.add_option('-p','--product', + help="REQUIRED: product name (list with 'bugzilla info -p')") + p.add_option('-v','--version', + help="REQUIRED: product version") + p.add_option('-c','--component', + help="REQUIRED: component name (list with 'bugzilla info -c PRODUCT')") + p.add_option('-l','--comment', + help="REQUIRED: initial bug comment") + p.add_option('-s','--summary',dest='short_desc', + help="REQUIRED: bug summary") + p.add_option('-o','--os',default='Linux',dest='op_sys', + help="OPTIONAL: operating system (default: Linux)") + p.add_option('-a','--arch',default='All',dest='rep_platform', + help="OPTIONAL: arch this bug occurs on (default: All)") + p.add_option('--severity',default='medium',dest='bug_severity', + help="OPTIONAL: bug severity (default: medium)") + p.add_option('--priority',default='medium',dest='priority', + help="OPTIONAL: bug priority (default: medium)") + p.add_option('-u','--url',dest='bug_file_loc',default='http://', + help="OPTIONAL: URL for further bug info") + p.add_option('--cc', + help="OPTIONAL: add emails to initial CC list") + # TODO: alias, assigned_to, reporter, qa_contact, dependson, blocked + elif action == 'query': + p.add_option('-p','--product', + help="product name (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')") + p.add_option('-l','--long_desc', + help="search inside bug comments") + p.add_option('-s','--short_desc', + help="search bug summaries") + p.add_option('-o','--cc', + help="search cc lists for given address") + p.add_option('-r','--reporter', + help="search for bugs reported by this address") + p.add_option('-a','--assigned_to', + help="search for bugs assigned to this address") + p.add_option('--blocked', + help="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") + elif action == 'info': + p.add_option('-p','--products',action='store_true', + help='Get a list of products') + p.add_option('-c','--components',metavar="PRODUCT", + help='List the components in the given product') + p.add_option('-o','--component_owners',metavar="PRODUCT", + help='List components (and their owners)') + p.add_option('-v','--versions',metavar="PRODUCT", + help='List the versions for the given product') + elif action == 'modify': + p.set_usage("usage: %prog modify [options] BUGID BUGID ...") + p.add_option('-l','--comment', + help='Add a comment') + # FIXME: check value for resolution + p.add_option('-k','--close',metavar="RESOLUTION", + help='Close with the given resolution') + # TODO: --keyword, --flag, --tag, --status, --assignee, --cc, ... + + if action in ('new','query'): + # output modifiers + p.add_option('-f','--full',action='store_const',dest='output', + 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('--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__': + # Set up parser for global args + parser = setup_parser() + # Parse the commandline, woo + (global_opt,args) = parser.parse_args() + # Get our action from these args + if len(args) and args[0] in cmdlist: + action = args.pop(0) + else: + parser.error("command must be one of: %s" % ','.join(cmdlist)) + # Parse action-specific args + action_parser = setup_action_parser(action) + (opt, args) = action_parser.parse_args(args) + + # 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: + 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: + 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.") + + # And now we actually execute the given command + buglist = list() # save the results of query/new/modify here + if action == 'info': + if opt.products: + for k in sorted(bz.products): + print k + + if opt.components: + for c in sorted(bz.getcomponents(opt.components)): + print c + + if opt.component_owners: + component_details = bz.getcomponentsdetails(opt.component_owners) + for c in sorted(component_details): + print "%s: %s" % (c, component_details[c]['initialowner']) + + if opt.versions: + for p in bz.querydata['product']: + if p['name'] == opt.versions: + for v in p['versions']: + print v + + elif action == 'query': + # shortcut for specific bug_ids + if opt.bug_id: + log.debug("bz.getbugs(%s)", opt.bug_id.split(',')) + buglist=bz.getbugs(opt.bug_id.split(',')) + else: + # Construct the query from the list of queryable options + 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'): + 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. + # ex.: {'email1':'foo@bar.com','emailcc1':True} + q['email%i' % email_count] = i + q['email%s%i' % (a,email_count)] = True + 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' + q['value%i-0-0' % chart_id] = i + chart_id += 1 + else: + q[a] = i + log.debug("bz.query: %s", q) + buglist = bz.query(q) + + elif action == 'new': + data = dict() + required=['product','component','version','short_desc','comment', + 'rep_platform','bug_severity','op_sys','bug_file_loc','priority'] + optional=['cc'] + for a in required + optional: + i = getattr(opt,a) + if i: + data[a] = i + for k in required: + if k not in data: + parser.error('Missing required argument: %s' % k) + log.debug("bz.createbug(%s)", data) + b = bz.createbug(**data) + buglist = [b] + + elif action == 'modify': + bugid_list = [] + for a in args: + if ',' in a: + for b in a.split(','): + bugid_list.append(b) + else: + bugid_list.append(a) + # Surely there's a simpler way to do that.. + # bail out if no bugs were given + if not bugid_list: + parser.error('No bug IDs given (maybe you forgot an argument somewhere?)') + # Iterate over a list of Bug objects + # FIXME: this should totally use some multicall magic + buglist = bz.getbugssimple(bugid_list) + for id,bug in zip(bugid_list,buglist): + if not bug: + log.error(" failed to load bug %s" % id) + continue + log.debug("modifying bug %s" % bug.bug_id) + if opt.comment: + log.debug(" add comment: %s" % opt.comment) + bug.addcomment(opt.comment) + if opt.close: + log.debug(" close %s" % opt.close) + bug.close(opt.close) + else: + print "Sorry - '%s' not implemented yet." % action + + # If we're doing new/query/modify, output our results + if action in ('new','query'): + if opt.outputformat: + format_field_re = re.compile("%{[a-z0-9_]+}") + def bug_field(matchobj): + fieldname = matchobj.group()[2:-1] + return str(getattr(b,fieldname)) + for b in buglist: + print format_field_re.sub(bug_field,opt.outputformat) + elif opt.output == 'ids': + for b in buglist: + print b.bug_id + elif opt.output == 'full': + fullbuglist = bz.getbugs([b.bug_id for b in buglist]) + for b in fullbuglist: + print b + if b.cc: print "CC: %s" % " ".join(b.cc) + if b.blocked: print "Blocked: %s" % " ".join([str(i) for i in b.blocked]) + if b.dependson: print "Depends: %s" % " ".join([str(i) for i in b.dependson]) + for c in b.longdescs: + print "* %s - %s (%s):\n%s\n" % (c['time'],c['name'],c['email'] or c['safe_email'],c['body']) + elif opt.output == 'normal': + for b in buglist: + print b + else: + parser.error("opt.output was set to something weird.") diff --git a/bugzilla b/bugzilla deleted file mode 100755 index caeda15..0000000 --- a/bugzilla +++ /dev/null @@ -1,302 +0,0 @@ -#!/usr/bin/python -# bugzilla - a commandline frontend for the python bugzilla module -# -# Copyright (C) 2007 Red Hat Inc. -# Author: Will Woods -# -# 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, optparse -import os, sys, glob, re -import logging - -version = '0.2' -default_bz = 'https://bugzilla.redhat.com/xmlrpc.cgi' - -# Initial simple logging stuff -logging.basicConfig() -log = logging.getLogger("bugzilla") -if '--debug' in sys.argv: - log.setLevel(logging.DEBUG) -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') -def setup_parser(): - u = "usage: %prog [global options] COMMAND [options]" - u += "\nCommands: %s" % ', '.join(cmdlist) - p = optparse.OptionParser(usage=u) - p.disable_interspersed_args() - # General bugzilla connection options - 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.") - p.add_option('--password', - help="password. Will attempt to use browser cookie if not specified.") - p.add_option('--cookiefile', - help="cookie file to use for bugzilla authentication") - p.add_option('--verbose',action='store_true', - help="give more info about what's going on") - p.add_option('--debug',action='store_true', - help="output bunches of debugging info") - return p - -def setup_action_parser(action): - p = optparse.OptionParser(usage="usage: %%prog %s [options]" % action) - # TODO: product and version could default to current system - # info (read from /etc/redhat-release?) - if action == 'new': - p.add_option('-p','--product', - help="REQUIRED: product name (list with 'bugzilla info -p')") - p.add_option('-v','--version', - help="REQUIRED: product version") - p.add_option('-c','--component', - help="REQUIRED: component name (list with 'bugzilla info -c PRODUCT')") - p.add_option('-l','--comment', - help="REQUIRED: initial bug comment") - p.add_option('-s','--summary',dest='short_desc', - help="REQUIRED: bug summary") - p.add_option('-o','--os',default='Linux',dest='op_sys', - help="OPTIONAL: operating system (default: Linux)") - p.add_option('-a','--arch',default='All',dest='rep_platform', - help="OPTIONAL: arch this bug occurs on (default: All)") - p.add_option('--severity',default='medium',dest='bug_severity', - help="OPTIONAL: bug severity (default: medium)") - p.add_option('--priority',default='medium',dest='priority', - help="OPTIONAL: bug priority (default: medium)") - p.add_option('-u','--url',dest='bug_file_loc',default='http://', - help="OPTIONAL: URL for further bug info") - p.add_option('--cc', - help="OPTIONAL: add emails to initial CC list") - # TODO: alias, assigned_to, reporter, qa_contact, dependson, blocked - elif action == 'query': - p.add_option('-p','--product', - help="product name (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')") - p.add_option('-l','--long_desc', - help="search inside bug comments") - p.add_option('-s','--short_desc', - help="search bug summaries") - p.add_option('-o','--cc', - help="search cc lists for given address") - p.add_option('-r','--reporter', - help="search for bugs reported by this address") - p.add_option('-a','--assigned_to', - help="search for bugs assigned to this address") - p.add_option('--blocked', - help="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") - elif action == 'info': - p.add_option('-p','--products',action='store_true', - help='Get a list of products') - p.add_option('-c','--components',metavar="PRODUCT", - help='List the components in the given product') - p.add_option('-o','--component_owners',metavar="PRODUCT", - help='List components (and their owners)') - p.add_option('-v','--versions',metavar="PRODUCT", - help='List the versions for the given product') - elif action == 'modify': - p.set_usage("usage: %prog modify [options] BUGID BUGID ...") - p.add_option('-l','--comment', - help='Add a comment') - # FIXME: check value for resolution - p.add_option('-k','--close',metavar="RESOLUTION", - help='Close with the given resolution') - # TODO: --keyword, --flag, --tag, --status, --assignee, --cc, ... - - if action in ('new','query'): - # output modifiers - p.add_option('-f','--full',action='store_const',dest='output', - 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('--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__': - # Set up parser for global args - parser = setup_parser() - # Parse the commandline, woo - (global_opt,args) = parser.parse_args() - # Get our action from these args - if len(args) and args[0] in cmdlist: - action = args.pop(0) - else: - parser.error("command must be one of: %s" % ','.join(cmdlist)) - # Parse action-specific args - action_parser = setup_action_parser(action) - (opt, args) = action_parser.parse_args(args) - - # 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: - 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: - 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.") - - # And now we actually execute the given command - buglist = list() # save the results of query/new/modify here - if action == 'info': - if opt.products: - for k in sorted(bz.products): - print k - - if opt.components: - for c in sorted(bz.getcomponents(opt.components)): - print c - - if opt.component_owners: - component_details = bz.getcomponentsdetails(opt.component_owners) - for c in sorted(component_details): - print "%s: %s" % (c, component_details[c]['initialowner']) - - if opt.versions: - for p in bz.querydata['product']: - if p['name'] == opt.versions: - for v in p['versions']: - print v - - elif action == 'query': - # shortcut for specific bug_ids - if opt.bug_id: - log.debug("bz.getbugs(%s)", opt.bug_id.split(',')) - buglist=bz.getbugs(opt.bug_id.split(',')) - else: - # Construct the query from the list of queryable options - 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'): - 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. - # ex.: {'email1':'foo@bar.com','emailcc1':True} - q['email%i' % email_count] = i - q['email%s%i' % (a,email_count)] = True - 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' - q['value%i-0-0' % chart_id] = i - chart_id += 1 - else: - q[a] = i - log.debug("bz.query: %s", q) - buglist = bz.query(q) - - elif action == 'new': - data = dict() - required=['product','component','version','short_desc','comment', - 'rep_platform','bug_severity','op_sys','bug_file_loc','priority'] - optional=['cc'] - for a in required + optional: - i = getattr(opt,a) - if i: - data[a] = i - for k in required: - if k not in data: - parser.error('Missing required argument: %s' % k) - log.debug("bz.createbug(%s)", data) - b = bz.createbug(**data) - buglist = [b] - - elif action == 'modify': - bugid_list = [] - for a in args: - if ',' in a: - for b in a.split(','): - bugid_list.append(b) - else: - bugid_list.append(a) - # Surely there's a simpler way to do that.. - # bail out if no bugs were given - if not bugid_list: - parser.error('No bug IDs given (maybe you forgot an argument somewhere?)') - # Iterate over a list of Bug objects - # FIXME: this should totally use some multicall magic - buglist = bz.getbugssimple(bugid_list) - for id,bug in zip(bugid_list,buglist): - if not bug: - log.error(" failed to load bug %s" % id) - continue - log.debug("modifying bug %s" % bug.bug_id) - if opt.comment: - log.debug(" add comment: %s" % opt.comment) - bug.addcomment(opt.comment) - if opt.close: - log.debug(" close %s" % opt.close) - bug.close(opt.close) - else: - print "Sorry - '%s' not implemented yet." % action - - # If we're doing new/query/modify, output our results - if action in ('new','query'): - if opt.outputformat: - format_field_re = re.compile("%{[a-z0-9_]+}") - def bug_field(matchobj): - fieldname = matchobj.group()[2:-1] - return str(getattr(b,fieldname)) - for b in buglist: - print format_field_re.sub(bug_field,opt.outputformat) - elif opt.output == 'ids': - for b in buglist: - print b.bug_id - elif opt.output == 'full': - fullbuglist = bz.getbugs([b.bug_id for b in buglist]) - for b in fullbuglist: - print b - if b.cc: print "CC: %s" % " ".join(b.cc) - if b.blocked: print "Blocked: %s" % " ".join([str(i) for i in b.blocked]) - if b.dependson: print "Depends: %s" % " ".join([str(i) for i in b.dependson]) - for c in b.longdescs: - print "* %s - %s (%s):\n%s\n" % (c['time'],c['name'],c['email'] or c['safe_email'],c['body']) - elif opt.output == 'normal': - for b in buglist: - print b - else: - parser.error("opt.output was set to something weird.") diff --git a/bugzilla.py b/bugzilla.py deleted file mode 100644 index fe597e2..0000000 --- a/bugzilla.py +++ /dev/null @@ -1,887 +0,0 @@ -# bugzilla.py - a Python interface to bugzilla.redhat.com, using xmlrpclib. -# -# Copyright (C) 2007 Red Hat Inc. -# Author: Will Woods -# -# 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 xmlrpclib, urllib2, cookielib -import os.path, base64, copy - -version = '0.3' -user_agent = 'bugzilla.py/%s (Python-urllib2/%s)' % \ - (version,urllib2.__version__) - -def replace_getbug_errors_with_None(rawlist): - '''r is a raw xmlrpc response. - If it represents an error, None is returned. - Otherwise, r is returned. - This is mostly used for XMLRPC Multicall handling.''' - # Yes, this is a naive implementation - # XXX: return a generator? - result = [] - for r in rawlist: - if isinstance(r,dict) and 'bug_id' in r: - result.append(r) - else: - result.append(None) - return result - -class Bugzilla(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. - ''' - def __init__(self,**kwargs): - # Settings the user might want to tweak - self.user = '' - self.password = '' - self.url = '' - # Bugzilla object state info that users shouldn't mess with - self._cookiejar = None - self._proxy = None - self._opener = None - self._querydata = None - self._querydefaults = None - self._products = None - 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) - self._cookiejar = cj - self._cookiejar.filename = cookiefile - - def connect(self,url): - '''Connect to the bugzilla instance with the given url.''' - # Set up the transport - 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() - # 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.url = url - - # 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): - '''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. - ''' - self.user = user - self.password = password - try: - r = self._proxy.bugzilla.login(self.user,self.password) - except xmlrpclib.Fault, f: - r = False - return r - - #---- 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 - - def _getbugfields(self): - return self._proxy.bugzilla.getBugFields(self.user,self.password) - 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 - on the Bug object.''' - if force_refresh or not self._bugfields: - try: - self._bugfields = self._getbugfields() - except xmlrpclib.Fault, f: - if f.faultCode == 'Client': - # okay, this instance doesn't have getbugfields. fine. - self._bugfields = [] - else: - # something bad actually happened on the server. blow up. - raise f - - return self._bugfields - 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 - instance. Since this is a weighty call - takes a good 5-10sec on - bugzilla.redhat.com - we load the info in this private method and the - user instead plays with the querydata and querydefaults attributes of - the bugzilla object.''' - # Only fetch the data if we don't already have it, or are forced to - if force_refresh or not (self._querydata and self._querydefaults): - (self._querydata, self._querydefaults) = self._getqueryinfo() - # TODO: map _querydata to a dict, as with _components_details? - return (self._querydata, self._querydefaults) - # Set querydata and querydefaults as properties so they auto-create - # themselves when touched by a user. This bit was lifted from YumBase, - # because skvidal is much smarter than I am. - querydata = property(fget=lambda self: self.getqueryinfo()[0], - fdel=lambda self: setattr(self,"_querydata",None)) - 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.''' - if force_refresh or not self._products: - self._products = self._getproducts() - return self._products - # Bugzilla.products is a property - we cache the product list on the first - # call and return it for each subsequent call. - products = property(fget=lambda self: self.getproducts(), - fdel=lambda self: setattr(self,'_products',None)) - - 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: - self._components[product] = self._getcomponents(product) - 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 - each component, the value is a dict with the following keys: - description, initialowner, initialqacontact, initialcclist''' - # XXX inconsistent: we don't do this list->dict mapping with querydata - if force_refresh or product not in self._components_details: - clist = self._getcomponentsdetails(product) - cdict = dict() - for item in clist: - name = item['component'] - del item['component'] - cdict[name] = item - self._components_details[product] = cdict - return self._components_details[product] - def getcomponentdetails(self,product,component,force_refresh=False): - '''Get details for a single component. Returns a dict with the - following keys: - description, initialowner, initialqacontact, initialcclist''' - 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 - 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) - 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) - 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) - - # these return Bug objects - def getbug(self,id): - '''Return a Bug object with the full complement of bug data - already loaded.''' - return Bug(bugzilla=self,dict=self._getbug(id)) - def getbugsimple(self,id): - '''Return a Bug object given bug id, populated with simple info''' - return Bug(bugzilla=self,dict=self._getbugsimple(id)) - def getbugs(self,idlist): - '''Return a list of Bug objects with the full complement of bug data - already loaded. If there's a problem getting the data for a given id, - the corresponding item in the returned list will be None.''' - return [(b and Bug(bugzilla=self,dict=b)) or None for b in self._getbugs(idlist)] - def getbugssimple(self,idlist): - '''Return a list of Bug objects for the given bug ids, populated with - simple info. As with getbugs(), if there's a problem getting the data - for a given bug ID, the corresponding item in the returned list will - be None.''' - return [(b and Bug(bugzilla=self,dict=b)) or None for b in self._getbugssimple(idlist)] - 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. - ''' - r = self._query(query) - return [Bug(bugzilla=self,dict=b) for b in r['bugs']] - - def simplequery(self,product,version='',component='',string='',matchtype='allwordssubstr'): - '''Convenience method - query for bugs filed against the given - product, version, and component whose comments match the given string. - matchtype specifies the type of match to be done. matchtype may be - any of the types listed in querydefaults['long_desc_type_list'], e.g.: - ['allwordssubstr','anywordssubstr','substring','casesubstring', - 'allwords','anywords','regexp','notregexp'] - Return value is the same as with query(). - ''' - q = {'product':product,'version':version,'component':component, - 'long_desc':string,'long_desc_type':matchtype} - return self.query(q) - - #---- 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,self.password,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,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) - - 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,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. - cclist must be a list (not a tuple!) of addresses. - action may be 'add', 'remove', or 'makeexact'. - comment specifies an optional comment to add to the bug. - if mail is True, email will be generated for this change. - ''' - data = {'id':id, 'action':action, 'cc':','.join(cclist), - 'comment':comment, 'nomail':nomail} - return self._proxy.bugzilla.updateCC(data,self.user,self.password) - - 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,self.user,self.password) - - # 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,self.user,self.password) - - #---- Methods for working with attachments - - 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.''' - # 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 - data = '' - chunk = fh.read(chunksize) - while chunk: - # we could use chunk.encode('base64') but that throws a newline - # at the end of every output chunk, which increases the size of - # the output. - data = data + base64.b64encode(chunk) - chunk = fh.read(chunksize) - return data - - 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. - attachfile may be a filename (which will be opened) or a file-like - object, which must provide a 'read' method. If it's not one of these, - this method will raise a TypeError. - description is the short description of this attachment. - Optional keyword args are as follows: - filename: this will be used as the filename for the attachment. - REQUIRED if attachfile is a file-like object with no - 'name' attribute, otherwise the filename or .name - attribute will be used. - 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! - ''' - if isinstance(attachfile,str): - f = open(attachfile) - elif hasattr(attachfile,'read'): - f = attachfile - else: - raise TypeError, "attachfile must be filename or file-like object" - kwargs['description'] = description - if 'filename' not in kwargs: - kwargs['filename'] = os.path.basename(f.name) - # 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) - return attachid - - 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 = urllib2.urlopen(att_uri) - # RFC 2183 defines the content-disposition header, if you're curious - disp = att.headers['content-disposition'].split(';') - [filename_parm] = [i for i in disp if i.strip().startswith('filename=')] - (dummy,filename) = filename_parm.split('=') - # RFC 2045/822 defines the grammar for the filename value, but - # I think we just need to remove the quoting. I hope. - att.name = filename.strip('"') - # Hooray, now we have a file-like object with .read() and .name - return att - - #---- createbug - big complicated 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, mailresults]''' - return self._proxy.bugzilla.createBug(data,self.user,self.password) - - def createbug(self,check_args=False,**data): - '''Create a bug with the given info. Returns the bug ID. - 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 - are required. - - "product" => "", - # REQUIRED Name of Bugzilla product. - # Ex: Red Hat Enterprise Linux - "component" => "", - # REQUIRED Name of component in Bugzilla product. - # Ex: anaconda - "version" => "", - # REQUIRED Version in the list for the Bugzilla product. - # Ex: 4.5 - # versions are listed in querydata['product'][]['versions'] - "rep_platform" => "", - # REQUIRED Valid architecture from the rep_platform list. - # Ex: i386 - # See querydefaults['rep_platform_list'] for accepted values. - "bug_severity" => "medium", - # REQUIRED Valid severity from the list of severities. - # See querydefaults['bug_severity_list'] for accepted values. - "op_sys" => "Linux", - # REQUIRED Operating system bug occurs on. - # See querydefaults['op_sys_list'] for accepted values. - "bug_file_loc" => "http://", - # REQUIRED URL to additional information for bug report. - # Ex: http://people.redhat.com/dkl - "short_desc" => "", - # REQUIRED One line summary describing the bug report. - "comment" => "", - # REQUIRED A detail descript about the bug report. - - "alias" => "", - # OPTIONAL Will give the bug an alias name. - # Alias can't be merely numerical. - # Alias can't contain spaces or commas. - # Alias can't be more than 20 chars long. - # Alias has to be unique. - "assigned_to" => "", - # OPTIONAL Will be determined by component owner otherwise. - "reporter" => "", - # OPTIONAL Will use current login if blank. - "qa_contact" => "", - # OPTIONAL Will be determined by component qa_contact otherwise. - "cc" => "", - # OPTIONAL Space or Comma separated list of Bugzilla accounts. - "priority" => "urgent", - # OPTIONAL Valid priority from the list of priorities. - # Ex: medium - # See querydefaults['priority_list'] for accepted values. - "bug_status" => 'NEW', - # OPTIONAL Status to place the new bug in. - # Default: NEW - "blocked" => '', - # OPTIONAL Comma or space separate list of bug id's - # this report blocks. - "dependson" => '', - # 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: - if i not in data or not data[i]: - 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 - # roundtrip. Basically it's only useful if you're mucking around with - # createbug() in ipython and you've already loaded querydefaults. - if check_args: - if data['op_sys'] not in self.querydefaults['op_sys_list']: - raise ValueError, "invalid value for op_sys: %s" % data['op_sys'] - if data['rep_platform'] not in self.querydefaults['rep_platform_list']: - raise ValueError, "invalid value for rep_platform: %s" % data['rep_platform'] - # Actually perform the createbug call. - # We return a nearly-empty Bug object, which is kind of a bummer 'cuz - # it'll take another network roundtrip to fill it. We *could* fake it - # 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) - return Bug(self,bug_id=bug_id) - # Trivia: this method has ~5.8 lines of comment per line of code. Yow! - -class CookieTransport(xmlrpclib.Transport): - '''A subclass of xmlrpclib.Transport that supports cookies.''' - cookiejar = None - scheme = 'http' - - # Cribbed from xmlrpclib.Transport.send_user_agent - def send_cookies(self, connection, cookie_request): - if self.cookiejar is None: - self.cookiejar = cookielib.CookieJar() - elif self.cookiejar: - # Let the cookiejar figure out what cookies are appropriate - self.cookiejar.add_cookie_header(cookie_request) - # Pull the cookie headers out of the request object... - cookielist=list() - for h,v in cookie_request.header_items(): - if h.startswith('Cookie'): - cookielist.append([h,v]) - # ...and put them over the connection - for h,v in cookielist: - connection.putheader(h,v) - - # This is the same request() method from xmlrpclib.Transport, - # with a couple additions noted below - def request(self, host, handler, request_body, verbose=0): - h = self.make_connection(host) - if verbose: - h.set_debuglevel(1) - - # ADDED: construct the URL and Request object for proper cookie handling - request_url = "%s://%s/" % (self.scheme,host) - cookie_request = urllib2.Request(request_url) - - self.send_request(h,handler,request_body) - self.send_host(h,host) - self.send_cookies(h,cookie_request) # ADDED. creates cookiejar if None. - self.send_user_agent(h) - self.send_content(h,request_body) - - 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) - # And write back any changes - if hasattr(self.cookiejar,'save'): - self.cookiejar.save(self.cookiejar.filename) - - if errcode != 200: - raise xmlrpclib.ProtocolError( - host + handler, - errcode, errmsg, - headers - ) - - self.verbose = verbose - - try: - sock = h._conn.sock - except AttributeError: - sock = None - - return self._parse_response(h.getfile(), sock) - -class SafeCookieTransport(xmlrpclib.SafeTransport,CookieTransport): - '''SafeTransport subclass that supports cookies.''' - scheme = 'https' - request = CookieTransport.request - -class Bug(object): - '''A container object for a bug report. Requires a Bugzilla instance - - every Bug is on a Bugzilla, obviously. - Optional keyword args: - dict=DICT - populate attributes with the result of a getBug() call - bug_id=ID - if dict does not contain bug_id, this is required before - you can read any attributes or make modifications to this - bug. - autorefresh - automatically refresh the data in this bug after calling - a method that modifies the bug. Defaults to True. You can - call refresh() to do this manually. - ''' - def __init__(self,bugzilla,**kwargs): - self.bugzilla = bugzilla - self.autorefresh = True - if 'dict' in kwargs and kwargs['dict']: - self.__dict__.update(kwargs['dict']) - if 'bug_id' in kwargs: - 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" - - self.url = bugzilla.url.replace('xmlrpc.cgi', - 'show_bug.cgi?id=%i' % self.bug_id) - - # TODO: set properties for missing bugfields - # The problem here is that the property doesn't know its own name, - # otherwise we could just do .refresh() and return __dict__[f] after. - # basically I need a suicide property that can replace itself after - # it's called. Or something. - #for f in bugzilla.bugfields: - # if f in self.__dict__: continue - # setattr(self,f,property(fget=lambda self: self.refresh())) - - def __str__(self): - '''Return a simple string representation of this bug''' - # XXX Not really sure why we get short_desc sometimes and - # short_short_desc other times. I feel like I'm working around - # a bug here, so keep an eye on this. - if 'short_short_desc' in self.__dict__: - desc = self.short_short_desc - else: - desc = self.short_desc - return "#%-6s %-10s - %s - %s" % (self.bug_id,self.bug_status, - self.assigned_to,desc) - def __repr__(self): - return '' % (self.bug_id,self.bugzilla.url, - id(self)) - - def __getattr__(self,name): - if 'bug_id' in self.__dict__: - if self.bugzilla.bugfields and name not in self.bugzilla.bugfields: - # We have a list of fields, and you ain't on it. Bail out. - raise AttributeError, "field %s not in bugzilla.bugfields" % name - #print "Bug %i missing %s - loading" % (self.bug_id,name) - self.refresh() - if name in self.__dict__: - return self.__dict__[name] - raise AttributeError, "Bug object has no attribute '%s'" % name - - def refresh(self): - '''Refresh all the data in this Bug.''' - r = self.bugzilla._getbug(self.bug_id) - self.__dict__.update(r) - - def reload(self): - '''An alias for reload()''' - self.refresh() - - def setstatus(self,status,comment='',private=False,private_in_it=False,nomail=False): - '''Update the status for this bug report. - Valid values for status are listed in querydefaults['bug_status_list'] - Commonly-used values are ASSIGNED, MODIFIED, and NEEDINFO. - 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 - - def setassignee(self,assigned_to='',reporter='',qa_contact='',comment=''): - '''Set any of the assigned_to, reporter, or qa_contact fields to a new - bugzilla account, with an optional comment, e.g. - setassignee(reporter='sadguy@brokencomputer.org', - assigned_to='wwoods@redhat.com') - setassignee(qa_contact='wwoods@redhat.com',comment='wwoods QA ftw') - You must set at least one of the three assignee fields, or this method - will throw a ValueError. - Returns [bug_id, mailresults].''' - if not (assigned_to or reporter or qa_contact): - # XXX is ValueError the right thing to throw here? - raise ValueError, "You must set one of assigned_to, reporter, or qa_contact" - # 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 - 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 - comment as private. You can also set a timestamp for the comment, in - "YYYY-MM-DD HH:MM:SS" form. Worktime is undocumented upstream. - If bz_gid is set, and the entire bug is not already private to that - group, this comment will be private.''' - self.bugzilla._addcomment(self.bug_id,comment,private,timestamp, - worktime,bz_gid) - # FIXME 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'] - For bugzilla.redhat.com that's: - ['NOTABUG','WONTFIX','DEFERRED','WORKSFORME','CURRENTRELEASE', - 'RAWHIDE','ERRATA','DUPLICATE','UPSTREAM','NEXTRELEASE','CANTFIX', - 'INSUFFICIENT_DATA'] - If using DUPLICATE, you need to set dupeid to the ID of the other bug. - If using WORKSFORME/CURRENTRELEASE/RAWHIDE/ERRATA/UPSTREAM/NEXTRELEASE - you can (and should) set 'new_fixed_in' to a string representing the - version that fixes the bug. - You can optionally add a comment while closing the bug. Set 'isprivate' - to True if you want that comment to be private. - If you want to suppress sending out mail for this bug closing, set - nomail=True. - ''' - self.bugzilla._closebug(self.bug_id,resolution,dupeid,fixedin, - comment,isprivate,private_in_it,nomail) - # FIXME 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 - - def getwhiteboard(self,which='status'): - '''Get the current value of the whiteboard specified by 'which'. - Known whiteboard names: 'status','internal','devel','qa'. - Defaults to the 'status' whiteboard.''' - return getattr(self,"%s_whiteboard" % which) - def appendwhiteboard(self,text,which='status'): - '''Append the given text (with a space before it) to the given - whiteboard. Defaults to using status_whiteboard.''' - self._dowhiteboard(text,which,'append') - def prependwhiteboard(self,text,which='status'): - '''Prepend the given text (with a space following it) to the given - whiteboard. Defaults to using status_whiteboard.''' - self._dowhiteboard(text,which,'prepend') - def setwhiteboard(self,text,which='status'): - '''Overwrites the contents of the given whiteboard with the given text. - Defaults to using status_whiteboard.''' - self._dowhiteboard(text,which,'overwrite') - def addtag(self,tag,which='status'): - '''Adds the given tag to the given bug.''' - whiteboard = self.getwhiteboard(which) - if whiteboard: - self.appendwhiteboard(tag,which) - else: - self.setwhiteboard(tag,which) - def gettags(self,which='status'): - '''Get a list of tags (basically just whitespace-split the given - whiteboard)''' - return self.getwhiteboard(which).split() - def deltag(self,tag,which='status'): - '''Removes the given tag from the given bug.''' - tags = self.gettags(which) - tags.remove(tag) - self.setwhiteboard(' '.join(tags),which) -# TODO: add a sync() method that writes the changed data in the Bug object -# back to Bugzilla. Someday. diff --git a/bugzilla/__init__.py b/bugzilla/__init__.py new file mode 100644 index 0000000..0afe5ba --- /dev/null +++ b/bugzilla/__init__.py @@ -0,0 +1,45 @@ +# python-bugzilla - a Python interface to bugzilla using xmlrpclib. +# +# Copyright (C) 2007,2008 Red Hat Inc. +# Author: Will Woods +# +# 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 +from rhbugzilla import RHBugzilla +import xmlrpclib + +def getBugzillaClassForURL(url): + s = xmlrpclib.ServerProxy(url) + # RH Bugzilla method + prodinfo = {} + try: + prodinfo = s.bugzilla.getProdInfo() + return RHBugzilla + except xmlrpclib.Fault: + pass + + try: + r = s.Bugzilla.version() + version = r['version'] + if version.startswith('3.'): + return Bugzilla3 + except xmlrpclib.Fault: + pass + + return None + +class Bugzilla(object): + '''Magical Bugzilla class that figures out which Bugzilla implementation + to use and uses that.''' + def __init__(self,**kwargs): + if 'url' in kwargs: + c = getBugzillaClassForURL(kwargs['url']) + if c: + self.__class__ = c + c.__init__(self,**kwargs) + # FIXME raise an error or something here, jeez diff --git a/bugzilla/base.py b/bugzilla/base.py new file mode 100644 index 0000000..6fb8f89 --- /dev/null +++ b/bugzilla/base.py @@ -0,0 +1,843 @@ +# bugzilla.py - a Python interface to bugzilla.redhat.com, using xmlrpclib. +# +# Copyright (C) 2007,2008 Red Hat Inc. +# Author: Will Woods +# +# 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 xmlrpclib, urllib2, cookielib +import os.path, base64, copy + +version = '0.5' +user_agent = 'Python-urllib2/%s bugzilla.py/%s' % \ + (urllib2.__version__,version) + +def replace_getbug_errors_with_None(rawlist): + '''r is a raw xmlrpc response. + If it represents an error, None is returned. + Otherwise, r is returned. + This is mostly used for XMLRPC Multicall handling.''' + # Yes, this is a naive implementation + # XXX: return a generator? + result = [] + for r in rawlist: + if isinstance(r,dict) and 'bug_id' in r: + result.append(r) + else: + result.append(None) + return result + +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. + + 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.user_agent = user_agent + # Bugzilla object state info that users shouldn't mess with + self._cookiejar = None + self._proxy = None + self._opener = None + self._querydata = None + self._querydefaults = None + self._products = None + 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) + self._cookiejar = cj + self._cookiejar.filename = cookiefile + + def connect(self,url): + '''Connect to the bugzilla instance with the given url.''' + # Set up the transport + if url.startswith('https'): + self._transport = SafeCookieTransport() + else: + self._transport = CookieTransport() + self._transport.user_agent = self.user_agent + self._transport.cookiejar = self._cookiejar or cookielib.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',self.user_agent)] + self.url = url + + # 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): + '''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. + ''' + self.user = user + self.password = password + try: + r = self._proxy.bugzilla.login(self.user,self.password) + except xmlrpclib.Fault, f: + r = False + return r + + #---- 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 + + def _getbugfields(self): + '''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 + on the Bug object.''' + if force_refresh or not self._bugfields: + try: + self._bugfields = self._getbugfields() + except xmlrpclib.Fault, f: + if f.faultCode == 'Client': + # okay, this instance doesn't have getbugfields. fine. + self._bugfields = [] + else: + # something bad actually happened on the server. blow up. + raise f + + return self._bugfields + bugfields = property(fget=lambda self: self.getbugfields(), + fdel=lambda self: setattr(self,'_bugfields',None)) + + 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 + instance. Since this is a weighty call - takes a good 5-10sec on + bugzilla.redhat.com - we load the info in this private method and the + user instead plays with the querydata and querydefaults attributes of + the bugzilla object.''' + # Only fetch the data if we don't already have it, or are forced to + if force_refresh or not (self._querydata and self._querydefaults): + (self._querydata, self._querydefaults) = self._getqueryinfo() + # TODO: map _querydata to a dict, as with _components_details? + return (self._querydata, self._querydefaults) + # Set querydata and querydefaults as properties so they auto-create + # themselves when touched by a user. This bit was lifted from YumBase, + # because skvidal is much smarter than I am. + querydata = property(fget=lambda self: self.getqueryinfo()[0], + fdel=lambda self: setattr(self,"_querydata",None)) + querydefaults = property(fget=lambda self: self.getqueryinfo()[1], + fdel=lambda self: setattr(self,"_querydefaults",None)) + + def getproducts(self,force_refresh=False): + '''Return a dict of product names and product descriptions.''' + if force_refresh or not self._products: + self._products = self._getproducts() + return self._products + # Bugzilla.products is a property - we cache the product list on the first + # call and return it for each subsequent call. + products = property(fget=lambda self: self.getproducts(), + fdel=lambda self: setattr(self,'_products',None)) + + 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: + self._components[product] = self._getcomponents(product) + return self._components[product] + # TODO - add a .components property that acts like a dict? + + 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 + each component, the value is a dict with the following keys: + description, initialowner, initialqacontact, initialcclist''' + # XXX inconsistent: we don't do this list->dict mapping with querydata + if force_refresh or product not in self._components_details: + clist = self._getcomponentsdetails(product) + cdict = dict() + for item in clist: + name = item['component'] + del item['component'] + cdict[name] = item + self._components_details[product] = cdict + return self._components_details[product] + def getcomponentdetails(self,product,component,force_refresh=False): + '''Get details for a single component. Returns a dict with the + following keys: + description, initialowner, initialqacontact, initialcclist''' + 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 + + def _getbug(self,id): + '''IMPLEMENT ME: Return a dict of full bug info for the given bug id''' + raise NotImplementedError + def _getbugsimple(self,id): + '''IMPLEMENT ME: Return a short dict of simple bug info for the given + bug id''' + raise NotImplementedError + def _query(self,query): + '''IMPLEMENT ME: Query bugzilla and return a list of matching bugs.''' + raise NotImplementedError + + # 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 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 replace_getbug_errors_with_None(raw_results) + + # these return Bug objects + def getbug(self,id): + '''Return a Bug object with the full complement of bug data + already loaded.''' + return Bug(bugzilla=self,dict=self._getbug(id)) + def getbugsimple(self,id): + '''Return a Bug object given bug id, populated with simple info''' + return Bug(bugzilla=self,dict=self._getbugsimple(id)) + def getbugs(self,idlist): + '''Return a list of Bug objects with the full complement of bug data + already loaded. If there's a problem getting the data for a given id, + the corresponding item in the returned list will be None.''' + return [(b and Bug(bugzilla=self,dict=b)) or None for b in self._getbugs(idlist)] + def getbugssimple(self,idlist): + '''Return a list of Bug objects for the given bug ids, populated with + simple info. As with getbugs(), if there's a problem getting the data + for a given bug ID, the corresponding item in the returned list will + be None.''' + return [(b and Bug(bugzilla=self,dict=b)) or None for b in self._getbugssimple(idlist)] + 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. + ''' + r = self._query(query) + return [Bug(bugzilla=self,dict=b) for b in r['bugs']] + + def simplequery(self,product,version='',component='',string='',matchtype='allwordssubstr'): + '''Convenience method - query for bugs filed against the given + product, version, and component whose comments match the given string. + matchtype specifies the type of match to be done. matchtype may be + any of the types listed in querydefaults['long_desc_type_list'], e.g.: + ['allwordssubstr','anywordssubstr','substring','casesubstring', + 'allwords','anywords','regexp','notregexp'] + Return value is the same as with query(). + ''' + q = {'product':product,'version':version,'component':component, + 'long_desc':string,'long_desc_type':matchtype} + return self.query(q) + + #---- 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) + + # FIXME inconsistent method signatures + # FIXME add more comments on proper implementation + def _addcomment(self,id,comment,private=False, + 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): + '''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): + '''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,deplist): + '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug. + 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): + '''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'. + comment specifies an optional comment to add to the bug. + if mail is True, email will be generated for this change. + ''' + raise NotImplementedError + def _updatewhiteboard(self,id,text,which,action): + '''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.''' + 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.''' + raise NotImplementedError + + #---- Methods for working with attachments + + 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. 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 + data = '' + chunk = fh.read(chunksize) + while chunk: + # we could use chunk.encode('base64') but that throws a newline + # at the end of every output chunk, which increases the size of + # the output. + data = data + base64.b64encode(chunk) + 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. + attachfile may be a filename (which will be opened) or a file-like + object, which must provide a 'read' method. If it's not one of these, + this method will raise a TypeError. + description is the short description of this attachment. + Optional keyword args are as follows: + filename: this will be used as the filename for the attachment. + REQUIRED if attachfile is a file-like object with no + 'name' attribute, otherwise the filename or .name + attribute will be used. + 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! + ''' + if isinstance(attachfile,str): + f = open(attachfile) + elif hasattr(attachfile,'read'): + f = attachfile + else: + raise TypeError, "attachfile must be filename or file-like object" + kwargs['description'] = description + if 'filename' not in kwargs: + kwargs['filename'] = os.path.basename(f.name) + # TODO: guess contenttype? + if 'contenttype' not in kwargs: + kwargs['contenttype'] = 'application/octet-stream' + 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._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(';') + [filename_parm] = [i for i in disp if i.strip().startswith('filename=')] + (dummy,filename) = filename_parm.split('=') + # RFC 2045/822 defines the grammar for the filename value, but + # I think we just need to remove the quoting. I hope. + att.name = filename.strip('"') + # Hooray, now we have a file-like object with .read() and .name + return att + + #---- createbug - big complicated call to create a new bug + + def _createbug(self,**data): + '''IMPLEMENT ME: Raw xmlrpc call for createBug() + Doesn't bother guessing defaults or checking argument validity. + Returns [bug_id, mailresults]''' + raise NotImplementedError + + def createbug(self,check_args=False,**data): + '''Create a bug with the given info. Returns the bug ID. + 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 + are required. + + "product" => "", + # REQUIRED Name of Bugzilla product. + # Ex: Red Hat Enterprise Linux + "component" => "", + # REQUIRED Name of component in Bugzilla product. + # Ex: anaconda + "version" => "", + # REQUIRED Version in the list for the Bugzilla product. + # Ex: 4.5 + # versions are listed in querydata['product'][]['versions'] + "rep_platform" => "", + # REQUIRED Valid architecture from the rep_platform list. + # Ex: i386 + # See querydefaults['rep_platform_list'] for accepted values. + "bug_severity" => "medium", + # REQUIRED Valid severity from the list of severities. + # See querydefaults['bug_severity_list'] for accepted values. + "op_sys" => "Linux", + # REQUIRED Operating system bug occurs on. + # See querydefaults['op_sys_list'] for accepted values. + "bug_file_loc" => "http://", + # REQUIRED URL to additional information for bug report. + # Ex: http://people.redhat.com/dkl + "short_desc" => "", + # REQUIRED One line summary describing the bug report. + "comment" => "", + # REQUIRED A detail descript about the bug report. + + "alias" => "", + # OPTIONAL Will give the bug an alias name. + # Alias can't be merely numerical. + # Alias can't contain spaces or commas. + # Alias can't be more than 20 chars long. + # Alias has to be unique. + "assigned_to" => "", + # OPTIONAL Will be determined by component owner otherwise. + "reporter" => "", + # OPTIONAL Will use current login if blank. + "qa_contact" => "", + # OPTIONAL Will be determined by component qa_contact otherwise. + "cc" => "", + # OPTIONAL Space or Comma separated list of Bugzilla accounts. + "priority" => "urgent", + # OPTIONAL Valid priority from the list of priorities. + # Ex: medium + # See querydefaults['priority_list'] for accepted values. + "bug_status" => 'NEW', + # OPTIONAL Status to place the new bug in. + # Default: NEW + "blocked" => '', + # OPTIONAL Comma or space separate list of bug id's + # this report blocks. + "dependson" => '', + # 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: + if i not in data or not data[i]: + 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 + # roundtrip. Basically it's only useful if you're mucking around with + # createbug() in ipython and you've already loaded querydefaults. + if check_args: + if data['op_sys'] not in self.querydefaults['op_sys_list']: + raise ValueError, "invalid value for op_sys: %s" % data['op_sys'] + if data['rep_platform'] not in self.querydefaults['rep_platform_list']: + raise ValueError, "invalid value for rep_platform: %s" % data['rep_platform'] + # Actually perform the createbug call. + # We return a nearly-empty Bug object, which is kind of a bummer 'cuz + # it'll take another network roundtrip to fill it. We *could* fake it + # 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) + return Bug(self,bug_id=bug_id) + # Trivia: this method has ~5.8 lines of comment per line of code. Yow! + +class CookieTransport(xmlrpclib.Transport): + '''A subclass of xmlrpclib.Transport that supports cookies.''' + cookiejar = None + scheme = 'http' + + # Cribbed from xmlrpclib.Transport.send_user_agent + def send_cookies(self, connection, cookie_request): + if self.cookiejar is None: + self.cookiejar = cookielib.CookieJar() + elif self.cookiejar: + # Let the cookiejar figure out what cookies are appropriate + self.cookiejar.add_cookie_header(cookie_request) + # Pull the cookie headers out of the request object... + cookielist=list() + for h,v in cookie_request.header_items(): + if h.startswith('Cookie'): + cookielist.append([h,v]) + # ...and put them over the connection + for h,v in cookielist: + connection.putheader(h,v) + + # This is the same request() method from xmlrpclib.Transport, + # with a couple additions noted below + def request(self, host, handler, request_body, verbose=0): + h = self.make_connection(host) + if verbose: + h.set_debuglevel(1) + + # ADDED: construct the URL and Request object for proper cookie handling + request_url = "%s://%s/" % (self.scheme,host) + cookie_request = urllib2.Request(request_url) + + self.send_request(h,handler,request_body) + self.send_host(h,host) + self.send_cookies(h,cookie_request) # ADDED. creates cookiejar if None. + self.send_user_agent(h) + self.send_content(h,request_body) + + 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) + # And write back any changes + if hasattr(self.cookiejar,'save'): + self.cookiejar.save(self.cookiejar.filename) + + if errcode != 200: + raise xmlrpclib.ProtocolError( + host + handler, + errcode, errmsg, + headers + ) + + self.verbose = verbose + + try: + sock = h._conn.sock + except AttributeError: + sock = None + + return self._parse_response(h.getfile(), sock) + +class SafeCookieTransport(xmlrpclib.SafeTransport,CookieTransport): + '''SafeTransport subclass that supports cookies.''' + scheme = 'https' + request = CookieTransport.request + +class Bug(object): + '''A container object for a bug report. Requires a Bugzilla instance - + every Bug is on a Bugzilla, obviously. + Optional keyword args: + dict=DICT - populate attributes with the result of a getBug() call + bug_id=ID - if dict does not contain bug_id, this is required before + you can read any attributes or make modifications to this + bug. + autorefresh - automatically refresh the data in this bug after calling + a method that modifies the bug. Defaults to True. You can + call refresh() to do this manually. + ''' + def __init__(self,bugzilla,**kwargs): + self.bugzilla = bugzilla + self.autorefresh = True + if 'dict' in kwargs and kwargs['dict']: + self.__dict__.update(kwargs['dict']) + if 'bug_id' in kwargs: + 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" + + self.url = bugzilla.url.replace('xmlrpc.cgi', + 'show_bug.cgi?id=%i' % self.bug_id) + + # TODO: set properties for missing bugfields + # The problem here is that the property doesn't know its own name, + # otherwise we could just do .refresh() and return __dict__[f] after. + # basically I need a suicide property that can replace itself after + # it's called. Or something. + #for f in bugzilla.bugfields: + # if f in self.__dict__: continue + # setattr(self,f,property(fget=lambda self: self.refresh())) + + def __str__(self): + '''Return a simple string representation of this bug''' + # XXX Not really sure why we get short_desc sometimes and + # short_short_desc other times. I feel like I'm working around + # a bug here, so keep an eye on this. + if 'short_short_desc' in self.__dict__: + desc = self.short_short_desc + else: + desc = self.short_desc + return "#%-6s %-10s - %s - %s" % (self.bug_id,self.bug_status, + self.assigned_to,desc) + def __repr__(self): + return '' % (self.bug_id,self.bugzilla.url, + id(self)) + + def __getattr__(self,name): + if 'bug_id' in self.__dict__: + if self.bugzilla.bugfields and name not in self.bugzilla.bugfields: + # We have a list of fields, and you ain't on it. Bail out. + raise AttributeError, "field %s not in bugzilla.bugfields" % name + #print "Bug %i missing %s - loading" % (self.bug_id,name) + self.refresh() + if name in self.__dict__: + return self.__dict__[name] + raise AttributeError, "Bug object has no attribute '%s'" % name + + def refresh(self): + '''Refresh all the data in this Bug.''' + r = self.bugzilla._getbug(self.bug_id) + self.__dict__.update(r) + + def reload(self): + '''An alias for reload()''' + self.refresh() + + def setstatus(self,status,comment='',private=False,private_in_it=False,nomail=False): + '''Update the status for this bug report. + Valid values for status are listed in querydefaults['bug_status_list'] + Commonly-used values are ASSIGNED, MODIFIED, and NEEDINFO. + 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 + + def setassignee(self,assigned_to='',reporter='',qa_contact='',comment=''): + '''Set any of the assigned_to, reporter, or qa_contact fields to a new + bugzilla account, with an optional comment, e.g. + setassignee(reporter='sadguy@brokencomputer.org', + assigned_to='wwoods@redhat.com') + setassignee(qa_contact='wwoods@redhat.com',comment='wwoods QA ftw') + You must set at least one of the three assignee fields, or this method + will throw a ValueError. + Returns [bug_id, mailresults].''' + if not (assigned_to or reporter or qa_contact): + # XXX is ValueError the right thing to throw here? + raise ValueError, "You must set one of assigned_to, reporter, or qa_contact" + # 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 + 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 + comment as private. You can also set a timestamp for the comment, in + "YYYY-MM-DD HH:MM:SS" form. Worktime is undocumented upstream. + If bz_gid is set, and the entire bug is not already private to that + group, this comment will be private.''' + self.bugzilla._addcomment(self.bug_id,comment,private,timestamp, + worktime,bz_gid) + # FIXME 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'] + For bugzilla.redhat.com that's: + ['NOTABUG','WONTFIX','DEFERRED','WORKSFORME','CURRENTRELEASE', + 'RAWHIDE','ERRATA','DUPLICATE','UPSTREAM','NEXTRELEASE','CANTFIX', + 'INSUFFICIENT_DATA'] + If using DUPLICATE, you need to set dupeid to the ID of the other bug. + If using WORKSFORME/CURRENTRELEASE/RAWHIDE/ERRATA/UPSTREAM/NEXTRELEASE + you can (and should) set 'new_fixed_in' to a string representing the + version that fixes the bug. + You can optionally add a comment while closing the bug. Set 'isprivate' + to True if you want that comment to be private. + If you want to suppress sending out mail for this bug closing, set + nomail=True. + ''' + self.bugzilla._closebug(self.bug_id,resolution,dupeid,fixedin, + comment,isprivate,private_in_it,nomail) + # FIXME 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 + + def getwhiteboard(self,which='status'): + '''Get the current value of the whiteboard specified by 'which'. + Known whiteboard names: 'status','internal','devel','qa'. + Defaults to the 'status' whiteboard.''' + return getattr(self,"%s_whiteboard" % which) + def appendwhiteboard(self,text,which='status'): + '''Append the given text (with a space before it) to the given + whiteboard. Defaults to using status_whiteboard.''' + self._dowhiteboard(text,which,'append') + def prependwhiteboard(self,text,which='status'): + '''Prepend the given text (with a space following it) to the given + whiteboard. Defaults to using status_whiteboard.''' + self._dowhiteboard(text,which,'prepend') + def setwhiteboard(self,text,which='status'): + '''Overwrites the contents of the given whiteboard with the given text. + Defaults to using status_whiteboard.''' + self._dowhiteboard(text,which,'overwrite') + def addtag(self,tag,which='status'): + '''Adds the given tag to the given bug.''' + whiteboard = self.getwhiteboard(which) + if whiteboard: + self.appendwhiteboard(tag,which) + else: + self.setwhiteboard(tag,which) + def gettags(self,which='status'): + '''Get a list of tags (basically just whitespace-split the given + whiteboard)''' + return self.getwhiteboard(which).split() + def deltag(self,tag,which='status'): + '''Removes the given tag from the given bug.''' + tags = self.gettags(which) + tags.remove(tag) + self.setwhiteboard(' '.join(tags),which) +# TODO: add a sync() method that writes the changed data in the Bug object +# back to Bugzilla. Someday. + +class Bugzilla(object): + '''Magical Bugzilla class that figures out which Bugzilla implementation + to use and uses that.''' + # FIXME STUB + pass diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py new file mode 100644 index 0000000..3dda1a5 --- /dev/null +++ b/bugzilla/bugzilla3.py @@ -0,0 +1,189 @@ +# bugzilla3.py - a Python interface to Bugzilla 3.x using xmlrpclib. +# +# Copyright (C) 2008 Red Hat Inc. +# Author: Will Woods +# +# 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 + +version = '0.1' +user_agent = bugzilla.base.user_agent + ' Bugzilla3/%s' % version + +class Bugzilla3(bugzilla.base.BugzillaBase): + '''Concrete implementation of the Bugzilla protocol. This one uses the + methods provided by standard Bugzilla 3.x releases.''' + def __init__(self,**kwargs): + bugzilla.base.BugzillaBase.__init__(self,**kwargs) + self.user_agent = user_agent + + #---- Methods and properties with basic bugzilla info + + # Connect the backend methods to the XMLRPC methods + def _getbugfields(self): + #return self._proxy.bugzilla.getBugFields(self.user,self.password) + raise NotImplementedError + def _getqueryinfo(self): + #return self._proxy.bugzilla.getQueryInfo(self.user,self.password) + raise NotImplementedError + def _getproducts(self): + product_ids = self._proxy.Product.get_accessible_products() + return self._proxy.Product.get_products(product_ids) + def _getcomponents(self,product): + #return self._proxy.bugzilla.getProdCompInfo(product,self.user,self.password) + raise NotImplementedError + def _getcomponentsdetails(self,product): + #return self._proxy.bugzilla.getProdCompDetails(product,self.user,self.password) + raise NotImplementedError + + #---- 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.Bug.get(id) + def _getbugsimple(self,id): + '''Return a short dict of simple bug info for the given bug id''' + # Bugzilla3 doesn't have this + return self._getbug(id) + 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) + + #---- 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,self.password,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,self.password,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,self.password, + 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,self.user,self.password) + def _updatedeps(self,id,deplist): + '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug. + 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. + cclist must be a list (not a tuple!) of addresses. + action may be 'add', 'remove', or 'makeexact'. + comment specifies an optional comment to add to the bug. + if mail is True, email will be generated for this change. + ''' + data = {'id':id, 'action':action, 'cc':','.join(cclist), + 'comment':comment, 'nomail':nomail} + return self._proxy.bugzilla.updateCC(data,self.user,self.password) + 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,self.user,self.password) + # 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,self.user,self.password) + + #---- 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,self.user,self.password) + + #---- 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, mailresults]''' + return self._proxy.bugzilla.createBug(data,self.user,self.password) diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py new file mode 100644 index 0000000..5bfaabb --- /dev/null +++ b/bugzilla/rhbugzilla.py @@ -0,0 +1,189 @@ +# rhbugzilla.py - a Python interface to Red Hat Bugzilla using xmlrpclib. +# +# Copyright (C) 2008 Red Hat Inc. +# Author: Will Woods +# +# 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 + +version = '0.1' +user_agent = bugzilla.base.user_agent + ' RHBugzilla/%s' % version + +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.''' + def __init__(self,**kwargs): + bugzilla.base.BugzillaBase.__init__(self,**kwargs) + self.user_agent = user_agent + + #---- Methods and properties with basic bugzilla info + + # Connect the backend methods to the XMLRPC methods + def _getbugfields(self): + return self._proxy.bugzilla.getBugFields(self.user,self.password) + def _getqueryinfo(self): + return self._proxy.bugzilla.getQueryInfo(self.user,self.password) + def _getproducts(self): + return self._proxy.bugzilla.getProdInfo(self.user, self.password) + def _getcomponents(self,product): + return self._proxy.bugzilla.getProdCompInfo(product,self.user,self.password) + def _getcomponentsdetails(self,product): + return self._proxy.bugzilla.getProdCompDetails(product,self.user,self.password) + + #---- 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, 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 + 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) + + #---- 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,self.password,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,self.password,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,self.password, + 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,self.user,self.password) + def _updatedeps(self,id,deplist): + '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug. + 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. + cclist must be a list (not a tuple!) of addresses. + action may be 'add', 'remove', or 'makeexact'. + comment specifies an optional comment to add to the bug. + if mail is True, email will be generated for this change. + ''' + data = {'id':id, 'action':action, 'cc':','.join(cclist), + 'comment':comment, 'nomail':nomail} + return self._proxy.bugzilla.updateCC(data,self.user,self.password) + 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,self.user,self.password) + # 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,self.user,self.password) + + #---- 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,self.user,self.password) + + #---- 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, mailresults]''' + return self._proxy.bugzilla.createBug(data,self.user,self.password) 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'], ) -- cgit From c98f4dc5a25b62ab5d0edf33ebadcd89f7fcf75e Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 25 Mar 2008 18:32:44 -0400 Subject: Improve Bugzilla3 class - getbug() works now! I think! --- bugzilla/base.py | 4 ++++ bugzilla/bugzilla3.py | 29 +++++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index 6fb8f89..18da60a 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -713,6 +713,10 @@ class Bug(object): desc = self.short_short_desc else: desc = self.short_desc + # 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): diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index 3dda1a5..5e38c97 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -23,16 +23,33 @@ class Bugzilla3(bugzilla.base.BugzillaBase): #---- 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): - #return self._proxy.bugzilla.getBugFields(self.user,self.password) - raise NotImplementedError + '''Get a list of valid fields for bugs.''' + #I don't think BZ3 provides a getbugfields() method, so right + #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): #return self._proxy.bugzilla.getQueryInfo(self.user,self.password) raise NotImplementedError 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() - return self._proxy.Product.get_products(product_ids) + r = self._proxy.Product.get_products(product_ids) + pdict = {} + for p in r['products']: + pdict[p['name']] = p['description'] + return pdict + def _getcomponents(self,product): #return self._proxy.bugzilla.getProdCompInfo(product,self.user,self.password) raise NotImplementedError @@ -42,13 +59,17 @@ class Bugzilla3(bugzilla.base.BugzillaBase): #---- Methods for reading bugs and bug info + def _getbugs(self,idlist): + 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._proxy.Bug.get(id) + return self._getbugs([id])[0] def _getbugsimple(self,id): '''Return a short dict of simple bug info for the given bug id''' # Bugzilla3 doesn't have this return self._getbug(id) + 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']. -- cgit From ff62f623bcd5a58317388b3f1fc774435727e2ca Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 25 Mar 2008 20:52:42 -0400 Subject: update man page a bit --- bugzilla.1 | 62 +++++++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 13 deletions(-) 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 -- cgit From 248d7bb3c1fe4c0174bfbcb7e135f9d154f2abe7 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Wed, 26 Mar 2008 18:26:30 -0400 Subject: Add comment for later --- bugzilla/base.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bugzilla/base.py b/bugzilla/base.py index 18da60a..ad9128e 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -127,6 +127,10 @@ class BugzillaBase(object): #---- Methods and properties with basic bugzilla info + # XXX FIXME Uh-oh. I think MultiCall support is a RHism. + # Even worse, RH's bz3 instance supports the RH methods but *NOT* mc! + # 1) move all multicall-calls into RHBugzilla, and + # 2) either make MC optional, or prefer Bugzilla3 over RHBugzilla 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 -- cgit From 95406dfc9f3518a467c99f03ace1fc57ac1de509 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Fri, 16 May 2008 17:27:36 -0400 Subject: Fix inconsistent comment --- bugzilla/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index ad9128e..e784450 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -509,7 +509,7 @@ class BugzillaBase(object): 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 -- cgit From 0f2942820323a254a6237c7f986d90e2d15848d3 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Thu, 5 Jun 2008 13:53:18 -0400 Subject: Quietly fixup missing bug_file_loc --- bugzilla/base.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index e784450..d2b33e9 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -577,7 +577,10 @@ class BugzillaBase(object): # let's try to save a network roundtrip here if possible.. for i in 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 -- cgit From 45196bb528040160894e52a585969e33c6e96c3c Mon Sep 17 00:00:00 2001 From: Will Woods Date: Thu, 5 Jun 2008 15:44:04 -0400 Subject: Comment changes --- bin/bugzilla | 2 +- bugzilla/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/bugzilla b/bin/bugzilla index caeda15..af4bb3b 100755 --- a/bin/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 # # This program is free software; you can redistribute it and/or modify it diff --git a/bugzilla/__init__.py b/bugzilla/__init__.py index 0afe5ba..94f4886 100644 --- a/bugzilla/__init__.py +++ b/bugzilla/__init__.py @@ -42,4 +42,4 @@ class Bugzilla(object): if c: self.__class__ = c c.__init__(self,**kwargs) - # FIXME raise an error or something here, jeez + # FIXME no url? raise an error or something here, jeez -- cgit From 05e77b608c9eb898004f5c193e18cda28203e422 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Thu, 5 Jun 2008 16:05:20 -0400 Subject: Add a whole bunch of useful flags, courtesy of Chris Ward at Red Hat --- bin/bugzilla | 163 ++++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 138 insertions(+), 25 deletions(-) diff --git a/bin/bugzilla b/bin/bugzilla index af4bb3b..060820b 100755 --- a/bin/bugzilla +++ b/bin/bugzilla @@ -14,7 +14,7 @@ import bugzilla, optparse import os, sys, glob, re import logging -version = '0.2' +version = '0.3' default_bz = 'https://bugzilla.redhat.com/xmlrpc.cgi' # Initial simple logging stuff @@ -84,33 +84,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") - # 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') @@ -135,6 +178,8 @@ 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}'") @@ -203,27 +248,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) @@ -298,5 +401,15 @@ 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.") -- cgit From 8dcf06ea263105485096b6a3e2ab9da09ec4b3d5 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Thu, 5 Jun 2008 16:46:30 -0400 Subject: Add some logging --- bugzilla/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bugzilla/__init__.py b/bugzilla/__init__.py index 94f4886..8561921 100644 --- a/bugzilla/__init__.py +++ b/bugzilla/__init__.py @@ -12,6 +12,8 @@ from bugzilla3 import Bugzilla3 from rhbugzilla import RHBugzilla import xmlrpclib +import logging +log = logging.getLogger("bugzilla") def getBugzillaClassForURL(url): s = xmlrpclib.ServerProxy(url) @@ -42,4 +44,5 @@ class Bugzilla(object): if c: self.__class__ = c c.__init__(self,**kwargs) + log.debug("Using Bugzilla subclass: %s" % c.__name__) # FIXME no url? raise an error or something here, jeez -- cgit From dbd928a4b7ec66e10cf4b3c0e310ca211d76bbc2 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Fri, 6 Jun 2008 09:38:52 -0400 Subject: Fix cookie handling for bugzilla installations not on root of host, add some logging, abstractify login method --- bugzilla/base.py | 46 ++++++++++++++++++++++++++++++++++------------ bugzilla/bugzilla3.py | 15 ++++++++++++--- bugzilla/rhbugzilla.py | 9 +++++++-- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index d2b33e9..1aa2e89 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -11,6 +11,8 @@ import xmlrpclib, urllib2, cookielib import os.path, base64, copy +import logging +log = logging.getLogger('bugzilla') version = '0.5' user_agent = 'Python-urllib2/%s bugzilla.py/%s' % \ @@ -109,10 +111,15 @@ class BugzillaBase(object): # 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): + '''IMPLEMENT ME: backend login method''' + raise NotImplementedError + def login(self,user,password): '''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. + login fails, otherwise returns some kind of login info - typically + either a numeric userid, or 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. @@ -120,7 +127,7 @@ class BugzillaBase(object): self.user = user self.password = password try: - r = self._proxy.bugzilla.login(self.user,self.password) + r = self._login(self.user,self.password) except xmlrpclib.Fault, f: r = False return r @@ -502,10 +509,14 @@ class BugzillaBase(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): '''IMPLEMENT ME: Raw xmlrpc call for createBug() Doesn't bother guessing defaults or checking argument validity. - Returns [bug_id, mailresults]''' + Returns bug_id''' raise NotImplementedError def createbug(self,check_args=False,**data): @@ -571,11 +582,9 @@ class BugzillaBase(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]: if i == 'bug_file_loc': data[i] = 'http://' @@ -597,10 +606,20 @@ class BugzillaBase(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 @@ -609,14 +628,19 @@ 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 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: @@ -630,7 +654,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) @@ -642,13 +667,10 @@ 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) diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index 5e38c97..6849358 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -21,6 +21,10 @@ class Bugzilla3(bugzilla.base.BugzillaBase): bugzilla.base.BugzillaBase.__init__(self,**kwargs) self.user_agent = user_agent + def _login(self,user,password): + '''Backend login method for Bugzilla3''' + return self._proxy.User.login({'login':user,'password':password}) + #---- Methods and properties with basic bugzilla info def _getuserforid(self,userid): @@ -83,7 +87,8 @@ class Bugzilla3(bugzilla.base.BugzillaBase): in 'displaycolumns', and the SQL query used by this query will be in 'sql'. ''' - return self._proxy.bugzilla.runQuery(query,self.user,self.password) + #return self._proxy.bugzilla.runQuery(query,self.user,self.password) + raise NotImplementedError, "Bugzilla 3.0 does not support this method." #---- Methods for modifying existing bugs. @@ -203,8 +208,12 @@ class Bugzilla3(bugzilla.base.BugzillaBase): #---- 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, mailresults]''' - return self._proxy.bugzilla.createBug(data,self.user,self.password) + Returns bug_id''' + r = self._proxy.Bug.create(data) + return r['id'] diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py index 5bfaabb..0c905ac 100644 --- a/bugzilla/rhbugzilla.py +++ b/bugzilla/rhbugzilla.py @@ -21,6 +21,10 @@ class RHBugzilla(bugzilla.base.BugzillaBase): bugzilla.base.BugzillaBase.__init__(self,**kwargs) self.user_agent = user_agent + def _login(self,user,password): + '''Backend login method for RHBugzilla.''' + return self._proxy.bugzilla.login(user,password) + #---- Methods and properties with basic bugzilla info # Connect the backend methods to the XMLRPC methods @@ -185,5 +189,6 @@ class RHBugzilla(bugzilla.base.BugzillaBase): 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) + Returns bug_id''' + r = self._proxy.bugzilla.createBug(data,self.user,self.password) + return r[0] -- cgit From 1cc470aa2d6bd55b31cbecc71c48f0b589013fd5 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Fri, 6 Jun 2008 09:39:49 -0400 Subject: ignore build dir --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7335c53..0eaa88b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.swp MANIFEST dist +build -- cgit From 1805ac8a6645c3d9ee191e597320d7792f6b91f8 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Fri, 6 Jun 2008 13:23:23 -0400 Subject: remove broken cookiefile junk, add more debugging --- bin/bugzilla | 27 +++------------------------ bugzilla/base.py | 20 +++++++++----------- 2 files changed, 12 insertions(+), 35 deletions(-) diff --git a/bin/bugzilla b/bin/bugzilla index 060820b..7643c31 100755 --- a/bin/bugzilla +++ b/bin/bugzilla @@ -25,17 +25,6 @@ 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') def setup_parser(): u = "usage: %prog [global options] COMMAND [options]" @@ -46,11 +35,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.") - p.add_option('--cookiefile', - help="cookie file to use for bugzilla authentication") + help="password") p.add_option('--verbose',action='store_true', help="give more info about what's going on") p.add_option('--debug',action='store_true', @@ -205,16 +192,8 @@ if __name__ == '__main__': if global_opt.user and global_opt.password: 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: - 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.") + log.info('No authentication info found.') # And now we actually execute the given command buglist = list() # save the results of query/new/modify here diff --git a/bugzilla/base.py b/bugzilla/base.py index 1aa2e89..f00bc86 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -72,8 +72,6 @@ class BugzillaBase(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: @@ -83,14 +81,6 @@ class BugzillaBase(object): #---- 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) - self._cookiejar = cj - self._cookiejar.filename = cookiefile - def connect(self,url): '''Connect to the bugzilla instance with the given url.''' # Set up the transport @@ -328,6 +318,7 @@ class BugzillaBase(object): 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''' @@ -645,6 +636,8 @@ class CookieTransport(xmlrpclib.Transport): # ...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 @@ -712,14 +705,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) -- cgit From 951c16ed6f67be44bfe507d48cd36de49942b9f7 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Fri, 6 Jun 2008 17:01:54 -0400 Subject: Begin adding .bugzillarc support --- bugzilla/base.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index f00bc86..e97cd40 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -1,4 +1,4 @@ -# 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,2008 Red Hat Inc. # Author: Will Woods @@ -81,6 +81,20 @@ class BugzillaBase(object): #---- Methods for establishing bugzilla connection and logging in + configpath = ['/etc/bugzillarc','~/.bugzillarc'] + def readconfig(self,configpath=None): + '''Read bugzillarc file(s) into memory.''' + configpath = [os.path.expanduser(p) for p in configpath] + import ConfigParser + c = ConfigParser.SafeConfigParser() + if not configpath: + configpath = self.configpath + r = c.read(configpath) + if not r: + return + # FIXME save parsed config in a more lightweight form? + self.conf = c + def connect(self,url): '''Connect to the bugzilla instance with the given url.''' # Set up the transport @@ -97,6 +111,8 @@ class BugzillaBase(object): self._opener = urllib2.build_opener(handler) self._opener.addheaders = [('User-agent',self.user_agent)] self.url = url + # TODO if we have a matching config section for this url, load those + # values now # 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 -- cgit From 735e18f2d1aa144af541ad972ca419a2b4cef005 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Mon, 9 Jun 2008 14:13:46 -0400 Subject: .bugzillarc support, small fix for bugzilla3 compatibility --- bugzilla/base.py | 58 +++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index e97cd40..1ecb790 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -84,16 +84,28 @@ class BugzillaBase(object): configpath = ['/etc/bugzillarc','~/.bugzillarc'] def readconfig(self,configpath=None): '''Read bugzillarc file(s) into memory.''' - configpath = [os.path.expanduser(p) for p in configpath] import ConfigParser - c = ConfigParser.SafeConfigParser() 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 - # FIXME save parsed config in a more lightweight form? - self.conf = c + # 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.''' @@ -111,8 +123,10 @@ class BugzillaBase(object): self._opener = urllib2.build_opener(handler) self._opener.addheaders = [('User-agent',self.user_agent)] self.url = url - # TODO if we have a matching config section for this url, load those - # values now + self.readconfig() # we've changed URLs - reload config + if (self.user and self.password): + log.info("user and password present - doing login()") + self.login() # 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 @@ -121,17 +135,29 @@ class BugzillaBase(object): '''IMPLEMENT ME: backend login method''' raise NotImplementedError - def login(self,user,password): + 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 some kind of login info - typically either a numeric userid, or 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. + + 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._login(self.user,self.password) except xmlrpclib.Fault, f: @@ -754,8 +780,14 @@ 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__: -- cgit From 35f9cb409068820018480408376e2c4332d1f315 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 10 Jun 2008 11:06:57 -0400 Subject: Fix up selftest a bit --- selftest.py | 117 +++++++++++++++++++++++++++++++++--------------------------- 1 file changed, 65 insertions(+), 52 deletions(-) diff --git a/selftest.py b/selftest.py index 30f212d..b2622f7 100755 --- a/selftest.py +++ b/selftest.py @@ -14,81 +14,94 @@ 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':1, + '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':1, + 'private_bug':31337, # FIXME + '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() + k = sorted(prod.keys()) + print "Products found: %s, %s, %s...(%i more)" % (k[0],k[1],k[2],len(k)-3) - print "Reading public bug (#%i)" % public_bug - print bz.getbugsimple(public_bug) + 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." + break + print "Awesome. We're done." -- cgit From 4782cbf51cdf89faa977ccace668f75e97e21e6d Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 10 Jun 2008 11:21:32 -0400 Subject: stop sending passwords with rhbugzilla requests - rely on the login() cookie everywhere --- bugzilla/base.py | 17 +++++++++++++++-- bugzilla/bugzilla3.py | 23 ++++++++--------------- bugzilla/rhbugzilla.py | 40 ++++++++++++++++++++-------------------- 3 files changed, 43 insertions(+), 37 deletions(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index 1ecb790..1be4224 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -62,6 +62,7 @@ class BugzillaBase(object): self.password = '' self.url = '' self.user_agent = user_agent + self.logged_in = False # Bugzilla object state info that users shouldn't mess with self._cookiejar = None self._proxy = None @@ -108,7 +109,14 @@ class BugzillaBase(object): 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 if url.startswith('https'): self._transport = SafeCookieTransport() @@ -139,7 +147,8 @@ class BugzillaBase(object): '''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 some kind of login info - typically - either a numeric userid, or a dict of user info. + 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. @@ -147,6 +156,7 @@ class BugzillaBase(object): 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. + ''' if user: self.user = user @@ -160,6 +170,9 @@ class BugzillaBase(object): try: 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 diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index 6849358..5877a4d 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -42,7 +42,6 @@ class Bugzilla3(bugzilla.base.BugzillaBase): keylist.append('assigned_to') return keylist def _getqueryinfo(self): - #return self._proxy.bugzilla.getQueryInfo(self.user,self.password) raise NotImplementedError def _getproducts(self): '''This throws away a bunch of data that RH's getProdInfo @@ -55,10 +54,8 @@ class Bugzilla3(bugzilla.base.BugzillaBase): return pdict def _getcomponents(self,product): - #return self._proxy.bugzilla.getProdCompInfo(product,self.user,self.password) raise NotImplementedError def _getcomponentsdetails(self,product): - #return self._proxy.bugzilla.getProdCompDetails(product,self.user,self.password) raise NotImplementedError #---- Methods for reading bugs and bug info @@ -87,7 +84,6 @@ class Bugzilla3(bugzilla.base.BugzillaBase): in 'displaycolumns', and the SQL query used by this query will be in 'sql'. ''' - #return self._proxy.bugzilla.runQuery(query,self.user,self.password) raise NotImplementedError, "Bugzilla 3.0 does not support this method." #---- Methods for modifying existing bugs. @@ -106,8 +102,7 @@ class Bugzilla3(bugzilla.base.BugzillaBase): 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) + 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 @@ -117,8 +112,7 @@ class Bugzilla3(bugzilla.base.BugzillaBase): 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) + 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 @@ -156,14 +150,13 @@ class Bugzilla3(bugzilla.base.BugzillaBase): $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,self.password, - dupeid,fixedin,comment,isprivate,private_in_it,nomail) + raise NotImplementedError 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) + raise NotImplementedError def _updatedeps(self,id,deplist): '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug. updateDepends($bug_id,$data,$username,$password,$nodependencyemail) @@ -178,13 +171,13 @@ class Bugzilla3(bugzilla.base.BugzillaBase): ''' 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 'overwrite') using the given text.''' data = {'type':which,'text':text,'action':action} - return self._proxy.bugzilla.updateWhiteboard(id,data,self.user,self.password) + raise NotImplementedError # TODO: update this when the XMLRPC interface grows requestee support def _updateflags(self,id,flags): '''Updates the flags associated with a bug report. @@ -194,7 +187,7 @@ class Bugzilla3(bugzilla.base.BugzillaBase): 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) + raise NotImplementedError #---- Methods for working with attachments @@ -204,7 +197,7 @@ class Bugzilla3(bugzilla.base.BugzillaBase): # want to override _attachment_uri here. def _attachfile(self,id,**attachdata): - return self._proxy.bugzilla.addAttachment(id,attachdata,self.user,self.password) + raise NotImplementedError #---- createbug - call to create a new bug diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py index 0c905ac..c5964e2 100644 --- a/bugzilla/rhbugzilla.py +++ b/bugzilla/rhbugzilla.py @@ -11,7 +11,7 @@ import bugzilla.base -version = '0.1' +version = '0.2' user_agent = bugzilla.base.user_agent + ' RHBugzilla/%s' % version class RHBugzilla(bugzilla.base.BugzillaBase): @@ -29,24 +29,24 @@ class RHBugzilla(bugzilla.base.BugzillaBase): # Connect the backend methods to the XMLRPC methods def _getbugfields(self): - return self._proxy.bugzilla.getBugFields(self.user,self.password) + return self._proxy.bugzilla.getBugFields() def _getqueryinfo(self): - return self._proxy.bugzilla.getQueryInfo(self.user,self.password) + return self._proxy.bugzilla.getQueryInfo() def _getproducts(self): - return self._proxy.bugzilla.getProdInfo(self.user, self.password) + return self._proxy.bugzilla.getProdInfo() def _getcomponents(self,product): - return self._proxy.bugzilla.getProdCompInfo(product,self.user,self.password) + return self._proxy.bugzilla.getProdCompInfo(product) def _getcomponentsdetails(self,product): - return self._proxy.bugzilla.getProdCompDetails(product,self.user,self.password) + return self._proxy.bugzilla.getProdCompDetails(product) #---- 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, self.user, self.password) + 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, self.user, self.password) + 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. @@ -66,7 +66,7 @@ class RHBugzilla(bugzilla.base.BugzillaBase): in 'displaycolumns', and the SQL query used by this query will be in 'sql'. ''' - return self._proxy.bugzilla.runQuery(query,self.user,self.password) + return self._proxy.bugzilla.runQuery(query) #---- Methods for modifying existing bugs. @@ -84,8 +84,8 @@ class RHBugzilla(bugzilla.base.BugzillaBase): 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) + 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 @@ -95,8 +95,8 @@ class RHBugzilla(bugzilla.base.BugzillaBase): 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) + 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 @@ -134,14 +134,14 @@ class RHBugzilla(bugzilla.base.BugzillaBase): $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,self.password, + 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,self.user,self.password) + return self._proxy.bugzilla.changeAssignment(id,data) def _updatedeps(self,id,deplist): '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug. updateDepends($bug_id,$data,$username,$password,$nodependencyemail) @@ -156,13 +156,13 @@ class RHBugzilla(bugzilla.base.BugzillaBase): ''' data = {'id':id, 'action':action, 'cc':','.join(cclist), 'comment':comment, 'nomail':nomail} - return self._proxy.bugzilla.updateCC(data,self.user,self.password) + 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,self.user,self.password) + 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. @@ -172,7 +172,7 @@ class RHBugzilla(bugzilla.base.BugzillaBase): 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) + return self._proxy.bugzilla.updateFlags(id,flags) #---- Methods for working with attachments @@ -182,7 +182,7 @@ class RHBugzilla(bugzilla.base.BugzillaBase): # want to override _attachment_uri here. def _attachfile(self,id,**attachdata): - return self._proxy.bugzilla.addAttachment(id,attachdata,self.user,self.password) + return self._proxy.bugzilla.addAttachment(id,attachdata) #---- createbug - call to create a new bug @@ -190,5 +190,5 @@ class RHBugzilla(bugzilla.base.BugzillaBase): '''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,self.user,self.password) + r = self._proxy.bugzilla.createBug(data) return r[0] -- cgit From 68299ba63acc14fc38f2dc2c7fd1373c0aff1119 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 10 Jun 2008 14:54:15 -0400 Subject: Explicitly mark methods unsupported by bugzilla3.0. Change the format of the .product attribute to be more like Bugzilla 3.x. --- bugzilla/base.py | 19 ++++++++++++++++++- bugzilla/bugzilla3.py | 33 +++++++++++++++------------------ bugzilla/rhbugzilla.py | 16 +++++++++++++++- 3 files changed, 48 insertions(+), 20 deletions(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index 1be4224..554d89f 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -273,7 +273,13 @@ class BugzillaBase(object): fdel=lambda self: setattr(self,"_querydefaults",None)) 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 @@ -281,6 +287,17 @@ class BugzillaBase(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,force_refresh=False): '''Return a dict of components:descriptions for the given product.''' diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index 5877a4d..d5b1e9b 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -42,19 +42,18 @@ class Bugzilla3(bugzilla.base.BugzillaBase): keylist.append('assigned_to') return keylist def _getqueryinfo(self): - raise NotImplementedError + 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) - pdict = {} - for p in r['products']: - pdict[p['name']] = p['description'] - return pdict - + return r['products'] def _getcomponents(self,product): - raise NotImplementedError + 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 @@ -102,7 +101,7 @@ class Bugzilla3(bugzilla.base.BugzillaBase): bz_gid: if present, and the entire bug is *not* already private to this group ID, this comment will be marked private. ''' - raise NotImplementedError + raise NotImplementedError, "Bugzilla 3.0 does not support this method." 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 @@ -112,7 +111,7 @@ class Bugzilla3(bugzilla.base.BugzillaBase): Less common: 'VERIFIED','ON_DEV','ON_QA','REOPENED' 'CLOSED' is not valid with this method; use closebug() instead. ''' - raise NotImplementedError + raise NotImplementedError, "Bugzilla 3.0 does not support this method." 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 @@ -150,18 +149,18 @@ class Bugzilla3(bugzilla.base.BugzillaBase): $nomail # OPTIONAL Flag that is either 1 or 0 if you want email to be sent or not for this change ''' - raise NotImplementedError + raise NotImplementedError, "Bugzilla 3.0 does not support this method." 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]''' - raise NotImplementedError + raise NotImplementedError, "Bugzilla 3.0 does not support this method." def _updatedeps(self,id,deplist): '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug. updateDepends($bug_id,$data,$username,$password,$nodependencyemail) #data: 'blocked'=>id,'dependson'=>id,'action' => ('add','remove')''' - raise NotImplementedError + raise NotImplementedError, "Bugzilla 3.0 does not support this method." 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. @@ -169,15 +168,13 @@ class Bugzilla3(bugzilla.base.BugzillaBase): comment specifies an optional comment to add to the bug. if mail is True, email will be generated for this change. ''' - data = {'id':id, 'action':action, 'cc':','.join(cclist), - 'comment':comment, 'nomail':nomail} - raise NotImplementedError + raise NotImplementedError, "Bugzilla 3.0 does not support this method." 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} - raise NotImplementedError + 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): '''Updates the flags associated with a bug report. @@ -187,7 +184,7 @@ class Bugzilla3(bugzilla.base.BugzillaBase): NOTE: the Red Hat XMLRPC interface does not yet support setting the requestee (as in: needinfo from smartguy@answers.com). Alas.''' - raise NotImplementedError + raise NotImplementedError, "Bugzilla 3.0 does not support this method." #---- Methods for working with attachments @@ -197,7 +194,7 @@ class Bugzilla3(bugzilla.base.BugzillaBase): # want to override _attachment_uri here. def _attachfile(self,id,**attachdata): - raise NotImplementedError + raise NotImplementedError, "Bugzilla 3.0 does not support this method." #---- createbug - call to create a new bug diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py index c5964e2..a069a2b 100644 --- a/bugzilla/rhbugzilla.py +++ b/bugzilla/rhbugzilla.py @@ -33,10 +33,24 @@ class RHBugzilla(bugzilla.base.BugzillaBase): def _getqueryinfo(self): return self._proxy.bugzilla.getQueryInfo() def _getproducts(self): - return self._proxy.bugzilla.getProdInfo() + '''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) #---- Methods for reading bugs and bug info -- cgit From 7f9fbe2bf33f448e6452e28b1d8a5426e5cf0767 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 10 Jun 2008 14:54:42 -0400 Subject: Test the product/component handling --- selftest.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/selftest.py b/selftest.py index b2622f7..e378b21 100755 --- a/selftest.py +++ b/selftest.py @@ -17,7 +17,7 @@ import xmlrpclib bugzillas = { 'Red Hat':{ 'url':'https://bugzilla.redhat.com/xmlrpc.cgi', - 'public_bug':1, + 'public_bug':427301, 'private_bug':250666, 'bugidlist':(1,2,3,1337), 'query':{'product':'Fedora', @@ -26,8 +26,8 @@ bugzillas = { }, 'Bugzilla 3.0':{ 'url':'https://landfill.bugzilla.org/bugzilla-3.0-branch/xmlrpc.cgi', - 'public_bug':1, - 'private_bug':31337, # FIXME + 'public_bug':4433, + 'private_bug':6620, # FIXME - does this instance have groups? 'bugidlist':(1,2,3,4433), 'query':{'product':'WorldControl', 'component':'WeatherControl', @@ -54,8 +54,16 @@ def selftest(data,user='',password=''): print "Reading product list" prod = bz.getproducts() - k = sorted(prod.keys()) - print "Products found: %s, %s, %s...(%i more)" % (k[0],k[1],k[2],len(k)-3) + prodlist = [p['name'] for p in prod] + print "Products found: %s, %s, %s...(%i more)" % \ + (prodlist[0],prodlist[1],prodlist[2],len(prodlist)-3) + + 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']) @@ -103,5 +111,5 @@ if __name__ == '__main__': selftest(data,user,password) except KeyboardInterrupt: print "Exiting on keyboard interrupt." - break + sys.exit(1) print "Awesome. We're done." -- cgit From f64e769813a61ac8d93f82ba3592c334626611e3 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Sat, 21 Jun 2008 11:30:51 -0400 Subject: todos for man pages --- TODO | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TODO b/TODO index 4d1b6a9..fc05cf9 100644 --- a/TODO +++ b/TODO @@ -2,3 +2,5 @@ - 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 +- auto-generate the man page +- actually install the man page -- cgit From e423a2a54df23092ec11f890f58b4cb242425cc2 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Mon, 14 Jul 2008 16:41:00 -0400 Subject: comment changes, remove unneeded extra Bugzilla object --- bugzilla/base.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index 554d89f..1ec39d7 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -34,6 +34,7 @@ def replace_getbug_errors_with_None(rawlist): return result class BugzillaBase(object): + # FIXME: remove doc info about cookie handling, add info about .bugzillarc '''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) @@ -409,8 +410,9 @@ class BugzillaBase(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']] @@ -943,10 +945,4 @@ class Bug(object): tags.remove(tag) self.setwhiteboard(' '.join(tags),which) # TODO: add a sync() method that writes the changed data in the Bug object -# back to Bugzilla. Someday. - -class Bugzilla(object): - '''Magical Bugzilla class that figures out which Bugzilla implementation - to use and uses that.''' - # FIXME STUB - pass +# back to Bugzilla? -- cgit From c4508eafd17be977fcddab229ae1f80c7a47f0d4 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Mon, 14 Jul 2008 16:41:52 -0400 Subject: add TODO reminder about bz 3.2 --- TODO | 1 + 1 file changed, 1 insertion(+) diff --git a/TODO b/TODO index fc05cf9..0254dfc 100644 --- a/TODO +++ b/TODO @@ -1,4 +1,5 @@ - flesh out Bugzilla3 class +- Add Bugzilla32 class - Bugzilla 3.2 can actually *search*! - 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 -- cgit From 2f5579929fe21479831b378a1dc5f0b561c346c0 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 5 Aug 2008 15:06:36 -0400 Subject: Add login command and python-bugzilla-specific cookie file. Based on a patch by Zack Cerza. --- bin/bugzilla | 19 +++++++++++++++---- bugzilla/base.py | 42 +++++++++++++++++++++++++++++------------- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/bin/bugzilla b/bin/bugzilla index 7643c31..9623187 100755 --- a/bin/bugzilla +++ b/bin/bugzilla @@ -13,6 +13,7 @@ import bugzilla, optparse import os, sys, glob, re import logging +import getpass version = '0.3' default_bz = 'https://bugzilla.redhat.com/xmlrpc.cgi' @@ -25,7 +26,7 @@ if '--debug' in sys.argv: elif '--verbose' in sys.argv: log.setLevel(logging.INFO) -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) @@ -189,15 +190,25 @@ 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: + 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) else: - log.info('No authentication info found.') + 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) + else: + log.info('No authentication info found.') # And now we actually execute the given command buglist = list() # save the results of query/new/modify here - if action == 'info': + if action == 'login': + pass + elif action == 'info': if opt.products: for k in sorted(bz.products): print k diff --git a/bugzilla/base.py b/bugzilla/base.py index 1ec39d7..2a4d56c 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -39,16 +39,14 @@ class BugzillaBase(object): 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. + 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). + + You can also specify 'user' and 'password' in a bugzillarc file, either + /etc/bugzillarc or ~/.bugzillarc. The latter will override the former. + Be sure to set appropriate permissions on those files if you choose to + store your password in one of them! The methods which start with a single underscore are thin wrappers around xmlrpc calls; those should be safe for multicall usage. @@ -62,6 +60,7 @@ class BugzillaBase(object): 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 @@ -83,6 +82,18 @@ class BugzillaBase(object): #---- Methods for establishing bugzilla connection and logging in + 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() + self._cookiejar = cj + self._cookiejar.filename = self.cookiefile + configpath = ['/etc/bugzillarc','~/.bugzillarc'] def readconfig(self,configpath=None): '''Read bugzillarc file(s) into memory.''' @@ -119,12 +130,13 @@ class BugzillaBase(object): you'll have to login() yourself before some methods will work. ''' # Set up the transport + self.initcookiefile() if url.startswith('https'): self._transport = SafeCookieTransport() else: self._transport = CookieTransport() self._transport.user_agent = self.user_agent - self._transport.cookiejar = self._cookiejar or cookielib.CookieJar() + 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) @@ -693,7 +705,7 @@ 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 cookiejar") + log.debug("send_cookies(): creating in-memory cookiejar") self.cookiejar = cookielib.CookieJar() elif self.cookiejar: log.debug("send_cookies(): using existing cookiejar") @@ -740,7 +752,11 @@ class CookieTransport(xmlrpclib.Transport): 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( -- cgit From b17845248f58f00b1d4d845775bbd10b5522dc72 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 5 Aug 2008 15:10:17 -0400 Subject: Fix syntax error typo --- bugzilla/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index 2a4d56c..01eda94 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -756,7 +756,7 @@ class CookieTransport(xmlrpclib.Transport): self.cookiejar.save(self.cookiejar.filename) except e: log.error("Couldn't write cookiefile %s: %s" % \ - (self.cookiejar.filename,str(e)) + (self.cookiejar.filename,str(e))) if errcode != 200: raise xmlrpclib.ProtocolError( -- cgit From 67717a9b8b7d8379cfc5fa711e7c27ce7d71f66f Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 5 Aug 2008 15:50:24 -0400 Subject: forgot to bring back --cookiefile --- bin/bugzilla | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bin/bugzilla b/bin/bugzilla index 9623187..aa63883 100755 --- a/bin/bugzilla +++ b/bin/bugzilla @@ -39,6 +39,8 @@ def setup_parser(): help="username") p.add_option('--password', help="password") + p.add_option('--cookiefile', + help="cookie file to use for bugzilla authentication") p.add_option('--verbose',action='store_true', help="give more info about what's going on") p.add_option('--debug',action='store_true', -- cgit From 4ece95e2108afb80c8fa7e35ba6157358b0f6664 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 5 Aug 2008 15:50:41 -0400 Subject: API reminder --- TODO | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TODO b/TODO index 0254dfc..fc0a196 100644 --- a/TODO +++ b/TODO @@ -5,3 +5,5 @@ - make the abstract methods return stuff closer to Bugzilla3's return values - 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 -- cgit From 2de4aecfcdf639aed2d895d84a629651c45c1351 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 5 Aug 2008 15:51:31 -0400 Subject: Add _logout method, initial Bugzilla32 class --- bugzilla/bugzilla3.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index d5b1e9b..c9bde08 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -11,20 +11,25 @@ import bugzilla.base -version = '0.1' -user_agent = bugzilla.base.user_agent + ' Bugzilla3/%s' % version - class Bugzilla3(bugzilla.base.BugzillaBase): '''Concrete implementation of the Bugzilla protocol. This one uses the - methods provided by standard Bugzilla 3.x releases.''' + 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 = user_agent + 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): @@ -207,3 +212,13 @@ class Bugzilla3(bugzilla.base.BugzillaBase): Returns bug_id''' r = self._proxy.Bug.create(data) return r['id'] + +# Bugzilla 3.2 adds a whole bunch of new goodies. +class Bugzilla32(Bugzilla3): + '''Concrete implementation of the Bugzilla protocol. This one uses the + methods provided by standard Bugzilla 3.2.x releases.''' + + version = '0.1' + user_agent = bugzilla.base.user_agent + ' Bugzilla32/%s' % version + + # TODO: add goodies (_query, etc) -- cgit From 9434dd7a60859bc8b1f72fe28d8733bd0556c385 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 5 Aug 2008 15:58:19 -0400 Subject: Use Bugzilla32 where applicable --- bugzilla/__init__.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/bugzilla/__init__.py b/bugzilla/__init__.py index 8561921..2e5ee75 100644 --- a/bugzilla/__init__.py +++ b/bugzilla/__init__.py @@ -9,7 +9,7 @@ # option) any later version. See http://www.gnu.org/copyleft/gpl.html for # the full text of the license. -from bugzilla3 import Bugzilla3 +from bugzilla3 import Bugzilla3, Bugzilla32 from rhbugzilla import RHBugzilla import xmlrpclib import logging @@ -17,23 +17,36 @@ log = logging.getLogger("bugzilla") def getBugzillaClassForURL(url): s = xmlrpclib.ServerProxy(url) - # RH Bugzilla method - prodinfo = {} + rhbz = False + bzversion = '' + c = None + + # Check for a RH-only method try: prodinfo = s.bugzilla.getProdInfo() - return RHBugzilla + rhbz = True except xmlrpclib.Fault: pass + # Try to get the bugzilla version string try: r = s.Bugzilla.version() - version = r['version'] - if version.startswith('3.'): - return Bugzilla3 + bzversion = r['version'] except xmlrpclib.Fault: pass - return None + # current preference order: RHBugzilla, Bugzilla3 + # RH BZ 3.2 will have rhbz == True and bzversion == 3.1.x or 3.2.x. + # To prefer Bugzilla32 over RHBugzilla do: if rhbz and (bzversion == '') + if rhbz: + 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 -- cgit From 02454166d136f69ec859660dcea040acaee850cd Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 5 Aug 2008 16:11:24 -0400 Subject: Add disconnect() and logout() methods --- bugzilla/base.py | 59 ++++++++++++++++++++++++++++++++++++-------------- bugzilla/rhbugzilla.py | 7 ++++++ 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index 01eda94..6a56fac 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -41,12 +41,20 @@ class BugzillaBase(object): 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). + 'cookiefile' attribute (which defaults to ~/.bugzillacookies). Once you + get cookies this way, you will be considered logged in until the cookie + expires. - You can also specify 'user' and 'password' in a bugzillarc file, either + You may also specify 'user' and 'password' in a bugzillarc file, either /etc/bugzillarc or ~/.bugzillarc. The latter will override the former. - Be sure to set appropriate permissions on those files if you choose to - store your password in one of them! + 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! The methods which start with a single underscore are thin wrappers around xmlrpc calls; those should be safe for multicall usage. @@ -64,8 +72,19 @@ class BugzillaBase(object): 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 @@ -73,12 +92,6 @@ class BugzillaBase(object): self._bugfields = None self._components = dict() self._components_details = dict() - 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 @@ -130,7 +143,7 @@ class BugzillaBase(object): you'll have to login() yourself before some methods will work. ''' # Set up the transport - self.initcookiefile() + self.initcookiefile() # sets _cookiejar if url.startswith('https'): self._transport = SafeCookieTransport() else: @@ -149,6 +162,10 @@ class BugzillaBase(object): 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. @@ -169,7 +186,6 @@ class BugzillaBase(object): 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. - ''' if user: self.user = user @@ -190,12 +206,23 @@ class BugzillaBase(object): r = False return r + def _logout(self): + '''IMPLEMENT ME: backend login method''' + raise NotImplementedError + + 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 - # XXX FIXME Uh-oh. I think MultiCall support is a RHism. - # Even worse, RH's bz3 instance supports the RH methods but *NOT* mc! - # 1) move all multicall-calls into RHBugzilla, and - # 2) either make MC optional, or prefer Bugzilla3 over RHBugzilla + # XXX FIXME Uh-oh. I think MultiCall support is a RHism. We should probably + # move all multicall-based methods into RHBugzilla. 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 diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py index a069a2b..09b09a4 100644 --- a/bugzilla/rhbugzilla.py +++ b/bugzilla/rhbugzilla.py @@ -25,6 +25,13 @@ class RHBugzilla(bugzilla.base.BugzillaBase): '''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 # Connect the backend methods to the XMLRPC methods -- cgit From 9a6cd5b4fc096a8213bfe976bc88a7e506d7b2c5 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 5 Aug 2008 17:05:15 -0400 Subject: bump version number, improve login action --- bin/bugzilla | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/bin/bugzilla b/bin/bugzilla index aa63883..835260d 100755 --- a/bin/bugzilla +++ b/bin/bugzilla @@ -15,7 +15,7 @@ import os, sys, glob, re import logging import getpass -version = '0.3' +version = '0.4' default_bz = 'https://bugzilla.redhat.com/xmlrpc.cgi' # Initial simple logging stuff @@ -192,6 +192,26 @@ if __name__ == '__main__': # Connect to bugzilla log.info('Connecting to %s',global_opt.bugzilla) bz=bugzilla.Bugzilla(url=global_opt.bugzilla) + + # 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() @@ -204,13 +224,12 @@ if __name__ == '__main__': if os.path.exists(cookiefile): log.info('Using cookies in %s for authentication', cookiefile) else: - log.info('No authentication info found.') + # 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 - if action == 'login': - pass - elif action == 'info': + if action == 'info': if opt.products: for k in sorted(bz.products): print k -- cgit From 5c54609e23fca1104e195ad4755b6ca098db6759 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Fri, 8 Aug 2008 09:59:14 -0400 Subject: Move multicall methods into rhbugzilla, since normal bugzilla instances don't support it --- bugzilla/base.py | 30 ++++++++---------------------- bugzilla/rhbugzilla.py | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index 6a56fac..c9eaac9 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -396,36 +396,22 @@ class BugzillaBase(object): def _getbug(self,id): '''IMPLEMENT ME: Return a dict of full bug info for the given bug id''' raise NotImplementedError + def _getbugs(self,idlist): + '''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): + '''IMPLEMENT ME: Return a list of short bug dicts, one for each of the + given bug ids''' + raise NotImplementedError def _query(self,query): '''IMPLEMENT ME: Query bugzilla and return a list of matching bugs.''' raise NotImplementedError - # 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 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 replace_getbug_errors_with_None(raw_results) - # these return Bug objects def getbug(self,id): '''Return a Bug object with the full complement of bug data diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py index 09b09a4..d19c863 100644 --- a/bugzilla/rhbugzilla.py +++ b/bugzilla/rhbugzilla.py @@ -74,6 +74,28 @@ class RHBugzilla(bugzilla.base.BugzillaBase): 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 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 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']. -- cgit From 3b1228e764c241f8dc74b930d1a9baef67e0e210 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Fri, 8 Aug 2008 10:01:47 -0400 Subject: Fix up _getbugssimple --- bugzilla/bugzilla3.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index c9bde08..fae316f 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -65,15 +65,15 @@ class Bugzilla3(bugzilla.base.BugzillaBase): #---- 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] - def _getbugsimple(self,id): - '''Return a short dict of simple bug info for the given bug id''' - # Bugzilla3 doesn't have this - return self._getbug(id) + # Bugzilla3 doesn't have getbugsimple - alias to the full method(s) + _getbugsimple = _getbug + _getbugssimple = _getbugs def _query(self,query): '''Query bugzilla and return a list of matching bugs. -- cgit From 504db87ae33aae1703931a6226058db49d5b99ea Mon Sep 17 00:00:00 2001 From: Will Woods Date: Fri, 15 Aug 2008 10:27:40 -0400 Subject: Add _update_bugs, implement most of the backend calls for BZ32, add stubs for the rest. --- bugzilla/bugzilla3.py | 136 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 132 insertions(+), 4 deletions(-) diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index fae316f..b3c508f 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -40,8 +40,8 @@ class Bugzilla3(bugzilla.base.BugzillaBase): # Connect the backend methods to the XMLRPC methods def _getbugfields(self): '''Get a list of valid fields for bugs.''' - #I don't think BZ3 provides a getbugfields() method, so right - #we fake it by looking at bug #1. Yuck. + # 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') @@ -213,7 +213,7 @@ class Bugzilla3(bugzilla.base.BugzillaBase): r = self._proxy.Bug.create(data) return r['id'] -# Bugzilla 3.2 adds a whole bunch of new goodies. +# Bugzilla 3.2 adds a whole bunch of new goodies on top of Bugzilla3. class Bugzilla32(Bugzilla3): '''Concrete implementation of the Bugzilla protocol. This one uses the methods provided by standard Bugzilla 3.2.x releases.''' @@ -221,4 +221,132 @@ class Bugzilla32(Bugzilla3): version = '0.1' user_agent = bugzilla.base.user_agent + ' Bugzilla32/%s' % version - # TODO: add goodies (_query, etc) + 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. + ''' + # The following is true for rhbz; not sure if it's the case for BZ3.2 + #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']). + return self._proxy.Bug.search(query) + + 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) + + 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 + return self._proxy.Bug.update({'ids':ids,'updates':updates}) + + 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" + Ignored by BZ32. + worktime: amount of time spent on this comment, in hours + 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.Bug.add_comment({'id':id, + 'comment':comment, + 'private':private, + 'worktime':worktime}) + + #---- Methods for updating bugs. + + # 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. + + 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(ids=[id],updates=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(ids=[id],updates=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(ids=[id],updates=data) + + def _updatedeps(self,id,deplist): + '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug. + updateDepends($bug_id,$data,$username,$password,$nodependencyemail) + #data: 'blocked'=>id,'dependson'=>id,'action' => ('add','remove')''' + raise NotImplementedError, "wwoods needs to port this method." + 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', 'remove', or 'makeexact'. + comment specifies an optional comment to add to the bug. + if mail is True, email will be generated for this change. + ''' + raise NotImplementedError, "wwoods needs to port this method." + 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} + raise NotImplementedError, "wwoods needs to port this method." + # 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.''' + raise NotImplementedError, "wwoods needs to port this method." + + #---- 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): + raise NotImplementedError, "wwoods needs to port this method." + -- cgit From 55e6ad21d89440e9cec9614cdb01e8cd45e549a8 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Fri, 15 Aug 2008 10:33:18 -0400 Subject: Remove unneccesary comments/docstrings on unimplemented methods --- bugzilla/bugzilla3.py | 105 +------------------------------------------------- 1 file changed, 1 insertion(+), 104 deletions(-) diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index b3c508f..8ff42d1 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -75,129 +75,27 @@ class Bugzilla3(bugzilla.base.BugzillaBase): _getbugsimple = _getbug _getbugssimple = _getbugs + # Bugzilla 3.0 doesn't have a *lot* of things, actually. 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'. - ''' raise NotImplementedError, "Bugzilla 3.0 does not support this method." - - #---- 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. - ''' raise NotImplementedError, "Bugzilla 3.0 does not support this method." 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. - ''' raise NotImplementedError, "Bugzilla 3.0 does not support this method." 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 - ''' raise NotImplementedError, "Bugzilla 3.0 does not support this method." 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]''' raise NotImplementedError, "Bugzilla 3.0 does not support this method." def _updatedeps(self,id,deplist): - '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug. - updateDepends($bug_id,$data,$username,$password,$nodependencyemail) - #data: 'blocked'=>id,'dependson'=>id,'action' => ('add','remove')''' raise NotImplementedError, "Bugzilla 3.0 does not support this method." 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', 'remove', or 'makeexact'. - comment specifies an optional comment to add to the bug. - if mail is True, email will be generated for this change. - ''' raise NotImplementedError, "Bugzilla 3.0 does not support this method." 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} 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): - '''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.''' raise NotImplementedError, "Bugzilla 3.0 does not support this method." - - #---- 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): raise NotImplementedError, "Bugzilla 3.0 does not support this method." @@ -205,7 +103,6 @@ class Bugzilla3(bugzilla.base.BugzillaBase): 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. -- cgit From 0e070830f198a314fbedebd108de97c738636bd4 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Fri, 15 Aug 2008 10:37:11 -0400 Subject: Let's not skip version 0.4 just yet. --- bugzilla/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index c9eaac9..59c505f 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -14,7 +14,7 @@ import os.path, base64, copy import logging log = logging.getLogger('bugzilla') -version = '0.5' +version = '0.4' user_agent = 'Python-urllib2/%s bugzilla.py/%s' % \ (urllib2.__version__,version) -- cgit From d4f2581e5cb6fa11b9510c2e0a3ea6e52411fec4 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Fri, 15 Aug 2008 12:43:27 -0400 Subject: Modify _updatedeps slightly (it wasn't being used by anything anyway) and implement it for bz32 and rhbz --- bugzilla/base.py | 7 ++++--- bugzilla/bugzilla3.py | 16 +++++++++++----- bugzilla/rhbugzilla.py | 26 ++++++++++++++++++++++---- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index 59c505f..8158998 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -476,10 +476,11 @@ class BugzillaBase(object): def _setassignee(self,id,**data): '''IMPLEMENT ME: set the assignee of the given bug ID''' raise NotImplementedError - def _updatedeps(self,id,deplist): + def _updatedeps(self,id,blocked,dependson,action): '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug. - updateDepends($bug_id,$data,$username,$password,$nodependencyemail) - #data: 'blocked'=>id,'dependson'=>id,'action' => ('add','remove')''' + blocked, dependson: list of bug ids/aliases + action: 'add' or 'remove' + ''' raise NotImplementedError def _updatecc(self,id,cclist,action,comment='',nomail=False): '''IMPLEMENT ME: Update the CC list using the action and account list diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index 8ff42d1..c64c43f 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -207,11 +207,17 @@ class Bugzilla32(Bugzilla3): update = dict([(k,v) for k,v in data.iteritems() if v != '']) return self._update_bug(ids=[id],updates=data) - def _updatedeps(self,id,deplist): - '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug. - updateDepends($bug_id,$data,$username,$password,$nodependencyemail) - #data: 'blocked'=>id,'dependson'=>id,'action' => ('add','remove')''' - raise NotImplementedError, "wwoods needs to port this method." + 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(ids=id,updates=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. diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py index d19c863..984787b 100644 --- a/bugzilla/rhbugzilla.py +++ b/bugzilla/rhbugzilla.py @@ -185,11 +185,29 @@ class RHBugzilla(bugzilla.base.BugzillaBase): data: 'assigned_to','reporter','qa_contact','comment' returns: [$id, $mailresults]''' return self._proxy.bugzilla.changeAssignment(id,data) - def _updatedeps(self,id,deplist): - '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug. + 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')''' - raise NotImplementedError + #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 = [] + 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. -- cgit From 704d7167015c373ecb387e46bc56c9190c10f0e8 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Fri, 15 Aug 2008 12:43:59 -0400 Subject: Fix typo in _setassignee --- bugzilla/bugzilla3.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index c64c43f..bcefcde 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -205,7 +205,7 @@ class Bugzilla32(Bugzilla3): returns: [$id, $mailresults]''' # drop empty items update = dict([(k,v) for k,v in data.iteritems() if v != '']) - return self._update_bug(ids=[id],updates=data) + return self._update_bug(ids=[id],updates=update) def _updatedeps(self,id,blocked,dependson,action): '''Update the deps (blocked/dependson) for the given bug. -- cgit From 470c7dc20943d09ae8c98e6d0484d77e5b1eb778 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Fri, 15 Aug 2008 14:18:51 -0400 Subject: Add RHBugzilla32 class and shuffle methods around - API docs say that most of the good stuff is RH-specific. Still. --- bugzilla/bugzilla3.py | 56 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index bcefcde..3fe3052 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -110,14 +110,46 @@ class Bugzilla3(bugzilla.base.BugzillaBase): r = self._proxy.Bug.create(data) return r['id'] -# Bugzilla 3.2 adds a whole bunch of new goodies on top of Bugzilla3. +# Bugzilla 3.2 adds some new goodies on top of Bugzilla3. class Bugzilla32(Bugzilla3): '''Concrete implementation of the Bugzilla protocol. This one uses the - methods provided by standard Bugzilla 3.2.x releases.''' + 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: comment timestamp, in the form "YYYY-MM-DD HH:MM:SS" + Ignored by BZ32. + worktime: amount of time spent on this comment, in hours + 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.Bug.add_comment({'id':id, + 'comment':comment, + 'private':private, + 'work_time':worktime}) + +class RHBugzilla32(Bugzilla32): + '''Concrete implementation of the Bugzilla protocol. This one uses the + methods provided by Red Hat's Bugzilla 3.1.4+ instance, which are supposed + to make their way into Bugzilla 3.4. + + This class was written using bugzilla.redhat.com's API docs: + https://bugzilla.redhat.com/docs/en/html/api/ + ''' + + version = '0.1' + user_agent = bugzilla.base.user_agent + ' RHBugzilla32/%s' % version + 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']. @@ -137,6 +169,8 @@ class Bugzilla32(Bugzilla3): #used (see the list in querydefaults['default_column_list']). return self._proxy.Bug.search(query) + #---- Methods for updating bugs. + 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)''' @@ -152,24 +186,6 @@ class Bugzilla32(Bugzilla3): # TODO I think we need to catch XMLRPC exceptions to get return self._proxy.Bug.update({'ids':ids,'updates':updates}) - 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" - Ignored by BZ32. - worktime: amount of time spent on this comment, in hours - 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.Bug.add_comment({'id':id, - 'comment':comment, - 'private':private, - 'worktime':worktime}) - - #---- Methods for updating bugs. - # 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. -- cgit From f7f2627754bec10383cf909aa3375a2cf2a9a5f2 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Fri, 15 Aug 2008 15:38:57 -0400 Subject: Partial implementation of _updatewhiteboard --- bugzilla/bugzilla3.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index 3fe3052..d730f68 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -242,12 +242,24 @@ class RHBugzilla32(Bugzilla32): if mail is True, email will be generated for this change. ''' raise NotImplementedError, "wwoods needs to port this method." + 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} - raise NotImplementedError, "wwoods needs to port this method." + '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: + raise NotImplementedError, "append/prepend not supported yet" + self._update_bug(ids=[id],updates=update) + # TODO: update this when the XMLRPC interface grows requestee support def _updateflags(self,id,flags): '''Updates the flags associated with a bug report. -- cgit From 94bd4fce33bc55b5be23aca35ad23b0ac05b92e4 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Fri, 15 Aug 2008 15:42:41 -0400 Subject: clean up update_bug calls --- bugzilla/bugzilla3.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index d730f68..8186111 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -191,12 +191,14 @@ class RHBugzilla32(Bugzilla32): # 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(ids=[id],updates=update) + 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 @@ -212,7 +214,7 @@ class RHBugzilla32(Bugzilla32): update['comment'] = comment if isprivate: update['commentprivacy'] = True - return self._update_bug(ids=[id],updates=update) + return self._update_bug(id,update) def _setassignee(self,id,**data): '''Raw xmlrpc call to set one of the assignee fields on a bug. @@ -221,7 +223,7 @@ class RHBugzilla32(Bugzilla32): returns: [$id, $mailresults]''' # drop empty items update = dict([(k,v) for k,v in data.iteritems() if v != '']) - return self._update_bug(ids=[id],updates=update) + return self._update_bug(id,update) def _updatedeps(self,id,blocked,dependson,action): '''Update the deps (blocked/dependson) for the given bug. @@ -232,7 +234,7 @@ class RHBugzilla32(Bugzilla32): raise ValueError, "action must be 'add' or 'delete'" update={'%s_blocked' % action: blocked, '%s_dependson' % action: dependson} - self._update_bug(ids=id,updates=update) + 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. @@ -258,7 +260,7 @@ class RHBugzilla32(Bugzilla32): update[which] = text else: raise NotImplementedError, "append/prepend not supported yet" - self._update_bug(ids=[id],updates=update) + self._update_bug(id,update) # TODO: update this when the XMLRPC interface grows requestee support def _updateflags(self,id,flags): -- cgit From ff1ce61e55ae0b2f5e8fb30d39c77f8bdddfe4fa Mon Sep 17 00:00:00 2001 From: Will Woods Date: Fri, 15 Aug 2008 17:00:56 -0400 Subject: finish _updatewhiteboard --- bugzilla/bugzilla3.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index 8186111..562c975 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -259,7 +259,14 @@ class RHBugzilla32(Bugzilla32): if action == 'overwrite': update[which] = text else: - raise NotImplementedError, "append/prepend not supported yet" + r = self._getbug(id) + if which not in r: + raise ValueError, "No such whiteboard %s in bug %i" % (which,id) + wb = r[which] + if action == 'prepend': + update[which] = text+' '+wb + elif action == 'append': + update[which] = wb+' '+text self._update_bug(id,update) # TODO: update this when the XMLRPC interface grows requestee support -- cgit From 772bb9f201521535df57fa8b37d2da2af1c3a5fb Mon Sep 17 00:00:00 2001 From: Will Woods Date: Mon, 18 Aug 2008 13:40:25 -0400 Subject: Fix up _updatecc - change the action param to match other methods and implement it for bz32. --- bugzilla/base.py | 6 ++++-- bugzilla/bugzilla3.py | 18 ++++++++++++++++-- bugzilla/rhbugzilla.py | 7 ++++++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index 8158998..db9b2e9 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -479,16 +479,18 @@ class BugzillaBase(object): 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 'remove' + action: 'add' or 'delete' ''' raise NotImplementedError def _updatecc(self,id,cclist,action,comment='',nomail=False): '''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. ''' raise NotImplementedError def _updatewhiteboard(self,id,text,which,action): diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index 562c975..602c49c 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -239,11 +239,25 @@ class RHBugzilla32(Bugzilla32): 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', '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. ''' - raise NotImplementedError, "wwoods needs to port this method." + 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') + 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. diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py index 984787b..a0a5656 100644 --- a/bugzilla/rhbugzilla.py +++ b/bugzilla/rhbugzilla.py @@ -211,10 +211,15 @@ class RHBugzilla(bugzilla.base.BugzillaBase): 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', '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. ''' + # 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) -- cgit From 7328a5650d39e6d77c0a6b3ad7cb059de4242316 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Mon, 18 Aug 2008 13:40:53 -0400 Subject: Add addcc/deletecc to Bug object --- bugzilla/base.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bugzilla/base.py b/bugzilla/base.py index db9b2e9..a189e8f 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -976,5 +976,13 @@ 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: add a sync() method that writes the changed data in the Bug object # back to Bugzilla? -- cgit From 47522f2fcbdcd280a8d26f5558821af71a334057 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Mon, 18 Aug 2008 13:41:10 -0400 Subject: Fix possible string formatting bug --- bugzilla/bugzilla3.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index 602c49c..a06e58c 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -275,7 +275,8 @@ class RHBugzilla32(Bugzilla32): else: r = self._getbug(id) if which not in r: - raise ValueError, "No such whiteboard %s in bug %i" % (which,id) + raise ValueError, "No such whiteboard %s in bug %s" % + (which,str(id)) wb = r[which] if action == 'prepend': update[which] = text+' '+wb -- cgit From 9159247d71a0ce134fd1b443524edaa05ecf859d Mon Sep 17 00:00:00 2001 From: Will Woods Date: Mon, 18 Aug 2008 13:46:32 -0400 Subject: update TODO --- TODO | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/TODO b/TODO index fc0a196..9f73ffd 100644 --- a/TODO +++ b/TODO @@ -1,8 +1,10 @@ -- flesh out Bugzilla3 class -- Add Bugzilla32 class - Bugzilla 3.2 can actually *search*! +- Finish up RHBZ3 - 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 -- cgit From 54632f64194e9d930c7d15ffddf25670e5fde5ab Mon Sep 17 00:00:00 2001 From: Will Woods Date: Mon, 18 Aug 2008 16:25:25 -0400 Subject: Fix syntax error --- bugzilla/bugzilla3.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index a06e58c..b2947f4 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -275,7 +275,7 @@ class RHBugzilla32(Bugzilla32): else: r = self._getbug(id) if which not in r: - raise ValueError, "No such whiteboard %s in bug %s" % + raise ValueError, "No such whiteboard %s in bug %s" % \ (which,str(id)) wb = r[which] if action == 'prepend': -- cgit From ac4209072d01d15a5a52aa3bcaacd1a9af7865af Mon Sep 17 00:00:00 2001 From: Will Woods Date: Mon, 18 Aug 2008 16:25:52 -0400 Subject: Comment cleanups --- bugzilla/bugzilla3.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index b2947f4..676d4f1 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -111,6 +111,7 @@ class Bugzilla3(bugzilla.base.BugzillaBase): 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. @@ -138,10 +139,11 @@ class Bugzilla32(Bugzilla3): 'private':private, 'work_time':worktime}) +# FIXME: class RHBugzilla32(Bugzilla32): '''Concrete implementation of the Bugzilla protocol. This one uses the methods provided by Red Hat's Bugzilla 3.1.4+ instance, which are supposed - to make their way into Bugzilla 3.4. + to make their way into a later upstream Bugzilla release (probably 4.0). This class was written using bugzilla.redhat.com's API docs: https://bugzilla.redhat.com/docs/en/html/api/ @@ -256,6 +258,8 @@ class RHBugzilla32(Bugzilla32): raise AttributeError, "Can't find cc list in bug %s" % str(id) self._updatecc(id,r['cc'],'delete') self._updatecc(id,cclist,'add') + # FIXME 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'" -- cgit From aaf46833270ce5f963d566162ff3b3af638bb8ba Mon Sep 17 00:00:00 2001 From: Will Woods Date: Mon, 18 Aug 2008 17:43:19 -0400 Subject: cleanups - remove unsupported methods, update comments --- bugzilla/bugzilla3.py | 45 +++++++++++---------------------------------- 1 file changed, 11 insertions(+), 34 deletions(-) diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index 676d4f1..8e799c9 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -139,11 +139,14 @@ class Bugzilla32(Bugzilla3): 'private':private, 'work_time':worktime}) -# FIXME: class RHBugzilla32(Bugzilla32): '''Concrete implementation of the Bugzilla protocol. This one uses the - methods provided by Red Hat's Bugzilla 3.1.4+ instance, which are supposed - to make their way into a later upstream Bugzilla release (probably 4.0). + methods provided by Red Hat's Bugzilla 3.1.4+ 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. This class was written using bugzilla.redhat.com's API docs: https://bugzilla.redhat.com/docs/en/html/api/ @@ -165,19 +168,10 @@ class RHBugzilla32(Bugzilla32): buglist is a list of dicts describing bugs, and 'sql' contains the SQL generated by executing the search. ''' - # The following is true for rhbz; not sure if it's the case for BZ3.2 - #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']). return self._proxy.Bug.search(query) #---- Methods for updating bugs. - 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) - 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 @@ -188,6 +182,11 @@ class RHBugzilla32(Bugzilla32): # TODO I think we need to catch XMLRPC exceptions to get 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. @@ -287,25 +286,3 @@ class RHBugzilla32(Bugzilla32): elif action == 'append': update[which] = wb+' '+text self._update_bug(id,update) - - # 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.''' - raise NotImplementedError, "wwoods needs to port this method." - - #---- 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): - raise NotImplementedError, "wwoods needs to port this method." - -- cgit From 037f5df1207c86d6a7ca0aeff6f584d66aee7d75 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Mon, 18 Aug 2008 18:00:11 -0400 Subject: comment cleanups --- bugzilla/bugzilla3.py | 3 ++- bugzilla/rhbugzilla.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index 8e799c9..ddd310d 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -179,7 +179,8 @@ class RHBugzilla32(Bugzilla32): 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 + # 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): diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py index a0a5656..6ea8902 100644 --- a/bugzilla/rhbugzilla.py +++ b/bugzilla/rhbugzilla.py @@ -198,6 +198,7 @@ class RHBugzilla(bugzilla.base.BugzillaBase): 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':''} -- cgit From 2cbd34ec163f503648245f2076f5601ed9498de6 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 19 Aug 2008 14:06:18 -0400 Subject: Move bugzilla3.RHBugzilla32 to rhbugzilla.RHBugzilla3, and import that in toplevel bugzilla module --- bugzilla/__init__.py | 2 +- bugzilla/bugzilla3.py | 148 ------------------------------------------------ bugzilla/rhbugzilla.py | 150 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 149 deletions(-) diff --git a/bugzilla/__init__.py b/bugzilla/__init__.py index 2e5ee75..8dc06db 100644 --- a/bugzilla/__init__.py +++ b/bugzilla/__init__.py @@ -10,7 +10,7 @@ # the full text of the license. from bugzilla3 import Bugzilla3, Bugzilla32 -from rhbugzilla import RHBugzilla +from rhbugzilla import RHBugzilla, RHBugzilla3 import xmlrpclib import logging log = logging.getLogger("bugzilla") diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index ddd310d..ea017e5 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -139,151 +139,3 @@ class Bugzilla32(Bugzilla3): 'private':private, 'work_time':worktime}) -class RHBugzilla32(Bugzilla32): - '''Concrete implementation of the Bugzilla protocol. This one uses the - methods provided by Red Hat's Bugzilla 3.1.4+ 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. - - This class was written using bugzilla.redhat.com's API docs: - https://bugzilla.redhat.com/docs/en/html/api/ - ''' - - version = '0.1' - user_agent = bugzilla.base.user_agent + ' RHBugzilla32/%s' % version - - 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') - # FIXME 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/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py index 6ea8902..647d8bc 100644 --- a/bugzilla/rhbugzilla.py +++ b/bugzilla/rhbugzilla.py @@ -10,6 +10,7 @@ # the full text of the license. import bugzilla.base +from bugzilla3 import Bugzilla32 version = '0.2' user_agent = bugzilla.base.user_agent + ' RHBugzilla/%s' % version @@ -259,3 +260,152 @@ class RHBugzilla(bugzilla.base.BugzillaBase): 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.1.4+ 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. + + This class was written using bugzilla.redhat.com's API docs: + https://bugzilla.redhat.com/docs/en/html/api/ + ''' + + version = '0.1' + user_agent = bugzilla.base.user_agent + ' RHBugzilla3/%s' % version + + 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') + # FIXME 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) -- cgit From 99ca8c8862575d4311f874d001e3c76ac025c37d Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 19 Aug 2008 14:34:40 -0400 Subject: Prefer RHBugzilla3 over RHBugzilla --- bugzilla/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bugzilla/__init__.py b/bugzilla/__init__.py index 8dc06db..0bab22e 100644 --- a/bugzilla/__init__.py +++ b/bugzilla/__init__.py @@ -39,7 +39,10 @@ def getBugzillaClassForURL(url): # RH BZ 3.2 will have rhbz == True and bzversion == 3.1.x or 3.2.x. # To prefer Bugzilla32 over RHBugzilla do: if rhbz and (bzversion == '') if rhbz: - c = RHBugzilla + if bzversion.startswith('3.'): + c = RHBugzilla3 + else: + c = RHBugzilla elif bzversion.startswith('3.'): if bzversion.startswith('3.0'): c = Bugzilla3 -- cgit From 91d0aef5632d3fc59c97ee9869b20b941e536c65 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 19 Aug 2008 17:15:03 -0400 Subject: minor comment tweak --- bugzilla/rhbugzilla.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py index 647d8bc..beb31df 100644 --- a/bugzilla/rhbugzilla.py +++ b/bugzilla/rhbugzilla.py @@ -263,7 +263,7 @@ class RHBugzilla(bugzilla.base.BugzillaBase): class RHBugzilla3(Bugzilla32, RHBugzilla): '''Concrete implementation of the Bugzilla protocol. This one uses the - methods provided by Red Hat's Bugzilla 3.1.4+ instance, which is a superset + 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). -- cgit From 2d8243e272243ed0692db47a53f3b939a8440b13 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Fri, 22 Aug 2008 16:28:35 -0400 Subject: replace_getbug_errors_with_None moved to bugzilla.base - fix call --- bugzilla/rhbugzilla.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py index beb31df..978999b 100644 --- a/bugzilla/rhbugzilla.py +++ b/bugzilla/rhbugzilla.py @@ -85,7 +85,7 @@ class RHBugzilla(bugzilla.base.BugzillaBase): raw_results = mc.run() del mc # check results for xmlrpc errors, and replace them with None - return replace_getbug_errors_with_None(raw_results) + 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.''' @@ -95,7 +95,7 @@ class RHBugzilla(bugzilla.base.BugzillaBase): raw_results = mc.run() del mc # check results for xmlrpc errors, and replace them with None - return replace_getbug_errors_with_None(raw_results) + return bugzilla.base.replace_getbug_errors_with_None(raw_results) def _query(self,query): '''Query bugzilla and return a list of matching bugs. -- cgit From dcbd893cbc7b4104545fcfc1e49fe9f72ed407c4 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Mon, 25 Aug 2008 13:09:41 -0400 Subject: Comment cleanups and extra debugging info --- bugzilla/__init__.py | 6 +++++- bugzilla/base.py | 1 + bugzilla/bugzilla3.py | 6 ++---- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/bugzilla/__init__.py b/bugzilla/__init__.py index 0bab22e..ebc8bb4 100644 --- a/bugzilla/__init__.py +++ b/bugzilla/__init__.py @@ -11,6 +11,7 @@ from bugzilla3 import Bugzilla3, Bugzilla32 from rhbugzilla import RHBugzilla, RHBugzilla3 +from base import version import xmlrpclib import logging log = logging.getLogger("bugzilla") @@ -55,10 +56,13 @@ class Bugzilla(object): '''Magical Bugzilla class that figures out which Bugzilla implementation to use and uses that.''' def __init__(self,**kwargs): + log.debug("Bugzilla v%s initializing" % base.version) if 'url' in kwargs: + log.debug("Choosing implementation for %s" % kwargs['url']) c = getBugzillaClassForURL(kwargs['url']) if c: self.__class__ = c c.__init__(self,**kwargs) - log.debug("Using Bugzilla subclass: %s" % c.__name__) + log.debug("Using Bugzilla subclass %s v%s" % \ + (c.__name__,c.version)) # FIXME no url? raise an error or something here, jeez diff --git a/bugzilla/base.py b/bugzilla/base.py index a189e8f..03c8e82 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -984,5 +984,6 @@ class Bug(object): 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(), getflag(), setflag() # TODO: add a sync() method that writes the changed data in the Bug object # back to Bugzilla? diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py index ea017e5..41a3440 100644 --- a/bugzilla/bugzilla3.py +++ b/bugzilla/bugzilla3.py @@ -128,11 +128,9 @@ class Bugzilla32(Bugzilla3): '''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" - Ignored by BZ32. + timestamp: Ignored by BZ32. worktime: amount of time spent on this comment, in hours - bz_gid: if present, and the entire bug is *not* already private - to this group ID, this comment will be marked private. + bz_gid: Ignored by BZ32. ''' return self._proxy.Bug.add_comment({'id':id, 'comment':comment, -- cgit From b96496b875f3561099b94f644792686b9ec95885 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Mon, 25 Aug 2008 13:50:45 -0400 Subject: Handle ^C a little more cleanly --- bin/bugzilla | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bin/bugzilla b/bin/bugzilla index 835260d..c55efc8 100755 --- a/bin/bugzilla +++ b/bin/bugzilla @@ -175,7 +175,7 @@ def setup_action_parser(action): "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 @@ -424,3 +424,10 @@ if __name__ == '__main__': 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) -- cgit From 876bda5792071952ec592d1d9b451a3bb9e2d94c Mon Sep 17 00:00:00 2001 From: Will Woods Date: Mon, 25 Aug 2008 13:51:16 -0400 Subject: More useful debugging info --- bugzilla/__init__.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bugzilla/__init__.py b/bugzilla/__init__.py index ebc8bb4..dfda0c4 100644 --- a/bugzilla/__init__.py +++ b/bugzilla/__init__.py @@ -17,6 +17,7 @@ import logging log = logging.getLogger("bugzilla") def getBugzillaClassForURL(url): + log.debug("Choosing subclass for %s" % url) s = xmlrpclib.ServerProxy(url) rhbz = False bzversion = '' @@ -24,17 +25,21 @@ def getBugzillaClassForURL(url): # 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)) # current preference order: RHBugzilla, Bugzilla3 # RH BZ 3.2 will have rhbz == True and bzversion == 3.1.x or 3.2.x. @@ -56,13 +61,11 @@ class Bugzilla(object): '''Magical Bugzilla class that figures out which Bugzilla implementation to use and uses that.''' def __init__(self,**kwargs): - log.debug("Bugzilla v%s initializing" % base.version) + log.info("Bugzilla v%s initializing" % base.version) if 'url' in kwargs: - log.debug("Choosing implementation for %s" % kwargs['url']) c = getBugzillaClassForURL(kwargs['url']) if c: self.__class__ = c c.__init__(self,**kwargs) - log.debug("Using Bugzilla subclass %s v%s" % \ - (c.__name__,c.version)) + log.info("Chose subclass %s v%s" % (c.__name__,c.version)) # FIXME no url? raise an error or something here, jeez -- cgit From 2f4456720c01793688ca0ac10546ad3328d6a641 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 26 Aug 2008 10:51:30 -0400 Subject: Update comments --- bugzilla/base.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index 03c8e82..6b7aa0d 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -34,7 +34,6 @@ def replace_getbug_errors_with_None(rawlist): return result class BugzillaBase(object): - # FIXME: remove doc info about cookie handling, add info about .bugzillarc '''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) @@ -221,8 +220,7 @@ class BugzillaBase(object): #---- Methods and properties with basic bugzilla info - # XXX FIXME Uh-oh. I think MultiCall support is a RHism. We should probably - # move all multicall-based methods into RHBugzilla. + # FIXME MultiCall support is a RHism, so this should move into rhbugzilla 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 @@ -889,7 +887,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 @@ -906,7 +904,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 @@ -916,7 +914,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'] @@ -935,13 +933,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'. @@ -984,6 +982,6 @@ class Bug(object): 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(), getflag(), setflag() +# TODO: attach(file), getflag(), setflag() # TODO: add a sync() method that writes the changed data in the Bug object # back to Bugzilla? -- cgit From ed5255764779dbbee7d6fba22d6b24b04e7853bd Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 26 Aug 2008 10:51:48 -0400 Subject: Fix version/useragent for RHBugzilla --- bugzilla/rhbugzilla.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py index 978999b..3cbc83c 100644 --- a/bugzilla/rhbugzilla.py +++ b/bugzilla/rhbugzilla.py @@ -12,15 +12,16 @@ import bugzilla.base from bugzilla3 import Bugzilla32 -version = '0.2' -user_agent = bugzilla.base.user_agent + ' RHBugzilla/%s' % version - 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.''' + + 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 = user_agent + self.user_agent = self.__class__.user_agent def _login(self,user,password): '''Backend login method for RHBugzilla.''' @@ -380,7 +381,7 @@ class RHBugzilla3(Bugzilla32, RHBugzilla): raise AttributeError, "Can't find cc list in bug %s" % str(id) self._updatecc(id,r['cc'],'delete') self._updatecc(id,cclist,'add') - # FIXME we don't check inputs on other backend methods, maybe this + # 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'" -- cgit From e22c1f4fbfb268cf4970ae874e8d5988cb5f834f Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 26 Aug 2008 16:21:36 -0400 Subject: Move multicall methods to RHBugzilla, since they're RH-specific --- bugzilla/base.py | 61 ---------------------------------------------- bugzilla/rhbugzilla.py | 66 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 62 deletions(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index 6b7aa0d..eb53f46 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -55,9 +55,6 @@ class BugzillaBase(object): Be sure to set appropriate permissions on bugzillarc if you choose to store your password in it! - The methods which start with a single underscore are thin wrappers around - xmlrpc calls; those should be safe for multicall usage. - 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. @@ -220,41 +217,6 @@ class BugzillaBase(object): #---- Methods and properties with basic bugzilla info - # FIXME MultiCall support is a RHism, so this should move into rhbugzilla - 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 _getbugfields(self): '''IMPLEMENT ME: Get bugfields from Bugzilla.''' raise NotImplementedError @@ -366,29 +328,6 @@ class BugzillaBase(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 def _getbug(self,id): diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py index 3cbc83c..d7d6295 100644 --- a/bugzilla/rhbugzilla.py +++ b/bugzilla/rhbugzilla.py @@ -14,7 +14,15 @@ from bugzilla3 import Bugzilla32 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.''' + 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 @@ -36,6 +44,40 @@ class RHBugzilla(bugzilla.base.BugzillaBase): #---- 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() @@ -61,6 +103,28 @@ class RHBugzilla(bugzilla.base.BugzillaBase): 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 -- cgit From 3730239a0cad74fa7ab0a8a69bb12daa79a1174e Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 26 Aug 2008 17:34:21 -0400 Subject: Attempt to support multicall _getbugs in RHBugzilla3 --- bugzilla/rhbugzilla.py | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py index d7d6295..34b8318 100644 --- a/bugzilla/rhbugzilla.py +++ b/bugzilla/rhbugzilla.py @@ -332,16 +332,41 @@ class RHBugzilla3(Bugzilla32, RHBugzilla): 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. - + 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) + + 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']. -- cgit From 428fdd43cba0a16dca1068c15f229c29244a09ff Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 26 Aug 2008 17:38:57 -0400 Subject: move "import copy" along with multicall methods --- bugzilla/base.py | 2 +- bugzilla/rhbugzilla.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/bugzilla/base.py b/bugzilla/base.py index eb53f46..8024736 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -10,7 +10,7 @@ # the full text of the license. import xmlrpclib, urllib2, cookielib -import os.path, base64, copy +import os.path, base64 import logging log = logging.getLogger('bugzilla') diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py index 34b8318..b525ed4 100644 --- a/bugzilla/rhbugzilla.py +++ b/bugzilla/rhbugzilla.py @@ -11,6 +11,7 @@ import bugzilla.base from bugzilla3 import Bugzilla32 +import copy class RHBugzilla(bugzilla.base.BugzillaBase): '''Concrete implementation of the Bugzilla protocol. This one uses the -- cgit From 71bab41bf6068f464827700f68ab7cfbae51e7ab Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 26 Aug 2008 17:52:48 -0400 Subject: Forgot xmlrpclib import --- bugzilla/rhbugzilla.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py index b525ed4..cdad54d 100644 --- a/bugzilla/rhbugzilla.py +++ b/bugzilla/rhbugzilla.py @@ -11,7 +11,7 @@ import bugzilla.base from bugzilla3 import Bugzilla32 -import copy +import copy, xmlrpclib class RHBugzilla(bugzilla.base.BugzillaBase): '''Concrete implementation of the Bugzilla protocol. This one uses the -- cgit From 8765e33c2267b1408bd4d11d1bf596ad1d53eb21 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Thu, 4 Sep 2008 12:15:38 -0400 Subject: Create cookiefile with 0600 perms --- bugzilla/base.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bugzilla/base.py b/bugzilla/base.py index 8024736..04b7883 100644 --- a/bugzilla/base.py +++ b/bugzilla/base.py @@ -100,6 +100,12 @@ class BugzillaBase(object): 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 = self.cookiefile -- cgit From ce753c44a0445b796cd19851cba8dff2e5d455d0 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Thu, 4 Sep 2008 12:28:16 -0400 Subject: Add comments, raise exception if Bugzilla class autodetection fails --- TODO | 1 - bugzilla/__init__.py | 11 +++++++---- bugzilla/rhbugzilla.py | 1 + 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/TODO b/TODO index 9f73ffd..5e342ac 100644 --- a/TODO +++ b/TODO @@ -1,4 +1,3 @@ -- Finish up RHBZ3 - 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 diff --git a/bugzilla/__init__.py b/bugzilla/__init__.py index dfda0c4..564f00b 100644 --- a/bugzilla/__init__.py +++ b/bugzilla/__init__.py @@ -41,9 +41,8 @@ def getBugzillaClassForURL(url): pass log.debug("bzversion='%s'" % str(bzversion)) - # current preference order: RHBugzilla, Bugzilla3 + # 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. - # To prefer Bugzilla32 over RHBugzilla do: if rhbz and (bzversion == '') if rhbz: if bzversion.startswith('3.'): c = RHBugzilla3 @@ -59,7 +58,8 @@ def getBugzillaClassForURL(url): class Bugzilla(object): '''Magical Bugzilla class that figures out which Bugzilla implementation - to use and uses that.''' + 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: @@ -68,4 +68,7 @@ class Bugzilla(object): self.__class__ = c c.__init__(self,**kwargs) log.info("Chose subclass %s v%s" % (c.__name__,c.version)) - # FIXME no url? raise an error or something here, jeez + 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/rhbugzilla.py b/bugzilla/rhbugzilla.py index cdad54d..47a3e22 100644 --- a/bugzilla/rhbugzilla.py +++ b/bugzilla/rhbugzilla.py @@ -353,6 +353,7 @@ class RHBugzilla3(Bugzilla32, RHBugzilla): 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: -- cgit