1# base.py - the base classes etc. for a Python interface to bugzilla 2# 3# Copyright (C) 2007, 2008, 2009, 2010 Red Hat Inc. 4# Author: Will Woods <wwoods@redhat.com> 5# 6# This work is licensed under the GNU GPLv2 or later. 7# See the COPYING file in the top-level directory. 8 9import copy 10from logging import getLogger 11 12from ._util import to_encoding 13 14 15log = getLogger(__name__) 16 17 18class Bug(object): 19 """ 20 A container object for a bug report. Requires a Bugzilla instance - 21 every Bug is on a Bugzilla, obviously. 22 Optional keyword args: 23 dict=DICT - populate attributes with the result of a getBug() call 24 bug_id=ID - if dict does not contain bug_id, this is required before 25 you can read any attributes or make modifications to this 26 bug. 27 """ 28 def __init__(self, bugzilla, bug_id=None, dict=None, autorefresh=False): 29 # pylint: disable=redefined-builtin 30 # API had pre-existing issue that we can't change ('dict' usage) 31 32 self.bugzilla = bugzilla 33 self._rawdata = {} 34 self.autorefresh = autorefresh 35 36 # pylint: disable=protected-access 37 self._aliases = self.bugzilla._get_bug_aliases() 38 # pylint: enable=protected-access 39 40 if not dict: 41 dict = {} 42 if bug_id: 43 dict["id"] = bug_id 44 45 self._update_dict(dict) 46 self.weburl = bugzilla.url.replace('xmlrpc.cgi', 47 'show_bug.cgi?id=%i' % self.bug_id) 48 49 def __str__(self): 50 """ 51 Return a simple string representation of this bug 52 53 This is available only for compatibility. Using 'str(bug)' and 54 'print(bug)' is not recommended because of potential encoding issues. 55 Please use unicode(bug) where possible. 56 """ 57 return to_encoding(self.__unicode__()) 58 59 def __unicode__(self): 60 """ 61 Return a simple unicode string representation of this bug 62 """ 63 return "#%-6s %-10s - %s - %s" % (self.bug_id, self.bug_status, 64 self.assigned_to, self.summary) 65 66 def __repr__(self): 67 url = "" 68 if self.bugzilla: 69 url = self.bugzilla.url 70 return '<Bug #%i on %s at %#x>' % (self.bug_id, url, id(self)) 71 72 def __getattr__(self, name): 73 refreshed = False 74 while True: 75 if refreshed and name in self.__dict__: 76 # If name was in __dict__ to begin with, __getattr__ would 77 # have never been called. 78 return self.__dict__[name] 79 80 for newname, oldname in self._aliases: 81 if name == oldname and newname in self.__dict__: 82 return self.__dict__[newname] 83 84 # Doing dir(bugobj) does getattr __members__/__methods__, 85 # don't refresh for those 86 if name.startswith("__") and name.endswith("__"): 87 break 88 89 if refreshed or not self.autorefresh: 90 break 91 92 log.info("Bug %i missing attribute '%s' - doing implicit " 93 "refresh(). This will be slow, if you want to avoid " 94 "this, properly use query/getbug include_fields, and " 95 "set bugzilla.bug_autorefresh = False to force failure.", 96 self.bug_id, name) 97 98 # We pass the attribute name to getbug, since for something like 99 # 'attachments' which downloads lots of data we really want the 100 # user to opt in. 101 self.refresh(extra_fields=[name]) 102 refreshed = True 103 104 msg = ("Bug object has no attribute '%s'." % name) 105 if not self.autorefresh: 106 msg += ("\nIf '%s' is a bugzilla attribute, it may not have " 107 "been cached when the bug was fetched. You may want " 108 "to adjust your include_fields for getbug/query." % name) 109 raise AttributeError(msg) 110 111 def get_raw_data(self): 112 """ 113 Return the raw API dictionary data that has been used to 114 populate this bug 115 """ 116 return copy.deepcopy(self._rawdata) 117 118 def refresh(self, include_fields=None, exclude_fields=None, 119 extra_fields=None): 120 """ 121 Refresh the bug with the latest data from bugzilla 122 """ 123 # pylint: disable=protected-access 124 extra_fields = list(self._rawdata.keys()) + (extra_fields or []) 125 r = self.bugzilla._getbug(self.bug_id, 126 include_fields=include_fields, exclude_fields=exclude_fields, 127 extra_fields=extra_fields) 128 # pylint: enable=protected-access 129 self._update_dict(r) 130 reload = refresh 131 132 def _translate_dict(self, newdict): 133 if self.bugzilla: 134 self.bugzilla.post_translation({}, newdict) 135 136 for newname, oldname in self._aliases: 137 if oldname not in newdict: 138 continue 139 140 if newname not in newdict: 141 newdict[newname] = newdict[oldname] 142 elif newdict[newname] != newdict[oldname]: 143 log.debug("Update dict contained differing alias values " 144 "d[%s]=%s and d[%s]=%s , dropping the value " 145 "d[%s]", newname, newdict[newname], oldname, 146 newdict[oldname], oldname) 147 del(newdict[oldname]) 148 149 150 def _update_dict(self, newdict): 151 """ 152 Update internal dictionary, in a way that ensures no duplicate 153 entries are stored WRT field aliases 154 """ 155 self._translate_dict(newdict) 156 self._rawdata.update(newdict) 157 self.__dict__.update(newdict) 158 159 if 'id' not in self.__dict__ and 'bug_id' not in self.__dict__: 160 raise TypeError("Bug object needs a bug_id") 161 162 163 ################## 164 # pickle helpers # 165 ################## 166 167 def __getstate__(self): 168 ret = self._rawdata.copy() 169 ret["_aliases"] = self._aliases 170 return ret 171 172 def __setstate__(self, vals): 173 self._rawdata = {} 174 self.bugzilla = None 175 self._aliases = vals.get("_aliases", []) 176 self.autorefresh = False 177 self._update_dict(vals) 178 179 180 ##################### 181 # Modify bug status # 182 ##################### 183 184 def setstatus(self, status, comment=None, private=False): 185 """ 186 Update the status for this bug report. 187 Commonly-used values are ASSIGNED, MODIFIED, and NEEDINFO. 188 189 To change bugs to CLOSED, use .close() instead. 190 """ 191 # Note: fedora bodhi uses this function 192 vals = self.bugzilla.build_update(status=status, 193 comment=comment, 194 comment_private=private) 195 log.debug("setstatus: update=%s", vals) 196 197 return self.bugzilla.update_bugs(self.bug_id, vals) 198 199 def close(self, resolution, dupeid=None, fixedin=None, 200 comment=None, isprivate=False): 201 """ 202 Close this bug. 203 Valid values for resolution are in bz.querydefaults['resolution_list'] 204 For bugzilla.redhat.com that's: 205 ['NOTABUG', 'WONTFIX', 'DEFERRED', 'WORKSFORME', 'CURRENTRELEASE', 206 'RAWHIDE', 'ERRATA', 'DUPLICATE', 'UPSTREAM', 'NEXTRELEASE', 207 'CANTFIX', 'INSUFFICIENT_DATA'] 208 If using DUPLICATE, you need to set dupeid to the ID of the other bug. 209 If using WORKSFORME/CURRENTRELEASE/RAWHIDE/ERRATA/UPSTREAM/NEXTRELEASE 210 you can (and should) set 'new_fixed_in' to a string representing the 211 version that fixes the bug. 212 You can optionally add a comment while closing the bug. Set 'isprivate' 213 to True if you want that comment to be private. 214 """ 215 # Note: fedora bodhi uses this function 216 vals = self.bugzilla.build_update(comment=comment, 217 comment_private=isprivate, 218 resolution=resolution, 219 dupe_of=dupeid, 220 fixed_in=fixedin, 221 status=str("CLOSED")) 222 log.debug("close: update=%s", vals) 223 224 return self.bugzilla.update_bugs(self.bug_id, vals) 225 226 227 ##################### 228 # Modify bug emails # 229 ##################### 230 231 def setassignee(self, assigned_to=None, 232 qa_contact=None, comment=None): 233 """ 234 Set any of the assigned_to or qa_contact fields to a new 235 bugzilla account, with an optional comment, e.g. 236 setassignee(assigned_to='wwoods@redhat.com') 237 setassignee(qa_contact='wwoods@redhat.com', comment='wwoods QA ftw') 238 239 You must set at least one of the two assignee fields, or this method 240 will throw a ValueError. 241 242 Returns [bug_id, mailresults]. 243 """ 244 if not (assigned_to or qa_contact): 245 raise ValueError("You must set one of assigned_to " 246 " or qa_contact") 247 248 vals = self.bugzilla.build_update(assigned_to=assigned_to, 249 qa_contact=qa_contact, 250 comment=comment) 251 log.debug("setassignee: update=%s", vals) 252 253 return self.bugzilla.update_bugs(self.bug_id, vals) 254 255 def addcc(self, cclist, comment=None): 256 """ 257 Adds the given email addresses to the CC list for this bug. 258 cclist: list of email addresses (strings) 259 comment: optional comment to add to the bug 260 """ 261 vals = self.bugzilla.build_update(comment=comment, 262 cc_add=cclist) 263 log.debug("addcc: update=%s", vals) 264 265 return self.bugzilla.update_bugs(self.bug_id, vals) 266 267 def deletecc(self, cclist, comment=None): 268 """ 269 Removes the given email addresses from the CC list for this bug. 270 """ 271 vals = self.bugzilla.build_update(comment=comment, 272 cc_remove=cclist) 273 log.debug("deletecc: update=%s", vals) 274 275 return self.bugzilla.update_bugs(self.bug_id, vals) 276 277 278 #################### 279 # comment handling # 280 #################### 281 282 def addcomment(self, comment, private=False): 283 """ 284 Add the given comment to this bug. Set private to True to mark this 285 comment as private. 286 """ 287 # Note: fedora bodhi uses this function 288 vals = self.bugzilla.build_update(comment=comment, 289 comment_private=private) 290 log.debug("addcomment: update=%s", vals) 291 292 return self.bugzilla.update_bugs(self.bug_id, vals) 293 294 def getcomments(self): 295 """ 296 Returns an array of comment dictionaries for this bug 297 """ 298 comment_list = self.bugzilla.get_comments([self.bug_id]) 299 return comment_list['bugs'][str(self.bug_id)]['comments'] 300 301 302 ##################### 303 # Get/Set bug flags # 304 ##################### 305 306 def get_flag_type(self, name): 307 """ 308 Return flag_type information for a specific flag 309 310 Older RHBugzilla returned a lot more info here, but it was 311 non-upstream and is now gone. 312 """ 313 for t in self.flags: 314 if t['name'] == name: 315 return t 316 return None 317 318 def get_flags(self, name): 319 """ 320 Return flag value information for a specific flag 321 """ 322 ft = self.get_flag_type(name) 323 if not ft: 324 return None 325 326 return [ft] 327 328 def get_flag_status(self, name): 329 """ 330 Return a flag 'status' field 331 332 This method works only for simple flags that have only a 'status' field 333 with no "requestee" info, and no multiple values. For more complex 334 flags, use get_flags() to get extended flag value information. 335 """ 336 f = self.get_flags(name) 337 if not f: 338 return None 339 340 # This method works only for simple flags that have only one 341 # value set. 342 assert len(f) <= 1 343 344 return f[0]['status'] 345 346 def updateflags(self, flags): 347 """ 348 Thin wrapper around build_update(flags=X). This only handles simple 349 status changes, anything like needinfo requestee needs to call 350 build_update + update_bugs directly 351 352 :param flags: Dictionary of the form {"flagname": "status"}, example 353 {"needinfo": "?", "devel_ack": "+"} 354 """ 355 flaglist = [] 356 for key, value in flags.items(): 357 flaglist.append({"name": key, "status": value}) 358 return self.bugzilla.update_bugs([self.bug_id], 359 self.bugzilla.build_update(flags=flaglist)) 360 361 362 ######################## 363 # Experimental methods # 364 ######################## 365 366 def get_attachments(self, include_fields=None, exclude_fields=None): 367 """ 368 Helper call to Bugzilla.get_attachments. If you want to fetch 369 specific attachment IDs, use that function instead 370 """ 371 if "attachments" in self.__dict__: 372 return self.attachments 373 374 data = self.bugzilla.get_attachments([self.bug_id], None, 375 include_fields, exclude_fields) 376 return data["bugs"][str(self.bug_id)] 377 378 def get_attachment_ids(self): 379 """ 380 Helper function to return only the attachment IDs for this bug 381 """ 382 return [a["id"] for a in self.get_attachments(exclude_fields=["data"])] 383 384 def get_history_raw(self): 385 """ 386 Experimental. Get the history of changes for this bug. 387 """ 388 return self.bugzilla.bugs_history_raw([self.bug_id]) 389 390 391class User(object): 392 """ 393 Container object for a bugzilla User. 394 395 :arg bugzilla: Bugzilla instance that this User belongs to. 396 Rest of the params come straight from User.get() 397 """ 398 def __init__(self, bugzilla, **kwargs): 399 self.bugzilla = bugzilla 400 self.__userid = kwargs.get('id') 401 self.__name = kwargs.get('name') 402 403 self.__email = kwargs.get('email', self.__name) 404 self.__can_login = kwargs.get('can_login', False) 405 406 self.real_name = kwargs.get('real_name', None) 407 self.password = None 408 409 self.groups = kwargs.get('groups', {}) 410 self.groupnames = [] 411 for g in self.groups: 412 if "name" in g: 413 self.groupnames.append(g["name"]) 414 self.groupnames.sort() 415 416 417 ######################## 418 # Read-only attributes # 419 ######################## 420 421 # We make these properties so that the user cannot set them. They are 422 # unaffected by the update() method so it would be misleading to let them 423 # be changed. 424 @property 425 def userid(self): 426 return self.__userid 427 428 @property 429 def email(self): 430 return self.__email 431 432 @property 433 def can_login(self): 434 return self.__can_login 435 436 # name is a key in some methods. Mark it dirty when we change it # 437 @property 438 def name(self): 439 return self.__name 440 441 def refresh(self): 442 """ 443 Update User object with latest info from bugzilla 444 """ 445 newuser = self.bugzilla.getuser(self.email) 446 self.__dict__.update(newuser.__dict__) 447 448 def updateperms(self, action, groups): 449 """ 450 A method to update the permissions (group membership) of a bugzilla 451 user. 452 453 :arg action: add, remove, or set 454 :arg groups: list of groups to be added to (i.e. ['fedora_contrib']) 455 """ 456 self.bugzilla.updateperms(self.name, action, groups) 457 458 459class Group(object): 460 """ 461 Container object for a bugzilla Group. 462 463 :arg bugzilla: Bugzilla instance that this Group belongs to. 464 Rest of the params come straight from Group.get() 465 """ 466 def __init__(self, bugzilla, **kwargs): 467 self.bugzilla = bugzilla 468 self.__groupid = kwargs.get('id') 469 470 self.name = kwargs.get('name') 471 self.description = kwargs.get('description', self.name) 472 self.is_active = kwargs.get('is_active', False) 473 self.icon_url = kwargs.get('icon_url', None) 474 self.is_active_bug_group = kwargs.get('is_active_bug_group', None) 475 476 self.membership = kwargs.get('membership', []) 477 self.__member_emails = set() 478 self._refresh_member_emails_list() 479 480 ######################## 481 # Read-only attributes # 482 ######################## 483 484 # We make these properties so that the user cannot set them. They are 485 # unaffected by the update() method so it would be misleading to let them 486 # be changed. 487 @property 488 def groupid(self): 489 return self.__groupid 490 491 @property 492 def member_emails(self): 493 return sorted(self.__member_emails) 494 495 def _refresh_member_emails_list(self): 496 """ 497 Refresh the list of emails of the members of the group. 498 """ 499 if self.membership: 500 for m in self.membership: 501 if "email" in m: 502 self.__member_emails.add(m["email"]) 503 504 def refresh(self, membership=False): 505 """ 506 Update Group object with latest info from bugzilla 507 """ 508 newgroup = self.bugzilla.getgroup( 509 self.name, membership=membership) 510 self.__dict__.update(newgroup.__dict__) 511 self._refresh_member_emails_list() 512 513 def members(self): 514 """ 515 Retrieve the members of this Group from bugzilla 516 """ 517 if not self.membership: 518 self.refresh(membership=True) 519 return self.membership 520