Blame SOURCES/0001-groups-manager-Re-introduce-yum-groups-manager-funct.patch

a6dba4
From 40f08d7a22907e6292c314462c01de94584c0854 Mon Sep 17 00:00:00 2001
a6dba4
From: Marek Blaha <mblaha@redhat.com>
a6dba4
Date: Tue, 27 Oct 2020 15:46:03 +0100
a6dba4
Subject: [PATCH 1/2] [groups-manager] Re-introduce yum-groups-manager
a6dba4
 functionality (RhBug:1826016)
a6dba4
a6dba4
Implements 'dnf groups-manager' command with features:
a6dba4
- read, merge, print and write groups metadata files
a6dba4
- edit group attributes name (with translated variants),
a6dba4
  description (with translated variants), uservisible, displayorder
a6dba4
- add packgages to group
a6dba4
- remove packages from group
a6dba4
a6dba4
= changelog =
a6dba4
msg:           Re-introduce yum-groups-manager functionality
a6dba4
type:          enhancement
a6dba4
resolves:      https://bugzilla.redhat.com/show_bug.cgi?id=1826016
a6dba4
---
a6dba4
 dnf-plugins-core.spec     |  22 ++-
a6dba4
 doc/CMakeLists.txt        |   2 +
a6dba4
 doc/conf.py               |   2 +
a6dba4
 doc/groups-manager.rst    |  94 ++++++++++++
a6dba4
 doc/index.rst             |   1 +
a6dba4
 libexec/dnf-utils.in      |   1 +
a6dba4
 plugins/CMakeLists.txt    |   1 +
a6dba4
 plugins/groups_manager.py | 314 ++++++++++++++++++++++++++++++++++++++
a6dba4
 8 files changed, 432 insertions(+), 5 deletions(-)
a6dba4
 create mode 100644 doc/groups-manager.rst
a6dba4
 create mode 100644 plugins/groups_manager.py
a6dba4
a6dba4
diff --git a/dnf-plugins-core.spec b/dnf-plugins-core.spec
a6dba4
index d13a996..42d0884 100644
a6dba4
--- a/dnf-plugins-core.spec
a6dba4
+++ b/dnf-plugins-core.spec
a6dba4
@@ -58,6 +58,7 @@ Provides:       dnf-command(debug-dump)
a6dba4
 Provides:       dnf-command(debug-restore)
a6dba4
 Provides:       dnf-command(debuginfo-install)
a6dba4
 Provides:       dnf-command(download)
a6dba4
+Provides:       dnf-command(groups-manager)
a6dba4
 Provides:       dnf-command(repoclosure)
a6dba4
 Provides:       dnf-command(repograph)
a6dba4
 Provides:       dnf-command(repomanage)
a6dba4
@@ -73,6 +74,7 @@ Provides:       dnf-plugin-debuginfo-install = %{version}-%{release}
a6dba4
 Provides:       dnf-plugin-download = %{version}-%{release}
a6dba4
 Provides:       dnf-plugin-generate_completion_cache = %{version}-%{release}
a6dba4
 Provides:       dnf-plugin-needs_restarting = %{version}-%{release}
a6dba4
+Provides:       dnf-plugin-groups-manager = %{version}-%{release}
a6dba4
 Provides:       dnf-plugin-repoclosure = %{version}-%{release}
a6dba4
 Provides:       dnf-plugin-repodiff = %{version}-%{release}
a6dba4
 Provides:       dnf-plugin-repograph = %{version}-%{release}
a6dba4
@@ -87,7 +89,7 @@ Conflicts:      dnf-plugins-extras-common-data < %{dnf_plugins_extra}
a6dba4
 
a6dba4
 %description
a6dba4
 Core Plugins for DNF. This package enhances DNF with builddep, config-manager,
a6dba4
-copr, debug, debuginfo-install, download, needs-restarting, repoclosure,
a6dba4
+copr, debug, debuginfo-install, download, needs-restarting, groups-manager, repoclosure,
a6dba4
 repograph, repomanage, reposync, changelog and repodiff commands. Additionally
a6dba4
 provides generate_completion_cache passive plugin.
a6dba4
 
a6dba4
@@ -129,7 +131,8 @@ Conflicts:      python-%{name} < %{version}-%{release}
a6dba4
 %description -n python2-%{name}
a6dba4
 Core Plugins for DNF, Python 2 interface. This package enhances DNF with builddep,
a6dba4
 config-manager, copr, degug, debuginfo-install, download, needs-restarting,
a6dba4
-repoclosure, repograph, repomanage, reposync, changelog and repodiff commands.
a6dba4
+groups-manager, repoclosure, repograph, repomanage, reposync, changelog
a6dba4
+and repodiff commands.
a6dba4
 Additionally provides generate_completion_cache passive plugin.
a6dba4
 %endif
a6dba4
 
a6dba4
@@ -163,7 +166,8 @@ Conflicts:      python-%{name} < %{version}-%{release}
a6dba4
 %description -n python3-%{name}
a6dba4
 Core Plugins for DNF, Python 3 interface. This package enhances DNF with builddep,
a6dba4
 config-manager, copr, debug, debuginfo-install, download, needs-restarting,
a6dba4
-repoclosure, repograph, repomanage, reposync, changelog and repodiff commands.
a6dba4
+groups-manager, repoclosure, repograph, repomanage, reposync, changelog
a6dba4
+and repodiff commands.
a6dba4
 Additionally provides generate_completion_cache passive plugin.
a6dba4
 %endif
a6dba4
 
a6dba4
@@ -190,8 +194,8 @@ Summary:        Yum-utils CLI compatibility layer
a6dba4
 %description -n %{yum_utils_subpackage_name}
a6dba4
 As a Yum-utils CLI compatibility layer, supplies in CLI shims for
a6dba4
 debuginfo-install, repograph, package-cleanup, repoclosure, repomanage,
a6dba4
-repoquery, reposync, repotrack, repodiff, builddep, config-manager, debug
a6dba4
-and download that use new implementations using DNF.
a6dba4
+repoquery, reposync, repotrack, repodiff, builddep, config-manager, debug,
a6dba4
+download and yum-groups-manager that use new implementations using DNF.
a6dba4
 %endif
a6dba4
 
a6dba4
 %if 0%{?rhel} == 0 && %{with python2}
a6dba4
@@ -458,6 +462,7 @@ ln -sf %{_libexecdir}/dnf-utils %{buildroot}%{_bindir}/yum-builddep
a6dba4
 ln -sf %{_libexecdir}/dnf-utils %{buildroot}%{_bindir}/yum-config-manager
a6dba4
 ln -sf %{_libexecdir}/dnf-utils %{buildroot}%{_bindir}/yum-debug-dump
a6dba4
 ln -sf %{_libexecdir}/dnf-utils %{buildroot}%{_bindir}/yum-debug-restore
a6dba4
+ln -sf %{_libexecdir}/dnf-utils %{buildroot}%{_bindir}/yum-groups-manager
a6dba4
 ln -sf %{_libexecdir}/dnf-utils %{buildroot}%{_bindir}/yumdownloader
a6dba4
 # These commands don't have a dedicated man page, so let's just point them
a6dba4
 # to the utils page which contains their descriptions.
a6dba4
@@ -483,6 +488,7 @@ PYTHONPATH=./plugins nosetests-%{python3_version} -s tests/
a6dba4
 %{_mandir}/man8/dnf-debuginfo-install.*
a6dba4
 %{_mandir}/man8/dnf-download.*
a6dba4
 %{_mandir}/man8/dnf-generate_completion_cache.*
a6dba4
+%{_mandir}/man8/dnf-groups-manager.*
a6dba4
 %{_mandir}/man8/dnf-needs-restarting.*
a6dba4
 %{_mandir}/man8/dnf-repoclosure.*
a6dba4
 %{_mandir}/man8/dnf-repodiff.*
a6dba4
@@ -513,6 +519,7 @@ PYTHONPATH=./plugins nosetests-%{python3_version} -s tests/
a6dba4
 %{python2_sitelib}/dnf-plugins/debuginfo-install.*
a6dba4
 %{python2_sitelib}/dnf-plugins/download.*
a6dba4
 %{python2_sitelib}/dnf-plugins/generate_completion_cache.*
a6dba4
+%{python2_sitelib}/dnf-plugins/groups_manager.*
a6dba4
 %{python2_sitelib}/dnf-plugins/needs_restarting.*
a6dba4
 %{python2_sitelib}/dnf-plugins/repoclosure.*
a6dba4
 %{python2_sitelib}/dnf-plugins/repodiff.*
a6dba4
@@ -538,6 +545,7 @@ PYTHONPATH=./plugins nosetests-%{python3_version} -s tests/
a6dba4
 %{python3_sitelib}/dnf-plugins/debuginfo-install.py
a6dba4
 %{python3_sitelib}/dnf-plugins/download.py
a6dba4
 %{python3_sitelib}/dnf-plugins/generate_completion_cache.py
a6dba4
+%{python3_sitelib}/dnf-plugins/groups_manager.py
a6dba4
 %{python3_sitelib}/dnf-plugins/needs_restarting.py
a6dba4
 %{python3_sitelib}/dnf-plugins/repoclosure.py
a6dba4
 %{python3_sitelib}/dnf-plugins/repodiff.py
a6dba4
@@ -552,6 +560,7 @@ PYTHONPATH=./plugins nosetests-%{python3_version} -s tests/
a6dba4
 %{python3_sitelib}/dnf-plugins/__pycache__/debuginfo-install.*
a6dba4
 %{python3_sitelib}/dnf-plugins/__pycache__/download.*
a6dba4
 %{python3_sitelib}/dnf-plugins/__pycache__/generate_completion_cache.*
a6dba4
+%{python3_sitelib}/dnf-plugins/__pycache__/groups_manager.*
a6dba4
 %{python3_sitelib}/dnf-plugins/__pycache__/needs_restarting.*
a6dba4
 %{python3_sitelib}/dnf-plugins/__pycache__/repoclosure.*
a6dba4
 %{python3_sitelib}/dnf-plugins/__pycache__/repodiff.*
a6dba4
@@ -579,6 +588,7 @@ PYTHONPATH=./plugins nosetests-%{python3_version} -s tests/
a6dba4
 %{_bindir}/yum-config-manager
a6dba4
 %{_bindir}/yum-debug-dump
a6dba4
 %{_bindir}/yum-debug-restore
a6dba4
+%{_bindir}/yum-groups-manager
a6dba4
 %{_bindir}/yumdownloader
a6dba4
 %{_mandir}/man1/debuginfo-install.*
a6dba4
 %{_mandir}/man1/needs-restarting.*
a6dba4
@@ -591,6 +601,7 @@ PYTHONPATH=./plugins nosetests-%{python3_version} -s tests/
a6dba4
 %{_mandir}/man1/yum-config-manager.*
a6dba4
 %{_mandir}/man1/yum-debug-dump.*
a6dba4
 %{_mandir}/man1/yum-debug-restore.*
a6dba4
+%{_mandir}/man1/yum-groups-manager.*
a6dba4
 %{_mandir}/man1/yumdownloader.*
a6dba4
 %{_mandir}/man1/package-cleanup.*
a6dba4
 %{_mandir}/man1/dnf-utils.*
a6dba4
@@ -612,6 +623,7 @@ PYTHONPATH=./plugins nosetests-%{python3_version} -s tests/
a6dba4
 %exclude %{_mandir}/man1/yum-config-manager.*
a6dba4
 %exclude %{_mandir}/man1/yum-debug-dump.*
a6dba4
 %exclude %{_mandir}/man1/yum-debug-restore.*
a6dba4
+%exclude %{_mandir}/man1/yum-groups-manager.*
a6dba4
 %exclude %{_mandir}/man1/yumdownloader.*
a6dba4
 %exclude %{_mandir}/man1/package-cleanup.*
a6dba4
 %exclude %{_mandir}/man1/dnf-utils.*
a6dba4
diff --git a/doc/CMakeLists.txt b/doc/CMakeLists.txt
a6dba4
index dd97eb2..3fb665d 100644
a6dba4
--- a/doc/CMakeLists.txt
a6dba4
+++ b/doc/CMakeLists.txt
a6dba4
@@ -26,6 +26,7 @@ INSTALL(FILES ${CMAKE_CURRENT_BINARY_DIR}/dnf-builddep.8
a6dba4
     ${CMAKE_CURRENT_BINARY_DIR}/dnf-debuginfo-install.8
a6dba4
     ${CMAKE_CURRENT_BINARY_DIR}/dnf-download.8
a6dba4
     ${CMAKE_CURRENT_BINARY_DIR}/dnf-generate_completion_cache.8
a6dba4
+    ${CMAKE_CURRENT_BINARY_DIR}/dnf-groups-manager.8
a6dba4
     ${CMAKE_CURRENT_BINARY_DIR}/dnf-leaves.8
a6dba4
     ${CMAKE_CURRENT_BINARY_DIR}/dnf-needs-restarting.8
a6dba4
     ${CMAKE_CURRENT_BINARY_DIR}/dnf-repoclosure.8
a6dba4
@@ -61,6 +62,7 @@ INSTALL(FILES ${CMAKE_CURRENT_BINARY_DIR}/debuginfo-install.1
a6dba4
     ${CMAKE_CURRENT_BINARY_DIR}/yum-config-manager.1
a6dba4
     ${CMAKE_CURRENT_BINARY_DIR}/yum-debug-dump.1
a6dba4
     ${CMAKE_CURRENT_BINARY_DIR}/yum-debug-restore.1
a6dba4
+    ${CMAKE_CURRENT_BINARY_DIR}/yum-groups-manager.1
a6dba4
     ${CMAKE_CURRENT_BINARY_DIR}/yumdownloader.1
a6dba4
     ${CMAKE_CURRENT_BINARY_DIR}/package-cleanup.1
a6dba4
     ${CMAKE_CURRENT_BINARY_DIR}/dnf-utils.1
a6dba4
diff --git a/doc/conf.py b/doc/conf.py
a6dba4
index d760ef3..645185a 100644
a6dba4
--- a/doc/conf.py
a6dba4
+++ b/doc/conf.py
a6dba4
@@ -251,6 +251,7 @@ man_pages = [
a6dba4
     ('download', 'dnf-download', u'DNF download Plugin', AUTHORS, 8),
a6dba4
     ('generate_completion_cache', 'dnf-generate_completion_cache',
a6dba4
         u'DNF generate_completion_cache Plugin', AUTHORS, 8),
a6dba4
+    ('groups-manager', 'dnf-groups-manager', u'DNF groups-manager Plugin', AUTHORS, 8),
a6dba4
     ('leaves', 'dnf-leaves', u'DNF leaves Plugin', AUTHORS, 8),
a6dba4
     ('local', 'dnf-local', u'DNF local Plugin', AUTHORS, 8),
a6dba4
     ('needs_restarting', 'dnf-needs-restarting', u'DNF needs_restarting Plugin', AUTHORS, 8),
a6dba4
@@ -268,6 +269,7 @@ man_pages = [
a6dba4
     ('copr', 'yum-copr', u'redirecting to DNF copr Plugin', AUTHORS, 8),
a6dba4
     ('debuginfo-install', 'debuginfo-install', u'redirecting to DNF debuginfo-install Plugin',
a6dba4
      AUTHORS, 1),
a6dba4
+    ('groups-manager', 'yum-groups-manager', u'redirecting to DNF groups-manager Plugin', AUTHORS, 1),
a6dba4
     ('needs_restarting', 'needs-restarting', u'redirecting to DNF needs-restarting Plugin',
a6dba4
      AUTHORS, 1),
a6dba4
     ('repoclosure', 'repoclosure', u'redirecting to DNF repoclosure Plugin', AUTHORS, 1),
a6dba4
diff --git a/doc/groups-manager.rst b/doc/groups-manager.rst
a6dba4
new file mode 100644
a6dba4
index 0000000..f8f76a1
a6dba4
--- /dev/null
a6dba4
+++ b/doc/groups-manager.rst
a6dba4
@@ -0,0 +1,94 @@
a6dba4
+..
a6dba4
+  Copyright (C) 2020  Red Hat, Inc.
a6dba4
+
a6dba4
+  This copyrighted material is made available to anyone wishing to use,
a6dba4
+  modify, copy, or redistribute it subject to the terms and conditions of
a6dba4
+  the GNU General Public License v.2, or (at your option) any later version.
a6dba4
+  This program is distributed in the hope that it will be useful, but WITHOUT
a6dba4
+  ANY WARRANTY expressed or implied, including the implied warranties of
a6dba4
+  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General
a6dba4
+  Public License for more details.  You should have received a copy of the
a6dba4
+  GNU General Public License along with this program; if not, write to the
a6dba4
+  Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
a6dba4
+  02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
a6dba4
+  source code or documentation are not subject to the GNU General Public
a6dba4
+  License and may only be used or replicated with the express permission of
a6dba4
+  Red Hat, Inc.
a6dba4
+
a6dba4
+=========================
a6dba4
+DNF groups-manager Plugin
a6dba4
+=========================
a6dba4
+
a6dba4
+Create and edit groups repository metadata files.
a6dba4
+
a6dba4
+--------
a6dba4
+Synopsis
a6dba4
+--------
a6dba4
+
a6dba4
+``dnf groups-manager [options] [package-name-spec [package-name-spec ...]]``
a6dba4
+
a6dba4
+-----------
a6dba4
+Description
a6dba4
+-----------
a6dba4
+groups-manager plugin is used to create or edit a group metadata file for a repository. This is often much easier than writing/editing the XML by hand. The groups-manager can load an entire file of groups metadata and either create a new group or edit an existing group and then write all of the groups metadata back out.
a6dba4
+
a6dba4
+---------
a6dba4
+Arguments
a6dba4
+---------
a6dba4
+
a6dba4
+``<package-name-spec>``
a6dba4
+    Package to add to a group or remove from a group.
a6dba4
+
a6dba4
+-------
a6dba4
+Options
a6dba4
+-------
a6dba4
+
a6dba4
+All general DNF options are accepted, see `Options` in :manpage:`dnf(8)` for details.
a6dba4
+
a6dba4
+``--load=<path_to_comps.xml>``
a6dba4
+    Load the groups metadata information from the specified file before performing any operations. Metadata from all files are merged together if the option is specified multiple times.
a6dba4
+
a6dba4
+``--save=<path_to_comps.xml>``
a6dba4
+    Save the result to this file. You can specify the name of a file you are loading from as the data will only be saved when all the operations have been performed. This option can also be specified multiple times.
a6dba4
+
a6dba4
+``--merge=<path_to_comps.xml>``
a6dba4
+    This is the same as loading and saving a file, however the "merge" file is loaded before any others and saved last.
a6dba4
+
a6dba4
+``--print``
a6dba4
+    Also print the result to stdout.
a6dba4
+
a6dba4
+``--id=<id>``
a6dba4
+    The id to lookup/use for the group. If you don't specify an ``<id>``, but do specify a name that doesn't refer to an existing group, then an id for the group is generated based on the name.
a6dba4
+
a6dba4
+``-n <name>, --name=<name>``
a6dba4
+    The name to lookup/use for the group. If you specify an existing group id, then the group with that id will have it's name changed to this value.
a6dba4
+
a6dba4
+``--description=<description>``
a6dba4
+    The description to use for the group.
a6dba4
+
a6dba4
+``--display-order=<display_order>``
a6dba4
+    Change the integer which controls the order groups are presented in, for example in ``dnf grouplist``.
a6dba4
+
a6dba4
+``--translated-name=<lang:text>``
a6dba4
+    A translation of the group name in the given language. The syntax is ``lang:text``. Eg. ``en:my-group-name-in-english``
a6dba4
+
a6dba4
+``--translated-description=<lang:text>``
a6dba4
+    A translation of the group description in the given language. The syntax is ``lang:text``. Eg. ``en:my-group-description-in-english``.
a6dba4
+
a6dba4
+``--user-visible``
a6dba4
+    Make the group visible in ``dnf grouplist`` (this is the default).
a6dba4
+
a6dba4
+``--not-user-visible``
a6dba4
+    Make the group not visible in ``dnf grouplist``.
a6dba4
+
a6dba4
+``--mandatory``
a6dba4
+    Store the package names specified within the mandatory section of the specified group, the default is to use the default section.
a6dba4
+
a6dba4
+``--optional``
a6dba4
+    Store the package names specified within the optional section of the specified group, the default is to use the default section.
a6dba4
+
a6dba4
+``--remove``
a6dba4
+    Instead of adding packages remove them. Note that the packages are removed from all sections (default, mandatory and optional).
a6dba4
+
a6dba4
+``--dependencies``
a6dba4
+    Also include the names of the direct dependencies for each package specified.
a6dba4
diff --git a/doc/index.rst b/doc/index.rst
a6dba4
index 91bb36e..7213253 100644
a6dba4
--- a/doc/index.rst
a6dba4
+++ b/doc/index.rst
a6dba4
@@ -33,6 +33,7 @@ This documents core plugins of DNF:
a6dba4
    debuginfo-install
a6dba4
    download
a6dba4
    generate_completion_cache
a6dba4
+   groups-manager
a6dba4
    leaves
a6dba4
    local
a6dba4
    migrate
a6dba4
diff --git a/libexec/dnf-utils.in b/libexec/dnf-utils.in
a6dba4
index 667ce13..af1e893 100644
a6dba4
--- a/libexec/dnf-utils.in
a6dba4
+++ b/libexec/dnf-utils.in
a6dba4
@@ -37,6 +37,7 @@ MAPPING = {'debuginfo-install': ['debuginfo-install'],
a6dba4
            'yum-config-manager': ['config-manager'],
a6dba4
            'yum-debug-dump': ['debug-dump'],
a6dba4
            'yum-debug-restore': ['debug-restore'],
a6dba4
+           'yum-groups-manager': ['groups-manager'],
a6dba4
            'yumdownloader': ['download']
a6dba4
            }
a6dba4
 
a6dba4
diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt
a6dba4
index 7465e53..f66d3df 100644
a6dba4
--- a/plugins/CMakeLists.txt
a6dba4
+++ b/plugins/CMakeLists.txt
a6dba4
@@ -6,6 +6,7 @@ INSTALL (FILES config_manager.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins)
a6dba4
 INSTALL (FILES copr.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins)
a6dba4
 INSTALL (FILES download.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins)
a6dba4
 INSTALL (FILES generate_completion_cache.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins)
a6dba4
+INSTALL (FILES groups_manager.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins)
a6dba4
 INSTALL (FILES leaves.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins)
a6dba4
 if (${WITHOUT_LOCAL} STREQUAL "0")
a6dba4
 INSTALL (FILES local.py DESTINATION ${PYTHON_INSTALL_DIR}/dnf-plugins)
a6dba4
diff --git a/plugins/groups_manager.py b/plugins/groups_manager.py
a6dba4
new file mode 100644
a6dba4
index 0000000..382df37
a6dba4
--- /dev/null
a6dba4
+++ b/plugins/groups_manager.py
a6dba4
@@ -0,0 +1,314 @@
a6dba4
+# groups_manager.py
a6dba4
+# DNF plugin for managing comps groups metadata files
a6dba4
+#
a6dba4
+# Copyright (C) 2020 Red Hat, Inc.
a6dba4
+#
a6dba4
+# This copyrighted material is made available to anyone wishing to use,
a6dba4
+# modify, copy, or redistribute it subject to the terms and conditions of
a6dba4
+# the GNU General Public License v.2, or (at your option) any later version.
a6dba4
+# This program is distributed in the hope that it will be useful, but WITHOUT
a6dba4
+# ANY WARRANTY expressed or implied, including the implied warranties of
a6dba4
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General
a6dba4
+# Public License for more details.  You should have received a copy of the
a6dba4
+# GNU General Public License along with this program; if not, write to the
a6dba4
+# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
a6dba4
+# 02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
a6dba4
+# source code or documentation are not subject to the GNU General Public
a6dba4
+# License and may only be used or replicated with the express permission of
a6dba4
+# Red Hat, Inc.
a6dba4
+#
a6dba4
+
a6dba4
+from __future__ import absolute_import
a6dba4
+from __future__ import unicode_literals
a6dba4
+
a6dba4
+import argparse
a6dba4
+import gzip
a6dba4
+import libcomps
a6dba4
+import os
a6dba4
+import re
a6dba4
+import shutil
a6dba4
+import tempfile
a6dba4
+
a6dba4
+from dnfpluginscore import _, logger
a6dba4
+import dnf
a6dba4
+import dnf.cli
a6dba4
+
a6dba4
+
a6dba4
+RE_GROUP_ID_VALID = '-a-z0-9_.:'
a6dba4
+RE_GROUP_ID = re.compile(r'^[{}]+$'.format(RE_GROUP_ID_VALID))
a6dba4
+RE_LANG = re.compile(r'^[-a-zA-Z0-9_.@]+$')
a6dba4
+COMPS_XML_OPTIONS = {
a6dba4
+    'default_explicit': True,
a6dba4
+    'uservisible_explicit': True,
a6dba4
+    'empty_groups': True}
a6dba4
+
a6dba4
+
a6dba4
+def group_id_type(value):
a6dba4
+    '''group id validator'''
a6dba4
+    if not RE_GROUP_ID.match(value):
a6dba4
+        raise argparse.ArgumentTypeError(_('Invalid group id'))
a6dba4
+    return value
a6dba4
+
a6dba4
+
a6dba4
+def translation_type(value):
a6dba4
+    '''translated texts validator'''
a6dba4
+    data = value.split(':', 2)
a6dba4
+    if len(data) != 2:
a6dba4
+        raise argparse.ArgumentTypeError(
a6dba4
+            _("Invalid translated data, should be in form 'lang:text'"))
a6dba4
+    lang, text = data
a6dba4
+    if not RE_LANG.match(lang):
a6dba4
+        raise argparse.ArgumentTypeError(_('Invalid/empty language for translated data'))
a6dba4
+    return lang, text
a6dba4
+
a6dba4
+
a6dba4
+def text_to_id(text):
a6dba4
+    '''generate group id based on its name'''
a6dba4
+    group_id = text.lower()
a6dba4
+    group_id = re.sub('[^{}]'.format(RE_GROUP_ID_VALID), '', group_id)
a6dba4
+    if not group_id:
a6dba4
+        raise dnf.cli.CliError(
a6dba4
+            _("Can't generate group id from '{}'. Please specify group id using --id.").format(
a6dba4
+                text))
a6dba4
+    return group_id
a6dba4
+
a6dba4
+
a6dba4
+@dnf.plugin.register_command
a6dba4
+class GroupsManagerCommand(dnf.cli.Command):
a6dba4
+    aliases = ('groups-manager',)
a6dba4
+    summary = _('create and edit groups metadata file')
a6dba4
+
a6dba4
+    def __init__(self, cli):
a6dba4
+        super(GroupsManagerCommand, self).__init__(cli)
a6dba4
+        self.comps = libcomps.Comps()
a6dba4
+
a6dba4
+    @staticmethod
a6dba4
+    def set_argparser(parser):
a6dba4
+        # input / output options
a6dba4
+        parser.add_argument('--load', action='append', default=[],
a6dba4
+                            metavar='COMPS.XML',
a6dba4
+                            help=_('load groups metadata from file'))
a6dba4
+        parser.add_argument('--save', action='append', default=[],
a6dba4
+                            metavar='COMPS.XML',
a6dba4
+                            help=_('save groups metadata to file'))
a6dba4
+        parser.add_argument('--merge', metavar='COMPS.XML',
a6dba4
+                            help=_('load and save groups metadata to file'))
a6dba4
+        parser.add_argument('--print', action='store_true', default=False,
a6dba4
+                            help=_('print the result metadata to stdout'))
a6dba4
+        # group options
a6dba4
+        parser.add_argument('--id', type=group_id_type,
a6dba4
+                            help=_('group id'))
a6dba4
+        parser.add_argument('-n', '--name', help=_('group name'))
a6dba4
+        parser.add_argument('--description',
a6dba4
+                            help=_('group description'))
a6dba4
+        parser.add_argument('--display-order', type=int,
a6dba4
+                            help=_('group display order'))
a6dba4
+        parser.add_argument('--translated-name', action='append', default=[],
a6dba4
+                            metavar='LANG:TEXT', type=translation_type,
a6dba4
+                            help=_('translated name for the group'))
a6dba4
+        parser.add_argument('--translated-description', action='append', default=[],
a6dba4
+                            metavar='LANG:TEXT', type=translation_type,
a6dba4
+                            help=_('translated description for the group'))
a6dba4
+        visible = parser.add_mutually_exclusive_group()
a6dba4
+        visible.add_argument('--user-visible', dest='user_visible', action='store_true',
a6dba4
+                             default=None,
a6dba4
+                             help=_('make the group user visible (default)'))
a6dba4
+        visible.add_argument('--not-user-visible', dest='user_visible', action='store_false',
a6dba4
+                             default=None,
a6dba4
+                             help=_('make the group user invisible'))
a6dba4
+
a6dba4
+        # package list options
a6dba4
+        section = parser.add_mutually_exclusive_group()
a6dba4
+        section.add_argument('--mandatory', action='store_true',
a6dba4
+                             help=_('add packages to the mandatory section'))
a6dba4
+        section.add_argument('--optional', action='store_true',
a6dba4
+                             help=_('add packages to the optional section'))
a6dba4
+        section.add_argument('--remove', action='store_true', default=False,
a6dba4
+                             help=_('remove packages from the group instead of adding them'))
a6dba4
+        parser.add_argument('--dependencies', action='store_true',
a6dba4
+                            help=_('include also direct dependencies for packages'))
a6dba4
+
a6dba4
+        parser.add_argument("packages", nargs='*', metavar='PACKAGE',
a6dba4
+                            help=_('package specification'))
a6dba4
+
a6dba4
+    def configure(self):
a6dba4
+        demands = self.cli.demands
a6dba4
+
a6dba4
+        if self.opts.packages:
a6dba4
+            demands.sack_activation = True
a6dba4
+            demands.available_repos = True
a6dba4
+            demands.load_system_repo = False
a6dba4
+
a6dba4
+        # handle --merge option (shortcut to --load and --save the same file)
a6dba4
+        if self.opts.merge:
a6dba4
+            self.opts.load.insert(0, self.opts.merge)
a6dba4
+            self.opts.save.append(self.opts.merge)
a6dba4
+
a6dba4
+        # check that group is specified when editing is attempted
a6dba4
+        if (self.opts.description
a6dba4
+                or self.opts.display_order
a6dba4
+                or self.opts.translated_name
a6dba4
+                or self.opts.translated_description
a6dba4
+                or self.opts.user_visible is not None
a6dba4
+                or self.opts.packages):
a6dba4
+            if not self.opts.id and not self.opts.name:
a6dba4
+                raise dnf.cli.CliError(
a6dba4
+                    _("Can't edit group without specifying it (use --id or --name)"))
a6dba4
+
a6dba4
+    def load_input_files(self):
a6dba4
+        """
a6dba4
+        Loads all input xml files.
a6dba4
+        Returns True if at least one file was successfuly loaded
a6dba4
+        """
a6dba4
+        for file_name in self.opts.load:
a6dba4
+            file_comps = libcomps.Comps()
a6dba4
+            try:
a6dba4
+                if file_name.endswith('.gz'):
a6dba4
+                    # libcomps does not support gzipped files - decompress to temporary
a6dba4
+                    # location
a6dba4
+                    with gzip.open(file_name) as gz_file:
a6dba4
+                        temp_file = tempfile.NamedTemporaryFile(delete=False)
a6dba4
+                        try:
a6dba4
+                            shutil.copyfileobj(gz_file, temp_file)
a6dba4
+                            # close temp_file to ensure the content is flushed to disk
a6dba4
+                            temp_file.close()
a6dba4
+                            file_comps.fromxml_f(temp_file.name)
a6dba4
+                        finally:
a6dba4
+                            os.unlink(temp_file.name)
a6dba4
+                else:
a6dba4
+                    file_comps.fromxml_f(file_name)
a6dba4
+            except (IOError, OSError, libcomps.ParserError) as err:
a6dba4
+                # gzip module raises OSError on reading from malformed gz file
a6dba4
+                # get_last_errors() output often contains duplicit lines, remove them
a6dba4
+                seen = set()
a6dba4
+                for error in file_comps.get_last_errors():
a6dba4
+                    if error in seen:
a6dba4
+                        continue
a6dba4
+                    logger.error(error.strip())
a6dba4
+                    seen.add(error)
a6dba4
+                raise dnf.exceptions.Error(
a6dba4
+                    _("Can't load file \"{}\": {}").format(file_name, err))
a6dba4
+            else:
a6dba4
+                self.comps += file_comps
a6dba4
+
a6dba4
+    def save_output_files(self):
a6dba4
+        for file_name in self.opts.save:
a6dba4
+            try:
a6dba4
+                # xml_f returns a list of errors / log entries
a6dba4
+                errors = self.comps.xml_f(file_name, xml_options=COMPS_XML_OPTIONS)
a6dba4
+            except libcomps.XMLGenError as err:
a6dba4
+                errors = [err]
a6dba4
+            if errors:
a6dba4
+                # xml_f() method could return more than one error. In this case
a6dba4
+                # raise the latest of them and log the others.
a6dba4
+                for err in errors[:-1]:
a6dba4
+                    logger.error(err.strip())
a6dba4
+                raise dnf.exceptions.Error(_("Can't save file \"{}\": {}").format(
a6dba4
+                    file_name, errors[-1].strip()))
a6dba4
+
a6dba4
+
a6dba4
+    def find_group(self, group_id, name):
a6dba4
+        '''
a6dba4
+        Try to find group according to command line parameters - first by id
a6dba4
+        then by name.
a6dba4
+        '''
a6dba4
+        group = None
a6dba4
+        if group_id:
a6dba4
+            for grp in self.comps.groups:
a6dba4
+                if grp.id == group_id:
a6dba4
+                    group = grp
a6dba4
+                    break
a6dba4
+        if group is None and name:
a6dba4
+            for grp in self.comps.groups:
a6dba4
+                if grp.name == name:
a6dba4
+                    group = grp
a6dba4
+                    break
a6dba4
+        return group
a6dba4
+
a6dba4
+    def edit_group(self, group):
a6dba4
+        '''
a6dba4
+        Set attributes and package lists for selected group
a6dba4
+        '''
a6dba4
+        def langlist_to_strdict(lst):
a6dba4
+            str_dict = libcomps.StrDict()
a6dba4
+            for lang, text in lst:
a6dba4
+                str_dict[lang] = text
a6dba4
+            return str_dict
a6dba4
+
a6dba4
+        # set group attributes
a6dba4
+        if self.opts.name:
a6dba4
+            group.name = self.opts.name
a6dba4
+        if self.opts.description:
a6dba4
+            group.desc = self.opts.description
a6dba4
+        if self.opts.display_order:
a6dba4
+            group.display_order = self.opts.display_order
a6dba4
+        if self.opts.user_visible is not None:
a6dba4
+            group.uservisible = self.opts.user_visible
a6dba4
+        if self.opts.translated_name:
a6dba4
+            group.name_by_lang = langlist_to_strdict(self.opts.translated_name)
a6dba4
+        if self.opts.translated_description:
a6dba4
+            group.desc_by_lang = langlist_to_strdict(self.opts.translated_description)
a6dba4
+
a6dba4
+        # edit packages list
a6dba4
+        if self.opts.packages:
a6dba4
+            # find packages according to specifications from command line
a6dba4
+            packages = set()
a6dba4
+            for pkg_spec in self.opts.packages:
a6dba4
+                q = self.base.sack.query().filterm(name__glob=pkg_spec).latest()
a6dba4
+                if not q:
a6dba4
+                    logger.warning(_("No match for argument: {}").format(pkg_spec))
a6dba4
+                    continue
a6dba4
+                packages.update(q)
a6dba4
+            if self.opts.dependencies:
a6dba4
+                # add packages that provide requirements
a6dba4
+                requirements = set()
a6dba4
+                for pkg in packages:
a6dba4
+                    requirements.update(pkg.requires)
a6dba4
+                packages.update(self.base.sack.query().filterm(provides=requirements))
a6dba4
+
a6dba4
+            pkg_names = {pkg.name for pkg in packages}
a6dba4
+
a6dba4
+            if self.opts.remove:
a6dba4
+                for pkg_name in pkg_names:
a6dba4
+                    for pkg in group.packages_match(name=pkg_name,
a6dba4
+                                                    type=libcomps.PACKAGE_TYPE_UNKNOWN):
a6dba4
+                        group.packages.remove(pkg)
a6dba4
+            else:
a6dba4
+                if self.opts.mandatory:
a6dba4
+                    pkg_type = libcomps.PACKAGE_TYPE_MANDATORY
a6dba4
+                elif self.opts.optional:
a6dba4
+                    pkg_type = libcomps.PACKAGE_TYPE_OPTIONAL
a6dba4
+                else:
a6dba4
+                    pkg_type = libcomps.PACKAGE_TYPE_DEFAULT
a6dba4
+                for pkg_name in sorted(pkg_names):
a6dba4
+                    if not group.packages_match(name=pkg_name, type=pkg_type):
a6dba4
+                        group.packages.append(libcomps.Package(name=pkg_name, type=pkg_type))
a6dba4
+
a6dba4
+    def run(self):
a6dba4
+        self.load_input_files()
a6dba4
+
a6dba4
+        if self.opts.id or self.opts.name:
a6dba4
+            # we are adding / editing a group
a6dba4
+            group = self.find_group(group_id=self.opts.id, name=self.opts.name)
a6dba4
+            if group is None:
a6dba4
+                # create a new group
a6dba4
+                if self.opts.remove:
a6dba4
+                    raise dnf.exceptions.Error(_("Can't remove packages from non-existent group"))
a6dba4
+                group = libcomps.Group()
a6dba4
+                if self.opts.id:
a6dba4
+                    group.id = self.opts.id
a6dba4
+                    group.name = self.opts.id
a6dba4
+                elif self.opts.name:
a6dba4
+                    group_id = text_to_id(self.opts.name)
a6dba4
+                    if self.find_group(group_id=group_id, name=None):
a6dba4
+                        raise dnf.cli.CliError(
a6dba4
+                            _("Group id '{}' generated from '{}' is duplicit. "
a6dba4
+                              "Please specify group id using --id.").format(
a6dba4
+                                  group_id, self.opts.name))
a6dba4
+                    group.id = group_id
a6dba4
+                self.comps.groups.append(group)
a6dba4
+            self.edit_group(group)
a6dba4
+
a6dba4
+        self.save_output_files()
a6dba4
+        if self.opts.print or (not self.opts.save):
a6dba4
+            print(self.comps.xml_str(xml_options=COMPS_XML_OPTIONS))
a6dba4
-- 
a6dba4
2.26.2
a6dba4