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 getpass 10import locale 11from logging import getLogger 12import mimetypes 13import os 14import sys 15 16from io import BytesIO 17 18from ._authfiles import (_BugzillaRCFile, 19 _BugzillaCookieCache, _BugzillaTokenCache) 20from .apiversion import __version__ 21from ._backendrest import _BackendREST 22from ._backendxmlrpc import _BackendXMLRPC 23from ._compatimports import Mapping, urlparse, urlunparse, parse_qsl 24from .bug import Bug, Group, User 25from .exceptions import BugzillaError 26from ._rhconverters import _RHBugzillaConverters 27from ._session import _BugzillaSession 28from ._util import listify 29 30 31log = getLogger(__name__) 32 33 34def _nested_update(d, u): 35 # Helper for nested dict update() 36 for k, v in list(u.items()): 37 if isinstance(v, Mapping): 38 d[k] = _nested_update(d.get(k, {}), v) 39 else: 40 d[k] = v 41 return d 42 43 44class _FieldAlias(object): 45 """ 46 Track API attribute names that differ from what we expose in users. 47 48 For example, originally 'short_desc' was the name of the property that 49 maps to 'summary' on modern bugzilla. We want pre-existing API users 50 to be able to continue to use Bug.short_desc, and 51 query({"short_desc": "foo"}). This class tracks that mapping. 52 53 @oldname: The old attribute name 54 @newname: The modern attribute name 55 @is_api: If True, use this mapping for values sent to the xmlrpc API 56 (like the query example) 57 @is_bug: If True, use this mapping for Bug attribute names. 58 """ 59 def __init__(self, newname, oldname, is_api=True, is_bug=True): 60 self.newname = newname 61 self.oldname = oldname 62 self.is_api = is_api 63 self.is_bug = is_bug 64 65 66class _BugzillaAPICache(object): 67 """ 68 Helper class that holds cached API results for things like products, 69 components, etc. 70 """ 71 def __init__(self): 72 self.products = [] 73 self.component_names = {} 74 self.bugfields = [] 75 self.version_raw = None 76 self.version_parsed = (0, 0) 77 78 79class Bugzilla(object): 80 """ 81 The main API object. Connects to a bugzilla instance over XMLRPC, and 82 provides wrapper functions to simplify dealing with API calls. 83 84 The most common invocation here will just be with just a URL: 85 86 bzapi = Bugzilla("http://bugzilla.example.com") 87 88 If you have previously logged into that URL, and have cached login 89 cookies/tokens, you will automatically be logged in. Otherwise to 90 log in, you can either pass auth options to __init__, or call a login 91 helper like interactive_login(). 92 93 If you are not logged in, you won't be able to access restricted data like 94 user email, or perform write actions like bug create/update. But simple 95 querys will work correctly. 96 97 If you are unsure if you are logged in, you can check the .logged_in 98 property. 99 100 Another way to specify auth credentials is via a 'bugzillarc' file. 101 See readconfig() documentation for details. 102 """ 103 @staticmethod 104 def url_to_query(url): 105 """ 106 Given a big huge bugzilla query URL, returns a query dict that can 107 be passed along to the Bugzilla.query() method. 108 """ 109 q = {} 110 111 # pylint: disable=unpacking-non-sequence 112 (ignore1, ignore2, path, 113 ignore, query, ignore3) = urlparse(url) 114 115 base = os.path.basename(path) 116 if base not in ('buglist.cgi', 'query.cgi'): 117 return {} 118 119 for (k, v) in parse_qsl(query): 120 if k not in q: 121 q[k] = v 122 elif isinstance(q[k], list): 123 q[k].append(v) 124 else: 125 oldv = q[k] 126 q[k] = [oldv, v] 127 128 # Handle saved searches 129 if base == "buglist.cgi" and "namedcmd" in q and "sharer_id" in q: 130 q = { 131 "sharer_id": q["sharer_id"], 132 "savedsearch": q["namedcmd"], 133 } 134 135 return q 136 137 @staticmethod 138 def fix_url(url, force_rest=False): 139 """ 140 Turn passed url into a bugzilla XMLRPC web url 141 142 :param force_rest: If True, generate a REST API url 143 """ 144 scheme, netloc, path, params, query, fragment = urlparse(url) 145 if not scheme: 146 scheme = 'https' 147 148 if path and not netloc: 149 netloc = path.split("/", 1)[0] 150 path = "/".join(path.split("/")[1:]) or None 151 152 if not path: 153 path = 'xmlrpc.cgi' 154 if force_rest: 155 path = "rest/" 156 157 newurl = urlunparse((scheme, netloc, path, params, query, fragment)) 158 return newurl 159 160 @staticmethod 161 def get_rcfile_default_url(): 162 """ 163 Helper to check all the default bugzillarc file paths for 164 a [DEFAULT] url=X section, and if found, return it. 165 """ 166 configpaths = _BugzillaRCFile.get_default_configpaths() 167 rcfile = _BugzillaRCFile() 168 rcfile.set_configpaths(configpaths) 169 return rcfile.get_default_url() 170 171 172 def __init__(self, url=-1, user=None, password=None, cookiefile=-1, 173 sslverify=True, tokenfile=-1, use_creds=True, api_key=None, 174 cert=None, configpaths=-1, 175 force_rest=False, force_xmlrpc=False, requests_session=None): 176 """ 177 :param url: The bugzilla instance URL, which we will connect 178 to immediately. Most users will want to specify this at 179 __init__ time, but you can defer connecting by passing 180 url=None and calling connect(URL) manually 181 :param user: optional username to connect with 182 :param password: optional password for the connecting user 183 :param cert: optional certificate file for client side certificate 184 authentication 185 :param cookiefile: Location to cache the login session cookies so you 186 don't have to keep specifying username/password. Bugzilla 5+ will 187 use tokens instead of cookies. 188 If -1, use the default path. If None, don't use or save 189 any cookiefile. 190 :param sslverify: Set this to False to skip SSL hostname and CA 191 validation checks, like out of date certificate 192 :param tokenfile: Location to cache the API login token so youi 193 don't have to keep specifying username/password. 194 If -1, use the default path. If None, don't use 195 or save any tokenfile. 196 :param use_creds: If False, this disables cookiefile, tokenfile, 197 and configpaths by default. This is a convenience option to 198 unset those values at init time. If those values are later 199 changed, they may be used for future operations. 200 :param sslverify: Maps to 'requests' sslverify parameter. Set to 201 False to disable SSL verification, but it can also be a path 202 to file or directory for custom certs. 203 :param api_key: A bugzilla5+ API key 204 :param configpaths: A list of possible bugzillarc locations. 205 :param force_rest: Force use of the REST API 206 :param force_xmlrpc: Force use of the XMLRPC API. If neither force_X 207 parameter are specified, heuristics will be used to determine 208 which API to use, with XMLRPC preferred for back compatability. 209 :param requests_session: An optional requests.Session object the 210 API will use to contact the remote bugzilla instance. This 211 way the API user can set up whatever auth bits they may need. 212 """ 213 if url == -1: 214 raise TypeError("Specify a valid bugzilla url, or pass url=None") 215 216 # Settings the user might want to tweak 217 self.user = user or '' 218 self.password = password or '' 219 self.api_key = api_key 220 self.cert = cert or None 221 self.url = '' 222 223 self._backend = None 224 self._session = None 225 self._user_requests_session = requests_session 226 self._sslverify = sslverify 227 self._cache = _BugzillaAPICache() 228 self._bug_autorefresh = False 229 self._is_redhat_bugzilla = False 230 231 self._rcfile = _BugzillaRCFile() 232 self._cookiecache = _BugzillaCookieCache() 233 self._tokencache = _BugzillaTokenCache() 234 235 self._force_rest = force_rest 236 self._force_xmlrpc = force_xmlrpc 237 238 if not use_creds: 239 cookiefile = None 240 tokenfile = None 241 configpaths = [] 242 243 if cookiefile == -1: 244 cookiefile = self._cookiecache.get_default_path() 245 if tokenfile == -1: 246 tokenfile = self._tokencache.get_default_path() 247 if configpaths == -1: 248 configpaths = _BugzillaRCFile.get_default_configpaths() 249 250 self._setcookiefile(cookiefile) 251 self._settokenfile(tokenfile) 252 self._setconfigpath(configpaths) 253 254 if url: 255 self.connect(url) 256 257 def _detect_is_redhat_bugzilla(self): 258 if self._is_redhat_bugzilla: 259 return True 260 261 if "bugzilla.redhat.com" in self.url: 262 log.info("Using RHBugzilla for URL containing bugzilla.redhat.com") 263 return True 264 265 return False 266 267 def _init_class_from_url(self): 268 """ 269 Detect if we should use RHBugzilla class, and if so, set it 270 """ 271 from .oldclasses import RHBugzilla # pylint: disable=cyclic-import 272 273 if self._detect_is_redhat_bugzilla(): 274 self.__class__ = RHBugzilla 275 self._is_redhat_bugzilla = True 276 277 def _get_field_aliases(self): 278 # List of field aliases. Maps old style RHBZ parameter 279 # names to actual upstream values. Used for createbug() and 280 # query include_fields at least. 281 ret = [] 282 283 def _add(*args, **kwargs): 284 ret.append(_FieldAlias(*args, **kwargs)) 285 286 def _add_both(newname, origname): 287 _add(newname, origname, is_api=False) 288 _add(origname, newname, is_bug=False) 289 290 _add('summary', 'short_desc') 291 _add('description', 'comment') 292 _add('platform', 'rep_platform') 293 _add('severity', 'bug_severity') 294 _add('status', 'bug_status') 295 _add('id', 'bug_id') 296 _add('blocks', 'blockedby') 297 _add('blocks', 'blocked') 298 _add('depends_on', 'dependson') 299 _add('creator', 'reporter') 300 _add('url', 'bug_file_loc') 301 _add('dupe_of', 'dupe_id') 302 _add('dupe_of', 'dup_id') 303 _add('comments', 'longdescs') 304 _add('creation_time', 'opendate') 305 _add('creation_time', 'creation_ts') 306 _add('whiteboard', 'status_whiteboard') 307 _add('last_change_time', 'delta_ts') 308 309 if self._is_redhat_bugzilla: 310 _add_both('fixed_in', 'cf_fixed_in') 311 _add_both('qa_whiteboard', 'cf_qa_whiteboard') 312 _add_both('devel_whiteboard', 'cf_devel_whiteboard') 313 _add_both('internal_whiteboard', 'cf_internal_whiteboard') 314 315 _add('component', 'components', is_bug=False) 316 _add('version', 'versions', is_bug=False) 317 # Yes, sub_components is the field name the API expects 318 _add('sub_components', 'sub_component', is_bug=False) 319 # flags format isn't exactly the same but it's the closest approx 320 _add('flags', 'flag_types') 321 322 return ret 323 324 def _get_user_agent(self): 325 return 'python-bugzilla/%s' % __version__ 326 user_agent = property(_get_user_agent) 327 328 @property 329 def bz_ver_major(self): 330 return self._cache.version_parsed[0] 331 332 @property 333 def bz_ver_minor(self): 334 return self._cache.version_parsed[1] 335 336 337 ################### 338 # Private helpers # 339 ################### 340 341 def _get_version(self): 342 """ 343 Return version number as a float 344 """ 345 return float("%d.%d" % (self.bz_ver_major, self.bz_ver_minor)) 346 347 def _get_bug_aliases(self): 348 return [(f.newname, f.oldname) 349 for f in self._get_field_aliases() if f.is_bug] 350 351 def _get_api_aliases(self): 352 return [(f.newname, f.oldname) 353 for f in self._get_field_aliases() if f.is_api] 354 355 356 ################# 357 # Auth handling # 358 ################# 359 360 def _getcookiefile(self): 361 return self._cookiecache.get_filename() 362 def _delcookiefile(self): 363 self._setcookiefile(None) 364 def _setcookiefile(self, cookiefile): 365 self._cookiecache.set_filename(cookiefile) 366 cookiefile = property(_getcookiefile, _setcookiefile, _delcookiefile) 367 368 def _gettokenfile(self): 369 return self._tokencache.get_filename() 370 def _settokenfile(self, filename): 371 self._tokencache.set_filename(filename) 372 def _deltokenfile(self): 373 self._settokenfile(None) 374 tokenfile = property(_gettokenfile, _settokenfile, _deltokenfile) 375 376 def _getconfigpath(self): 377 return self._rcfile.get_configpaths() 378 def _setconfigpath(self, configpaths): 379 return self._rcfile.set_configpaths(configpaths) 380 def _delconfigpath(self): 381 return self._rcfile.set_configpaths(None) 382 configpath = property(_getconfigpath, _setconfigpath, _delconfigpath) 383 384 385 ############################# 386 # Login/connection handling # 387 ############################# 388 389 def readconfig(self, configpath=None, overwrite=True): 390 """ 391 :param configpath: Optional bugzillarc path to read, instead of 392 the default list. 393 394 This function is called automatically from Bugzilla connect(), which 395 is called at __init__ if a URL is passed. Calling it manually is 396 just for passing in a non-standard configpath. 397 398 The locations for the bugzillarc file are preferred in this order: 399 400 ~/.config/python-bugzilla/bugzillarc 401 ~/.bugzillarc 402 /etc/bugzillarc 403 404 It has content like: 405 [bugzilla.yoursite.com] 406 user = username 407 password = password 408 Or 409 [bugzilla.yoursite.com] 410 api_key = key 411 412 The file can have multiple sections for different bugzilla instances. 413 A 'url' field in the [DEFAULT] section can be used to set a default 414 URL for the bugzilla command line tool. 415 416 Be sure to set appropriate permissions on bugzillarc if you choose to 417 store your password in it! 418 419 :param overwrite: If True, bugzillarc will clobber any already 420 set self.user/password/api_key/cert value. 421 """ 422 if configpath: 423 self._setconfigpath(configpath) 424 data = self._rcfile.parse(self.url) 425 426 for key, val in data.items(): 427 if key == "api_key" and (overwrite or not self.api_key): 428 log.debug("bugzillarc: setting api_key") 429 self.api_key = val 430 elif key == "user" and (overwrite or not self.user): 431 log.debug("bugzillarc: setting user=%s", val) 432 self.user = val 433 elif key == "password" and (overwrite or not self.password): 434 log.debug("bugzillarc: setting password") 435 self.password = val 436 elif key == "cert" and (overwrite or not self.cert): 437 log.debug("bugzillarc: setting cert") 438 self.cert = val 439 else: 440 log.debug("bugzillarc: unknown key=%s", key) 441 442 def _set_bz_version(self, version): 443 self._cache.version_raw = version 444 try: 445 major, minor = [int(i) for i in version.split(".")[0:2]] 446 except Exception: 447 log.debug("version doesn't match expected format X.Y.Z, " 448 "assuming 5.0", exc_info=True) 449 major = 5 450 minor = 0 451 self._cache.version_parsed = (major, minor) 452 453 def _get_backend_class(self, url): # pragma: no cover 454 # This is a hook for the test suite to do some mock hackery 455 if self._force_rest and self._force_xmlrpc: 456 raise BugzillaError( 457 "Cannot specify both force_rest and force_xmlrpc") 458 459 xmlurl = self.fix_url(url) 460 if self._force_xmlrpc: 461 return _BackendXMLRPC, xmlurl 462 463 resturl = self.fix_url(url, force_rest=self._force_rest) 464 if self._force_rest: 465 return _BackendREST, resturl 466 467 # Simple heuristic if the original url has a path in it 468 if "/xmlrpc" in url: 469 return _BackendXMLRPC, xmlurl 470 if "/rest" in url: 471 return _BackendREST, resturl 472 473 # We were passed something like bugzilla.example.com but we 474 # aren't sure which method to use, try probing 475 if _BackendXMLRPC.probe(xmlurl): 476 return _BackendXMLRPC, xmlurl 477 if _BackendREST.probe(resturl): 478 return _BackendREST, resturl 479 480 # Otherwise fallback to XMLRPC default and let it fail 481 return _BackendXMLRPC, xmlurl 482 483 def connect(self, url=None): 484 """ 485 Connect to the bugzilla instance with the given url. This is 486 called by __init__ if a URL is passed. Or it can be called manually 487 at any time with a passed URL. 488 489 This will also read any available config files (see readconfig()), 490 which may set 'user' and 'password', and others. 491 492 If 'user' and 'password' are both set, we'll run login(). Otherwise 493 you'll have to login() yourself before some methods will work. 494 """ 495 if self._session: 496 self.disconnect() 497 498 url = url or self.url 499 backendclass, newurl = self._get_backend_class(url) 500 if url != newurl: 501 log.debug("Converted url=%s to fixed url=%s", url, newurl) 502 self.url = newurl 503 log.debug("Connecting with URL %s", self.url) 504 505 # we've changed URLs - reload config 506 self.readconfig(overwrite=False) 507 508 self._session = _BugzillaSession(self.url, self.user_agent, 509 cookiecache=self._cookiecache, 510 sslverify=self._sslverify, 511 cert=self.cert, 512 tokencache=self._tokencache, 513 api_key=self.api_key, 514 requests_session=self._user_requests_session) 515 self._backend = backendclass(self.url, self._session) 516 517 if (self.user and self.password): 518 log.info("user and password present - doing login()") 519 self.login() 520 521 if self.api_key: 522 log.debug("using API key") 523 524 version = self._backend.bugzilla_version()["version"] 525 log.debug("Bugzilla version string: %s", version) 526 self._set_bz_version(version) 527 self._init_class_from_url() 528 529 530 @property 531 def _proxy(self): 532 """ 533 Return an xmlrpc ServerProxy instance that will work seamlessly 534 with bugzilla 535 536 Some apps have historically accessed _proxy directly, like 537 fedora infrastrucutre pieces. So we consider it part of the API 538 """ 539 return self._backend.get_xmlrpc_proxy() 540 541 def is_xmlrpc(self): 542 """ 543 :returns: True if using the XMLRPC API 544 """ 545 return self._backend.is_xmlrpc() 546 547 def is_rest(self): 548 """ 549 :returns: True if using the REST API 550 """ 551 return self._backend.is_rest() 552 553 def get_requests_session(self): 554 """ 555 Give API users access to the Requests.session object we use for 556 talking to the remote bugzilla instance. 557 558 :returns: The Requests.session object backing the open connection. 559 """ 560 return self._session.get_requests_session() 561 562 def disconnect(self): 563 """ 564 Disconnect from the given bugzilla instance. 565 """ 566 self._backend = None 567 self._session = None 568 self._cache = _BugzillaAPICache() 569 570 def login(self, user=None, password=None, restrict_login=None): 571 """ 572 Attempt to log in using the given username and password. Subsequent 573 method calls will use this username and password. Returns False if 574 login fails, otherwise returns some kind of login info - typically 575 either a numeric userid, or a dict of user info. 576 577 If user is not set, the value of Bugzilla.user will be used. If *that* 578 is not set, ValueError will be raised. If login fails, BugzillaError 579 will be raised. 580 581 The login session can be restricted to current user IP address 582 with restrict_login argument. (Bugzilla 4.4+) 583 584 This method will be called implicitly at the end of connect() if user 585 and password are both set. So under most circumstances you won't need 586 to call this yourself. 587 """ 588 if self.api_key: 589 raise ValueError("cannot login when using an API key") 590 591 if user: 592 self.user = user 593 if password: 594 self.password = password 595 596 if not self.user: 597 raise ValueError("missing username") 598 if not self.password: 599 raise ValueError("missing password") 600 601 payload = {"login": self.user} 602 if restrict_login: 603 payload['restrict_login'] = True 604 log.debug("logging in with options %s", str(payload)) 605 payload['password'] = self.password 606 607 try: 608 ret = self._backend.user_login(payload) 609 self.password = '' 610 log.info("login succeeded for user=%s", self.user) 611 return ret 612 except Exception as e: 613 log.debug("Login exception: %s", str(e), exc_info=True) 614 raise BugzillaError("Login failed: %s" % 615 BugzillaError.get_bugzilla_error_string(e)) 616 617 def interactive_save_api_key(self): 618 """ 619 Helper method to interactively ask for an API key, verify it 620 is valid, and save it to a bugzillarc file referenced via 621 self.configpaths 622 """ 623 sys.stdout.write('API Key: ') 624 sys.stdout.flush() 625 api_key = sys.stdin.readline().strip() 626 627 self.disconnect() 628 self.api_key = api_key 629 630 log.info('Checking API key... ') 631 self.connect() 632 633 if not self.logged_in: # pragma: no cover 634 raise BugzillaError("Login with API_KEY failed") 635 log.info('API Key accepted') 636 637 wrote_filename = self._rcfile.save_api_key(self.url, self.api_key) 638 log.info("API key written to filename=%s", wrote_filename) 639 640 msg = "Login successful." 641 if wrote_filename: 642 msg += " API key written to %s" % wrote_filename 643 print(msg) 644 645 def interactive_login(self, user=None, password=None, force=False, 646 restrict_login=None): 647 """ 648 Helper method to handle login for this bugzilla instance. 649 650 :param user: bugzilla username. If not specified, prompt for it. 651 :param password: bugzilla password. If not specified, prompt for it. 652 :param force: Unused 653 :param restrict_login: restricts session to IP address 654 """ 655 ignore = force 656 log.debug('Calling interactive_login') 657 658 if not user: 659 sys.stdout.write('Bugzilla Username: ') 660 sys.stdout.flush() 661 user = sys.stdin.readline().strip() 662 if not password: 663 password = getpass.getpass('Bugzilla Password: ') 664 665 log.info('Logging in... ') 666 out = self.login(user, password, restrict_login) 667 msg = "Login successful." 668 if "token" in out and self.tokenfile: 669 msg += " Token cache saved to %s" % self.tokenfile 670 if self._get_version() >= 5.0: 671 msg += "\nToken usage is deprecated. " 672 msg += "Consider using bugzilla API keys instead." 673 print(msg) 674 675 def logout(self): 676 """ 677 Log out of bugzilla. Drops server connection and user info, and 678 destroys authentication cookies. 679 """ 680 self._backend.user_logout() 681 self.disconnect() 682 self.user = '' 683 self.password = '' 684 685 @property 686 def logged_in(self): 687 """ 688 This is True if this instance is logged in else False. 689 690 We test if this session is authenticated by calling the User.get() 691 XMLRPC method with ids set. Logged-out users cannot pass the 'ids' 692 parameter and will result in a 505 error. If we tried to login with a 693 token, but the token was incorrect or expired, the server returns a 694 32000 error. 695 696 For Bugzilla 5 and later, a new method, User.valid_login is available 697 to test the validity of the token. However, this will require that the 698 username be cached along with the token in order to work effectively in 699 all scenarios and is not currently used. For more information, refer to 700 the following url. 701 702 http://bugzilla.readthedocs.org/en/latest/api/core/v1/user.html#valid-login 703 """ 704 try: 705 self._backend.user_get({"ids": [1]}) 706 return True 707 except Exception as e: 708 code = BugzillaError.get_bugzilla_error_code(e) 709 if code in [505, 32000]: 710 return False 711 raise e 712 713 714 ###################### 715 # Bugfields querying # 716 ###################### 717 718 def getbugfields(self, force_refresh=False, names=None): 719 """ 720 Calls getBugFields, which returns a list of fields in each bug 721 for this bugzilla instance. This can be used to set the list of attrs 722 on the Bug object. 723 724 :param force_refresh: If True, overwrite the bugfield cache 725 with these newly checked values. 726 :param names: Only check for the passed bug field names 727 """ 728 def _fieldnames(): 729 data = {"include_fields": ["name"]} 730 if names: 731 data["names"] = names 732 r = self._backend.bug_fields(data) 733 return [f['name'] for f in r['fields']] 734 735 if force_refresh or not self._cache.bugfields: 736 log.debug("Refreshing bugfields") 737 self._cache.bugfields = _fieldnames() 738 self._cache.bugfields.sort() 739 log.debug("bugfields = %s", self._cache.bugfields) 740 741 return self._cache.bugfields 742 bugfields = property(fget=lambda self: self.getbugfields(), 743 fdel=lambda self: setattr(self, '_bugfields', None)) 744 745 746 #################### 747 # Product querying # 748 #################### 749 750 def product_get(self, ids=None, names=None, 751 include_fields=None, exclude_fields=None, 752 ptype=None): 753 """ 754 Raw wrapper around Product.get 755 https://bugzilla.readthedocs.io/en/latest/api/core/v1/product.html#get-product 756 757 This does not perform any caching like other product API calls. 758 If ids, names, or ptype is not specified, we default to 759 ptype=accessible for historical reasons 760 761 @ids: List of product IDs to lookup 762 @names: List of product names to lookup 763 @ptype: Either 'accessible', 'selectable', or 'enterable'. If 764 specified, we return data for all those 765 @include_fields: Only include these fields in the output 766 @exclude_fields: Do not include these fields in the output 767 """ 768 if ids is None and names is None and ptype is None: 769 ptype = "accessible" 770 771 if ptype: 772 raw = None 773 if ptype == "accessible": 774 raw = self._backend.product_get_accessible() 775 elif ptype == "enterable": 776 raw = self._backend.product_get_enterable() 777 elif ptype == "selectable": 778 raw = self._backend.product_get_selectable() 779 780 if raw is None: 781 raise RuntimeError("Unknown ptype=%s" % ptype) 782 ids = raw['ids'] 783 log.debug("For ptype=%s found ids=%s", ptype, ids) 784 785 kwargs = {} 786 if ids: 787 kwargs["ids"] = listify(ids) 788 if names: 789 kwargs["names"] = listify(names) 790 if include_fields: 791 kwargs["include_fields"] = include_fields 792 if exclude_fields: 793 kwargs["exclude_fields"] = exclude_fields 794 795 ret = self._backend.product_get(kwargs) 796 return ret['products'] 797 798 def refresh_products(self, **kwargs): 799 """ 800 Refresh a product's cached info. Basically calls product_get 801 with the passed arguments, and tries to intelligently update 802 our product cache. 803 804 For example, if we already have cached info for product=foo, 805 and you pass in names=["bar", "baz"], the new cache will have 806 info for products foo, bar, baz. Individual product fields are 807 also updated. 808 """ 809 for product in self.product_get(**kwargs): 810 updated = False 811 for current in self._cache.products[:]: 812 if (current.get("id", -1) != product.get("id", -2) and 813 current.get("name", -1) != product.get("name", -2)): 814 continue 815 816 _nested_update(current, product) 817 updated = True 818 break 819 if not updated: 820 self._cache.products.append(product) 821 822 def getproducts(self, force_refresh=False, **kwargs): 823 """ 824 Query all products and return the raw dict info. Takes all the 825 same arguments as product_get. 826 827 On first invocation this will contact bugzilla and internally 828 cache the results. Subsequent getproducts calls or accesses to 829 self.products will return this cached data only. 830 831 :param force_refresh: force refreshing via refresh_products() 832 """ 833 if force_refresh or not self._cache.products: 834 self.refresh_products(**kwargs) 835 return self._cache.products 836 837 products = property( 838 fget=lambda self: self.getproducts(), 839 fdel=lambda self: setattr(self, '_products', None), 840 doc="Helper for accessing the products cache. If nothing " 841 "has been cached yet, this calls getproducts()") 842 843 844 ####################### 845 # components querying # 846 ####################### 847 848 def _lookup_product_in_cache(self, productname): 849 prodstr = isinstance(productname, str) and productname or None 850 prodint = isinstance(productname, int) and productname or None 851 for proddict in self._cache.products: 852 if prodstr == proddict.get("name", -1): 853 return proddict 854 if prodint == proddict.get("id", "nope"): 855 return proddict 856 return {} 857 858 def getcomponentsdetails(self, product, force_refresh=False): 859 """ 860 Wrapper around Product.get(include_fields=["components"]), 861 returning only the "components" data for the requested product, 862 slightly reworked to a dict mapping of components.name: components, 863 for historical reasons. 864 865 This uses the product cache, but will update it if the product 866 isn't found or "components" isn't cached for the product. 867 868 In cases like bugzilla.redhat.com where there are tons of 869 components for some products, this API will time out. You 870 should use product_get instead. 871 """ 872 proddict = self._lookup_product_in_cache(product) 873 874 if (force_refresh or not proddict or "components" not in proddict): 875 self.refresh_products(names=[product], 876 include_fields=["name", "id", "components"]) 877 proddict = self._lookup_product_in_cache(product) 878 879 ret = {} 880 for compdict in proddict["components"]: 881 ret[compdict["name"]] = compdict 882 return ret 883 884 def getcomponentdetails(self, product, component, force_refresh=False): 885 """ 886 Helper for accessing a single component's info. This is a wrapper 887 around getcomponentsdetails, see that for explanation 888 """ 889 d = self.getcomponentsdetails(product, force_refresh) 890 return d[component] 891 892 def getcomponents(self, product, force_refresh=False): 893 """ 894 Return a list of component names for the passed product. 895 896 On first invocation the value is cached, and subsequent calls 897 will return the cached data. 898 899 :param force_refresh: Force refreshing the cache, and return 900 the new data 901 """ 902 proddict = self._lookup_product_in_cache(product) 903 product_id = proddict.get("id", None) 904 905 if (force_refresh or product_id is None or 906 "components" not in proddict): 907 self.refresh_products( 908 names=[product], 909 include_fields=["name", "id", "components.name"]) 910 proddict = self._lookup_product_in_cache(product) 911 if "id" not in proddict: 912 raise BugzillaError("Product '%s' not found" % product) 913 product_id = proddict["id"] 914 915 if product_id not in self._cache.component_names: 916 names = [] 917 for comp in proddict.get("components", []): 918 name = comp.get("name") 919 if name: 920 names.append(name) 921 self._cache.component_names[product_id] = names 922 923 return self._cache.component_names[product_id] 924 925 926 ############################ 927 # component adding/editing # 928 ############################ 929 930 def _component_data_convert(self, data, update=False): 931 # Back compat for the old RH interface 932 convert_fields = [ 933 ("initialowner", "default_assignee"), 934 ("initialqacontact", "default_qa_contact"), 935 ("initialcclist", "default_cc"), 936 ] 937 for old, new in convert_fields: 938 if old in data: 939 data[new] = data.pop(old) 940 941 if update: 942 names = {"product": data.pop("product"), 943 "component": data.pop("component")} 944 updates = {} 945 for k in list(data.keys()): 946 updates[k] = data.pop(k) 947 948 data["names"] = [names] 949 data["updates"] = updates 950 951 952 def addcomponent(self, data): 953 """ 954 A method to create a component in Bugzilla. Takes a dict, with the 955 following elements: 956 957 product: The product to create the component in 958 component: The name of the component to create 959 description: A one sentence summary of the component 960 default_assignee: The bugzilla login (email address) of the initial 961 owner of the component 962 default_qa_contact (optional): The bugzilla login of the 963 initial QA contact 964 default_cc: (optional) The initial list of users to be CC'ed on 965 new bugs for the component. 966 is_active: (optional) If False, the component is hidden from 967 the component list when filing new bugs. 968 """ 969 data = data.copy() 970 self._component_data_convert(data) 971 return self._backend.component_create(data) 972 973 def editcomponent(self, data): 974 """ 975 A method to edit a component in Bugzilla. Takes a dict, with 976 mandatory elements of product. component, and initialowner. 977 All other elements are optional and use the same names as the 978 addcomponent() method. 979 """ 980 data = data.copy() 981 self._component_data_convert(data, update=True) 982 return self._backend.component_update(data) 983 984 985 ################### 986 # getbug* methods # 987 ################### 988 989 def _process_include_fields(self, include_fields, exclude_fields, 990 extra_fields): 991 """ 992 Internal helper to process include_fields lists 993 """ 994 def _convert_fields(_in): 995 for newname, oldname in self._get_api_aliases(): 996 if oldname in _in: 997 _in.remove(oldname) 998 if newname not in _in: 999 _in.append(newname) 1000 return _in 1001 1002 ret = {} 1003 if include_fields: 1004 include_fields = _convert_fields(include_fields) 1005 if "id" not in include_fields: 1006 include_fields.append("id") 1007 ret["include_fields"] = include_fields 1008 if exclude_fields: 1009 exclude_fields = _convert_fields(exclude_fields) 1010 ret["exclude_fields"] = exclude_fields 1011 if self._supports_getbug_extra_fields(): 1012 if extra_fields: 1013 ret["extra_fields"] = _convert_fields(extra_fields) 1014 return ret 1015 1016 def _get_bug_autorefresh(self): 1017 """ 1018 This value is passed to Bug.autorefresh for all fetched bugs. 1019 If True, and an uncached attribute is requested from a Bug, 1020 the Bug will update its contents and try again. 1021 """ 1022 return self._bug_autorefresh 1023 1024 def _set_bug_autorefresh(self, val): 1025 self._bug_autorefresh = bool(val) 1026 bug_autorefresh = property(_get_bug_autorefresh, _set_bug_autorefresh) 1027 1028 1029 def _getbug_extra_fields(self): 1030 """ 1031 Extra fields that need to be explicitly 1032 requested from Bug.get in order for the data to be returned. 1033 """ 1034 rhbz_extra_fields = [ 1035 "comments", "description", 1036 "external_bugs", "flags", "sub_components", 1037 "tags", 1038 ] 1039 if self._is_redhat_bugzilla: 1040 return rhbz_extra_fields 1041 return [] 1042 1043 def _supports_getbug_extra_fields(self): 1044 """ 1045 Return True if the bugzilla instance supports passing 1046 extra_fields to getbug 1047 1048 As of Dec 2012 it seems like only RH bugzilla actually has behavior 1049 like this, for upstream bz it returns all info for every Bug.get() 1050 """ 1051 return self._is_redhat_bugzilla 1052 1053 1054 def _getbugs(self, idlist, permissive, 1055 include_fields=None, exclude_fields=None, extra_fields=None): 1056 """ 1057 Return a list of dicts of full bug info for each given bug id. 1058 bug ids that couldn't be found will return None instead of a dict. 1059 """ 1060 ids = [] 1061 aliases = [] 1062 1063 def _alias_or_int(_v): 1064 if str(_v).isdigit(): 1065 return int(_v), None 1066 return None, str(_v) 1067 1068 for idstr in idlist: 1069 idint, alias = _alias_or_int(idstr) 1070 if alias: 1071 aliases.append(alias) 1072 else: 1073 ids.append(idstr) 1074 1075 extra_fields = listify(extra_fields or []) 1076 extra_fields += self._getbug_extra_fields() 1077 1078 getbugdata = {} 1079 if permissive: 1080 getbugdata["permissive"] = 1 1081 1082 getbugdata.update(self._process_include_fields( 1083 include_fields, exclude_fields, extra_fields)) 1084 1085 r = self._backend.bug_get(ids, aliases, getbugdata) 1086 1087 # Do some wrangling to ensure we return bugs in the same order 1088 # the were passed in, for historical reasons 1089 ret = [] 1090 for idval in idlist: 1091 idint, alias = _alias_or_int(idval) 1092 for bugdict in r["bugs"]: 1093 if idint and idint != bugdict.get("id", None): 1094 continue 1095 aliaslist = listify(bugdict.get("alias", None) or []) 1096 if alias and alias not in aliaslist: 1097 continue 1098 1099 ret.append(bugdict) 1100 break 1101 return ret 1102 1103 def _getbug(self, objid, **kwargs): 1104 """ 1105 Thin wrapper around _getbugs to handle the slight argument tweaks 1106 for fetching a single bug. The main bit is permissive=False, which 1107 will tell bugzilla to raise an explicit error if we can't fetch 1108 that bug. 1109 1110 This logic is called from Bug() too 1111 """ 1112 return self._getbugs([objid], permissive=False, **kwargs)[0] 1113 1114 def getbug(self, objid, 1115 include_fields=None, exclude_fields=None, extra_fields=None): 1116 """ 1117 Return a Bug object with the full complement of bug data 1118 already loaded. 1119 """ 1120 data = self._getbug(objid, 1121 include_fields=include_fields, exclude_fields=exclude_fields, 1122 extra_fields=extra_fields) 1123 return Bug(self, dict=data, autorefresh=self.bug_autorefresh) 1124 1125 def getbugs(self, idlist, 1126 include_fields=None, exclude_fields=None, extra_fields=None, 1127 permissive=True): 1128 """ 1129 Return a list of Bug objects with the full complement of bug data 1130 already loaded. If there's a problem getting the data for a given id, 1131 the corresponding item in the returned list will be None. 1132 """ 1133 data = self._getbugs(idlist, include_fields=include_fields, 1134 exclude_fields=exclude_fields, extra_fields=extra_fields, 1135 permissive=permissive) 1136 return [(b and Bug(self, dict=b, 1137 autorefresh=self.bug_autorefresh)) or None 1138 for b in data] 1139 1140 def get_comments(self, idlist): 1141 """ 1142 Returns a dictionary of bugs and comments. The comments key will 1143 be empty. See bugzilla docs for details 1144 """ 1145 return self._backend.bug_comments(idlist, {}) 1146 1147 1148 ################# 1149 # query methods # 1150 ################# 1151 1152 def build_query(self, 1153 product=None, 1154 component=None, 1155 version=None, 1156 long_desc=None, 1157 bug_id=None, 1158 short_desc=None, 1159 cc=None, 1160 assigned_to=None, 1161 reporter=None, 1162 qa_contact=None, 1163 status=None, 1164 blocked=None, 1165 dependson=None, 1166 keywords=None, 1167 keywords_type=None, 1168 url=None, 1169 url_type=None, 1170 status_whiteboard=None, 1171 status_whiteboard_type=None, 1172 fixed_in=None, 1173 fixed_in_type=None, 1174 flag=None, 1175 alias=None, 1176 qa_whiteboard=None, 1177 devel_whiteboard=None, 1178 bug_severity=None, 1179 priority=None, 1180 target_release=None, 1181 target_milestone=None, 1182 emailtype=None, 1183 include_fields=None, 1184 quicksearch=None, 1185 savedsearch=None, 1186 savedsearch_sharer_id=None, 1187 sub_component=None, 1188 tags=None, 1189 exclude_fields=None, 1190 extra_fields=None): 1191 """ 1192 Build a query string from passed arguments. Will handle 1193 query parameter differences between various bugzilla versions. 1194 1195 Most of the parameters should be self-explanatory. However, 1196 if you want to perform a complex query, and easy way is to 1197 create it with the bugzilla web UI, copy the entire URL it 1198 generates, and pass it to the static method 1199 1200 Bugzilla.url_to_query 1201 1202 Then pass the output to Bugzilla.query() 1203 1204 For details about the specific argument formats, see the bugzilla docs: 1205 https://bugzilla.readthedocs.io/en/latest/api/core/v1/bug.html#search-bugs 1206 """ 1207 query = { 1208 "alias": alias, 1209 "product": listify(product), 1210 "component": listify(component), 1211 "version": version, 1212 "id": bug_id, 1213 "short_desc": short_desc, 1214 "bug_status": status, 1215 "bug_severity": bug_severity, 1216 "priority": priority, 1217 "target_release": target_release, 1218 "target_milestone": target_milestone, 1219 "tag": listify(tags), 1220 "quicksearch": quicksearch, 1221 "savedsearch": savedsearch, 1222 "sharer_id": savedsearch_sharer_id, 1223 1224 # RH extensions... don't add any more. See comment below 1225 "sub_components": listify(sub_component), 1226 } 1227 1228 def add_bool(bzkey, value, bool_id, booltype=None): 1229 value = listify(value) 1230 if value is None: 1231 return bool_id 1232 1233 query["query_format"] = "advanced" 1234 for boolval in value: 1235 def make_bool_str(prefix): 1236 # pylint: disable=cell-var-from-loop 1237 return "%s%i-0-0" % (prefix, bool_id) 1238 1239 query[make_bool_str("field")] = bzkey 1240 query[make_bool_str("value")] = boolval 1241 query[make_bool_str("type")] = booltype or "substring" 1242 1243 bool_id += 1 1244 return bool_id 1245 1246 # RH extensions that we have to maintain here for back compat, 1247 # but all future custom fields should be specified via 1248 # cli --field option, or via extending the query dict() manually. 1249 # No more supporting custom fields in this API 1250 bool_id = 0 1251 bool_id = add_bool("keywords", keywords, bool_id, keywords_type) 1252 bool_id = add_bool("blocked", blocked, bool_id) 1253 bool_id = add_bool("dependson", dependson, bool_id) 1254 bool_id = add_bool("bug_file_loc", url, bool_id, url_type) 1255 bool_id = add_bool("cf_fixed_in", fixed_in, bool_id, fixed_in_type) 1256 bool_id = add_bool("flagtypes.name", flag, bool_id) 1257 bool_id = add_bool("status_whiteboard", 1258 status_whiteboard, bool_id, status_whiteboard_type) 1259 bool_id = add_bool("cf_qa_whiteboard", qa_whiteboard, bool_id) 1260 bool_id = add_bool("cf_devel_whiteboard", devel_whiteboard, bool_id) 1261 1262 def add_email(key, value, count): 1263 if value is None: 1264 return count 1265 if not emailtype: 1266 query[key] = value 1267 return count 1268 1269 query["query_format"] = "advanced" 1270 query['email%i' % count] = value 1271 query['email%s%i' % (key, count)] = True 1272 query['emailtype%i' % count] = emailtype 1273 return count + 1 1274 1275 email_count = 1 1276 email_count = add_email("cc", cc, email_count) 1277 email_count = add_email("assigned_to", assigned_to, email_count) 1278 email_count = add_email("reporter", reporter, email_count) 1279 email_count = add_email("qa_contact", qa_contact, email_count) 1280 1281 if long_desc is not None: 1282 query["query_format"] = "advanced" 1283 query["longdesc"] = long_desc 1284 query["longdesc_type"] = "allwordssubstr" 1285 1286 # 'include_fields' only available for Bugzilla4+ 1287 # 'extra_fields' is an RHBZ extension 1288 query.update(self._process_include_fields( 1289 include_fields, exclude_fields, extra_fields)) 1290 1291 # Strip out None elements in the dict 1292 for k, v in query.copy().items(): 1293 if v is None: 1294 del(query[k]) 1295 1296 self.pre_translation(query) 1297 return query 1298 1299 def query(self, query): 1300 """ 1301 Query bugzilla and return a list of matching bugs. 1302 query must be a dict with fields like those in in querydata['fields']. 1303 Returns a list of Bug objects. 1304 Also see the _query() method for details about the underlying 1305 implementation. 1306 """ 1307 try: 1308 r = self._backend.bug_search(query) 1309 log.debug("bug_search returned:\n%s", str(r)) 1310 except Exception as e: 1311 # Try to give a hint in the error message if url_to_query 1312 # isn't supported by this bugzilla instance 1313 if ("query_format" not in str(e) or 1314 not BugzillaError.get_bugzilla_error_code(e) or 1315 self._get_version() >= 5.0): 1316 raise 1317 raise BugzillaError("%s\nYour bugzilla instance does not " 1318 "appear to support API queries derived from bugzilla " 1319 "web URL queries." % e) 1320 1321 log.debug("Query returned %s bugs", len(r['bugs'])) 1322 return [Bug(self, dict=b, 1323 autorefresh=self.bug_autorefresh) for b in r['bugs']] 1324 1325 def pre_translation(self, query): 1326 """ 1327 In order to keep the API the same, Bugzilla4 needs to process the 1328 query and the result. This also applies to the refresh() function 1329 """ 1330 if self._is_redhat_bugzilla: 1331 _RHBugzillaConverters.pre_translation(query) 1332 query.update(self._process_include_fields( 1333 query.get("include_fields", []), None, None)) 1334 1335 def post_translation(self, query, bug): 1336 """ 1337 In order to keep the API the same, Bugzilla4 needs to process the 1338 query and the result. This also applies to the refresh() function 1339 """ 1340 if self._is_redhat_bugzilla: 1341 _RHBugzillaConverters.post_translation(query, bug) 1342 1343 def bugs_history_raw(self, bug_ids): 1344 """ 1345 Experimental. Gets the history of changes for 1346 particular bugs in the database. 1347 """ 1348 return self._backend.bug_history(bug_ids, {}) 1349 1350 1351 ####################################### 1352 # Methods for modifying existing bugs # 1353 ####################################### 1354 1355 # Bug() also has individual methods for many ops, like setassignee() 1356 1357 def update_bugs(self, ids, updates): 1358 """ 1359 A thin wrapper around bugzilla Bug.update(). Used to update all 1360 values of an existing bug report, as well as add comments. 1361 1362 The dictionary passed to this function should be generated with 1363 build_update(), otherwise we cannot guarantee back compatibility. 1364 """ 1365 tmp = updates.copy() 1366 return self._backend.bug_update(listify(ids), tmp) 1367 1368 def update_tags(self, idlist, tags_add=None, tags_remove=None): 1369 """ 1370 Updates the 'tags' field for a bug. 1371 """ 1372 tags = {} 1373 if tags_add: 1374 tags["add"] = listify(tags_add) 1375 if tags_remove: 1376 tags["remove"] = listify(tags_remove) 1377 1378 d = { 1379 "tags": tags, 1380 } 1381 1382 return self._backend.bug_update_tags(listify(idlist), d) 1383 1384 def update_flags(self, idlist, flags): 1385 """ 1386 A thin back compat wrapper around build_update(flags=X) 1387 """ 1388 return self.update_bugs(idlist, self.build_update(flags=flags)) 1389 1390 1391 def build_update(self, 1392 alias=None, 1393 assigned_to=None, 1394 blocks_add=None, 1395 blocks_remove=None, 1396 blocks_set=None, 1397 depends_on_add=None, 1398 depends_on_remove=None, 1399 depends_on_set=None, 1400 cc_add=None, 1401 cc_remove=None, 1402 is_cc_accessible=None, 1403 comment=None, 1404 comment_private=None, 1405 component=None, 1406 deadline=None, 1407 dupe_of=None, 1408 estimated_time=None, 1409 groups_add=None, 1410 groups_remove=None, 1411 keywords_add=None, 1412 keywords_remove=None, 1413 keywords_set=None, 1414 op_sys=None, 1415 platform=None, 1416 priority=None, 1417 product=None, 1418 qa_contact=None, 1419 is_creator_accessible=None, 1420 remaining_time=None, 1421 reset_assigned_to=None, 1422 reset_qa_contact=None, 1423 resolution=None, 1424 see_also_add=None, 1425 see_also_remove=None, 1426 severity=None, 1427 status=None, 1428 summary=None, 1429 target_milestone=None, 1430 target_release=None, 1431 url=None, 1432 version=None, 1433 whiteboard=None, 1434 work_time=None, 1435 fixed_in=None, 1436 qa_whiteboard=None, 1437 devel_whiteboard=None, 1438 internal_whiteboard=None, 1439 sub_component=None, 1440 flags=None, 1441 comment_tags=None): 1442 """ 1443 Returns a python dict() with properly formatted parameters to 1444 pass to update_bugs(). See bugzilla documentation for the format 1445 of the individual fields: 1446 1447 https://bugzilla.readthedocs.io/en/latest/api/core/v1/bug.html#create-bug 1448 """ 1449 ret = {} 1450 rhbzret = {} 1451 1452 # These are only supported for rhbugzilla 1453 # 1454 # This should not be extended any more. 1455 # If people want to handle custom fields, manually extend the 1456 # returned dictionary. 1457 rhbzargs = { 1458 "fixed_in": fixed_in, 1459 "devel_whiteboard": devel_whiteboard, 1460 "qa_whiteboard": qa_whiteboard, 1461 "internal_whiteboard": internal_whiteboard, 1462 "sub_component": sub_component, 1463 } 1464 if self._is_redhat_bugzilla: 1465 rhbzret = _RHBugzillaConverters.convert_build_update( 1466 component=component, **rhbzargs) 1467 else: 1468 for key, val in rhbzargs.items(): 1469 if val is not None: 1470 raise ValueError("bugzilla instance does not support " 1471 "updating '%s'" % key) 1472 1473 def s(key, val, convert=None): 1474 if val is None: 1475 return 1476 if convert: 1477 val = convert(val) 1478 ret[key] = val 1479 1480 def add_dict(key, add, remove, _set=None, convert=None): 1481 if add is remove is _set is None: 1482 return 1483 1484 def c(val): 1485 val = listify(val) 1486 if convert: 1487 val = [convert(v) for v in val] 1488 return val 1489 1490 newdict = {} 1491 if add is not None: 1492 newdict["add"] = c(add) 1493 if remove is not None: 1494 newdict["remove"] = c(remove) 1495 if _set is not None: 1496 newdict["set"] = c(_set) 1497 ret[key] = newdict 1498 1499 1500 s("alias", alias) 1501 s("assigned_to", assigned_to) 1502 s("is_cc_accessible", is_cc_accessible, bool) 1503 s("component", component) 1504 s("deadline", deadline) 1505 s("dupe_of", dupe_of, int) 1506 s("estimated_time", estimated_time, int) 1507 s("op_sys", op_sys) 1508 s("platform", platform) 1509 s("priority", priority) 1510 s("product", product) 1511 s("qa_contact", qa_contact) 1512 s("is_creator_accessible", is_creator_accessible, bool) 1513 s("remaining_time", remaining_time, float) 1514 s("reset_assigned_to", reset_assigned_to, bool) 1515 s("reset_qa_contact", reset_qa_contact, bool) 1516 s("resolution", resolution) 1517 s("severity", severity) 1518 s("status", status) 1519 s("summary", summary) 1520 s("target_milestone", target_milestone) 1521 s("target_release", target_release) 1522 s("url", url) 1523 s("version", version) 1524 s("whiteboard", whiteboard) 1525 s("work_time", work_time, float) 1526 s("flags", flags) 1527 s("comment_tags", comment_tags, listify) 1528 1529 add_dict("blocks", blocks_add, blocks_remove, blocks_set, 1530 convert=int) 1531 add_dict("depends_on", depends_on_add, depends_on_remove, 1532 depends_on_set, convert=int) 1533 add_dict("cc", cc_add, cc_remove) 1534 add_dict("groups", groups_add, groups_remove) 1535 add_dict("keywords", keywords_add, keywords_remove, keywords_set) 1536 add_dict("see_also", see_also_add, see_also_remove) 1537 1538 if comment is not None: 1539 ret["comment"] = {"comment": comment} 1540 if comment_private: 1541 ret["comment"]["is_private"] = comment_private 1542 1543 ret.update(rhbzret) 1544 return ret 1545 1546 1547 ######################################## 1548 # Methods for working with attachments # 1549 ######################################## 1550 1551 def attachfile(self, idlist, attachfile, description, **kwargs): 1552 """ 1553 Attach a file to the given bug IDs. Returns the ID of the attachment 1554 or raises XMLRPC Fault if something goes wrong. 1555 1556 attachfile may be a filename (which will be opened) or a file-like 1557 object, which must provide a 'read' method. If it's not one of these, 1558 this method will raise a TypeError. 1559 description is the short description of this attachment. 1560 1561 Optional keyword args are as follows: 1562 file_name: this will be used as the filename for the attachment. 1563 REQUIRED if attachfile is a file-like object with no 1564 'name' attribute, otherwise the filename or .name 1565 attribute will be used. 1566 comment: An optional comment about this attachment. 1567 is_private: Set to True if the attachment should be marked private. 1568 is_patch: Set to True if the attachment is a patch. 1569 content_type: The mime-type of the attached file. Defaults to 1570 application/octet-stream if not set. NOTE that text 1571 files will *not* be viewable in bugzilla unless you 1572 remember to set this to text/plain. So remember that! 1573 1574 Returns the list of attachment ids that were added. If only one 1575 attachment was added, we return the single int ID for back compat 1576 """ 1577 if isinstance(attachfile, str): 1578 f = open(attachfile, "rb") 1579 elif hasattr(attachfile, 'read'): 1580 f = attachfile 1581 else: 1582 raise TypeError("attachfile must be filename or file-like object") 1583 1584 # Back compat 1585 if "contenttype" in kwargs: 1586 kwargs["content_type"] = kwargs.pop("contenttype") 1587 if "ispatch" in kwargs: 1588 kwargs["is_patch"] = kwargs.pop("ispatch") 1589 if "isprivate" in kwargs: 1590 kwargs["is_private"] = kwargs.pop("isprivate") 1591 if "filename" in kwargs: 1592 kwargs["file_name"] = kwargs.pop("filename") 1593 1594 kwargs['summary'] = description 1595 1596 data = f.read() 1597 if not isinstance(data, bytes): # pragma: no cover 1598 data = data.encode(locale.getpreferredencoding()) 1599 1600 if 'file_name' not in kwargs and hasattr(f, "name"): 1601 kwargs['file_name'] = os.path.basename(f.name) 1602 if 'content_type' not in kwargs: 1603 ctype = None 1604 if kwargs['file_name']: 1605 ctype = mimetypes.guess_type( 1606 kwargs['file_name'], strict=False)[0] 1607 kwargs['content_type'] = ctype or 'application/octet-stream' 1608 1609 ret = self._backend.bug_attachment_create( 1610 listify(idlist), data, kwargs) 1611 1612 if "attachments" in ret: 1613 # Up to BZ 4.2 1614 ret = [int(k) for k in ret["attachments"].keys()] 1615 elif "ids" in ret: 1616 # BZ 4.4+ 1617 ret = ret["ids"] 1618 1619 if isinstance(ret, list) and len(ret) == 1: 1620 ret = ret[0] 1621 return ret 1622 1623 def openattachment_data(self, attachment_dict): 1624 """ 1625 Helper for turning passed API attachment dictionary into a 1626 filelike object 1627 """ 1628 ret = BytesIO() 1629 data = attachment_dict["data"] 1630 1631 if hasattr(data, "data"): 1632 # This is for xmlrpc Binary 1633 content = data.data # pragma: no cover 1634 else: 1635 import base64 1636 content = base64.b64decode(data) 1637 1638 ret.write(content) 1639 ret.name = attachment_dict["file_name"] 1640 ret.seek(0) 1641 return ret 1642 1643 def openattachment(self, attachid): 1644 """ 1645 Get the contents of the attachment with the given attachment ID. 1646 Returns a file-like object. 1647 """ 1648 attachments = self.get_attachments(None, attachid) 1649 data = attachments["attachments"][str(attachid)] 1650 return self.openattachment_data(data) 1651 1652 def updateattachmentflags(self, bugid, attachid, flagname, **kwargs): 1653 """ 1654 Updates a flag for the given attachment ID. 1655 Optional keyword args are: 1656 status: new status for the flag ('-', '+', '?', 'X') 1657 requestee: new requestee for the flag 1658 """ 1659 # Bug ID was used for the original custom redhat API, no longer 1660 # needed though 1661 ignore = bugid 1662 1663 flags = {"name": flagname} 1664 flags.update(kwargs) 1665 attachment_ids = [int(attachid)] 1666 update = {'flags': [flags]} 1667 1668 return self._backend.bug_attachment_update(attachment_ids, update) 1669 1670 def get_attachments(self, ids, attachment_ids, 1671 include_fields=None, exclude_fields=None): 1672 """ 1673 Wrapper for Bug.attachments. One of ids or attachment_ids is required 1674 1675 :param ids: Get attachments for this bug ID 1676 :param attachment_ids: Specific attachment ID to get 1677 1678 https://bugzilla.readthedocs.io/en/latest/api/core/v1/attachment.html#get-attachment 1679 """ 1680 params = {} 1681 if include_fields: 1682 params["include_fields"] = listify(include_fields) 1683 if exclude_fields: 1684 params["exclude_fields"] = listify(exclude_fields) 1685 1686 if attachment_ids: 1687 return self._backend.bug_attachment_get(attachment_ids, params) 1688 return self._backend.bug_attachment_get_all(ids, params) 1689 1690 1691 ##################### 1692 # createbug methods # 1693 ##################### 1694 1695 createbug_required = ('product', 'component', 'summary', 'version', 1696 'description') 1697 1698 def build_createbug(self, 1699 product=None, 1700 component=None, 1701 version=None, 1702 summary=None, 1703 description=None, 1704 comment_private=None, 1705 blocks=None, 1706 cc=None, 1707 assigned_to=None, 1708 keywords=None, 1709 depends_on=None, 1710 groups=None, 1711 op_sys=None, 1712 platform=None, 1713 priority=None, 1714 qa_contact=None, 1715 resolution=None, 1716 severity=None, 1717 status=None, 1718 target_milestone=None, 1719 target_release=None, 1720 url=None, 1721 sub_component=None, 1722 alias=None, 1723 comment_tags=None): 1724 """ 1725 Returns a python dict() with properly formatted parameters to 1726 pass to createbug(). See bugzilla documentation for the format 1727 of the individual fields: 1728 1729 https://bugzilla.readthedocs.io/en/latest/api/core/v1/bug.html#update-bug 1730 """ 1731 1732 localdict = {} 1733 if blocks: 1734 localdict["blocks"] = listify(blocks) 1735 if cc: 1736 localdict["cc"] = listify(cc) 1737 if depends_on: 1738 localdict["depends_on"] = listify(depends_on) 1739 if groups: 1740 localdict["groups"] = listify(groups) 1741 if keywords: 1742 localdict["keywords"] = listify(keywords) 1743 if description: 1744 localdict["description"] = description 1745 if comment_private: 1746 localdict["comment_is_private"] = True 1747 1748 # Most of the machinery and formatting here is the same as 1749 # build_update, so reuse that as much as possible 1750 ret = self.build_update(product=product, component=component, 1751 version=version, summary=summary, op_sys=op_sys, 1752 platform=platform, priority=priority, qa_contact=qa_contact, 1753 resolution=resolution, severity=severity, status=status, 1754 target_milestone=target_milestone, 1755 target_release=target_release, url=url, 1756 assigned_to=assigned_to, sub_component=sub_component, 1757 alias=alias, comment_tags=comment_tags) 1758 1759 ret.update(localdict) 1760 return ret 1761 1762 def _validate_createbug(self, *args, **kwargs): 1763 # Previous API required users specifying keyword args that mapped 1764 # to the XMLRPC arg names. Maintain that bad compat, but also allow 1765 # receiving a single dictionary like query() does 1766 if kwargs and args: # pragma: no cover 1767 raise BugzillaError("createbug: cannot specify positional " 1768 "args=%s with kwargs=%s, must be one or the " 1769 "other." % (args, kwargs)) 1770 if args: 1771 if len(args) > 1 or not isinstance(args[0], dict): 1772 raise BugzillaError( # pragma: no cover 1773 "createbug: positional arguments only " 1774 "accept a single dictionary.") 1775 data = args[0] 1776 else: 1777 data = kwargs 1778 1779 # If we're getting a call that uses an old fieldname, convert it to the 1780 # new fieldname instead. 1781 for newname, oldname in self._get_api_aliases(): 1782 if (newname in self.createbug_required and 1783 newname not in data and 1784 oldname in data): 1785 data[newname] = data.pop(oldname) 1786 1787 # Back compat handling for check_args 1788 if "check_args" in data: 1789 del(data["check_args"]) 1790 1791 return data 1792 1793 def createbug(self, *args, **kwargs): 1794 """ 1795 Create a bug with the given info. Returns a new Bug object. 1796 Check bugzilla API documentation for valid values, at least 1797 product, component, summary, version, and description need to 1798 be passed. 1799 """ 1800 data = self._validate_createbug(*args, **kwargs) 1801 rawbug = self._backend.bug_create(data) 1802 return Bug(self, bug_id=rawbug["id"], 1803 autorefresh=self.bug_autorefresh) 1804 1805 1806 ############################## 1807 # Methods for handling Users # 1808 ############################## 1809 1810 def getuser(self, username): 1811 """ 1812 Return a bugzilla User for the given username 1813 1814 :arg username: The username used in bugzilla. 1815 :raises XMLRPC Fault: Code 51 if the username does not exist 1816 :returns: User record for the username 1817 """ 1818 ret = self.getusers(username) 1819 return ret and ret[0] 1820 1821 def getusers(self, userlist): 1822 """ 1823 Return a list of Users from . 1824 1825 :userlist: List of usernames to lookup 1826 :returns: List of User records 1827 """ 1828 userlist = listify(userlist) 1829 rawusers = self._backend.user_get({"names": userlist}) 1830 userobjs = [User(self, **rawuser) for rawuser in 1831 rawusers.get('users', [])] 1832 1833 # Return users in same order they were passed in 1834 ret = [] 1835 for u in userlist: 1836 for uobj in userobjs[:]: 1837 if uobj.email == u: 1838 userobjs.remove(uobj) 1839 ret.append(uobj) 1840 break 1841 ret += userobjs 1842 return ret 1843 1844 1845 def searchusers(self, pattern): 1846 """ 1847 Return a bugzilla User for the given list of patterns 1848 1849 :arg pattern: List of patterns to match against. 1850 :returns: List of User records 1851 """ 1852 rawusers = self._backend.user_get({"match": listify(pattern)}) 1853 return [User(self, **rawuser) for rawuser in 1854 rawusers.get('users', [])] 1855 1856 def createuser(self, email, name='', password=''): 1857 """ 1858 Return a bugzilla User for the given username 1859 1860 :arg email: The email address to use in bugzilla 1861 :kwarg name: Real name to associate with the account 1862 :kwarg password: Password to set for the bugzilla account 1863 :raises XMLRPC Fault: Code 501 if the username already exists 1864 Code 500 if the email address isn't valid 1865 Code 502 if the password is too short 1866 Code 503 if the password is too long 1867 :return: User record for the username 1868 """ 1869 args = {"email": email} 1870 if name: 1871 args["name"] = name 1872 if password: 1873 args["password"] = password 1874 self._backend.user_create(args) 1875 return self.getuser(email) 1876 1877 def updateperms(self, user, action, groups): 1878 """ 1879 A method to update the permissions (group membership) of a bugzilla 1880 user. 1881 1882 :arg user: The e-mail address of the user to be acted upon. Can 1883 also be a list of emails. 1884 :arg action: add, remove, or set 1885 :arg groups: list of groups to be added to (i.e. ['fedora_contrib']) 1886 """ 1887 groups = listify(groups) 1888 if action == "rem": 1889 action = "remove" 1890 if action not in ["add", "remove", "set"]: 1891 raise BugzillaError("Unknown user permission action '%s'" % action) 1892 1893 update = { 1894 "names": listify(user), 1895 "groups": { 1896 action: groups, 1897 } 1898 } 1899 1900 return self._backend.user_update(update) 1901 1902 1903 ############################### 1904 # Methods for handling Groups # 1905 ############################### 1906 1907 def _getgroups(self, names, membership=False): 1908 """ 1909 Return a list of groups that match criteria. 1910 1911 :kwarg ids: list of group ids to return data on 1912 :kwarg membership: boolean specifying wether to query the members 1913 of the group or not. 1914 :raises XMLRPC Fault: Code 51: if a Bad Login Name was sent to the 1915 names array. 1916 Code 304: if the user was not authorized to see user they 1917 requested. 1918 Code 505: user is logged out and can't use the match or ids 1919 parameter. 1920 Code 805: logged in user do not have enough priviledges to view 1921 groups. 1922 """ 1923 params = {"membership": membership} 1924 params['names'] = listify(names) 1925 return self._backend.group_get(params) 1926 1927 def getgroup(self, name, membership=False): 1928 """ 1929 Return a bugzilla Group for the given name 1930 1931 :arg name: The group name used in bugzilla. 1932 :raises XMLRPC Fault: Code 51 if the name does not exist 1933 :raises XMLRPC Fault: Code 805 if the user does not have enough 1934 permissions to view groups 1935 :returns: Group record for the name 1936 """ 1937 ret = self.getgroups(name, membership=membership) 1938 return ret and ret[0] 1939 1940 def getgroups(self, grouplist, membership=False): 1941 """ 1942 Return a list of Groups from . 1943 1944 :userlist: List of group names to lookup 1945 :returns: List of Group records 1946 """ 1947 grouplist = listify(grouplist) 1948 groupobjs = [ 1949 Group(self, **rawgroup) 1950 for rawgroup in self._getgroups( 1951 names=grouplist, membership=membership).get('groups', []) 1952 ] 1953 1954 # Return in same order they were passed in 1955 ret = [] 1956 for g in grouplist: 1957 for gobj in groupobjs[:]: 1958 if gobj.name == g: 1959 groupobjs.remove(gobj) 1960 ret.append(gobj) 1961 break 1962 ret += groupobjs 1963 return ret 1964 1965 1966 ############################# 1967 # ExternalBugs API wrappers # 1968 ############################# 1969 1970 def add_external_tracker(self, bug_ids, ext_bz_bug_id, ext_type_id=None, 1971 ext_type_description=None, ext_type_url=None, 1972 ext_status=None, ext_description=None, 1973 ext_priority=None): 1974 """ 1975 Wrapper method to allow adding of external tracking bugs using the 1976 ExternalBugs::WebService::add_external_bug method. 1977 1978 This is documented at 1979 https://bugzilla.redhat.com/docs/en/html/api/extensions/ExternalBugs/lib/WebService.html#add_external_bug 1980 1981 bug_ids: A single bug id or list of bug ids to have external trackers 1982 added. 1983 ext_bz_bug_id: The external bug id (ie: the bug number in the 1984 external tracker). 1985 ext_type_id: The external tracker id as used by Bugzilla. 1986 ext_type_description: The external tracker description as used by 1987 Bugzilla. 1988 ext_type_url: The external tracker url as used by Bugzilla. 1989 ext_status: The status of the external bug. 1990 ext_description: The description of the external bug. 1991 ext_priority: The priority of the external bug. 1992 """ 1993 param_dict = {'ext_bz_bug_id': ext_bz_bug_id} 1994 if ext_type_id is not None: 1995 param_dict['ext_type_id'] = ext_type_id 1996 if ext_type_description is not None: 1997 param_dict['ext_type_description'] = ext_type_description 1998 if ext_type_url is not None: 1999 param_dict['ext_type_url'] = ext_type_url 2000 if ext_status is not None: 2001 param_dict['ext_status'] = ext_status 2002 if ext_description is not None: 2003 param_dict['ext_description'] = ext_description 2004 if ext_priority is not None: 2005 param_dict['ext_priority'] = ext_priority 2006 params = { 2007 'bug_ids': listify(bug_ids), 2008 'external_bugs': [param_dict], 2009 } 2010 return self._backend.externalbugs_add(params) 2011 2012 def update_external_tracker(self, ids=None, ext_type_id=None, 2013 ext_type_description=None, ext_type_url=None, 2014 ext_bz_bug_id=None, bug_ids=None, 2015 ext_status=None, ext_description=None, 2016 ext_priority=None): 2017 """ 2018 Wrapper method to allow adding of external tracking bugs using the 2019 ExternalBugs::WebService::update_external_bug method. 2020 2021 This is documented at 2022 https://bugzilla.redhat.com/docs/en/html/api/extensions/ExternalBugs/lib/WebService.html#update_external_bug 2023 2024 ids: A single external tracker bug id or list of external tracker bug 2025 ids. 2026 ext_type_id: The external tracker id as used by Bugzilla. 2027 ext_type_description: The external tracker description as used by 2028 Bugzilla. 2029 ext_type_url: The external tracker url as used by Bugzilla. 2030 ext_bz_bug_id: A single external bug id or list of external bug ids 2031 (ie: the bug number in the external tracker). 2032 bug_ids: A single bug id or list of bug ids to have external tracker 2033 info updated. 2034 ext_status: The status of the external bug. 2035 ext_description: The description of the external bug. 2036 ext_priority: The priority of the external bug. 2037 """ 2038 params = {} 2039 if ids is not None: 2040 params['ids'] = listify(ids) 2041 if ext_type_id is not None: 2042 params['ext_type_id'] = ext_type_id 2043 if ext_type_description is not None: 2044 params['ext_type_description'] = ext_type_description 2045 if ext_type_url is not None: 2046 params['ext_type_url'] = ext_type_url 2047 if ext_bz_bug_id is not None: 2048 params['ext_bz_bug_id'] = listify(ext_bz_bug_id) 2049 if bug_ids is not None: 2050 params['bug_ids'] = listify(bug_ids) 2051 if ext_status is not None: 2052 params['ext_status'] = ext_status 2053 if ext_description is not None: 2054 params['ext_description'] = ext_description 2055 if ext_priority is not None: 2056 params['ext_priority'] = ext_priority 2057 return self._backend.externalbugs_update(params) 2058 2059 def remove_external_tracker(self, ids=None, ext_type_id=None, 2060 ext_type_description=None, ext_type_url=None, 2061 ext_bz_bug_id=None, bug_ids=None): 2062 """ 2063 Wrapper method to allow removal of external tracking bugs using the 2064 ExternalBugs::WebService::remove_external_bug method. 2065 2066 This is documented at 2067 https://bugzilla.redhat.com/docs/en/html/api/extensions/ExternalBugs/lib/WebService.html#remove_external_bug 2068 2069 ids: A single external tracker bug id or list of external tracker bug 2070 ids. 2071 ext_type_id: The external tracker id as used by Bugzilla. 2072 ext_type_description: The external tracker description as used by 2073 Bugzilla. 2074 ext_type_url: The external tracker url as used by Bugzilla. 2075 ext_bz_bug_id: A single external bug id or list of external bug ids 2076 (ie: the bug number in the external tracker). 2077 bug_ids: A single bug id or list of bug ids to have external tracker 2078 info updated. 2079 """ 2080 params = {} 2081 if ids is not None: 2082 params['ids'] = listify(ids) 2083 if ext_type_id is not None: 2084 params['ext_type_id'] = ext_type_id 2085 if ext_type_description is not None: 2086 params['ext_type_description'] = ext_type_description 2087 if ext_type_url is not None: 2088 params['ext_type_url'] = ext_type_url 2089 if ext_bz_bug_id is not None: 2090 params['ext_bz_bug_id'] = listify(ext_bz_bug_id) 2091 if bug_ids is not None: 2092 params['bug_ids'] = listify(bug_ids) 2093 return self._backend.externalbugs_remove(params) 2094