1#!/usr/bin/env python
2# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
3# -*- coding: utf-8 -*-
4# -*- Mode: Python
5#
6# Copyright (C) 2013-2016 Red Hat, Inc.
7#
8# Author: Chenxiong Qi
9
10from __future__ import print_function
11
12import argparse
13import functools
14import glob
15import logging
16import mimetypes
17import os
18import re
19import shutil
20import six
21import subprocess
22import sys
23
24from collections import namedtuple
25from itertools import chain
26
27import xdg.BaseDirectory
28
29import rpm
30import koji
31
32# @file
33#
34# You might have known that abipkgdiff is a command line tool to compare two
35# RPM packages to find potential differences of ABI. This is really useful for
36# Fedora packagers and developers. Usually, excpet the RPM packages built
37# locally, if a packager wants to compare RPM packages he just built with
38# specific RPM packages that were already built and availabe in Koji,
39# fedabipkgdiff is the right tool for him.
40#
41# With fedabipkgdiff, packager is able to specify certain criteria to tell
42# fedabipkgdiff which RPM packages he wants to compare, then fedabipkgdiff will
43# find them, download them, and boom, run the abipkgdiff for you.
44#
45# Currently, fedabipkgdiff returns 0 if everything works well, otherwise, 1 if
46# something wrong.
47
48
49koji_config = koji.read_config('koji')
50DEFAULT_KOJI_SERVER = koji_config['server']
51DEFAULT_KOJI_TOPURL = koji_config['topurl']
52
53# The working directory where to hold all data including downloaded RPM
54# packages Currently, it's not configurable and hardcode here. In the future
55# version of fedabipkgdiff, I'll make it configurable by users.
56HOME_DIR = os.path.join(xdg.BaseDirectory.xdg_cache_home,
57                        os.path.splitext(os.path.basename(__file__))[0])
58
59DEFAULT_ABIPKGDIFF = 'abipkgdiff'
60
61# Mask for determining if underlying fedabipkgdiff succeeds or not.
62# This is for when the compared ABIs are equal
63ABIDIFF_OK = 0
64# This bit is set if there an application error.
65ABIDIFF_ERROR = 1
66# This bit is set if the tool is invoked in an non appropriate manner.
67ABIDIFF_USAGE_ERROR = 1 << 1
68# This bit is set if the ABIs being compared are different.
69ABIDIFF_ABI_CHANGE = 1 << 2
70
71
72# Used to construct abipkgdiff command line argument, package and associated
73# debuginfo package
74# fedabipkgdiff runs abipkgdiff in this form
75#
76#   abipkgdiff \
77#       --d1 /path/to/package1-debuginfo.rpm \
78#       --d2 /path/to/package2-debuginfo.rpm \
79#       /path/to/package1.rpm \
80#       /path/to/package2.rpm
81#
82# ComparisonHalf is a three-elements tuple in format
83#
84#   (package1.rpm, [package1-debuginfo.rpm..] package1-devel.rpm)
85#
86# - the first element is the subject representing the package to
87#   compare.  It's a dict representing the RPM we are interested in.
88#   That dict was retrieved from Koji XMLRPC API.
89# - the rest are ancillary packages used for the comparison. So, the
90#   second one is a vector containing the needed debuginfo packages
91#   (yes there can be more than one), and the last one is the package
92#   containing API of the ELF shared libraries carried by subject.
93#   All the packages are dicts representing RPMs and those dicts were
94#   retrieved fromt he KOji XMLRPC API.
95#
96# So, before calling abipkgdiff, fedabipkgdiff must prepare and pass
97# the following information
98#
99#   (/path/to/package1.rpm, [/paths/to/package1-debuginfo.rpm ..] /path/to/package1-devel.rpm)
100#   (/path/to/package2.rpm, [/paths/to/package2-debuginfo.rpm ..] /path/to/package1-devel.rpm)
101#
102ComparisonHalf = namedtuple('ComparisonHalf',
103                            ['subject', 'ancillary_debug', 'ancillary_devel'])
104
105
106global_config = None
107pathinfo = None
108session = None
109
110# There is no way to configure the log format so far. I hope I would have time
111# to make it available so that if fedabipkgdiff is scheduled and run by some
112# service, the logs logged into log file is muc usable.
113logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.CRITICAL)
114logger = logging.getLogger(os.path.basename(__file__))
115
116
117class KojiPackageNotFound(Exception):
118    """Package is not found in Koji"""
119
120
121class PackageNotFound(Exception):
122    """Package is not found locally"""
123
124
125class RpmNotFound(Exception):
126    """RPM is not found"""
127
128
129class NoBuildsError(Exception):
130    """No builds returned from a method to select specific builds"""
131
132
133class NoCompleteBuilds(Exception):
134    """No complete builds for a package
135
136    This is a serious problem, nothing can be done if there is no complete
137    builds for a package.
138    """
139
140
141class InvalidDistroError(Exception):
142    """Invalid distro error"""
143
144
145class CannotFindLatestBuildError(Exception):
146    """Cannot find latest build from a package"""
147
148
149class SetCleanCacheAction(argparse._StoreTrueAction):
150    """Custom Action making clean-cache as bundle of clean-cache-before and clean-cache-after"""
151
152    def __call__(self, parser, namespace, values, option_string=None):
153        setattr(namespace, 'clean_cache_before', self.const)
154        setattr(namespace, 'clean_cache_after', self.const)
155
156
157def is_distro_valid(distro):
158    """Adjust if a distro is valid
159
160    Currently, check for Fedora and RHEL.
161
162    :param str distro: a string representing a distro value.
163    :return: True if distro is the one specific to Fedora, like fc24, el7.
164    "rtype: bool
165    """
166    return re.match(r'^(fc|el)\d{1,2}$', distro) is not None
167
168
169def get_distro_from_string(str):
170    """Get the part of a string that designates the Fedora distro version number
171
172    For instance, when passed the string '2.3.fc12', this function
173    returns the string 'fc12'.
174
175    :param str the string to consider
176    :return: The sub-string of the parameter that represents the
177    Fedora distro version number, or None if the parameter does not
178    contain such a sub-string.
179    """
180
181    m = re.match(r'(.*)((fc|el)\d{1,2})(.*)', str)
182    if not m:
183        return None
184
185    distro = m.group(2)
186    return distro
187
188
189def match_nvr(s):
190    """Determine if a string is a N-V-R"""
191    return re.match(r'^([^/]+)-(.+)-(.+)$', s) is not None
192
193
194def match_nvra(s):
195    """Determine if a string is a N-V-R.A"""
196    return re.match(r'^([^/]+)-(.+)-(.+)\.(.+)$', s) is not None
197
198
199def is_rpm_file(filename):
200    """Return if a file is a RPM"""
201    return os.path.isfile(filename) and \
202        mimetypes.guess_type(filename)[0] == 'application/x-rpm'
203
204
205def cmp_nvr(left, right):
206    """Compare function for sorting a sequence of NVRs
207
208    This is the compare function used in sorted function to sort builds so that
209    fedabipkgdiff is able to select the latest build. Return value follows the
210    rules described in the part of paramter cmp of sorted documentation.
211
212    :param str left: left nvr to compare.
213    :param str right: right nvr to compare.
214    :return: -1, 0, or 1 that represents left is considered smaller than,
215    equal to, or larger than the right individually.
216    :rtype: int
217    """
218    left_nvr = koji.parse_NVR(left['nvr'])
219    right_nvr = koji.parse_NVR(right['nvr'])
220    return rpm.labelCompare(
221        (left_nvr['epoch'], left_nvr['version'], left_nvr['release']),
222        (right_nvr['epoch'], right_nvr['version'], right_nvr['release']))
223
224
225def log_call(func):
226    """A decorator that logs a method invocation
227
228    Method's name and all arguments, either positional or keyword arguments,
229    will be logged by logger.debug. Also, return value from the decorated
230    method will be logged just after the invocation is done.
231
232    This decorator does not catch any exception thrown from the decorated
233    method. If there is any exception thrown from decorated method, you can
234    catch them in the caller and obviously, no return value is logged.
235
236    :param callable func: a callable object to decorate
237    """
238    def proxy(*args, **kwargs):
239        logger.debug('Call %s, args: %s, kwargs: %s',
240                     func.__name__,
241                     args if args else '',
242                     kwargs if kwargs else '')
243        result = func(*args, **kwargs)
244        logger.debug('Result from %s: %s', func.__name__, result)
245        return result
246    return proxy
247
248
249def delete_download_cache():
250    """Delete download cache directory"""
251    download_dir = get_download_dir()
252    if global_config.dry_run:
253        print('DRY-RUN: Delete cached downloaded RPM packages at {0}'.format(download_dir))
254    else:
255        logger.debug('Delete cached downloaded RPM packages at {0}'.format(download_dir))
256        shutil.rmtree(download_dir)
257
258
259class RPM(object):
260    """Wrapper around an RPM descriptor received from Koji
261
262    The RPM descriptor that is returned from Koji XMLRPC API is a
263    dict. This wrapper class makes it eaiser to access all these
264    properties in the way of object.property.
265    """
266
267    def __init__(self, rpm_info):
268        """Initialize a RPM object
269
270        :param dict rpm_info: a dict representing an RPM descriptor
271        received from the Koji API, either listRPMs or getRPM
272        """
273        self.rpm_info = rpm_info
274
275    def __str__(self):
276        """Return the string representation of this RPM
277
278        Return the string representation of RPM information returned from Koji
279        directly so that RPM can be treated in same way.
280        """
281        return str(self.rpm_info)
282
283    def __getattr__(self, name):
284        """Access RPM information in the way of object.property
285
286        :param str name: the property name to access.
287        :raises AttributeError: if name is not one of keys of RPM information.
288        """
289        if name in self.rpm_info:
290            return self.rpm_info[name]
291        else:
292            raise AttributeError('No attribute name {0}'.format(name))
293
294    def is_peer(self, another_rpm):
295        """Determine if this is the peer of a given rpm.
296
297        Here is what "peer" means.
298
299        Consider a package P for which the tripplet Name, Version,
300        Release is made of the values {N,V,R}.  Then, consider a
301        package P' for which the similar tripplet is {N', V', R'}.
302
303        P' is a peer of P if N == N', and either V != V' or R != R'.
304        given package with a given NVR is another package with a N'V'
305        """
306        return self.name == another_rpm.name and \
307            self.arch == another_rpm.arch and \
308            not (self.version == another_rpm.version
309                 and self.release == another_rpm.release)
310
311    @property
312    def nvra(self):
313        """Return a RPM's N-V-R-A representation
314
315        An example: libabigail-1.0-0.8.rc4.1.fc23.x86_64
316        """
317        nvra, _ = os.path.splitext(self.filename)
318        return nvra
319
320    @property
321    def filename(self):
322        """Return a RPM file name
323
324        An example: libabigail-1.0-0.8.rc4.1.fc23.x86_64.rpm
325        """
326        return os.path.basename(pathinfo.rpm(self.rpm_info))
327
328    @property
329    def is_debuginfo(self):
330        """Check if the name of the current RPM denotes a debug info package"""
331        return koji.is_debuginfo(self.rpm_info['name'])
332
333    @property
334    def is_devel(self):
335        """Check if the name of current RPM denotes a development package"""
336        return self.rpm_info['name'].endswith('-devel')
337
338    @property
339    def download_url(self):
340        """Get the URL from where to download this RPM"""
341        build = session.getBuild(self.build_id)
342        return os.path.join(pathinfo.build(build), pathinfo.rpm(self.rpm_info))
343
344    @property
345    def downloaded_file(self):
346        """Get a pridictable downloaded file name with absolute path"""
347        # arch should be removed from the result returned from PathInfo.rpm
348        filename = os.path.basename(pathinfo.rpm(self.rpm_info))
349        return os.path.join(get_download_dir(), filename)
350
351    @property
352    def is_downloaded(self):
353        """Check if this RPM was already downloaded to local disk"""
354        return os.path.exists(self.downloaded_file)
355
356
357class LocalRPM(RPM):
358    """Representing a local RPM
359
360    Local RPM means the one that could be already downloaded or built from
361    where I can find it
362    """
363
364    def __init__(self, filename):
365        """Initialize local RPM with a filename
366
367        :param str filename: a filename pointing to a RPM file in local
368        disk. Note that, this file must not exist necessarily.
369        """
370        self.local_filename = filename
371        self.rpm_info = koji.parse_NVRA(os.path.basename(filename))
372
373    @property
374    def downloaded_file(self):
375        """Return filename of this RPM
376
377        Returned filename is just the one passed when initializing this RPM.
378
379        :return: filename of this RPM
380        :rtype: str
381        """
382        return self.local_filename
383
384    @property
385    def download_url(self):
386        raise NotImplementedError('LocalRPM has no URL to download')
387
388    def _find_rpm(self, rpm_filename):
389        """Search an RPM from the directory of the current instance of LocalRPM
390
391        :param str rpm_filename: filename of rpm to find, for example
392        foo-devel-0.1-1.fc24.
393        :return: an instance of LocalRPM representing the found rpm, or None if
394        no RPM was found.
395        """
396        search_dir = os.path.dirname(os.path.abspath(self.local_filename))
397        filename = os.path.join(search_dir, rpm_filename)
398        return LocalRPM(filename) if os.path.exists(filename) else None
399
400    @log_call
401    def find_debuginfo(self):
402        """Find debuginfo RPM package from a directory"""
403        filename = \
404            '%(name)s-debuginfo-%(version)s-%(release)s.%(arch)s.rpm' % \
405            self.rpm_info
406        return self._find_rpm(filename)
407
408    @log_call
409    def find_devel(self):
410        """Find development package from a directory"""
411        filename = \
412            '%(name)s-devel-%(version)s-%(release)s.%(arch)s.rpm' % \
413            self.rpm_info
414        return self._find_rpm(filename)
415
416
417class RPMCollection(object):
418    """Collection of RPMs
419
420    This is a simple collection containing RPMs collected from a
421    directory on the local filesystem or retrieved from Koji.
422
423    A collection can contain one or more sets of RPMs.  Each set of
424    RPMs being for a particular architecture.
425
426    For a given architecture, a set of RPMs is made of one RPM and its
427    ancillary RPMs.  An ancillary RPM is either a debuginfo RPM or a
428    devel RPM.
429
430    So a given RPMCollection would (informally) look like:
431
432    {
433      i686   => {foo.i686.rpm, foo-debuginfo.i686.rpm, foo-devel.i686.rpm}
434      x86_64 => {foo.x86_64.rpm, foo-debuginfo.x86_64.rpm, foo-devel.x86_64.rpm,}
435    }
436
437    """
438
439    def __init__(self, rpms=None):
440        # Mapping from arch to a list of rpm_infos.
441        # Note that *all* RPMs of the collections are present in this
442        # map; that is the RPM to consider and its ancillary RPMs.
443        self.rpms = {}
444
445        # Mapping from arch to another mapping containing index of debuginfo
446        # and development package
447        # e.g.
448        # self.ancillary_rpms = {'i686', {'debuginfo': foo-debuginfo.rpm,
449        #                                 'devel': foo-devel.rpm}}
450        self.ancillary_rpms = {}
451
452        if rpms:
453            for rpm in rpms:
454                self.add(rpm)
455
456    @classmethod
457    def gather_from_dir(cls, rpm_file, all_rpms=None):
458        """Gather RPM collection from local directory"""
459        dir_name = os.path.dirname(os.path.abspath(rpm_file))
460        filename = os.path.basename(rpm_file)
461
462        nvra = koji.parse_NVRA(filename)
463        rpm_files = glob.glob(os.path.join(
464            dir_name, '*-%(version)s-%(release)s.%(arch)s.rpm' % nvra))
465        rpm_col = cls()
466
467        if all_rpms:
468            selector = lambda rpm: True
469        else:
470            selector = lambda rpm: local_rpm.is_devel or \
471                local_rpm.is_debuginfo or local_rpm.filename == filename
472
473        found_debuginfo = 1
474
475        for rpm_file in rpm_files:
476            local_rpm = LocalRPM(rpm_file)
477
478            if local_rpm.is_debuginfo:
479                found_debuginfo <<= 1
480                if found_debuginfo == 4:
481                    raise RuntimeError(
482                        'Found more than one debuginfo package in '
483                         'this directory. At the moment, fedabipkgdiff '
484                        'is not able to deal with this case. '
485                        'Please create two separate directories and '
486                        'put an RPM and its ancillary debuginfo and '
487                        'devel RPMs in each directory.')
488
489            if selector(local_rpm):
490                rpm_col.add(local_rpm)
491
492        return rpm_col
493
494    def add(self, rpm):
495        """Add a RPM into this collection"""
496        self.rpms.setdefault(rpm.arch, []).append(rpm)
497
498        devel_debuginfo_default = {'debuginfo': None, 'devel': None}
499
500        if rpm.is_debuginfo:
501            self.ancillary_rpms.setdefault(
502                rpm.arch, devel_debuginfo_default)['debuginfo'] = rpm
503
504        if rpm.is_devel:
505            self.ancillary_rpms.setdefault(
506                rpm.arch, devel_debuginfo_default)['devel'] = rpm
507
508    def rpms_iter(self, arches=None, default_behavior=True):
509        """Iterator of RPMs to go through RPMs with specific arches"""
510        arches = sorted(self.rpms.keys())
511
512        for arch in arches:
513            for _rpm in self.rpms[arch]:
514                yield _rpm
515
516    def get_sibling_debuginfo(self, rpm):
517        """Get sibling debuginfo package of given rpm
518
519        The sibling debuginfo is a debug info package for the
520        'rpm'.  Note that if there are several debuginfo packages
521        associated to 'rpm' and users want to get the one which name
522        matches exactly 'rpm', then they might want to use the member
523        function 'get_matching_debuginfo' instead.
524
525        """
526        if rpm.arch not in self.ancillary_rpms:
527            return None
528        return self.ancillary_rpms[rpm.arch].get('debuginfo')
529
530    def get_matching_debuginfo(self, rpm):
531        """Get the debuginfo package that matches a given one """
532        all_debuginfo_list = self.get_all_debuginfo_rpms(rpm)
533        debuginfo_pkg = None
534        for d in all_debuginfo_list:
535            if d.name == '{0}-debuginfo'.format(rpm.name):
536                debuginfo_pkg = d
537                break
538        if not debuginfo_pkg:
539            debuginfo_pkg = self.get_sibling_debuginfo(rpm)
540
541        return debuginfo_pkg
542
543    def get_sibling_devel(self, rpm):
544        """Get sibling devel package of given rpm"""
545        if rpm.arch not in self.ancillary_rpms:
546            return None
547        return self.ancillary_rpms[rpm.arch].get('devel')
548
549    def get_peer_rpm(self, rpm):
550        """Get peer rpm of rpm from this collection"""
551        if rpm.arch not in self.rpms:
552            return None
553        for _rpm in self.rpms[rpm.arch]:
554            if _rpm.is_peer(rpm):
555                return _rpm
556        return None
557
558    def get_all_debuginfo_rpms(self, rpm_info):
559        """Return a list of descriptors of all the debuginfo RPMs associated
560        to a given RPM.
561
562        :param: dict rpm_info a dict representing an RPM.  This was
563        received from the Koji API, either from listRPMs or getRPM.
564        :return: a list of dicts containing RPM descriptors (dicts)
565        for the debuginfo RPMs associated to rpm_info
566        :retype: dict
567        """
568        rpm_infos = self.rpms[rpm_info.arch]
569        result = []
570        for r in rpm_infos:
571            if r.is_debuginfo:
572                result.append(r)
573        return result
574
575
576def generate_comparison_halves(rpm_col1, rpm_col2):
577    """Iterate RPM collection and peer's to generate comparison halves"""
578    for _rpm in rpm_col1.rpms_iter():
579        if _rpm.is_debuginfo:
580            continue
581        if _rpm.is_devel and not global_config.check_all_subpackages:
582            continue
583
584        if global_config.self_compare:
585            rpm2 = _rpm
586        else:
587            rpm2 = rpm_col2.get_peer_rpm(_rpm)
588            if rpm2 is None:
589                logger.warning('Peer RPM of {0} is not found.'.format(_rpm.filename))
590                continue
591
592        debuginfo_list1 = []
593        debuginfo_list2 = []
594
595        # If this is a *devel* package we are looking at, then get all
596        # the debug info packages associated to with the main package
597        # and stick them into the resulting comparison half.
598
599        if _rpm.is_devel:
600            debuginfo_list1 = rpm_col1.get_all_debuginfo_rpms(_rpm)
601        else:
602            debuginfo_list1.append(rpm_col1.get_matching_debuginfo(_rpm))
603
604        devel1 = rpm_col1.get_sibling_devel(_rpm)
605
606        if global_config.self_compare:
607            debuginfo_list2 = debuginfo_list1
608            devel2 = devel1
609        else:
610            if rpm2.is_devel:
611                debuginfo_list2 = rpm_col2.get_all_debuginfo_rpms(rpm2)
612            else:
613                debuginfo_list2.append(rpm_col2.get_matching_debuginfo(rpm2))
614            devel2 = rpm_col2.get_sibling_devel(rpm2)
615
616        yield (ComparisonHalf(subject=_rpm,
617                              ancillary_debug=debuginfo_list1,
618                              ancillary_devel=devel1),
619               ComparisonHalf(subject=rpm2,
620                              ancillary_debug=debuginfo_list2,
621                              ancillary_devel=devel2))
622
623
624class Brew(object):
625    """Interface to Koji XMLRPC API with enhancements specific to fedabipkgdiff
626
627    kojihub XMLRPC APIs are well-documented in koji's source code. For more
628    details information, please refer to class RootExports within kojihub.py.
629
630    For details of APIs used within fedabipkgdiff, refer to from line
631
632    https://pagure.io/koji/blob/master/f/hub/kojihub.py#_7835
633    """
634
635    def __init__(self, baseurl):
636        """Initialize Brew
637
638        :param str baseurl: the kojihub URL to initialize a session, that is
639        used to access koji XMLRPC APIs.
640        """
641        self.session = koji.ClientSession(baseurl)
642
643    @log_call
644    def listRPMs(self, buildID=None, arches=None, selector=None):
645        """Get list of RPMs of a build from Koji
646
647        Call kojihub.listRPMs to get list of RPMs. Return selected RPMs without
648        changing each RPM information.
649
650        A RPM returned from listRPMs contains following keys:
651
652        - id
653        - name
654        - version
655        - release
656        - nvr (synthesized for sorting purposes)
657        - arch
658        - epoch
659        - payloadhash
660        - size
661        - buildtime
662        - build_id
663        - buildroot_id
664        - external_repo_id
665        - external_repo_name
666        - metadata_only
667        - extra
668
669        :param int buildID: id of a build from which to list RPMs.
670        :param arches: to restrict to list RPMs with specified arches.
671        :type arches: list or tuple
672        :param selector: called to determine if a RPM should be selected and
673        included in the final returned result. Selector must be a callable
674        object and accepts one parameter of a RPM.
675        :type selector: a callable object
676        :return: a list of RPMs, each of them is a dict object
677        :rtype: list
678        """
679        if selector:
680            assert hasattr(selector, '__call__'), 'selector must be callable.'
681        rpms = self.session.listRPMs(buildID=buildID, arches=arches)
682        if selector:
683            rpms = [rpm for rpm in rpms if selector(rpm)]
684        return rpms
685
686    @log_call
687    def getRPM(self, rpminfo):
688        """Get a RPM from koji
689
690        Call kojihub.getRPM, and returns the result directly without any
691        change.
692
693        When not found a RPM, koji.getRPM will return None, then
694        this method will raise RpmNotFound error immediately to claim what is
695        happening. I want to raise fedabipkgdiff specific error rather than
696        koji's GenericError and then raise RpmNotFound again, so I just simply
697        don't use strict parameter to call koji.getRPM.
698
699        :param rpminfo: rpminfo may be a N-V-R.A or a map containing name,
700        version, release, and arch. For example, file-5.25-5.fc24.x86_64, and
701        `{'name': 'file', 'version': '5.25', 'release': '5.fc24', 'arch':
702        'x86_64'}`.
703        :type rpminfo: str or dict
704        :return: a map containing RPM information, that contains same keys as
705        method `Brew.listRPMs`.
706        :rtype: dict
707        :raises RpmNotFound: if a RPM cannot be found with rpminfo.
708        """
709        rpm = self.session.getRPM(rpminfo)
710        if rpm is None:
711            raise RpmNotFound('Cannot find RPM {0}'.format(rpminfo))
712        return rpm
713
714    @log_call
715    def listBuilds(self, packageID, state=None, topone=None,
716                   selector=None, order_by=None, reverse=None):
717        """Get list of builds from Koji
718
719        Call kojihub.listBuilds, and return selected builds without changing
720        each build information.
721
722        By default, only builds with COMPLETE state are queried and returns
723        afterwards.
724
725        :param int packageID: id of package to list builds from.
726        :param int state: build state. There are five states of a build in
727        Koji. fedabipkgdiff only cares about builds with COMPLETE state. If
728        state is omitted, builds with COMPLETE state are queried from Koji by
729        default.
730        :param bool topone: just return the top first build.
731        :param selector: a callable object used to select specific subset of
732        builds. Selector will be called immediately after Koji returns queried
733        builds. When each call to selector, a build is passed to
734        selector. Return True if select current build, False if not.
735        :type selector: a callable object
736        :param str order_by: the attribute name by which to order the builds,
737        for example, name, version, or nvr.
738        :param bool reverse: whether to order builds reversely.
739        :return: a list of builds, even if there is only one build.
740        :rtype: list
741        :raises TypeError: if selector is not callable, or if order_by is not a
742        string value.
743        """
744        if state is None:
745            state = koji.BUILD_STATES['COMPLETE']
746
747        if selector is not None and not hasattr(selector, '__call__'):
748            raise TypeError(
749                '{0} is not a callable object.'.format(str(selector)))
750
751        if order_by is not None and not isinstance(order_by, six.string_types):
752            raise TypeError('order_by {0} is invalid.'.format(order_by))
753
754        builds = self.session.listBuilds(packageID=packageID, state=state)
755        if selector is not None:
756            builds = [build for build in builds if selector(build)]
757        if order_by is not None:
758            # FIXME: is it possible to sort builds by using opts parameter of
759            # listBuilds
760            if order_by == 'nvr':
761                if six.PY2:
762                    builds = sorted(builds, cmp=cmp_nvr, reverse=reverse)
763                else:
764                    builds = sorted(builds,
765                                    key=functools.cmp_to_key(cmp_nvr),
766                                    reverse=reverse)
767            else:
768                builds = sorted(
769                    builds, key=lambda b: b[order_by], reverse=reverse)
770        if topone:
771            builds = builds[0:1]
772
773        return builds
774
775    @log_call
776    def getPackage(self, name):
777        """Get a package from Koji
778
779        :param str name: a package name.
780        :return: a mapping containing package information. For example,
781        `{'id': 1, 'name': 'package'}`.
782        :rtype: dict
783        """
784        package = self.session.getPackage(name)
785        if package is None:
786            package = self.session.getPackage(name.rsplit('-', 1)[0])
787            if package is None:
788                raise KojiPackageNotFound(
789                    'Cannot find package {0}.'.format(name))
790        return package
791
792    @log_call
793    def getBuild(self, buildID):
794        """Get a build from Koji
795
796        Call kojihub.getBuild. Return got build directly without change.
797
798        :param int buildID: id of build to get from Koji.
799        :return: the found build. Return None, if not found a build with
800        buildID.
801        :rtype: dict
802        """
803        return self.session.getBuild(buildID)
804
805    @log_call
806    def get_rpm_build_id(self, name, version, release, arch=None):
807        """Get build ID that contains a RPM with specific nvra
808
809        If arch is not omitted, a RPM can be identified by its N-V-R-A.
810
811        If arch is omitted, name is used to get associated package, and then
812        to get the build.
813
814        Example:
815
816        >>> brew = Brew('url to kojihub')
817        >>> brew.get_rpm_build_id('httpd', '2.4.18', '2.fc24')
818        >>> brew.get_rpm_build_id('httpd', '2.4.18', '2.fc25', 'x86_64')
819
820        :param str name: name of a rpm
821        :param str version: version of a rpm
822        :param str release: release of a rpm
823        :param arch: arch of a rpm
824        :type arch: str or None
825        :return: id of the build from where the RPM is built
826        :rtype: dict
827        :raises KojiPackageNotFound: if name is not found from Koji if arch
828        is None.
829        """
830        if arch is None:
831            package = self.getPackage(name)
832            selector = lambda item: item['version'] == version and \
833                item['release'] == release
834            builds = self.listBuilds(packageID=package['id'],
835                                     selector=selector)
836            if not builds:
837                raise NoBuildsError(
838                    'No builds are selected from package {0}.'.format(
839                        package['name']))
840            return builds[0]['build_id']
841        else:
842            rpm = self.getRPM({'name': name,
843                               'version': version,
844                               'release': release,
845                               'arch': arch,
846                               })
847            return rpm['build_id']
848
849    @log_call
850    def get_package_latest_build(self, package_name, distro):
851        """Get latest build from a package, for a particular distro.
852
853        Example:
854
855        >>> brew = Brew('url to kojihub')
856        >>> brew.get_package_latest_build('httpd', 'fc24')
857
858        :param str package_name: from which package to get the latest build
859        :param str distro: which distro the latest build belongs to
860        :return: the found build
861        :rtype: dict or None
862        :raises NoCompleteBuilds: if there is no latest build of a package.
863        """
864        package = self.getPackage(package_name)
865        selector = lambda item: item['release'].find(distro) > -1
866
867        builds = self.listBuilds(packageID=package['id'],
868                                 selector=selector,
869                                 order_by='nvr',
870                                 reverse=True)
871        if not builds:
872            # So we found no build which distro string exactly matches
873            # the 'distro' parameter.
874            #
875            # Now lets try to get builds which distro string are less
876            # than the value of the 'distro' parameter.  This is for
877            # cases when, for instance, the build of package foo that
878            # is present in current Fedora 27 is foo-1.fc26.  That
879            # build originates from Fedora 26 but is being re-used in
880            # Fedora 27.  So we want this function to pick up that
881            # foo-1.fc26, even though we want the builds of foo that
882            # match the distro string fc27.
883
884            selector = lambda build: get_distro_from_string(build['release']) and \
885                       get_distro_from_string(build['release']) <= distro
886
887            builds = self.listBuilds(packageID=package['id'],
888                                 selector=selector,
889                                 order_by='nvr',
890                                 reverse=True);
891
892        if not builds:
893            raise NoCompleteBuilds(
894                'No complete builds of package {0}'.format(package_name))
895
896        return builds[0]
897
898    @log_call
899    def select_rpms_from_a_build(self, build_id, package_name, arches=None,
900                                 select_subpackages=None):
901        """Select specific RPMs within a build
902
903        RPMs could be filtered be specific criterias by the parameters.
904
905        By default, fedabipkgdiff requires the RPM package, as well as
906        its associated debuginfo and devel packages.  These three
907        packages are selected, and noarch and src are excluded.
908
909        :param int build_id: from which build to select rpms.
910        :param str package_name: which rpm to select that matches this name.
911        :param arches: which arches to select. If arches omits, rpms with all
912        arches except noarch and src will be selected.
913        :type arches: list, tuple or None
914        :param bool select_subpackages: indicate whether to select all RPMs
915        with specific arch from build.
916        :return: a list of RPMs returned from listRPMs
917        :rtype: list
918        """
919        excluded_arches = ('noarch', 'src')
920
921        def rpms_selector(package_name, excluded_arches):
922            return lambda rpm: \
923                rpm['arch'] not in excluded_arches and \
924                (rpm['name'] == package_name or
925                 rpm['name'].endswith('-debuginfo') or
926                 rpm['name'].endswith('-devel'))
927
928        if select_subpackages:
929            selector = lambda rpm: rpm['arch'] not in excluded_arches
930        else:
931            selector = rpms_selector(package_name, excluded_arches)
932        rpm_infos = self.listRPMs(buildID=build_id,
933                                  arches=arches,
934                                  selector=selector)
935        return RPMCollection((RPM(rpm_info) for rpm_info in rpm_infos))
936
937    @log_call
938    def get_latest_built_rpms(self, package_name, distro, arches=None):
939        """Get RPMs from latest build of a package
940
941        :param str package_name: from which package to get the rpms
942        :param str distro: which distro the rpms belong to
943        :param arches: which arches the rpms belong to
944        :type arches: str or None
945        :return: the selected RPMs
946        :rtype: list
947        """
948        latest_build = self.get_package_latest_build(package_name, distro)
949        # Get rpm and debuginfo rpm from each arch
950        return self.select_rpms_from_a_build(latest_build['build_id'],
951                                             package_name,
952                                             arches=arches)
953
954
955@log_call
956def get_session():
957    """Get instance of Brew to talk with Koji"""
958    return Brew(global_config.koji_server)
959
960
961@log_call
962def get_download_dir():
963    """Return the directory holding all downloaded RPMs
964
965    If directory does not exist, it is created automatically.
966
967    :return: path to directory holding downloaded RPMs.
968    :rtype: str
969    """
970    download_dir = os.path.join(HOME_DIR, 'downloads')
971    if not os.path.exists(download_dir):
972        os.makedirs(download_dir)
973    return download_dir
974
975
976@log_call
977def download_rpm(url):
978    """Using curl to download a RPM from Koji
979
980    Currently, curl is called and runs in a spawned process. pycurl would be a
981    good way instead. This would be changed in the future.
982
983    :param str url: URL of a RPM to download.
984    :return: True if a RPM is downloaded successfully, False otherwise.
985    :rtype: bool
986    """
987    cmd = 'curl --location --silent {0} -o {1}'.format(
988        url, os.path.join(get_download_dir(),
989                          os.path.basename(url)))
990    if global_config.dry_run:
991        print('DRY-RUN: {0}'.format(cmd))
992        return
993
994    return_code = subprocess.call(cmd, shell=True)
995    if return_code > 0:
996        logger.error('curl fails with returned code: %d.', return_code)
997        return False
998    return True
999
1000
1001@log_call
1002def download_rpms(rpms):
1003    """Download RPMs
1004
1005    :param list rpms: list of RPMs to download.
1006    """
1007    def _download(rpm):
1008        if rpm.is_downloaded:
1009            logger.debug('Reuse %s', rpm.downloaded_file)
1010        else:
1011            logger.debug('Download %s', rpm.download_url)
1012            download_rpm(rpm.download_url)
1013
1014    for rpm in rpms:
1015        _download(rpm)
1016
1017
1018@log_call
1019def build_path_to_abipkgdiff():
1020    """Build the path to the 'abipkgidiff' program to use.
1021
1022    The path to 'abipkgdiff' is either the argument of the
1023    --abipkgdiff command line option, or the path to 'abipkgdiff' as
1024    found in the $PATH environment variable.
1025
1026    :return: str a string representing the path to the 'abipkgdiff'
1027    command.
1028    """
1029    if global_config.abipkgdiff:
1030        return global_config.abipkgdiff
1031    return DEFAULT_ABIPKGDIFF
1032
1033
1034def format_debug_info_pkg_options(option, debuginfo_list):
1035    """Given a list of debug info package descriptors return an option
1036    string that looks like:
1037
1038       option dbg.rpm1 option dbgrpm2 ...
1039
1040    :param: list debuginfo_list a list of instances of the RPM class
1041    representing the debug info rpms to use to construct the option
1042    string.
1043
1044    :return: str a string representing the option string that
1045    concatenate the 'option' parameter before the path to each RPM
1046    contained in 'debuginfo_list'.
1047    """
1048    options = []
1049
1050    for dbg_pkg in debuginfo_list:
1051        if dbg_pkg and dbg_pkg.downloaded_file:
1052            options.append(' {0} {1}'.format(option, dbg_pkg.downloaded_file))
1053
1054    return ' '.join(options) if options else ''
1055
1056@log_call
1057def abipkgdiff(cmp_half1, cmp_half2):
1058    """Run abipkgdiff against found two RPM packages
1059
1060    Construct and execute abipkgdiff to get ABI diff
1061
1062    abipkgdiff \
1063        --d1 package1-debuginfo --d2 package2-debuginfo \
1064        package1-rpm package2-rpm
1065
1066    Output to stdout or stderr from abipkgdiff is not captured. abipkgdiff is
1067    called synchronously. fedabipkgdiff does not return until underlying
1068    abipkgdiff finishes.
1069
1070    :param ComparisonHalf cmp_half1: the first comparison half.
1071    :param ComparisonHalf cmp_half2: the second comparison half.
1072    :return: return code of underlying abipkgdiff execution.
1073    :rtype: int
1074    """
1075    abipkgdiff_tool = build_path_to_abipkgdiff()
1076
1077    suppressions = ''
1078
1079    if global_config.suppr:
1080        suppressions = '--suppressions {0}'.format(global_config.suppr)
1081
1082    if global_config.no_devel_pkg:
1083        devel_pkg1 = ''
1084        devel_pkg2 = ''
1085    else:
1086        if cmp_half1.ancillary_devel is None:
1087            msg = 'Development package for {0} does not exist.'.format(cmp_half1.subject.filename)
1088            if global_config.error_on_warning:
1089                raise RuntimeError(msg)
1090            else:
1091                devel_pkg1 = ''
1092                logger.warning('{0} Ignored.'.format(msg))
1093        else:
1094            devel_pkg1 = '--devel-pkg1 {0}'.format(cmp_half1.ancillary_devel.downloaded_file)
1095
1096        if cmp_half2.ancillary_devel is None:
1097            msg = 'Development package for {0} does not exist.'.format(cmp_half2.subject.filename)
1098            if global_config.error_on_warning:
1099                raise RuntimeError(msg)
1100            else:
1101                devel_pkg2 = ''
1102                logger.warning('{0} Ignored.'.format(msg))
1103        else:
1104            devel_pkg2 = '--devel-pkg2 {0}'.format(cmp_half2.ancillary_devel.downloaded_file)
1105
1106    if cmp_half1.ancillary_debug is None:
1107        msg = 'Debuginfo package for {0} does not exist.'.format(cmp_half1.subject.filename)
1108        if global_config.error_on_warning:
1109            raise RuntimeError(msg)
1110        else:
1111            debuginfo_pkg1 = ''
1112            logger.warning('{0} Ignored.'.format(msg))
1113    else:
1114        debuginfo_pkg1 = format_debug_info_pkg_options("--d1", cmp_half1.ancillary_debug)
1115
1116    if cmp_half2.ancillary_debug is None:
1117        msg = 'Debuginfo package for {0} does not exist.'.format(cmp_half2.subject.filename)
1118        if global_config.error_on_warning:
1119            raise RuntimeError(msg)
1120        else:
1121            debuginfo_pkg2 = ''
1122            logger.warning('{0} Ignored.'.format(msg))
1123    else:
1124        debuginfo_pkg2 = format_debug_info_pkg_options("--d2", cmp_half2.ancillary_debug);
1125
1126    cmd = []
1127
1128    if global_config.self_compare:
1129        cmd = [
1130            abipkgdiff_tool,
1131            '--dso-only' if global_config.dso_only else '',
1132            '--self-check',
1133            debuginfo_pkg1,
1134            cmp_half1.subject.downloaded_file,
1135        ]
1136    else:
1137        cmd = [
1138            abipkgdiff_tool,
1139            suppressions,
1140            '--show-identical-binaries' if global_config.show_identical_binaries else '',
1141            '--no-default-suppression' if global_config.no_default_suppr else '',
1142            '--dso-only' if global_config.dso_only else '',
1143            debuginfo_pkg1,
1144            debuginfo_pkg2,
1145            devel_pkg1,
1146            devel_pkg2,
1147            cmp_half1.subject.downloaded_file,
1148            cmp_half2.subject.downloaded_file,
1149        ]
1150    cmd = [s for s in cmd if s != '']
1151
1152    if global_config.dry_run:
1153        print('DRY-RUN: {0}'.format(' '.join(cmd)))
1154        return
1155
1156    logger.debug('Run: %s', ' '.join(cmd))
1157
1158    print('Comparing the ABI of binaries between {0} and {1}:'.format(
1159        cmp_half1.subject.filename, cmp_half2.subject.filename))
1160    print()
1161
1162    proc = subprocess.Popen(' '.join(cmd), shell=True,
1163                            stdout=subprocess.PIPE, stderr=subprocess.PIPE,
1164                            universal_newlines=True)
1165    # So we could have done: stdout, stderr = proc.communicate()
1166    # But then the documentatin of proc.communicate says:
1167    #
1168    #    Note: The data read is buffered in memory, so do not use this
1169    #    method if the data size is large or unlimited. "
1170    #
1171    # In practice, we are seeing random cases where this
1172    # proc.communicate() function does *NOT* terminate and seems to be
1173    # in a deadlock state.  So we are avoiding it altogether.  We are
1174    # then busy looping, waiting for the spawn process to finish, and
1175    # then we get its output.
1176    #
1177
1178    while True:
1179        if proc.poll() != None:
1180            break
1181
1182    stdout = ''.join(proc.stdout.readlines())
1183    stderr = ''.join(proc.stderr.readlines())
1184
1185    is_ok = proc.returncode == ABIDIFF_OK
1186    is_internal_error = proc.returncode & ABIDIFF_ERROR or proc.returncode & ABIDIFF_USAGE_ERROR
1187    has_abi_change = proc.returncode & ABIDIFF_ABI_CHANGE
1188
1189    if is_internal_error:
1190        six.print_(stderr, file=sys.stderr)
1191    elif is_ok or has_abi_change:
1192        print(stdout)
1193
1194    return proc.returncode
1195
1196
1197@log_call
1198def run_abipkgdiff(rpm_col1, rpm_col2):
1199    """Run abipkgdiff
1200
1201    If one of the executions finds ABI differences, the return code is the
1202    return code from abipkgdiff.
1203
1204    :param RPMCollection rpm_col1: a collection of RPMs
1205    :param RPMCollection rpm_col2: same as rpm_col1
1206    :return: exit code of the last non-zero returned from underlying abipkgdiff
1207    :rtype: int
1208    """
1209    return_codes = [
1210        abipkgdiff(cmp_half1, cmp_half2) for cmp_half1, cmp_half2
1211        in generate_comparison_halves(rpm_col1, rpm_col2)]
1212    return max(return_codes, key=abs) if return_codes else 0
1213
1214
1215@log_call
1216def diff_local_rpm_with_latest_rpm_from_koji():
1217    """Diff against local rpm and remove latest rpm
1218
1219    This operation handles a local rpm and debuginfo rpm and remote ones
1220    located in remote Koji server, that has specific distro specificed by
1221    argument --from.
1222
1223    1/ Suppose the packager has just locally built a package named
1224    foo-3.0.fc24.rpm. To compare the ABI of this locally build package with the
1225    latest stable package from Fedora 23, one would do:
1226
1227    fedabipkgdiff --from fc23 ./foo-3.0.fc24.rpm
1228    """
1229
1230    from_distro = global_config.from_distro
1231    if not is_distro_valid(from_distro):
1232        raise InvalidDistroError('Invalid distro {0}'.format(from_distro))
1233
1234    local_rpm_file = global_config.NVR[0]
1235    if not os.path.exists(local_rpm_file):
1236        raise ValueError('{0} does not exist.'.format(local_rpm_file))
1237
1238    local_rpm = LocalRPM(local_rpm_file)
1239    rpm_col1 = session.get_latest_built_rpms(local_rpm.name,
1240                                             from_distro,
1241                                             arches=local_rpm.arch)
1242    rpm_col2 = RPMCollection.gather_from_dir(local_rpm_file)
1243
1244    if global_config.clean_cache_before:
1245        delete_download_cache()
1246
1247    download_rpms(rpm_col1.rpms_iter())
1248    result = run_abipkgdiff(rpm_col1, rpm_col2)
1249
1250    if global_config.clean_cache_after:
1251        delete_download_cache()
1252
1253    return result
1254
1255
1256@log_call
1257def diff_latest_rpms_based_on_distros():
1258    """abipkgdiff rpms based on two distros
1259
1260    2/ Suppose the packager wants to see how the ABIs of the package foo
1261    evolved between fedora 19 and fedora 22. She would thus type the command:
1262
1263    fedabipkgdiff --from fc19 --to fc22 foo
1264    """
1265
1266    from_distro = global_config.from_distro
1267    to_distro = global_config.to_distro
1268
1269    if not is_distro_valid(from_distro):
1270        raise InvalidDistroError('Invalid distro {0}'.format(from_distro))
1271
1272    if not is_distro_valid(to_distro):
1273        raise InvalidDistroError('Invalid distro {0}'.format(to_distro))
1274
1275    package_name = global_config.NVR[0]
1276
1277    rpm_col1 = session.get_latest_built_rpms(package_name,
1278                                             distro=global_config.from_distro)
1279    rpm_col2 = session.get_latest_built_rpms(package_name,
1280                                             distro=global_config.to_distro)
1281
1282    if global_config.clean_cache_before:
1283        delete_download_cache()
1284
1285    download_rpms(chain(rpm_col1.rpms_iter(), rpm_col2.rpms_iter()))
1286    result = run_abipkgdiff(rpm_col1, rpm_col2)
1287
1288    if global_config.clean_cache_after:
1289        delete_download_cache()
1290
1291    return result
1292
1293
1294@log_call
1295def diff_two_nvras_from_koji():
1296    """Diff two nvras from koji
1297
1298    The arch probably omits, that means febabipkgdiff will diff all arches. If
1299    specificed, the specific arch will be handled.
1300
1301    3/ Suppose the packager wants to compare the ABI of two packages designated
1302    by their name and version. She would issue a command like this:
1303
1304    fedabipkgdiff foo-1.0.fc19 foo-3.0.fc24
1305    fedabipkgdiff foo-1.0.fc19.i686 foo-1.0.fc24.i686
1306    """
1307    left_rpm = koji.parse_NVRA(global_config.NVR[0])
1308    right_rpm = koji.parse_NVRA(global_config.NVR[1])
1309
1310    if is_distro_valid(left_rpm['arch']) and \
1311            is_distro_valid(right_rpm['arch']):
1312        nvr = koji.parse_NVR(global_config.NVR[0])
1313        params1 = (nvr['name'], nvr['version'], nvr['release'], None)
1314
1315        nvr = koji.parse_NVR(global_config.NVR[1])
1316        params2 = (nvr['name'], nvr['version'], nvr['release'], None)
1317    else:
1318        params1 = (left_rpm['name'],
1319                   left_rpm['version'],
1320                   left_rpm['release'],
1321                   left_rpm['arch'])
1322        params2 = (right_rpm['name'],
1323                   right_rpm['version'],
1324                   right_rpm['release'],
1325                   right_rpm['arch'])
1326
1327    build_id = session.get_rpm_build_id(*params1)
1328    rpm_col1 = session.select_rpms_from_a_build(
1329        build_id, params1[0], arches=params1[3],
1330        select_subpackages=global_config.check_all_subpackages)
1331
1332    build_id = session.get_rpm_build_id(*params2)
1333    rpm_col2 = session.select_rpms_from_a_build(
1334        build_id, params2[0], arches=params2[3],
1335        select_subpackages=global_config.check_all_subpackages)
1336
1337    if global_config.clean_cache_before:
1338        delete_download_cache()
1339
1340    download_rpms(chain(rpm_col1.rpms_iter(), rpm_col2.rpms_iter()))
1341    result = run_abipkgdiff(rpm_col1, rpm_col2)
1342
1343    if global_config.clean_cache_after:
1344        delete_download_cache()
1345
1346    return result
1347
1348
1349@log_call
1350def self_compare_rpms_from_distro():
1351    """Compare ABI between same package from a distro
1352
1353    Doing ABI comparison on self package should return no
1354    ABI change and hence return code should be 0. This is useful
1355    to ensure that functionality of libabigail itself
1356    didn't break. This utility can be invoked like this:
1357
1358    fedabipkgdiff --self-compare -a --from fc25 foo
1359    """
1360
1361    from_distro = global_config.from_distro
1362
1363    if not is_distro_valid(from_distro):
1364        raise InvalidDistroError('Invalid distro {0}'.format(from_distro))
1365
1366    package_name = global_config.NVR[0]
1367
1368    rpm_col1 = session.get_latest_built_rpms(package_name,
1369                                             distro=global_config.from_distro)
1370
1371    if global_config.clean_cache_before:
1372        delete_download_cache()
1373
1374    download_rpms(rpm_col1.rpms_iter())
1375    result = run_abipkgdiff(rpm_col1, rpm_col1)
1376
1377    if global_config.clean_cache_after:
1378        delete_download_cache()
1379
1380    return result
1381
1382
1383@log_call
1384def diff_from_two_rpm_files(from_rpm_file, to_rpm_file):
1385    """Diff two RPM files"""
1386    rpm_col1 = RPMCollection.gather_from_dir(from_rpm_file)
1387    rpm_col2 = RPMCollection.gather_from_dir(to_rpm_file)
1388    if global_config.clean_cache_before:
1389        delete_download_cache()
1390    download_rpms(chain(rpm_col1.rpms_iter(), rpm_col2.rpms_iter()))
1391    result = run_abipkgdiff(rpm_col1, rpm_col2)
1392    if global_config.clean_cache_after:
1393        delete_download_cache()
1394    return result
1395
1396
1397def build_commandline_args_parser():
1398    parser = argparse.ArgumentParser(
1399        description='Compare ABI of shared libraries in RPM packages from the '
1400                    'Koji build system')
1401
1402    parser.add_argument(
1403        'NVR',
1404        nargs='*',
1405        help='RPM package N-V-R, N-V-R-A, N, or local RPM '
1406             'file names with relative or absolute path.')
1407    parser.add_argument(
1408        '--dry-run',
1409        required=False,
1410        dest='dry_run',
1411        action='store_true',
1412        help='Don\'t actually do the work. The commands that should be '
1413             'run will be sent to stdout.')
1414    parser.add_argument(
1415        '--from',
1416        required=False,
1417        metavar='DISTRO',
1418        dest='from_distro',
1419        help='baseline Fedora distribution name, for example, fc23')
1420    parser.add_argument(
1421        '--to',
1422        required=False,
1423        metavar='DISTRO',
1424        dest='to_distro',
1425        help='Fedora distribution name to compare against the baseline, for '
1426             'example, fc24')
1427    parser.add_argument(
1428        '-a',
1429        '--all-subpackages',
1430        required=False,
1431        action='store_true',
1432        dest='check_all_subpackages',
1433        help='Check all subpackages instead of only the package specificed in '
1434             'command line.')
1435    parser.add_argument(
1436        '--dso-only',
1437        required=False,
1438        action='store_true',
1439        dest='dso_only',
1440        help='Compare the ABI of shared libraries only. If this option is not '
1441             'provided, the tool compares the ABI of all ELF binaries.')
1442    parser.add_argument(
1443        '--debug',
1444        required=False,
1445        action='store_true',
1446        dest='debug',
1447        help='show debug output')
1448    parser.add_argument(
1449        '--traceback',
1450        required=False,
1451        action='store_true',
1452        dest='show_traceback',
1453        help='show traceback when there is an exception thrown.')
1454    parser.add_argument(
1455        '--server',
1456        required=False,
1457        metavar='URL',
1458        dest='koji_server',
1459        default=DEFAULT_KOJI_SERVER,
1460        help='URL of koji XMLRPC service. Default is {0}'.format(
1461            DEFAULT_KOJI_SERVER))
1462    parser.add_argument(
1463        '--topurl',
1464        required=False,
1465        metavar='URL',
1466        dest='koji_topurl',
1467        default=DEFAULT_KOJI_TOPURL,
1468        help='URL for RPM files access')
1469    parser.add_argument(
1470        '--abipkgdiff',
1471        required=False,
1472        metavar='ABIPKGDIFF',
1473        dest='abipkgdiff',
1474        default='',
1475        help="The path to the 'abipkgtool' command to use. "
1476             "By default use the one found in $PATH.")
1477    parser.add_argument(
1478        '--suppressions',
1479        required=False,
1480        metavar='SUPPR',
1481        dest='suppr',
1482        default='',
1483        help='The suppression specification file to use during comparison')
1484    parser.add_argument(
1485        '--no-default-suppression',
1486        required=False,
1487        action='store_true',
1488        dest='no_default_suppr',
1489        help='Do not load default suppression specifications')
1490    parser.add_argument(
1491        '--no-devel-pkg',
1492        required=False,
1493        action='store_true',
1494        dest='no_devel_pkg',
1495        help='Do not compare ABI with development package')
1496    parser.add_argument(
1497        '--show-identical-binaries',
1498        required=False,
1499        action='store_true',
1500        dest='show_identical_binaries',
1501        help='Show information about binaries whose ABI are identical')
1502    parser.add_argument(
1503        '--error-on-warning',
1504        required=False,
1505        action='store_true',
1506        dest='error_on_warning',
1507        help='Raise error instead of warning')
1508    parser.add_argument(
1509        '--clean-cache',
1510        required=False,
1511        action=SetCleanCacheAction,
1512        dest='clean_cache',
1513        default=None,
1514        help='A convenient way to clean cache without specifying '
1515             '--clean-cache-before and --clean-cache-after at same time')
1516    parser.add_argument(
1517        '--clean-cache-before',
1518        required=False,
1519        action='store_true',
1520        dest='clean_cache_before',
1521        default=None,
1522        help='Clean cache before ABI comparison')
1523    parser.add_argument(
1524        '--clean-cache-after',
1525        required=False,
1526        action='store_true',
1527        dest='clean_cache_after',
1528        default=None,
1529        help='Clean cache after ABI comparison')
1530    parser.add_argument(
1531        '--self-compare',
1532        required=False,
1533        action='store_true',
1534        dest='self_compare',
1535        default=None,
1536        help='ABI comparison on same package')
1537    return parser
1538
1539
1540def main():
1541    parser = build_commandline_args_parser()
1542
1543    args = parser.parse_args()
1544
1545    global global_config
1546    global_config = args
1547
1548    global pathinfo
1549    pathinfo = koji.PathInfo(topdir=global_config.koji_topurl)
1550
1551    global session
1552    session = get_session()
1553
1554    if global_config.debug:
1555        logger.setLevel(logging.DEBUG)
1556
1557    logger.debug(args)
1558
1559    if global_config.from_distro and global_config.self_compare and \
1560            global_config.NVR:
1561        return self_compare_rpms_from_distro()
1562
1563    if global_config.from_distro and global_config.to_distro is None and \
1564            global_config.NVR:
1565        return diff_local_rpm_with_latest_rpm_from_koji()
1566
1567    if global_config.from_distro and global_config.to_distro and \
1568            global_config.NVR:
1569        return diff_latest_rpms_based_on_distros()
1570
1571    if global_config.from_distro is None and global_config.to_distro is None:
1572        if len(global_config.NVR) > 1:
1573            left_one = global_config.NVR[0]
1574            right_one = global_config.NVR[1]
1575
1576            if is_rpm_file(left_one) and is_rpm_file(right_one):
1577                return diff_from_two_rpm_files(left_one, right_one)
1578
1579            both_nvr = match_nvr(left_one) and match_nvr(right_one)
1580            both_nvra = match_nvra(left_one) and match_nvra(right_one)
1581
1582            if both_nvr or both_nvra:
1583                return diff_two_nvras_from_koji()
1584
1585    six.print_('Unknown arguments. Please refer to --help.', file=sys.stderr)
1586    return 1
1587
1588
1589if __name__ == '__main__':
1590    try:
1591        sys.exit(main())
1592    except KeyboardInterrupt:
1593        if global_config is None:
1594            raise
1595        if global_config.debug:
1596            logger.debug('Terminate by user')
1597        else:
1598            six.print_('Terminate by user', file=sys.stderr)
1599        if global_config.show_traceback:
1600            raise
1601        else:
1602            sys.exit(2)
1603    except Exception as e:
1604        if global_config is None:
1605            raise
1606        if global_config.debug:
1607            logger.debug(str(e))
1608        else:
1609            six.print_(str(e), file=sys.stderr)
1610        if global_config.show_traceback:
1611            raise
1612        else:
1613            sys.exit(1)
1614