#4397 Clean up cli for comps management
Opened 2 months ago by mikem. Modified an hour ago
mikem/koji comps-cli-updates  into  master

file modified
+139 -10
@@ -111,18 +111,38 @@ 

      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] <tag> <group>"

+     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 <tag> <group>"
@@ -137,14 +157,25 @@ 

      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)

  
@@ -3097,6 +3128,55 @@ 

          session.groupPackageListAdd(tag, group, pkg)

  

  

+ def handle_remove_group_pkg(goptions, session, args):

+     "[admin] Remove packages from a group's package listing"

+     usage = "usage: %prog remove-group-pkg [options] <tag> <group> <pkg> [<pkg> ...]"

+     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 package names")

+     tag = args[0]

+     group = args[1]

+     packages = 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_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(taginfo['id'], group, pkg) for pkg in packages]

+ 

+ 

  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] <tag> <group> <pkg> [<pkg> ...]"
@@ -3169,6 +3249,55 @@ 

      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] <tag> <group> <req> [<req> ...]"

+     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"

      usage = "usage: %prog list-channels [options]"

file modified
+1
@@ -33,6 +33,7 @@ 

      repo_generation

      exporting_repositories

      tag_inheritance

+     managing_comps_data

      misc

      release_notes/release_notes

      migrations/migrations

@@ -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] <tag> [<group>]

+     (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] <file> <tag>

+     (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).

@@ -49,6 +49,8 @@ 

          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 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
@@ -64,6 +66,7 @@ 

          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

@@ -49,6 +49,8 @@ 

          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 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
@@ -64,6 +66,7 @@ 

          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

@@ -56,10 +56,11 @@ 

  

      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 @@ 

          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 @@ 

          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")

@@ -54,12 +54,13 @@ 

      @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 @@ 

          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 @@ 

      @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 @@ 

          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)

  

@@ -0,0 +1,195 @@ 

+ from __future__ import absolute_import

+ 

+ try:

+     from unittest import mock

+ except ImportError:

+     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] <tag> <group> <pkg> [<pkg> ...]

+ (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_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'

+         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()

+         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)

+ 

+     def test_handle_group_not_present(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 = []

+         self.session.groupPackageListRemove.return_value = None

+ 

+         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

@@ -0,0 +1,201 @@ 

+ from __future__ import absolute_import

+ 

+ try:

+     from unittest import mock

+ except ImportError:

+     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] <tag> <group> <req> [<req> ...]

+ (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

@@ -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] <tag> <group>

+ (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

Each of the three major data portions for comps (groups, packages, group reqs) has Add, Remove, Block, and Unblock calls in the api, but in the cli things are less consistent

  • we have a remove-group command, but no remove commands for packages or reqs
  • we have block-group, but not unblock-group

Furthermore, some of the cli sanity checks are unhelpful or just wrong

Fixes https://pagure.io/koji/issue/3199
Fixes https://pagure.io/koji/issue/4393

This is currently a draft. Unit tests still need updating, and I need to manually test it more.

Started from a rebase of #3201

For background, see #3199 and #4393

2 new commits added

  • docs updates
  • unit tests
12 days ago

1 new commit added

  • flake8
12 days ago

Only a trivial fix needed for the new tests:

-import mock
+try:
+    from unittest import mock
+except ImportError:
+    import mock

because mock isn't installed in py3 test env. Otherwise it's good to me

1 new commit added

  • update mock imports
an hour ago

Metadata Update from @mikem:
- Pull-request tagged with: testing-custom

an hour ago