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