File: plugins.py

package info (click to toggle)
zabbix-cli 3.5.3-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 2,860 kB
  • sloc: python: 18,557; makefile: 3
file content (178 lines) | stat: -rw-r--r-- 6,915 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
from __future__ import annotations

import logging
import sys
from pathlib import Path
from types import ModuleType
from typing import TYPE_CHECKING
from typing import Protocol
from typing import cast
from typing import runtime_checkable

if sys.version_info < (3, 10):
    from importlib_metadata import EntryPoint
else:
    from importlib.metadata import EntryPoint


from zabbix_cli.exceptions import PluginLoadError
from zabbix_cli.exceptions import PluginPostImportError
from zabbix_cli.output.console import error

if TYPE_CHECKING:
    from zabbix_cli.config.model import Config
    from zabbix_cli.config.model import PluginConfig


logger = logging.getLogger(__name__)


@runtime_checkable
class PluginModule(Protocol):
    def __configure__(self, config: PluginConfig) -> None: ...


class PluginLoader:
    def __init__(self) -> None:
        self.plugins: dict[str, ModuleType] = {}

    def load(self, config: Config) -> None:
        self._load_plugins(config)
        self._load_plugins_from_metadata(config)

    def _load_plugins(self, config: Config) -> None:
        """Load plugins from local Python modules."""
        logger.debug("Loading plugins from modules in configuration file.")
        for name, plugin_config in config.plugins.root.items():
            if not plugin_config.module:
                logger.debug("Plugin %s has no module defined. Skipping", name)
                continue
            if not plugin_config.enabled:
                logger.debug("Plugin %s is disabled, skipping", name)
                continue

            logger.debug("Loading plugin: %s", name)
            try:
                module = load_plugin_module(name, plugin_config)
            except Exception as e:
                # If the exception is already a PluginLoadError
                # or a subclass, use that, otherwise create a new
                # PluginLoadError so we get the correct message format.
                if not isinstance(e, PluginLoadError):
                    exc = PluginLoadError(name, plugin_config)
                else:
                    exc = e
                if not plugin_config.optional:
                    raise exc from e
                else:
                    # Use message created by the PluginLoadError
                    msg = exc.args[0] if exc.args else str(exc)
                    error(msg, exc_info=True)
            else:
                self._add_plugin(name, module)

    def _load_plugins_from_metadata(self, config: Config) -> None:
        """Load plugins from Python package entry points."""
        # Use backport for Python < 3.10
        if sys.version_info < (3, 10):
            from importlib_metadata import (
                entry_points,  # pyright: ignore[reportUnknownVariableType]
            )
        else:
            from importlib.metadata import entry_points

        discovered_plugins = entry_points(group="zabbix-cli.plugins")

        # HACK: Cast Tuple[Any, ...] result to concrete type.
        # In order to pass type checking on all Python versions,
        # we need to pretend that we are developing on 3.9 and
        # using the backport. The problem is that the backport
        # does not define a generic tuple type for the result,
        # and instead just subclasses tuple. So we need to cast
        # the result to the correct type.
        # This is one of the drawbacks of running in 3.9 mode, but
        # it's necessary to ensure we don't introduce features that
        # do not exist in our minimum supported version.
        discovered_plugins = cast(tuple[EntryPoint], discovered_plugins)
        for plugin in discovered_plugins:
            conf = config.plugins.get(plugin.name)
            try:
                module = load_plugin_from_entry_point(plugin)
            except Exception as e:
                # By default, broken plugings will not break the application
                # unless they are explicitly marked as required in the config.
                # This is a deviation from the standard behavior of plugins, but
                # since these are third party plugins, we want to be more lenient
                # and not break the entire application if a plugin is broken.
                if not conf or not conf.optional:
                    raise PluginLoadError(plugin.name, conf) from e
                else:
                    error(f"Error loading plugin {plugin.name}: {e}", exc_info=True)
            else:
                self._add_plugin(plugin.name, module)

    def _add_plugin(self, name: str, module: ModuleType) -> None:
        self.plugins[name] = module
        logger.info("Plugin loaded: %s", name)

    def configure_plugins(self, config: Config) -> None:
        for name, module in self.plugins.items():
            if not isinstance(module, PluginModule):
                logger.debug("Plugin %s has no __configure__ function. Skipping", name)
                continue
            plugin_config = config.plugins.root.get(name)
            if not plugin_config:
                logger.warning(
                    "No configuration found for plugin '%s'. Cannot run __configure__",
                    name,
                )
                continue
            try:
                module.__configure__(plugin_config)
            except Exception as e:
                raise PluginPostImportError(name, plugin_config) from e


def load_plugin_module(plugin_name: str, plugin_config: PluginConfig) -> ModuleType:
    """Load plugin from a Python module."""
    name_or_path = plugin_config.module
    p = Path(name_or_path)
    if p.exists():
        logger.debug("Loading plugin %s from file: %s", plugin_name, p)
        mod = _load_module_from_file(name_or_path, p)
    else:
        logger.debug("Importing plugin %s as module '%s'", plugin_name, name_or_path)
        mod = _load_module(name_or_path)
    return mod


def _load_module_from_file(mod: str, file: Path) -> ModuleType:
    """Load a module from a file."""
    import importlib.util

    spec = importlib.util.spec_from_file_location(mod, str(file.resolve()))
    if not spec or not spec.loader:
        raise ImportError(f"Could not load module from file: {file}")
    module = importlib.util.module_from_spec(spec)
    sys.modules[mod] = module
    spec.loader.exec_module(module)
    logger.info("Loaded module from file: %s", file)
    return module


def _load_module(mod: str) -> ModuleType:
    """Load a module."""
    import importlib

    module = importlib.import_module(mod)
    logger.info("Loaded module: %s", mod)
    return module


def load_plugin_from_entry_point(entry_point: EntryPoint) -> ModuleType:
    """Load a plugin from an entry point."""
    logger.debug("Loading plugin %s from metadata", entry_point.name)
    module = entry_point.load()
    if not isinstance(module, ModuleType):
        return sys.modules[entry_point.module]
    return module