From f7464aec2cb448b31ef62d7b2162cded43739819 Mon Sep 17 00:00:00 2001 From: Jana Cupova Date: Jul 22 2025 14:19:09 +0000 Subject: [PATCH 1/10] Add remove-group-pkg CLI command Fixes: https://pagure.io/koji/issue/3199 --- diff --git a/cli/koji_cli/commands.py b/cli/koji_cli/commands.py index efe07df..198b6da 100644 --- a/cli/koji_cli/commands.py +++ b/cli/koji_cli/commands.py @@ -3100,6 +3100,26 @@ def handle_add_group_pkg(goptions, session, args): session.groupPackageListAdd(tag, group, pkg) +def handle_remove_group_pkg(goptions, session, args): + "[admin] Remove a package from a group's package listing" + usage = "usage: %prog remove-group-pkg [options] [ ...]" + parser = OptionParser(usage=get_usage_str(usage)) + parser.add_option("--force", action='store_true', help="Override blocks if necessary") + (options, args) = parser.parse_args(args) + if len(args) < 3: + parser.error("You must specify a tag name, group name, and one or more package names") + tag = args[0] + group = args[1] + opts = {} + opts['force'] = options.force + activate_session(session, goptions) + dsttag = session.getTag(tag) + if dsttag is None: + error("No such tag: %s" % tag) + with session.multicall() as m: + [m.groupPackageListRemove(tag, group, pkg, **opts) for pkg in args[2:]] + + def handle_block_group_pkg(goptions, session, args): "[admin] Block a package from a group's package listing" usage = "usage: %prog block-group-pkg [options] [ ...]" diff --git a/tests/test_cli/data/list-commands-admin.txt b/tests/test_cli/data/list-commands-admin.txt index 6677619..1928c1b 100644 --- a/tests/test_cli/data/list-commands-admin.txt +++ b/tests/test_cli/data/list-commands-admin.txt @@ -49,6 +49,7 @@ admin commands: regen-repo Generate a current repo if there is not one remove-external-repo Remove an external repo from a tag or tags, or remove entirely remove-group Remove group from tag + remove-group-pkg Remove a package from a group's package listing remove-host-from-channel Remove a host from a channel remove-pkg Remove a package from the listing for tag remove-sig Remove signed RPMs from db and disk diff --git a/tests/test_cli/data/list-commands.txt b/tests/test_cli/data/list-commands.txt index a205772..809229d 100644 --- a/tests/test_cli/data/list-commands.txt +++ b/tests/test_cli/data/list-commands.txt @@ -49,6 +49,7 @@ admin commands: regen-repo Generate a current repo if there is not one remove-external-repo Remove an external repo from a tag or tags, or remove entirely remove-group Remove group from tag + remove-group-pkg Remove a package from a group's package listing remove-host-from-channel Remove a host from a channel remove-pkg Remove a package from the listing for tag remove-sig Remove signed RPMs from db and disk diff --git a/tests/test_cli/test_remove_group_pkg.py b/tests/test_cli/test_remove_group_pkg.py new file mode 100644 index 0000000..62277a6 --- /dev/null +++ b/tests/test_cli/test_remove_group_pkg.py @@ -0,0 +1,109 @@ +from __future__ import absolute_import + +import mock +import six + +from koji_cli.commands import handle_remove_group_pkg + +import koji +from . import utils + + +class TestRemoveGroupPkg(utils.CliTestCase): + + def setUp(self): + # Show long diffs in error output... + self.maxDiff = None + self.options = mock.MagicMock() + self.options.debug = False + self.session = mock.MagicMock() + self.session.getAPIVersion.return_value = koji.API_VERSION + self.activate_session_mock = mock.patch('koji_cli.commands.activate_session').start() + self.error_format = """Usage: %s remove-group-pkg [options] [ ...] +(Specify the --help global option for a list of other help options) + +%s: error: {message} +""" % (self.progname, self.progname) + + def test_handle_remove_pkg_not_existing_tag(self): + tag = 'tag' + package = 'package' + group = 'group' + args = [tag, group, package] + + self.session.getTag.return_value = None + self.assert_system_exit( + handle_remove_group_pkg, + self.options, self.session, args, + stderr='No such tag: %s\n' % tag, + stdout='', + activate_session=None, + exit_code=1) + # Finally, assert that things were called as we expected. + self.activate_session_mock.assert_called_once_with(self.session, self.options) + self.session.getTag.assert_called_once_with(tag) + self.session.groupPackageListRemove.assert_not_called() + self.session.multiCall.assert_not_called() + + def test_handle_remove_pkg_wrong_count_args(self): + tag = 'tag' + group = 'group' + args = [tag, group] + expected_error = self.format_error_message( + 'You must specify a tag name, group name, and one or more package names') + self.assert_system_exit( + handle_remove_group_pkg, + self.options, self.session, args, + stderr=expected_error, + stdout='', + activate_session=None, + exit_code=2) + # Finally, assert that things were called as we expected. + self.activate_session_mock.assert_not_called() + self.session.getTag.assert_not_called() + self.session.groupPackageListRemove.assert_not_called() + self.session.multiCall.assert_not_called() + + @mock.patch('sys.stdout', new_callable=six.StringIO) + @mock.patch('sys.stderr', new_callable=six.StringIO) + def test_handle_remove_pkg(self, stderr, stdout): + tag = 'tag' + dsttag = {'name': tag, 'id': 1} + package = 'package' + group = 'group' + args = [tag, group, package] + + self.session.getTag.return_value = dsttag + self.session.groupPackageListRemove.return_value = None + handle_remove_group_pkg(self.options, self.session, args) + actual = stderr.getvalue() + expected = '' + self.assertMultiLineEqual(actual, expected) + actual = stdout.getvalue() + expected = '' + self.assertMultiLineEqual(actual, expected) + # Finally, assert that things were called as we expected. + self.activate_session_mock.assert_called_once_with(self.session, self.options) + self.session.getTag.assert_called_once_with(tag) + + @mock.patch('sys.stdout', new_callable=six.StringIO) + @mock.patch('sys.stderr', new_callable=six.StringIO) + def test_handle_remove_pkg_with_force(self, stderr, stdout): + tag = 'tag' + dsttag = {'name': tag, 'id': 1} + package = 'package' + group = 'group' + args = [tag, group, package, '--force'] + + self.session.getTag.return_value = dsttag + self.session.groupPackageListRemove.return_value = None + handle_remove_group_pkg(self.options, self.session, args) + actual = stderr.getvalue() + expected = '' + self.assertMultiLineEqual(actual, expected) + actual = stdout.getvalue() + expected = '' + self.assertMultiLineEqual(actual, expected) + # Finally, assert that things were called as we expected. + self.activate_session_mock.assert_called_once_with(self.session, self.options) + self.session.getTag.assert_called_once_with(tag) From c5828a4b8e0e6454bc80ad7ad83dab73dc923fab Mon Sep 17 00:00:00 2001 From: Mike McLean Date: Jul 22 2025 14:19:09 +0000 Subject: [PATCH 2/10] update remove-group-pkg with sanity checks --- diff --git a/cli/koji_cli/commands.py b/cli/koji_cli/commands.py index 198b6da..be496e4 100644 --- a/cli/koji_cli/commands.py +++ b/cli/koji_cli/commands.py @@ -3101,23 +3101,50 @@ def handle_add_group_pkg(goptions, session, args): def handle_remove_group_pkg(goptions, session, args): - "[admin] Remove a package from a group's package listing" + "[admin] Remove packages from a group's package listing" usage = "usage: %prog remove-group-pkg [options] [ ...]" parser = OptionParser(usage=get_usage_str(usage)) - parser.add_option("--force", action='store_true', help="Override blocks if necessary") (options, args) = parser.parse_args(args) if len(args) < 3: parser.error("You must specify a tag name, group name, and one or more package names") tag = args[0] group = args[1] - opts = {} - opts['force'] = options.force + packages = args[2:] activate_session(session, goptions) - dsttag = session.getTag(tag) - if dsttag is None: + taginfo = session.getTag(tag) + if taginfo is None: error("No such tag: %s" % tag) + + # sanity checks + groups = session.getTagGroups(taginfo['id'], incl_reqs=False, incl_blocked=True) + for ginfo in groups: + if ginfo['name'] == group: + break + else: + error("Group %s is not present in tag %s" % (group, tag)) + pkg_idx = {p['package']: p for p in ginfo['packagelist']} + sane = True + for pkg in packages: + if pkg not in pkg_idx: + print("Package %s is not included in this group" % pkg) + sane = False + continue + pinfo = pkg_idx[pkg] + if pinfo['blocked']: + print("Package %s is blocked in this group. You could use unblock-group-pkg to unblock it" % pkg) + sane = False + continue + if pinfo['tag_id'] != taginfo['id']: + # listing is inherited + srctag = session.getTag(pinfo['tag_id']) + print("The entry for package %s is inherited from %s. You could use block-group-pkg to prevent this" % (pkg, srctag['name'])) + sane = False + continue + if not sane: + error('Invalid parameters') + with session.multicall() as m: - [m.groupPackageListRemove(tag, group, pkg, **opts) for pkg in args[2:]] + [m.groupPackageListRemove(taginfo['id'], group, pkg) for pkg in packages] def handle_block_group_pkg(goptions, session, args): From f4435d4a9c69885cbd48b338b733a5540b6a1f73 Mon Sep 17 00:00:00 2001 From: Mike McLean Date: Jul 22 2025 14:19:09 +0000 Subject: [PATCH 3/10] remove-group-req too --- diff --git a/cli/koji_cli/commands.py b/cli/koji_cli/commands.py index be496e4..8c37945 100644 --- a/cli/koji_cli/commands.py +++ b/cli/koji_cli/commands.py @@ -3218,6 +3218,52 @@ def handle_unblock_group_req(goptions, session, args): activate_session(session, goptions) session.groupReqListUnblock(tag, group, req) +def handle_remove_group_req(goptions, session, args): + "[admin] Remove entries from a group's requirement listing" + usage = "usage: %prog remove-group-req [options] [ ...]" + parser = OptionParser(usage=get_usage_str(usage)) + (options, args) = parser.parse_args(args) + if len(args) < 3: + parser.error("You must specify a tag name, group name, and one or more requirement names") + tag = args[0] + group = args[1] + requires = args[2:] + activate_session(session, goptions) + taginfo = session.getTag(tag) + if taginfo is None: + error("No such tag: %s" % tag) + + # sanity checks + groups = session.getTagGroups(taginfo['id'], incl_pkgs=False, incl_blocked=True) + for ginfo in groups: + if ginfo['name'] == group: + break + else: + error("Group %s is not present in tag %s" % (group, tag)) + req_idx = {r['name']: r for r in ginfo['grouplist']} + sane = True + for req in requires: + if req not in req_idx: + print("Req %s is not included in this group" % req) + sane = False + continue + rinfo = req_idx[req] + if rinfo['blocked']: + print("Req %s is blocked in this group. You could use unblock-group-req to unblock it" % req) + sane = False + continue + if rinfo['tag_id'] != taginfo['id']: + # listing is inherited + srctag = session.getTag(rinfo['tag_id']) + print("The entry for req %s is inherited from %s. You could use block-group-req to prevent this" % (req, srctag['name'])) + sane = False + continue + if not sane: + error('Invalid parameters') + + with session.multicall() as m: + [m.groupReqListRemove(taginfo['id'], group, req) for req in requires] + def anon_handle_list_channels(goptions, session, args): "[info] Print channels listing" From fb292aeca1e6909949ea439caa72a5ca6a79c6d1 Mon Sep 17 00:00:00 2001 From: Mike McLean Date: Jul 22 2025 14:19:10 +0000 Subject: [PATCH 4/10] update sanity check for remove-group --- diff --git a/cli/koji_cli/commands.py b/cli/koji_cli/commands.py index 8c37945..d4eb480 100644 --- a/cli/koji_cli/commands.py +++ b/cli/koji_cli/commands.py @@ -137,14 +137,23 @@ def handle_remove_group(goptions, session, args): if not (session.hasPerm('admin') or session.hasPerm('tag')): parser.error("This action requires tag or admin privileges") - dsttag = session.getTag(tag) - if not dsttag: + taginfo = session.getTag(tag) + if not taginfo: error("No such tag: %s" % tag) - groups = dict([(p['name'], p['group_id']) for p in session.getTagGroups(tag, inherit=False)]) - group_id = groups.get(group, None) - if group_id is None: + # sanity checks + groups = session.getTagGroups(taginfo['id'], incl_pkgs=False, incl_reqs=False, incl_blocked=True) + for ginfo in groups: + if ginfo['name'] == group: + break + else: error("Group %s doesn't exist within tag %s" % (group, tag)) + if ginfo['blocked']: + error("Group %s is blocked in this tag. You could use unblock-group to unblock it" % group) + if ginfo['tag_id'] != taginfo['id']: + # listing is inherited + srctag = session.getTag(ginfo['tag_id']) + error("The entry for group %s is inherited from %s. You could use block-group to prevent this" % (group, srctag['name'])) session.groupListRemove(tag, group) From 96fc238737ddb704751a8b22a38faada1c96ef78 Mon Sep 17 00:00:00 2001 From: Mike McLean Date: Jul 22 2025 14:19:10 +0000 Subject: [PATCH 5/10] clean up block-group; add unblock-group --- diff --git a/cli/koji_cli/commands.py b/cli/koji_cli/commands.py index d4eb480..c59a10e 100644 --- a/cli/koji_cli/commands.py +++ b/cli/koji_cli/commands.py @@ -111,18 +111,37 @@ def handle_block_group(goptions, session, args): if not (session.hasPerm('admin') or session.hasPerm('tag')): parser.error("This action requires tag or admin privileges") - dsttag = session.getTag(tag) - if not dsttag: + taginfo = session.getTag(tag) + if not taginfo: error("No such tag: %s" % tag) - groups = dict([(p['name'], p['group_id']) for p in session.getTagGroups(tag, inherit=False)]) - group_id = groups.get(group, None) - if group_id is None: + # sanity check + groups = session.getTagGroups(taginfo['id'], incl_pkgs=False, incl_reqs=False, incl_blocked=True) + for ginfo in groups: + if ginfo['name'] == group: + break + else: error("Group %s doesn't exist within tag %s" % (group, tag)) + if ginfo['blocked']: + error("Group %s is already blocked in this tag" % group) + # (we don't care if the entry is inherited for this) session.groupListBlock(tag, group) +def handle_unblock_group(goptions, session, args): + "[admin] Unblock a group from tag" + usage = "usage: %prog unblock-group [options] " + parser = OptionParser(usage=get_usage_str(usage)) + (options, args) = parser.parse_args(args) + if len(args) < 2: + parser.error("You must specify a tag name and group name") + tag = args[0] + group = args[1] + activate_session(session, goptions) + session.groupListUnblock(tag, group) + + def handle_remove_group(goptions, session, args): "[admin] Remove group from tag" usage = "usage: %prog remove-group " From da3938fbc17ff531afe25cb7a3b2a431ba3c86be Mon Sep 17 00:00:00 2001 From: Mike McLean Date: Jul 22 2025 14:19:10 +0000 Subject: [PATCH 6/10] ... --- diff --git a/cli/koji_cli/commands.py b/cli/koji_cli/commands.py index c59a10e..38bb903 100644 --- a/cli/koji_cli/commands.py +++ b/cli/koji_cli/commands.py @@ -134,7 +134,7 @@ def handle_unblock_group(goptions, session, args): usage = "usage: %prog unblock-group [options] " parser = OptionParser(usage=get_usage_str(usage)) (options, args) = parser.parse_args(args) - if len(args) < 2: + if len(args) != 2: parser.error("You must specify a tag name and group name") tag = args[0] group = args[1] From 0f06d749ef9062cc9f910f937ae803d6d5024590 Mon Sep 17 00:00:00 2001 From: Mike McLean Date: Jul 22 2025 14:19:10 +0000 Subject: [PATCH 7/10] unit tests --- diff --git a/tests/test_cli/data/list-commands-admin.txt b/tests/test_cli/data/list-commands-admin.txt index 1928c1b..84f3180 100644 --- a/tests/test_cli/data/list-commands-admin.txt +++ b/tests/test_cli/data/list-commands-admin.txt @@ -49,7 +49,8 @@ admin commands: regen-repo Generate a current repo if there is not one remove-external-repo Remove an external repo from a tag or tags, or remove entirely remove-group Remove group from tag - remove-group-pkg Remove a package from a group's package listing + remove-group-pkg Remove packages from a group's package listing + remove-group-req Remove entries from a group's requirement listing remove-host-from-channel Remove a host from a channel remove-pkg Remove a package from the listing for tag remove-sig Remove signed RPMs from db and disk @@ -65,6 +66,7 @@ admin commands: set-pkg-owner Set the owner for a package set-pkg-owner-global Set the owner for a package globally set-task-priority Set task priority + unblock-group Unblock a group from tag unblock-group-pkg Unblock a package from a group's package listing unblock-group-req Unblock a group's requirement listing unblock-pkg Unblock a package in the listing for tag diff --git a/tests/test_cli/data/list-commands.txt b/tests/test_cli/data/list-commands.txt index 809229d..9671bba 100644 --- a/tests/test_cli/data/list-commands.txt +++ b/tests/test_cli/data/list-commands.txt @@ -49,7 +49,8 @@ admin commands: regen-repo Generate a current repo if there is not one remove-external-repo Remove an external repo from a tag or tags, or remove entirely remove-group Remove group from tag - remove-group-pkg Remove a package from a group's package listing + remove-group-pkg Remove packages from a group's package listing + remove-group-req Remove entries from a group's requirement listing remove-host-from-channel Remove a host from a channel remove-pkg Remove a package from the listing for tag remove-sig Remove signed RPMs from db and disk @@ -65,6 +66,7 @@ admin commands: set-pkg-owner Set the owner for a package set-pkg-owner-global Set the owner for a package globally set-task-priority Set task priority + unblock-group Unblock a group from tag unblock-group-pkg Unblock a package from a group's package listing unblock-group-req Unblock a group's requirement listing unblock-pkg Unblock a package in the listing for tag diff --git a/tests/test_cli/test_block_group.py b/tests/test_cli/test_block_group.py index 2b5f499..41ebade 100644 --- a/tests/test_cli/test_block_group.py +++ b/tests/test_cli/test_block_group.py @@ -56,10 +56,11 @@ class TestBlockGroup(utils.CliTestCase): def test_handle_block_group_nonexistent_group(self): tag = 'tag' + tagID = 100 group = 'group' arguments = [tag, group] self.session.hasPerm.return_value = True - self.session.getTag.return_value = tag + self.session.getTag.return_value = {'name': tag, 'id': tagID} self.session.getTagGroups.return_value = [] # Run it and check immediate output @@ -75,18 +76,19 @@ class TestBlockGroup(utils.CliTestCase): self.activate_session_mock.assert_called_once_with(self.session, self.options) self.session.hasPerm.assert_called_once_with('admin') self.session.getTag.assert_called_once_with(tag) - self.session.getTagGroups.assert_called_once_with(tag, inherit=False) + self.session.getTagGroups.assert_called_once_with(tagID, incl_pkgs=False, incl_reqs=False, incl_blocked=True) self.session.groupListBlock.assert_not_called() @mock.patch('sys.stdout', new_callable=six.StringIO) def test_handle_block_group(self, stdout): tag = 'tag' + tagID = 100 group = 'group' arguments = [tag, group] self.session.hasPerm.return_value = True - self.session.getTag.return_value = tag + self.session.getTag.return_value = {'name': tag, 'id': tagID} self.session.getTagGroups.return_value = [ - {'name': 'group', 'group_id': 'groupId'}] + {'name': 'group', 'group_id': 'groupId', 'blocked': False}] # Run it and check immediate output rv = handle_block_group(self.options, self.session, arguments) @@ -98,10 +100,34 @@ class TestBlockGroup(utils.CliTestCase): self.activate_session_mock.assert_called_once_with(self.session, self.options) self.session.hasPerm.assert_called_once_with('admin') self.session.getTag.assert_called_once_with(tag) - self.session.getTagGroups.assert_called_once_with(tag, inherit=False) + self.session.getTagGroups.assert_called_once_with(tagID, incl_pkgs=False, incl_reqs=False, incl_blocked=True) self.session.groupListBlock.assert_called_once_with(tag, group) self.assertEqual(rv, None) + def test_handle_group_already_blocked(self): + tag = 'tag' + tagID = 100 + group = 'group' + arguments = [tag, group] + self.session.hasPerm.return_value = True + self.session.getTag.return_value = {'name': tag, 'id': tagID} + self.session.getTagGroups.return_value = [ + {'name': 'group', 'group_id': 'groupId', 'blocked': True}] + + # Run it and check immediate output + self.assert_system_exit( + handle_block_group, + self.options, self.session, arguments, + stderr='Group group is already blocked in this tag\n', + stdout='', + exit_code=1) + + # Finally, assert that things were called as we expected. + self.session.hasPerm.assert_called_once_with('admin') + self.session.getTag.assert_called_once_with(tag) + self.session.getTagGroups.assert_called_once_with(tagID, incl_pkgs=False, incl_reqs=False, incl_blocked=True) + self.session.groupListBlock.assert_not_called() + def test_handle_block_group_error_handling(self): expected = self.format_error_message( "Please specify a tag name and a group name") diff --git a/tests/test_cli/test_remove_group.py b/tests/test_cli/test_remove_group.py index a6b058f..a4e1638 100644 --- a/tests/test_cli/test_remove_group.py +++ b/tests/test_cli/test_remove_group.py @@ -54,12 +54,13 @@ class TestRemoveGroup(utils.CliTestCase): @mock.patch('koji_cli.commands.activate_session') def test_handle_remove_group_nonexistent_group(self, activate_session_mock, stdout, stderr): tag = 'tag' + tagID = 100 group = 'group' arguments = [tag, group] # Mock out the xmlrpc server self.session.hasPerm.return_value = True - self.session.getTag.return_value = tag + self.session.getTag.return_value = {'name': tag, 'id': tagID} self.session.getTagGroups.return_value = [] with self.assertRaises(SystemExit): @@ -69,7 +70,59 @@ class TestRemoveGroup(utils.CliTestCase): activate_session_mock.assert_called_once_with(self.session, self.options) self.session.hasPerm.assert_called_once_with('admin') self.session.getTag.assert_called_once_with(tag) - self.session.getTagGroups.assert_called_once_with(tag, inherit=False) + self.session.getTagGroups.assert_called_once_with(tagID, incl_pkgs=False, incl_reqs=False, incl_blocked=True) + self.session.groupListRemove.assert_not_called() + + @mock.patch('sys.stdout', new_callable=six.StringIO) + @mock.patch('sys.stderr', new_callable=six.StringIO) + @mock.patch('koji_cli.commands.activate_session') + def test_handle_remove_group_blocked_group(self, activate_session_mock, stdout, stderr): + tag = 'tag' + tagID = 100 + group = 'group' + arguments = [tag, group] + + # Mock out the xmlrpc server + self.session.hasPerm.return_value = True + self.session.getTag.return_value = {'name': tag, 'id': tagID} + self.session.getTagGroups.return_value = [{'name': 'group', 'tag_id': tagID, + 'blocked': True}] + + with self.assertRaises(SystemExit): + handle_remove_group(self.options, self.session, arguments) + + # assert that things were called as we expected. + activate_session_mock.assert_called_once_with(self.session, self.options) + self.session.hasPerm.assert_called_once_with('admin') + self.session.getTag.assert_called_once_with(tag) + self.session.getTagGroups.assert_called_once_with(tagID, incl_pkgs=False, incl_reqs=False, incl_blocked=True) + self.session.groupListRemove.assert_not_called() + + @mock.patch('sys.stdout', new_callable=six.StringIO) + @mock.patch('sys.stderr', new_callable=six.StringIO) + @mock.patch('koji_cli.commands.activate_session') + def test_handle_remove_group_inherited_group(self, activate_session_mock, stdout, stderr): + tag = 'tag' + tagID = 100 + parentID = 99 + group = 'group' + arguments = [tag, group] + + # Mock out the xmlrpc server + self.session.hasPerm.return_value = True + self.session.getTag.return_value = {'name': tag, 'id': tagID} + self.session.getTagGroups.return_value = [{'name': 'group', 'tag_id': parentID, + 'blocked': False}] + + with self.assertRaises(SystemExit): + handle_remove_group(self.options, self.session, arguments) + + # assert that things were called as we expected. + activate_session_mock.assert_called_once_with(self.session, self.options) + self.session.hasPerm.assert_called_once_with('admin') + self.session.getTagGroups.assert_called_once_with(tagID, incl_pkgs=False, incl_reqs=False, incl_blocked=True) + expected_calls = [mock.call(tag), mock.call(parentID)] + self.assertEqual(self.session.getTag.call_args_list, expected_calls) self.session.groupListRemove.assert_not_called() @mock.patch('sys.stdout', new_callable=six.StringIO) @@ -77,14 +130,16 @@ class TestRemoveGroup(utils.CliTestCase): @mock.patch('koji_cli.commands.activate_session') def test_handle_remove_group(self, activate_session_mock, stdout, stderr): tag = 'tag' + tagID = 100 group = 'group' arguments = [tag, group] # Mock out the xmlrpc server self.session.hasPerm.return_value = True - self.session.getTag.return_value = tag + self.session.getTag.return_value = {'name': tag, 'id': tagID} self.session.getTagGroups.return_value = [ - {'name': 'group', 'group_id': 'groupId'}] + {'name': 'group', 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID}] + # so case where group is present and directly in the tag rv = handle_remove_group(self.options, self.session, arguments) @@ -92,7 +147,7 @@ class TestRemoveGroup(utils.CliTestCase): activate_session_mock.assert_called_once_with(self.session, self.options) self.session.hasPerm.assert_called_once_with('admin') self.session.getTag.assert_called_once_with(tag) - self.session.getTagGroups.assert_called_once_with(tag, inherit=False) + self.session.getTagGroups.assert_called_once_with(tagID, incl_pkgs=False, incl_reqs=False, incl_blocked=True) self.session.groupListRemove.assert_called_once_with(tag, group) self.assertEqual(rv, None) diff --git a/tests/test_cli/test_remove_group_pkg.py b/tests/test_cli/test_remove_group_pkg.py index 62277a6..df167b0 100644 --- a/tests/test_cli/test_remove_group_pkg.py +++ b/tests/test_cli/test_remove_group_pkg.py @@ -25,6 +25,9 @@ class TestRemoveGroupPkg(utils.CliTestCase): %s: error: {message} """ % (self.progname, self.progname) + def tearDown(self): + mock.patch.stopall() + def test_handle_remove_pkg_not_existing_tag(self): tag = 'tag' package = 'package' @@ -68,12 +71,17 @@ class TestRemoveGroupPkg(utils.CliTestCase): @mock.patch('sys.stderr', new_callable=six.StringIO) def test_handle_remove_pkg(self, stderr, stdout): tag = 'tag' - dsttag = {'name': tag, 'id': 1} + tagID = 1 + dsttag = {'name': tag, 'id': tagID} package = 'package' group = 'group' args = [tag, group, package] self.session.getTag.return_value = dsttag + self.session.getTagGroups.return_value = [ + {'name': group, 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID, + 'packagelist': [{'package': package, 'blocked': False, 'tag_id': tagID}] + }] self.session.groupPackageListRemove.return_value = None handle_remove_group_pkg(self.options, self.session, args) actual = stderr.getvalue() @@ -86,24 +94,99 @@ class TestRemoveGroupPkg(utils.CliTestCase): self.activate_session_mock.assert_called_once_with(self.session, self.options) self.session.getTag.assert_called_once_with(tag) - @mock.patch('sys.stdout', new_callable=six.StringIO) - @mock.patch('sys.stderr', new_callable=six.StringIO) - def test_handle_remove_pkg_with_force(self, stderr, stdout): + def test_handle_group_not_present(self): tag = 'tag' - dsttag = {'name': tag, 'id': 1} + tagID = 1 + dsttag = {'name': tag, 'id': tagID} package = 'package' group = 'group' - args = [tag, group, package, '--force'] + args = [tag, group, package] self.session.getTag.return_value = dsttag + self.session.getTagGroups.return_value = [] self.session.groupPackageListRemove.return_value = None - handle_remove_group_pkg(self.options, self.session, args) - actual = stderr.getvalue() - expected = '' - self.assertMultiLineEqual(actual, expected) - actual = stdout.getvalue() - expected = '' - self.assertMultiLineEqual(actual, expected) - # Finally, assert that things were called as we expected. - self.activate_session_mock.assert_called_once_with(self.session, self.options) + + self.assert_system_exit( + handle_remove_group_pkg, + self.options, self.session, args, + stderr='Group group is not present in tag tag\n', + stdout='', + exit_code=1) + + self.session.getTag.assert_called_once_with(tag) + + def test_handle_package_not_included(self): + tag = 'tag' + tagID = 1 + dsttag = {'name': tag, 'id': tagID} + package = 'package' + group = 'group' + args = [tag, group, package] + + self.session.getTag.return_value = dsttag + self.session.getTagGroups.return_value = [ + {'name': group, 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID, + 'packagelist': [] + }] + self.session.groupPackageListRemove.return_value = None + + self.assert_system_exit( + handle_remove_group_pkg, + self.options, self.session, args, + stdout='Package package is not included in this group\n', + stderr='Invalid parameters\n', + exit_code=1) + + self.session.getTag.assert_called_once_with(tag) + + def test_handle_package_blocked(self): + tag = 'tag' + tagID = 1 + dsttag = {'name': tag, 'id': tagID} + package = 'package' + group = 'group' + args = [tag, group, package] + + self.session.getTag.return_value = dsttag + self.session.getTagGroups.return_value = [ + {'name': group, 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID, + 'packagelist': [{'package': package, 'blocked': True, 'tag_id': tagID}] + }] + self.session.groupPackageListRemove.return_value = None + + self.assert_system_exit( + handle_remove_group_pkg, + self.options, self.session, args, + stdout='Package package is blocked in this group. ' + 'You could use unblock-group-pkg to unblock it\n', + stderr='Invalid parameters\n', + exit_code=1) + self.session.getTag.assert_called_once_with(tag) + + def test_handle_package_inherited(self): + tag = 'tag' + tagID = 1 + dsttag = {'name': tag, 'id': tagID} + parentID = 20 + ptag = {'name': 'parent', 'id': parentID} + package = 'package' + group = 'group' + args = [tag, group, package] + + self.session.getTag.side_effect = [dsttag, ptag] + self.session.getTagGroups.return_value = [ + {'name': group, 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID, + 'packagelist': [{'package': package, 'blocked': False, 'tag_id': parentID}] + }] + self.session.groupPackageListRemove.return_value = None + + self.assert_system_exit( + handle_remove_group_pkg, + self.options, self.session, args, + stdout='The entry for package package is inherited from parent. You could use block-group-pkg to prevent this\n', + stderr='Invalid parameters\n', + exit_code=1) + + +# the end diff --git a/tests/test_cli/test_remove_group_req.py b/tests/test_cli/test_remove_group_req.py new file mode 100644 index 0000000..7970639 --- /dev/null +++ b/tests/test_cli/test_remove_group_req.py @@ -0,0 +1,198 @@ +from __future__ import absolute_import + +import mock +import six + +from koji_cli.commands import handle_remove_group_req + +import koji +from . import utils + + +class TestRemoveGroupReq(utils.CliTestCase): + + def setUp(self): + # Show long diffs in error output... + self.maxDiff = None + self.options = mock.MagicMock() + self.options.debug = False + self.session = mock.MagicMock() + self.session.getAPIVersion.return_value = koji.API_VERSION + self.activate_session_mock = mock.patch('koji_cli.commands.activate_session').start() + self.error_format = """Usage: %s remove-group-req [options] [ ...] +(Specify the --help global option for a list of other help options) + +%s: error: {message} +""" % (self.progname, self.progname) + + def tearDown(self): + mock.patch.stopall() + + def test_wrong_count_args(self): + tag = 'tag' + group = 'group' + args = [tag, group] + expected_error = self.format_error_message( + 'You must specify a tag name, group name, and one or more requirement names') + self.assert_system_exit( + handle_remove_group_req, + self.options, self.session, args, + stderr=expected_error, + stdout='', + activate_session=None, + exit_code=2) + # Finally, assert that things were called as we expected. + self.activate_session_mock.assert_not_called() + self.session.getTag.assert_not_called() + self.session.groupReqListRemove.assert_not_called() + self.session.multicall.assert_not_called() + + def test_no_such_tag(self): + tag = 'tag' + group = 'group' + req = 'other_group' + args = [tag, group, req] + self.session.getTag.return_value = None + + self.assert_system_exit( + handle_remove_group_req, + self.options, self.session, args, + stderr='No such tag: tag\n', + stdout='', + activate_session=None, + exit_code=1) + + # Finally, assert that things were called as we expected. + self.session.getTag.assert_called_once() + self.session.groupReqListRemove.assert_not_called() + self.session.multicall.assert_not_called() + + @mock.patch('sys.stdout', new_callable=six.StringIO) + @mock.patch('sys.stderr', new_callable=six.StringIO) + def test_success(self, stderr, stdout): + tag = 'tag' + tagID = 1 + dsttag = {'name': tag, 'id': tagID} + group = 'group' + req = 'other_group' + args = [tag, group, req] + + self.session.getTag.return_value = dsttag + self.session.getTagGroups.return_value = [ + {'name': group, 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID, + 'grouplist': [{'name': req, 'blocked': False, 'tag_id': tagID}] + }] + + handle_remove_group_req(self.options, self.session, args) + + actual = stderr.getvalue() + expected = '' + self.assertMultiLineEqual(actual, expected) + actual = stdout.getvalue() + expected = '' + self.assertMultiLineEqual(actual, expected) + # Finally, assert that things were called as we expected. + self.activate_session_mock.assert_called_once_with(self.session, self.options) + self.session.getTag.assert_called_once_with(tag) + self.session.multicall.assert_called_once() + + def test_handle_group_not_present(self): + tag = 'tag' + tagID = 1 + dsttag = {'name': tag, 'id': tagID} + group = 'group' + req = 'other_group' + args = [tag, group, req] + + self.session.getTag.return_value = dsttag + self.session.getTagGroups.return_value = [] + + self.assert_system_exit( + handle_remove_group_req, + self.options, self.session, args, + stderr='Group group is not present in tag tag\n', + stdout='', + exit_code=1) + + self.session.getTag.assert_called_once_with(tag) + self.session.groupReqListRemove.assert_not_called() + self.session.multicall.assert_not_called() + + def test_handle_req_not_included(self): + tag = 'tag' + tagID = 1 + dsttag = {'name': tag, 'id': tagID} + group = 'group' + req = 'other_group' + args = [tag, group, req] + + self.session.getTag.return_value = dsttag + self.session.getTagGroups.return_value = [ + {'name': group, 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID, + 'grouplist': [] + }] + + self.assert_system_exit( + handle_remove_group_req, + self.options, self.session, args, + stdout='Req other_group is not included in this group\n', + stderr='Invalid parameters\n', + exit_code=1) + + self.session.getTag.assert_called_once_with(tag) + self.session.groupReqListRemove.assert_not_called() + self.session.multicall.assert_not_called() + + def test_handle_req_blocked(self): + tag = 'tag' + tagID = 1 + dsttag = {'name': tag, 'id': tagID} + group = 'group' + req = 'other_group' + args = [tag, group, req] + + self.session.getTag.return_value = dsttag + self.session.getTagGroups.return_value = [ + {'name': group, 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID, + 'grouplist': [{'name': req, 'blocked': True, 'tag_id': tagID}] + }] + + self.assert_system_exit( + handle_remove_group_req, + self.options, self.session, args, + stdout='Req other_group is blocked in this group. ' + 'You could use unblock-group-req to unblock it\n', + stderr='Invalid parameters\n', + exit_code=1) + + self.session.getTag.assert_called_once_with(tag) + self.session.groupReqListRemove.assert_not_called() + self.session.multicall.assert_not_called() + + def test_handle_req_inherited(self): + tag = 'tag' + tagID = 1 + dsttag = {'name': tag, 'id': tagID} + parentID = 20 + ptag = {'name': 'parent', 'id': parentID} + group = 'group' + req = 'other_group' + args = [tag, group, req] + + self.session.getTag.side_effect = [dsttag, ptag] + self.session.getTagGroups.return_value = [ + {'name': group, 'group_id': 'groupId', 'blocked': False, 'tag_id': tagID, + 'grouplist': [{'name': req, 'blocked': False, 'tag_id': parentID}] + }] + + self.assert_system_exit( + handle_remove_group_req, + self.options, self.session, args, + stdout='The entry for req other_group is inherited from parent. You could use block-group-req to prevent this\n', + stderr='Invalid parameters\n', + exit_code=1) + self.session.groupReqListRemove.assert_not_called() + self.session.multicall.assert_not_called() + + +# the end diff --git a/tests/test_cli/test_unblock_group.py b/tests/test_cli/test_unblock_group.py new file mode 100644 index 0000000..797527a --- /dev/null +++ b/tests/test_cli/test_unblock_group.py @@ -0,0 +1,74 @@ +from __future__ import absolute_import + +try: + from unittest import mock +except ImportError: + import mock +import six +import koji + +from koji_cli.commands import handle_unblock_group +from . import utils + + +class TestBlockGroup(utils.CliTestCase): + # Show long diffs in error output... + maxDiff = None + + def setUp(self): + self.maxDiff = None + self.options = mock.MagicMock() + self.options.debug = False + self.session = mock.MagicMock() + self.session.getAPIVersion.return_value = koji.API_VERSION + self.activate_session_mock = mock.patch('koji_cli.commands.activate_session').start() + self.error_format = """Usage: %s unblock-group [options] +(Specify the --help global option for a list of other help options) + +%s: error: {message} +""" % (self.progname, self.progname) + + def tearDown(self): + mock.patch.stopall() + + def test_handle_unblock_group_badargs(self): + tag = 'tag' + group = 'group' + arguments = [tag, group, 'extra_arg'] + self.session.hasPerm.return_value = True + self.session.getTag.return_value = None + + expected = self.format_error_message( + "You must specify a tag name and group name") + # Run it and check immediate output + self.assert_system_exit( + handle_unblock_group, + self.options, self.session, arguments, + stderr=expected, + stdout='', + activate_session=None, + exit_code=2) + + # Finally, assert that things were called as we expected. + self.activate_session_mock.assert_not_called() + self.session.groupListUnblock.assert_not_called() + + @mock.patch('sys.stdout', new_callable=six.StringIO) + def test_handle_unblock_group(self, stdout): + tag = 'tag' + group = 'group' + arguments = [tag, group] + + # Run it and check immediate output + rv = handle_unblock_group(self.options, self.session, arguments) + actual = stdout.getvalue() + expected = '' + self.assertMultiLineEqual(actual, expected) + + # Finally, assert that things were called as we expected. + self.activate_session_mock.assert_called_once_with(self.session, self.options) + self.session.groupListUnblock.assert_called_once_with(tag, group) + self.assertEqual(rv, None) + + +# the end From 6b5eb875af25d254fe5dc0d0eb6a4bda527e284c Mon Sep 17 00:00:00 2001 From: Mike McLean Date: Jul 22 2025 14:19:10 +0000 Subject: [PATCH 8/10] docs updates --- diff --git a/docs/source/index.rst b/docs/source/index.rst index b7233f5..b052644 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -33,6 +33,7 @@ Contents repo_generation exporting_repositories tag_inheritance + managing_comps_data misc release_notes/release_notes migrations/migrations diff --git a/docs/source/managing_comps_data.rst b/docs/source/managing_comps_data.rst new file mode 100644 index 0000000..6cd4af4 --- /dev/null +++ b/docs/source/managing_comps_data.rst @@ -0,0 +1,157 @@ +Managing comps data for tags +============================ + +Koji can record comps data for each tag and uses that data to write a comps +file whenever it generates a yum repo for that tag. This document describes +how this data is stored, how to read it, and how to change it. + +For background on comps files, see + +* https://dnf5.readthedocs.io/en/latest/misc/comps.7.html +* https://fedoraproject.org/wiki/How_to_use_and_edit_comps.xml_for_package_groups + +The comps data *affects how rpm builds are performed*. +Various buildroots generated by Koji use these groups for their base install. +E.g. + +* rpm builds use the ``build`` group +* source rpm builds use the ``srpm-build`` group +* maven builds use the ``maven-build`` group +* wrapper rpm builds use the ``wrapper-rpm-build`` group +* appliance builds use the ``appliance-build`` group +* livecd builds use the ``livecd-build`` group +* livemedia builds use the ``livemedia-build`` group + + +Reading the data +---------------- + +The ``koji list-groups`` cli command will output the comps data for a given tag + +.. code-block:: text + + Usage: koji list-groups [options] [] + (Specify the --help global option for a list of other help options) + + Options: + -h, --help show this help message and exit + --event=EVENT# query at event + --ts=TIMESTAMP query at last event before timestamp + --repo=REPO# query at event for a repo + --show-blocked Show blocked packages and groups + +The command will optionally accept a group name to limit the output to a single group. + +Like many other types of data in Koji, the comps data is versioned and Koji remembers its history. +The ``--event/--ts/--repo`` options can be used to query this data from an earlier point in time. + +The ``show-blocked`` option will include blocked entries in the output. + +Alternately ``show-groups`` command can be used to present the groups data in different formats. +This command is a historical oddity, but still useful to export the data in comps format. + +Example +------- + +The following command prints the srpm-build group data for the f41-build tag. + +.. code-block:: text + + $ koji list-groups f41-build srpm-build + srpm-build [f41] + bash: None, mandatory [f41] + fedora-release: None, mandatory [f41] + fedpkg-minimal: None, mandatory [f41] + glibc-minimal-langpack: None, mandatory [f41] + gnupg2: None, mandatory [f41] + redhat-rpm-config: None, mandatory [f41] + rpm-build: None, mandatory [f41] + shadow-utils: None, mandatory [f41] + +Because our command included a specific group name, the command only outputs that one group. +Under this group, we see the list of packages in the group and their parameters. +The bracketed value at the end shows the tag that the entry is inherited from. + + +Groups data and inheritance +--------------------------- + +Like most other data associated with tags, groups data is inheritable. +Because the groups data is complex, so is the inheritance. +The groups data is inherited piecemeal, with individual groups, group package +entries, and group dependencies separately inheritable. + +You can prevent an entry from being inherited by blocking it. +This can be done via the ``block-group``, ``block-group-pkg``, and ``block-group-req`` +commands respectively (and undone with the corresponding unblock command). + +As noted above, you can see which tags an entry comes from via the ``list-groups`` command. + +So, a given tag will have a list of groups that includes inherited entries. +Each of those groups will have a list of packages and a list of group dependencies +which include inherited entries. + +For admin sanity, we recommend defining a comps in a base tag in your inheritance +and keeping adjustments in child tags to a minimum. +A common case is to add additional packages to the build group for a specialized +build tag. + + +Changing comps data +------------------- + +You can add, remove, block, and unblock individual entries with the relevant command + +.. code-block:: text + + add-group Add a group to a tag + remove-group Remove group from tag + block-group Block group in tag + unblock-group Unblock a group from tag + add-group-pkg Add a package to a group's package listing + remove-group-pkg Remove packages from a group's package listing + block-group-pkg Block a package from a group's package listing + unblock-group-pkg Unblock a package from a group's package listing + add-group-req Add a group to a group's required list + block-group-req Block a group's requirement listing + remove-group-req Remove entries from a group's requirement listing + unblock-group-req Unblock a group's requirement listing + +Additionally, you can also import groups data directly from a comps file with the ``import-comps`` command. +This is useful when setting up a new tag from scratch. + +.. code-block:: text + + Usage: koji import-comps [options] + (Specify the --help global option for a list of other help options) + + Options: + -h, --help show this help message and exit + --force force import + + +Blocking vs removing +-------------------- + +The various remove commands remove a specific entry from a specific tag. +They will fail if the entry is not actually in the specified tag. +This commonly happens when an entry is inherited. + +When your tag has an inherited comps entry that you want to remove, you have two +options: + +* block the entry in the tag +* remove the entry from the ancestor tag it is inherited from + +It is generally safest to block, as this only affects the tag of concern. +Removing the entry in an ancestor could affect other tags negatively. +However, there are certainly cases where it makes sense to remove the entry +from the ancestor. + + +Group dependencies +------------------ + +For historical reasons, Koji's comps data allows specifying group dependecies/requirements. +This is antiquated feature of comps that is longer used by dnf, +so in practice there is no need to specify such entries (i.e. the various ``*-req`` commands). From 3d42de2a99bd47a6181a059bba46d3d6ba78640b Mon Sep 17 00:00:00 2001 From: Mike McLean Date: Jul 22 2025 14:19:10 +0000 Subject: [PATCH 9/10] flake8 --- diff --git a/cli/koji_cli/commands.py b/cli/koji_cli/commands.py index 38bb903..2e15b96 100644 --- a/cli/koji_cli/commands.py +++ b/cli/koji_cli/commands.py @@ -116,7 +116,8 @@ def handle_block_group(goptions, session, args): error("No such tag: %s" % tag) # sanity check - groups = session.getTagGroups(taginfo['id'], incl_pkgs=False, incl_reqs=False, incl_blocked=True) + groups = session.getTagGroups(taginfo['id'], incl_pkgs=False, incl_reqs=False, + incl_blocked=True) for ginfo in groups: if ginfo['name'] == group: break @@ -161,7 +162,8 @@ def handle_remove_group(goptions, session, args): error("No such tag: %s" % tag) # sanity checks - groups = session.getTagGroups(taginfo['id'], incl_pkgs=False, incl_reqs=False, incl_blocked=True) + groups = session.getTagGroups(taginfo['id'], incl_pkgs=False, incl_reqs=False, + incl_blocked=True) for ginfo in groups: if ginfo['name'] == group: break @@ -172,7 +174,8 @@ def handle_remove_group(goptions, session, args): if ginfo['tag_id'] != taginfo['id']: # listing is inherited srctag = session.getTag(ginfo['tag_id']) - error("The entry for group %s is inherited from %s. You could use block-group to prevent this" % (group, srctag['name'])) + error("The entry for group %s is inherited from %s. " + "You could use block-group to prevent this" % (group, srctag['name'])) session.groupListRemove(tag, group) @@ -3159,13 +3162,15 @@ def handle_remove_group_pkg(goptions, session, args): continue pinfo = pkg_idx[pkg] if pinfo['blocked']: - print("Package %s is blocked in this group. You could use unblock-group-pkg to unblock it" % pkg) + print("Package %s is blocked in this group. " + "You could use unblock-group-pkg to unblock it" % pkg) sane = False continue if pinfo['tag_id'] != taginfo['id']: # listing is inherited srctag = session.getTag(pinfo['tag_id']) - print("The entry for package %s is inherited from %s. You could use block-group-pkg to prevent this" % (pkg, srctag['name'])) + print("The entry for package %s is inherited from %s. " + "You could use block-group-pkg to prevent this" % (pkg, srctag['name'])) sane = False continue if not sane: @@ -3246,6 +3251,7 @@ def handle_unblock_group_req(goptions, session, args): activate_session(session, goptions) session.groupReqListUnblock(tag, group, req) + def handle_remove_group_req(goptions, session, args): "[admin] Remove entries from a group's requirement listing" usage = "usage: %prog remove-group-req [options] [ ...]" @@ -3277,13 +3283,15 @@ def handle_remove_group_req(goptions, session, args): continue rinfo = req_idx[req] if rinfo['blocked']: - print("Req %s is blocked in this group. You could use unblock-group-req to unblock it" % req) + print("Req %s is blocked in this group. " + "You could use unblock-group-req to unblock it" % req) sane = False continue if rinfo['tag_id'] != taginfo['id']: # listing is inherited srctag = session.getTag(rinfo['tag_id']) - print("The entry for req %s is inherited from %s. You could use block-group-req to prevent this" % (req, srctag['name'])) + print("The entry for req %s is inherited from %s. " + "You could use block-group-req to prevent this" % (req, srctag['name'])) sane = False continue if not sane: From c45a3921468f542c10dc98a8ffc9e8779b234fb9 Mon Sep 17 00:00:00 2001 From: Mike McLean Date: Jul 22 2025 14:19:10 +0000 Subject: [PATCH 10/10] update mock imports --- diff --git a/tests/test_cli/test_remove_group_pkg.py b/tests/test_cli/test_remove_group_pkg.py index df167b0..42d6398 100644 --- a/tests/test_cli/test_remove_group_pkg.py +++ b/tests/test_cli/test_remove_group_pkg.py @@ -1,6 +1,9 @@ from __future__ import absolute_import -import mock +try: + from unittest import mock +except ImportError: + import mock import six from koji_cli.commands import handle_remove_group_pkg diff --git a/tests/test_cli/test_remove_group_req.py b/tests/test_cli/test_remove_group_req.py index 7970639..8bfea48 100644 --- a/tests/test_cli/test_remove_group_req.py +++ b/tests/test_cli/test_remove_group_req.py @@ -1,6 +1,9 @@ from __future__ import absolute_import -import mock +try: + from unittest import mock +except ImportError: + import mock import six from koji_cli.commands import handle_remove_group_req