1# plugin.py
2# The interface for building DNF plugins.
3#
4# Copyright (C) 2012-2016 Red Hat, Inc.
5#
6# This copyrighted material is made available to anyone wishing to use,
7# modify, copy, or redistribute it subject to the terms and conditions of
8# the GNU General Public License v.2, or (at your option) any later version.
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY expressed or implied, including the implied warranties of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General
12# Public License for more details.  You should have received a copy of the
13# GNU General Public License along with this program; if not, write to the
14# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
15# 02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
16# source code or documentation are not subject to the GNU General Public
17# License and may only be used or replicated with the express permission of
18# Red Hat, Inc.
19#
20
21from __future__ import absolute_import
22from __future__ import print_function
23from __future__ import unicode_literals
24
25import fnmatch
26import glob
27import importlib
28import inspect
29import logging
30import operator
31import os
32import sys
33import traceback
34
35import libdnf
36import dnf.logging
37import dnf.pycomp
38import dnf.util
39from dnf.i18n import _
40
41logger = logging.getLogger('dnf')
42
43DYNAMIC_PACKAGE = 'dnf.plugin.dynamic'
44
45
46class Plugin(object):
47    """The base class custom plugins must derive from. #:api"""
48
49    name = '<invalid>'
50    config_name = None
51
52    @classmethod
53    def read_config(cls, conf):
54        # :api
55        parser = libdnf.conf.ConfigParser()
56        name = cls.config_name if cls.config_name else cls.name
57        files = ['%s/%s.conf' % (path, name) for path in conf.pluginconfpath]
58        for file in files:
59            if os.path.isfile(file):
60                try:
61                    parser.read(file)
62                except Exception as e:
63                    raise dnf.exceptions.ConfigError(_("Parsing file failed: %s") % str(e))
64        return parser
65
66    def __init__(self, base, cli):
67        # :api
68        self.base = base
69        self.cli = cli
70
71    def pre_config(self):
72        # :api
73        pass
74
75    def config(self):
76        # :api
77        pass
78
79    def resolved(self):
80        # :api
81        pass
82
83    def sack(self):
84        # :api
85        pass
86
87    def pre_transaction(self):
88        # :api
89        pass
90
91    def transaction(self):
92        # :api
93        pass
94
95
96class Plugins(object):
97    def __init__(self):
98        self.plugin_cls = []
99        self.plugins = []
100
101    def _caller(self, method):
102        for plugin in self.plugins:
103            try:
104                getattr(plugin, method)()
105            except dnf.exceptions.Error:
106                raise
107            except Exception:
108                exc_type, exc_value, exc_traceback = sys.exc_info()
109                except_list = traceback.format_exception(exc_type, exc_value, exc_traceback)
110                logger.critical(''.join(except_list))
111
112    def _check_enabled(self, conf, enable_plugins):
113        """Checks whether plugins are enabled or disabled in configuration files
114           and removes disabled plugins from list"""
115        for plug_cls in self.plugin_cls[:]:
116            name = plug_cls.name
117            if any(fnmatch.fnmatch(name, pattern) for pattern in enable_plugins):
118                continue
119            parser = plug_cls.read_config(conf)
120            # has it enabled = False?
121            disabled = (parser.has_section('main')
122                        and parser.has_option('main', 'enabled')
123                        and not parser.getboolean('main', 'enabled'))
124            if disabled:
125                self.plugin_cls.remove(plug_cls)
126
127    def _load(self, conf, skips, enable_plugins):
128        """Dynamically load relevant plugin modules."""
129
130        if DYNAMIC_PACKAGE in sys.modules:
131            raise RuntimeError("load_plugins() called twice")
132        sys.modules[DYNAMIC_PACKAGE] = package = dnf.pycomp.ModuleType(DYNAMIC_PACKAGE)
133        package.__path__ = []
134
135        files = _get_plugins_files(conf.pluginpath, skips, enable_plugins)
136        _import_modules(package, files)
137        self.plugin_cls = _plugin_classes()[:]
138        self._check_enabled(conf, enable_plugins)
139        if len(self.plugin_cls) > 0:
140            names = sorted(plugin.name for plugin in self.plugin_cls)
141            logger.debug(_('Loaded plugins: %s'), ', '.join(names))
142
143    def _run_pre_config(self):
144        self._caller('pre_config')
145
146    def _run_config(self):
147        self._caller('config')
148
149    def _run_init(self, base, cli=None):
150        for p_cls in self.plugin_cls:
151            plugin = p_cls(base, cli)
152            self.plugins.append(plugin)
153
154    def run_sack(self):
155        self._caller('sack')
156
157    def run_resolved(self):
158        self._caller('resolved')
159
160    def run_pre_transaction(self):
161        self._caller('pre_transaction')
162
163    def run_transaction(self):
164        self._caller('transaction')
165
166    def _unload(self):
167        del sys.modules[DYNAMIC_PACKAGE]
168
169    def unload_removed_plugins(self, transaction):
170        """
171        Unload plugins that were removed in the `transaction`.
172        """
173        if not transaction.remove_set:
174            return
175
176        # gather all installed plugins and their files
177        plugins = dict()
178        for plugin in self.plugins:
179            plugins[inspect.getfile(plugin.__class__)] = plugin
180
181        # gather all removed files that are plugin files
182        plugin_files = set(plugins.keys())
183        erased_plugin_files = set()
184        for pkg in transaction.remove_set:
185            erased_plugin_files.update(plugin_files.intersection(pkg.files))
186        if not erased_plugin_files:
187            return
188
189        # check whether removed plugin file is added at the same time (upgrade of a plugin)
190        for pkg in transaction.install_set:
191            erased_plugin_files.difference_update(pkg.files)
192
193        # unload plugins that were removed in transaction
194        for plugin_file in erased_plugin_files:
195            self.plugins.remove(plugins[plugin_file])
196
197
198def _plugin_classes():
199    return Plugin.__subclasses__()
200
201
202def _import_modules(package, py_files):
203    for fn in py_files:
204        path, module = os.path.split(fn)
205        package.__path__.append(path)
206        (module, ext) = os.path.splitext(module)
207        name = '%s.%s' % (package.__name__, module)
208        try:
209            module = importlib.import_module(name)
210        except Exception as e:
211            logger.error(_('Failed loading plugin "%s": %s'), module, e)
212            logger.log(dnf.logging.SUBDEBUG, '', exc_info=True)
213
214
215def _get_plugins_files(paths, disable_plugins, enable_plugins):
216    plugins = []
217    disable_plugins = set(disable_plugins)
218    enable_plugins = set(enable_plugins)
219    pattern_enable_found = set()
220    pattern_disable_found = set()
221    for p in paths:
222        for fn in glob.glob('%s/*.py' % p):
223            (plugin_name, dummy) = os.path.splitext(os.path.basename(fn))
224            matched = True
225            enable_pattern_tested = False
226            for pattern_skip in disable_plugins:
227                if fnmatch.fnmatch(plugin_name, pattern_skip):
228                    pattern_disable_found.add(pattern_skip)
229                    matched = False
230                    for pattern_enable in enable_plugins:
231                        if fnmatch.fnmatch(plugin_name, pattern_enable):
232                            matched = True
233                            pattern_enable_found.add(pattern_enable)
234                    enable_pattern_tested = True
235            if not enable_pattern_tested:
236                for pattern_enable in enable_plugins:
237                    if fnmatch.fnmatch(plugin_name, pattern_enable):
238                        pattern_enable_found.add(pattern_enable)
239            if matched:
240                plugins.append(fn)
241    enable_not_found = enable_plugins.difference(pattern_enable_found)
242    if enable_not_found:
243        logger.warning(_("No matches found for the following enable plugin patterns: {}").format(
244            ", ".join(sorted(enable_not_found))))
245    disable_not_found = disable_plugins.difference(pattern_disable_found)
246    if disable_not_found:
247        logger.warning(_("No matches found for the following disable plugin patterns: {}").format(
248            ", ".join(sorted(disable_not_found))))
249    return plugins
250
251
252def register_command(command_class):
253    # :api
254    """A class decorator for automatic command registration."""
255    def __init__(self, base, cli):
256        if cli:
257            cli.register_command(command_class)
258    plugin_class = type(str(command_class.__name__ + 'Plugin'),
259                        (dnf.Plugin,),
260                        {"__init__": __init__,
261                         "name": command_class.aliases[0]})
262    command_class._plugin = plugin_class
263    return command_class
264