From 930b751540b9463b23f1239058fef7cb1eb98874 Mon Sep 17 00:00:00 2001
From: Yuming Zhu <yzhu@redhat.com>
Date: Mar 12 2018 08:57:37 +0000
Subject: [PATCH 1/3] cli: add `no_cmd` option to get_options


---

diff --git a/cli/koji b/cli/koji
index ab4cdc5..847b63c 100755
--- a/cli/koji
+++ b/cli/koji
@@ -81,7 +81,7 @@ def load_plugins(options, path):
             register_plugin(tracker.get(name))
 
 
-def get_options():
+def get_options(no_cmd=False):
     """process options from command line and config file"""
 
     common_commands = ['build', 'help', 'download-build',
@@ -167,6 +167,8 @@ def get_options():
                    (sys.prefix, sys.version_info[0], sys.version_info[1])
     load_plugins(options, plugins_path)
 
+    if no_cmd:
+        return options, args
     if options.help_commands:
         list_commands()
         sys.exit(0)

From cfee8c52db2795aa5c1b476bce6a680faf96462b Mon Sep 17 00:00:00 2001
From: Yuming Zhu <yzhu@redhat.com>
Date: Mar 13 2018 13:09:27 +0000
Subject: [PATCH 2/3] cli: misc refactoring of cli


- add multiple plugin paths supporting
- move function to lib
- add a`CommandExports` to move cli handlers into this namespace instead of `globals()`

---

diff --git a/cli/koji b/cli/koji
index 847b63c..b640c83 100755
--- a/cli/koji
+++ b/cli/koji
@@ -26,210 +26,20 @@
 
 from __future__ import absolute_import
 from __future__ import division
+
 import logging
-import optparse
 import os
-import re
-import six
 import sys
-import types
 
-import six.moves.configparser
 import six.moves.xmlrpc_client
 
 import koji
-import koji.util
-import koji.plugin
-
-from koji_cli.lib import _, OptionParser, get_epilog_str, greetings, \
-        warn, categories
-from koji_cli.commands import *
-
-
-def register_plugin(plugin):
-    """Scan a given plugin for handlers
-
-    Handlers are functions marked with one of the decorators defined in koji.plugin
-    """
-    for v in six.itervalues(vars(plugin)):
-        if isinstance(v, six.class_types):
-            #skip classes
-            continue
-        if callable(v):
-            if getattr(v, 'exported_cli', False):
-                if hasattr(v, 'export_alias'):
-                    name = getattr(v, 'export_alias')
-                else:
-                    name = v.__name__
-                # copy object to local namespace
-                globals()[name] = v
-
-
-def load_plugins(options, path):
-    """Load plugins specified by our configuration plus system plugins. Order
-    is that system plugins are first, so they can be overridden by
-    user-specified ones with same name."""
-    logger = logging.getLogger('koji.plugins')
-    if os.path.exists(path):
-        tracker = koji.plugin.PluginTracker(path=path)
-        for name in sorted(os.listdir(path)):
-            if not name.endswith('.py'):
-                continue
-            name = name[:-3]
-            logger.info('Loading plugin: %s', name)
-            tracker.load(name)
-            register_plugin(tracker.get(name))
-
+from koji_cli import commands
+from koji_cli.lib import CommandExports, get_options
 
-def get_options(no_cmd=False):
-    """process options from command line and config file"""
-
-    common_commands = ['build', 'help', 'download-build',
-                       'latest-pkg', 'search', 'list-targets']
-    usage = _("%%prog [global-options] command [command-options-and-arguments]"
-                "\n\nCommon commands: %s" % ', '.join(sorted(common_commands)))
-    parser = OptionParser(usage=usage)
-    parser.disable_interspersed_args()
-    progname = os.path.basename(sys.argv[0]) or 'koji'
-    parser.__dict__['origin_format_help'] = parser.format_help
-    parser.__dict__['format_help'] = lambda formatter=None: (
-        "%(origin_format_help)s%(epilog)s" % ({
-            'origin_format_help': parser.origin_format_help(formatter),
-            'epilog': get_epilog_str()}))
-    parser.add_option("-c", "--config", dest="configFile",
-                      help=_("use alternate configuration file"), metavar="FILE")
-    parser.add_option("-p", "--profile", default=progname,
-                      help=_("specify a configuration profile"))
-    parser.add_option("--keytab", help=_("specify a Kerberos keytab to use"), metavar="FILE")
-    parser.add_option("--principal", help=_("specify a Kerberos principal to use"))
-    parser.add_option("--krbservice", help=_("specify the Kerberos service name for the hub"))
-    parser.add_option("--runas", help=_("run as the specified user (requires special privileges)"))
-    parser.add_option("--user", help=_("specify user"))
-    parser.add_option("--password", help=_("specify password"))
-    parser.add_option("--noauth", action="store_true", default=False,
-                      help=_("do not authenticate"))
-    parser.add_option("--force-auth", action="store_true", default=False,
-                      help=_("authenticate even for read-only operations"))
-    parser.add_option("--authtype", help=_("force use of a type of authentication, options: noauth, ssl, password, or kerberos"))
-    parser.add_option("-d", "--debug", action="store_true",
-                      help=_("show debug output"))
-    parser.add_option("--debug-xmlrpc", action="store_true",
-                      help=_("show xmlrpc debug output"))
-    parser.add_option("-q", "--quiet", action="store_true", default=False,
-                      help=_("run quietly"))
-    parser.add_option("--skip-main", action="store_true", default=False,
-                      help=_("don't actually run main"))
-    parser.add_option("-s", "--server", help=_("url of XMLRPC server"))
-    parser.add_option("--topdir", help=_("specify topdir"))
-    parser.add_option("--weburl", help=_("url of the Koji web interface"))
-    parser.add_option("--topurl", help=_("url for Koji file access"))
-    parser.add_option("--pkgurl", help=optparse.SUPPRESS_HELP)
-    parser.add_option("--help-commands", action="store_true", default=False, help=_("list commands"))
-    (options, args) = parser.parse_args()
-
-    # load local config
-    try:
-        result = koji.read_config(options.profile, user_config=options.configFile)
-    except koji.ConfigurationError as e:
-        parser.error(e.args[0])
-        assert False  # pragma: no cover
-
-    # update options according to local config
-    for name, value in six.iteritems(result):
-        if getattr(options, name, None) is None:
-            setattr(options, name, value)
-
-    dir_opts = ('topdir', 'cert', 'serverca')
-    for name in dir_opts:
-        # expand paths here, so we don't have to worry about it later
-        value = os.path.expanduser(getattr(options, name))
-        setattr(options, name, value)
-
-    #honor topdir
-    if options.topdir:
-        koji.BASEDIR = options.topdir
-        koji.pathinfo.topdir = options.topdir
-
-    #pkgurl is obsolete
-    if options.pkgurl:
-        if options.topurl:
-            warn("Warning: the pkgurl option is obsolete")
-        else:
-            suggest = re.sub(r'/packages/?$', '', options.pkgurl)
-            if suggest != options.pkgurl:
-                warn("Warning: the pkgurl option is obsolete, using topurl=%r"
-                     % suggest)
-                options.topurl = suggest
-            else:
-                warn("Warning: The pkgurl option is obsolete, please use topurl instead")
-
-    plugins_path = '%s/lib/python%s.%s/site-packages/koji_cli_plugins' % \
-                   (sys.prefix, sys.version_info[0], sys.version_info[1])
-    load_plugins(options, plugins_path)
-
-    if no_cmd:
-        return options, args
-    if options.help_commands:
-        list_commands()
-        sys.exit(0)
-    if not args:
-        list_commands()
-        sys.exit(0)
-
-    aliases = {
-        'cancel-task' : 'cancel',
-        'cxl' : 'cancel',
-        'list-commands' : 'help',
-        'move-pkg': 'move-build',
-        'move': 'move-build',
-        'latest-pkg': 'latest-build',
-        'tag-pkg': 'tag-build',
-        'tag': 'tag-build',
-        'untag-pkg': 'untag-build',
-        'untag': 'untag-build',
-        'watch-tasks': 'watch-task',
-    }
-    cmd = args[0]
-    cmd = aliases.get(cmd, cmd)
-    if cmd.lower() in greetings:
-        cmd = "moshimoshi"
-    cmd = cmd.replace('-', '_')
-    if ('anon_handle_' + cmd) in globals():
-        if not options.force_auth and '--mine' not in args:
-            options.noauth = True
-        cmd = 'anon_handle_' + cmd
-    elif ('handle_' + cmd) in globals():
-        cmd = 'handle_' + cmd
-    else:
-        list_commands()
-        parser.error('Unknown command: %s' % args[0])
-        assert False  # pragma: no cover
-
-    return options, cmd, args[1:]
-
-
-def handle_help(options, session, args):
-    "[info] List available commands"
-    usage = _("usage: %prog help <category> ...")
-    usage += _("\n(Specify the --help global option for a list of other help options)")
-    parser = OptionParser(usage=usage)
-    # the --admin opt is for backwards compatibility. It is equivalent to: koji help admin
-    parser.add_option("--admin", action="store_true", help=optparse.SUPPRESS_HELP)
-
-    (options, args) = parser.parse_args(args)
-
-    chosen = set(args)
-    if options.admin:
-        chosen.add('admin')
-    avail = set(list(categories.keys()) + ['all'])
-    unavail = chosen - avail
-    for arg in unavail:
-        print("No such help category: %s" % arg)
-
-    if not chosen:
-        list_commands()
-    else:
-        list_commands(chosen)
+for n, v in six.iteritems(vars(commands)):
+    if not isinstance(v, six.class_types) and callable(v):
+        setattr(CommandExports, n, staticmethod(v))
 
 
 def fix_pyver(options, logger):
@@ -237,7 +47,7 @@ def fix_pyver(options, logger):
     pyver = getattr(options, 'pyver', None)
     if not pyver:
         return
-    if pyver not in [2,3]:
+    if pyver not in [2, 3]:
         logger.warning('Invalid python version requested: %s', pyver)
     if sys.version_info[0] == pyver:
         return
@@ -255,45 +65,14 @@ def fix_pyver(options, logger):
         logger.exception('Unable to execute with requested python version')
 
 
-def list_commands(categories_chosen=None):
-    if categories_chosen is None or "all" in categories_chosen:
-        categories_chosen = list(categories.keys())
-    else:
-        # copy list since we're about to modify it
-        categories_chosen = list(categories_chosen)
-    categories_chosen.sort()
-    handlers = []
-    for name,value in globals().items():
-        if name.startswith('handle_'):
-            alias = name.replace('handle_','')
-            alias = alias.replace('_','-')
-            handlers.append((alias,value))
-        elif name.startswith('anon_handle_'):
-            alias = name.replace('anon_handle_','')
-            alias = alias.replace('_','-')
-            handlers.append((alias,value))
-    handlers.sort()
-    print(_("Available commands:"))
-    for category in categories_chosen:
-        print(_("\n%s:" % categories[category]))
-        for alias,handler in handlers:
-            desc = handler.__doc__ or ''
-            if desc.startswith('[%s] ' % category):
-                desc = desc[len('[%s] ' % category):]
-            elif category != 'misc' or desc.startswith('['):
-                continue
-            print("        %-25s %s" % (alias, desc))
-
-    print("%s" % get_epilog_str().rstrip("\n"))
-
-
 if __name__ == "__main__":
     global options
     options, command, args = get_options()
 
     logger = logging.getLogger("koji")
     handler = logging.StreamHandler(sys.stderr)
-    handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(name)s: %(message)s'))
+    handler.setFormatter(
+        logging.Formatter('%(asctime)s [%(levelname)s] %(name)s: %(message)s'))
     handler.setLevel(logging.DEBUG)
     logger.addHandler(handler)
     if options.debug:
@@ -309,7 +88,7 @@ if __name__ == "__main__":
     session = koji.ClientSession(options.server, session_opts)
     rv = 0
     try:
-        rv = locals()[command].__call__(options, session, args)
+        rv = getattr(CommandExports, command).__call__(options, session, args)
         if not rv:
             rv = 0
     except (KeyboardInterrupt, SystemExit):
diff --git a/cli/koji.conf b/cli/koji.conf
index 26c24cf..17cf244 100644
--- a/cli/koji.conf
+++ b/cli/koji.conf
@@ -37,5 +37,9 @@
 ;certificate of the CA that issued the HTTP server certificate
 ;serverca = ~/.koji/serverca.crt
 
+;plugin paths, separated by ':'
+;plugin_paths = ~/.koji/plugins
+
+;[not_implemented_yet]
 ;enabled plugins for CLI, runroot and save_failed_tree are available
 ;plugins =
diff --git a/cli/koji_cli/lib.py b/cli/koji_cli/lib.py
index 341cecd..626ba0e 100644
--- a/cli/koji_cli/lib.py
+++ b/cli/koji_cli/lib.py
@@ -1,16 +1,20 @@
 # coding=utf-8
 from __future__ import absolute_import
 from __future__ import division
+
+import logging
 import optparse
 import os
 import random
-import requests
-import six
+import re
 import socket
 import string
 import sys
 import time
 from contextlib import closing
+
+import requests
+import six
 from six.moves import range
 
 try:
@@ -19,6 +23,7 @@ except ImportError:  # pragma: no cover
     krbV = None
 
 import koji
+import koji.plugin
 
 # fix OptionParser for python 2.3 (optparse verion 1.4.1+)
 # code taken from optparse version 1.5a2
@@ -636,3 +641,247 @@ def _list_tasks(options, session):
                 t['sub'] = True
 
     return tasklist
+
+
+def register_plugin(plugin):
+    """Scan a given plugin for handlers
+
+    Handlers are functions marked with one of the decorators defined in koji.plugin
+    """
+    for v in six.itervalues(vars(plugin)):
+        if isinstance(v, six.class_types):
+            #skip classes
+            continue
+        if callable(v):
+            if getattr(v, 'exported_cli', False):
+                if hasattr(v, 'export_alias'):
+                    name = getattr(v, 'export_alias')
+                else:
+                    name = v.__name__
+                # copy object to local namespace
+                setattr(CommandExports, name, staticmethod(v))
+
+
+def load_plugins(options, paths):
+    """Load plugins specified by our configuration plus system plugins. Order
+    is that system plugins are first, so they can be overridden by
+    user-specified ones with same name."""
+    logger = logging.getLogger('koji.plugins')
+    tracker = koji.plugin.PluginTracker(path=paths)
+    names = set()
+    for path in paths:
+        if os.path.exists(path):
+            for name in sorted(os.listdir(path)):
+                if not name.endswith('.py'):
+                    continue
+                name = name[:-3]
+                names.add(name)
+    for name in names:
+        logger.info('Loading plugin: %s', name)
+        tracker.load(name)
+        register_plugin(tracker.get(name))
+
+
+def get_options(no_cmd=False):
+    """process options from command line and config file.
+
+    If no_cmd is False, command is required, and it must be registered.
+    Returns (options, cmd, args)
+
+    If no_cmd is True, command would be an option.
+    Returns (options, args)
+    It's usually used by standalone cli script,
+    but global options and customized options should be divided by '--'.
+    """
+
+    common_commands = ['build', 'help', 'download-build',
+                       'latest-pkg', 'search', 'list-targets']
+    usage = _("%%prog [global-options] command [command-options-and-arguments]"
+                "\n\nCommon commands: %s" % ', '.join(sorted(common_commands)))
+    parser = OptionParser(usage=usage)
+    parser.disable_interspersed_args()
+    progname = os.path.basename(sys.argv[0]) or 'koji'
+    parser.__dict__['origin_format_help'] = parser.format_help
+    parser.__dict__['format_help'] = lambda formatter=None: (
+        "%(origin_format_help)s%(epilog)s" % ({
+            'origin_format_help': parser.origin_format_help(formatter),
+            'epilog': get_epilog_str()}))
+    parser.add_option("-c", "--config", dest="configFile",
+                      help=_("use alternate configuration file"), metavar="FILE")
+    parser.add_option("-p", "--profile", default=progname,
+                      help=_("specify a configuration profile"))
+    parser.add_option("--keytab", help=_("specify a Kerberos keytab to use"), metavar="FILE")
+    parser.add_option("--principal", help=_("specify a Kerberos principal to use"))
+    parser.add_option("--krbservice", help=_("specify the Kerberos service name for the hub"))
+    parser.add_option("--runas", help=_("run as the specified user (requires special privileges)"))
+    parser.add_option("--user", help=_("specify user"))
+    parser.add_option("--password", help=_("specify password"))
+    parser.add_option("--noauth", action="store_true", default=False,
+                      help=_("do not authenticate"))
+    parser.add_option("--force-auth", action="store_true", default=False,
+                      help=_("authenticate even for read-only operations"))
+    parser.add_option("--authtype", help=_("force use of a type of authentication, options: noauth, ssl, password, or kerberos"))
+    parser.add_option("-d", "--debug", action="store_true",
+                      help=_("show debug output"))
+    parser.add_option("--debug-xmlrpc", action="store_true",
+                      help=_("show xmlrpc debug output"))
+    parser.add_option("-q", "--quiet", action="store_true", default=False,
+                      help=_("run quietly"))
+    parser.add_option("--skip-main", action="store_true", default=False,
+                      help=_("don't actually run main"))
+    parser.add_option("-s", "--server", help=_("url of XMLRPC server"))
+    parser.add_option("--topdir", help=_("specify topdir"))
+    parser.add_option("--weburl", help=_("url of the Koji web interface"))
+    parser.add_option("--topurl", help=_("url for Koji file access"))
+    parser.add_option("--pkgurl", help=optparse.SUPPRESS_HELP)
+    parser.add_option("--plugin-paths", help=_("specify plugin paths divided by ':'"))
+    parser.add_option("--help-commands", action="store_true", default=False, help=_("list commands"))
+    (options, args) = parser.parse_args()
+
+    # load local config
+    try:
+        result = koji.read_config(options.profile, user_config=options.configFile)
+    except koji.ConfigurationError as e:
+        parser.error(e.args[0])
+        assert False  # pragma: no cover
+
+    # update options according to local config
+    for name, value in six.iteritems(result):
+        if getattr(options, name, None) is None:
+            setattr(options, name, value)
+
+    dir_opts = ('topdir', 'cert', 'serverca')
+    for name in dir_opts:
+        # expand paths here, so we don't have to worry about it later
+        value = os.path.expanduser(getattr(options, name))
+        setattr(options, name, value)
+
+    #honor topdir
+    if options.topdir:
+        koji.BASEDIR = options.topdir
+        koji.pathinfo.topdir = options.topdir
+
+    #pkgurl is obsolete
+    if options.pkgurl:
+        if options.topurl:
+            warn("Warning: the pkgurl option is obsolete")
+        else:
+            suggest = re.sub(r'/packages/?$', '', options.pkgurl)
+            if suggest != options.pkgurl:
+                warn("Warning: the pkgurl option is obsolete, using topurl=%r"
+                     % suggest)
+                options.topurl = suggest
+            else:
+                warn("Warning: The pkgurl option is obsolete, please use topurl instead")
+
+
+    # update plugin_paths to list
+    plugin_paths = options.plugin_paths or []
+    if plugin_paths:
+        plugin_paths = [os.path.expanduser(p) for p in plugin_paths.split(':')]
+    # always load plugins from koji_cli_plugins module
+    plugin_paths.append('%s/lib/python%s.%s/site-packages/koji_cli_plugins' %
+                        (sys.prefix, sys.version_info[0], sys.version_info[1]))
+    setattr(options, 'plugin_paths', plugin_paths)
+    load_plugins(options, plugin_paths)
+
+    if no_cmd:
+        return options, args
+    if options.help_commands:
+        list_commands()
+        sys.exit(0)
+    if not args:
+        list_commands()
+        sys.exit(0)
+
+    aliases = {
+        'cancel-task' : 'cancel',
+        'cxl' : 'cancel',
+        'list-commands' : 'help',
+        'move-pkg': 'move-build',
+        'move': 'move-build',
+        'latest-pkg': 'latest-build',
+        'tag-pkg': 'tag-build',
+        'tag': 'tag-build',
+        'untag-pkg': 'untag-build',
+        'untag': 'untag-build',
+        'watch-tasks': 'watch-task',
+    }
+    cmd = args[0]
+    cmd = aliases.get(cmd, cmd)
+    if cmd.lower() in greetings:
+        cmd = "moshimoshi"
+    cmd = cmd.replace('-', '_')
+    if hasattr(CommandExports, 'anon_handle_' + cmd):
+        if not options.force_auth and '--mine' not in args:
+            options.noauth = True
+        cmd = 'anon_handle_' + cmd
+    elif hasattr(CommandExports, 'handle_' + cmd):
+        cmd = 'handle_' + cmd
+    else:
+        list_commands()
+        parser.error('Unknown command: %s' % args[0])
+        assert False  # pragma: no cover
+
+    return options, cmd, args[1:]
+
+
+def list_commands(categories_chosen=None):
+    if categories_chosen is None or "all" in categories_chosen:
+        categories_chosen = list(categories.keys())
+    else:
+        # copy list since we're about to modify it
+        categories_chosen = list(categories_chosen)
+    categories_chosen.sort()
+    handlers = []
+    for name, value in six.iteritems(vars(CommandExports)):
+        if name.startswith('handle_'):
+            alias = name.replace('handle_', '')
+            alias = alias.replace('_', '-')
+            handlers.append((alias, value))
+        elif name.startswith('anon_handle_'):
+            alias = name.replace('anon_handle_', '')
+            alias = alias.replace('_', '-')
+            handlers.append((alias, value))
+    handlers.sort()
+    print(_("Available commands:"))
+    for category in categories_chosen:
+        print(_("\n%s:" % categories[category]))
+        for alias, handler in handlers:
+            if isinstance(handler, staticmethod):
+                handler = handler.__func__
+            desc = handler.__doc__ or ''
+            if desc.startswith('[%s] ' % category):
+                desc = desc[len('[%s] ' % category):]
+            elif category != 'misc' or desc.startswith('['):
+                continue
+            print("        %-25s %s" % (alias, desc))
+
+    print("%s" % get_epilog_str().rstrip("\n"))
+
+
+class CommandExports(object):
+
+    @staticmethod
+    def handle_help(options, session, args):
+        "[info] List available commands"
+        usage = _("usage: %prog help <category> ...")
+        usage += _("\n(Specify the --help global option for a list of other help options)")
+        parser = OptionParser(usage=usage)
+        # the --admin opt is for backwards compatibility. It is equivalent to: koji help admin
+        parser.add_option("--admin", action="store_true", help=optparse.SUPPRESS_HELP)
+
+        (options, args) = parser.parse_args(args)
+
+        chosen = set(args)
+        if options.admin:
+            chosen.add('admin')
+        avail = set(list(categories.keys()) + ['all'])
+        unavail = chosen - avail
+        for arg in unavail:
+            print("No such help category: %s" % arg)
+
+        if not chosen:
+            list_commands()
+        else:
+            list_commands(chosen)
diff --git a/koji/__init__.py b/koji/__init__.py
index 8e40e30..8037a7d 100644
--- a/koji/__init__.py
+++ b/koji/__init__.py
@@ -1661,7 +1661,8 @@ def read_config(profile_name, user_config=None):
         'authtype': None,
         'debug': False,
         'debug_xmlrpc': False,
-        'pyver' : None,
+        'pyver': None,
+        'plugin_paths': None,
     }
 
     result = config_defaults.copy()
diff --git a/tests/test_cli/data/plugins2/plugin2.py b/tests/test_cli/data/plugins2/plugin2.py
new file mode 100644
index 0000000..336aaf4
--- /dev/null
+++ b/tests/test_cli/data/plugins2/plugin2.py
@@ -0,0 +1,5 @@
+from koji.plugin import export_cli
+
+@export_cli
+def foo5():
+    pass
diff --git a/tests/test_cli/data/plugins2/plugin3.py b/tests/test_cli/data/plugins2/plugin3.py
new file mode 100644
index 0000000..304db20
--- /dev/null
+++ b/tests/test_cli/data/plugins2/plugin3.py
@@ -0,0 +1,7 @@
+from koji.plugin import export_cli, export_as
+
+@export_as('foo6')
+@export_cli
+def foo():
+    pass
+
diff --git a/tests/test_cli/test_list_commands.py b/tests/test_cli/test_list_commands.py
index 49a9140..95aa83c 100644
--- a/tests/test_cli/test_list_commands.py
+++ b/tests/test_cli/test_list_commands.py
@@ -1,25 +1,30 @@
 from __future__ import absolute_import
-import mock
+
 import os
-import six
 import unittest
 
+import mock
+import six
+
+import koji_cli.lib
 from . import loadcli
+
 cli = loadcli.cli
 
 
 class TestListCommands(unittest.TestCase):
-
     def setUp(self):
         self.options = mock.MagicMock()
         self.session = mock.MagicMock()
         self.args = mock.MagicMock()
         self.original_parser = cli.OptionParser
         cli.OptionParser = mock.MagicMock()
+        koji_cli.lib.OptionParser = cli.OptionParser
         self.parser = cli.OptionParser.return_value
 
     def tearDown(self):
         cli.OptionParser = self.original_parser
+        koji_cli.lib.OptionParser = self.original_parser
 
     # Show long diffs in error output...
     maxDiff = None
@@ -42,7 +47,7 @@ class TestListCommands(unittest.TestCase):
         options, arguments = mock.MagicMock(), mock.MagicMock()
         options.admin = True
         self.parser.parse_args.return_value = [options, arguments]
-        cli.handle_help(self.options, self.session, self.args)
+        cli.CommandExports.handle_help(self.options, self.session, self.args)
         actual = stdout.getvalue()
         if six.PY2:
             actual = actual.replace('nosetests', 'koji')
diff --git a/tests/test_cli/test_load_plugins.py b/tests/test_cli/test_load_plugins.py
index 411ee96..a4d85af 100644
--- a/tests/test_cli/test_load_plugins.py
+++ b/tests/test_cli/test_load_plugins.py
@@ -1,20 +1,24 @@
 from __future__ import absolute_import
-import mock
+
 import os
 import unittest
 
-from . import loadcli
-cli = loadcli.cli
+import mock
 
+from koji_cli import lib
 
-class TestLoadPlugins(unittest.TestCase):
 
+class TestLoadPlugins(unittest.TestCase):
     @mock.patch('logging.getLogger')
     def test_load_plugins(self, getLogger):
         options = mock.MagicMock()
-        cli.load_plugins(options, os.path.dirname(__file__) + '/data/plugins')
-        self.assertTrue(callable(cli.foobar))
-        self.assertTrue(callable(cli.foo2))
-        self.assertFalse(hasattr(cli, 'foo3'))
-        self.assertFalse(hasattr(cli, 'foo4'))
-        self.assertFalse(hasattr(cli, 'sth'))
+        lib.load_plugins(options, [os.path.dirname(__file__) + '/data/plugins',
+                                   os.path.dirname(
+                                       __file__) + '/data/plugins2'])
+        self.assertTrue(callable(lib.CommandExports.foobar))
+        self.assertTrue(callable(lib.CommandExports.foo2))
+        self.assertTrue(hasattr(lib.CommandExports, 'foo6'))
+        self.assertFalse(hasattr(lib.CommandExports, 'foo3'))
+        self.assertFalse(hasattr(lib.CommandExports, 'foo4'))
+        self.assertFalse(hasattr(lib.CommandExports, 'foo5'))
+        self.assertFalse(hasattr(lib.CommandExports, 'sth'))

From c7466dd4802a11a199afd2758b8cac40bbe56dcd Mon Sep 17 00:00:00 2001
From: Yuming Zhu <yzhu@redhat.com>
Date: Mar 13 2018 13:47:14 +0000
Subject: [PATCH 3/3] fix unittest: cli/test_list_commands


---

diff --git a/tests/test_cli/test_list_commands.py b/tests/test_cli/test_list_commands.py
index 95aa83c..b53b072 100644
--- a/tests/test_cli/test_list_commands.py
+++ b/tests/test_cli/test_list_commands.py
@@ -17,13 +17,11 @@ class TestListCommands(unittest.TestCase):
         self.options = mock.MagicMock()
         self.session = mock.MagicMock()
         self.args = mock.MagicMock()
-        self.original_parser = cli.OptionParser
-        cli.OptionParser = mock.MagicMock()
-        koji_cli.lib.OptionParser = cli.OptionParser
-        self.parser = cli.OptionParser.return_value
+        self.original_parser = koji_cli.lib.OptionParser
+        koji_cli.lib.OptionParser = mock.MagicMock()
+        self.parser = koji_cli.lib.OptionParser.return_value
 
     def tearDown(self):
-        cli.OptionParser = self.original_parser
         koji_cli.lib.OptionParser = self.original_parser
 
     # Show long diffs in error output...
@@ -31,7 +29,7 @@ class TestListCommands(unittest.TestCase):
 
     @mock.patch('sys.stdout', new_callable=six.StringIO)
     def test_list_commands(self, stdout):
-        cli.list_commands()
+        koji_cli.lib.list_commands()
         actual = stdout.getvalue()
         if six.PY2:
             actual = actual.replace('nosetests', 'koji')
@@ -47,7 +45,7 @@ class TestListCommands(unittest.TestCase):
         options, arguments = mock.MagicMock(), mock.MagicMock()
         options.admin = True
         self.parser.parse_args.return_value = [options, arguments]
-        cli.CommandExports.handle_help(self.options, self.session, self.args)
+        koji_cli.lib.CommandExports.handle_help(self.options, self.session, self.args)
         actual = stdout.getvalue()
         if six.PY2:
             actual = actual.replace('nosetests', 'koji')

