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