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