pluginator 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pluginator/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ from . import define
2
+ from .actions import Action, ActionContext
3
+ from .pytest import (
4
+ CommandLine,
5
+ install_pytest_plugins,
6
+ )
7
+ from .utils import call_context
8
+
9
+ __all__ = [
10
+ "define",
11
+ "Action",
12
+ "ActionContext",
13
+ "CommandLine",
14
+ "call_context",
15
+ "install_pytest_plugins",
16
+ ]
pluginator/actions.py ADDED
@@ -0,0 +1,92 @@
1
+ import importlib
2
+ import typing as t
3
+
4
+
5
+ class ActionContext:
6
+ def update(self, **kwargs):
7
+ for key, value in kwargs.items():
8
+ if not hasattr(self, key):
9
+ raise AttributeError(
10
+ f'Action context:{self.__class__.__name__} not contain attribute:"{key}"',
11
+ )
12
+ setattr(self, key, value)
13
+
14
+ def validate(self):
15
+ pass
16
+
17
+
18
+ class Action:
19
+ def __init__(self, name: str, module: str, *, enable: bool = True, default_config: dict | None = None):
20
+ self._name = name
21
+ self._module = module
22
+ self._enable = enable
23
+ self._config = default_config or {}
24
+ self._func = None
25
+ self._setup = None
26
+
27
+ def __call__(self, context: ActionContext) -> t.Any:
28
+ if self._enable:
29
+ context.validate()
30
+ return self._func(config=self._config, context=context)
31
+ return None
32
+
33
+ @property
34
+ def name(self) -> str:
35
+ return self._name
36
+
37
+ @property
38
+ def enabled(self) -> bool:
39
+ return self._enable
40
+
41
+ @property
42
+ def setup(self) -> t.Callable | None:
43
+ return self._setup
44
+
45
+ @property
46
+ def config(self) -> dict | None:
47
+ return self._config
48
+
49
+ def configure(self, action_config: dict):
50
+ if self._func:
51
+ raise RuntimeError("Action already initialized")
52
+
53
+ self._enable = action_config.get("enable", self._enable)
54
+ module = action_config.get("module", self._module)
55
+
56
+ self._config = {k: action_config.get("config", {}).get(k, v) for k, v in self._config.items()}
57
+
58
+ try:
59
+ self._module = importlib.import_module(module)
60
+ except ImportError as e:
61
+ raise ImportError(f'Failed to import module "{module}": {e}') from e
62
+
63
+ func = getattr(self._module, "main", None)
64
+ if func is None:
65
+ raise AttributeError(f'Module "{self._module}" does not define a "main" func attribute')
66
+
67
+ if not isinstance(func, t.Callable):
68
+ raise TypeError(f'"{func}" should be a callable object, not {type(func)}')
69
+
70
+ self._func = func
71
+
72
+ self._setup = getattr(self._module, "setup", None)
73
+
74
+
75
+ class ActionManager:
76
+ def __init__(self):
77
+ self._actions: dict[str, Action] = {}
78
+
79
+ def add_action(self, action: Action, plugin_config: dict | None = None) -> None:
80
+ if action.name in self._actions:
81
+ raise RuntimeError("Plugin action already exist")
82
+
83
+ action.configure(plugin_config.get("actions", {}).get(action.name, {}))
84
+ if action.enabled and action.setup:
85
+ action.setup(action.config)
86
+
87
+ self._actions[action.name] = action
88
+
89
+ def __call__(self, name: str, context: ActionContext) -> t.Any:
90
+ if action := self._actions.get(name):
91
+ return action(context)
92
+ raise RuntimeError(f'Plugin action "{name}" not found')
pluginator/define.py ADDED
@@ -0,0 +1,87 @@
1
+ import typing as t
2
+
3
+ from .actions import Action
4
+ from .pytest import (
5
+ BasePlugin,
6
+ BasePluginMeta,
7
+ CommandLine,
8
+ PluginMeta,
9
+ PluginOption,
10
+ )
11
+
12
+
13
+ def plugin(
14
+ name: str,
15
+ /,
16
+ *,
17
+ config: str | None = None,
18
+ default_config: dict | None = None,
19
+ deps: list[str] | None = None,
20
+ actions: list[Action] | None = None,
21
+ ):
22
+ """
23
+ Decorator for creating a plugin class.
24
+
25
+ Args:
26
+ name: plugin name.
27
+ config: yaml config file path.
28
+ deps: list of dependencies names.
29
+ default_config: configuration by default.
30
+ actions: list of actions.
31
+
32
+ Returns:
33
+ Decorator function that returns new plugin class.
34
+ """
35
+
36
+ def wrapper(cls: type) -> type[BasePlugin]:
37
+ meta = PluginMeta(
38
+ name=name, actions=actions, config_file=config, default_config=default_config, dependencies=deps
39
+ )
40
+ bases = (cls,) if BasePlugin in cls.__mro__ else (cls, BasePlugin)
41
+
42
+ return BasePluginMeta(cls.__name__, bases, {"__meta__": meta})
43
+
44
+ return wrapper
45
+
46
+
47
+ def option(
48
+ opt_type: type,
49
+ /,
50
+ *,
51
+ strict: bool = True,
52
+ nullable: bool = False,
53
+ required: bool = False,
54
+ env_var: str | None = None,
55
+ default_from: str | None = None,
56
+ plugin_config_key: str | None = None,
57
+ command_line: CommandLine | None = None,
58
+ hook: t.Callable[[t.Any], t.Any] | None = None,
59
+ ) -> PluginOption:
60
+ """
61
+ Create a plugin option.
62
+
63
+ Args:
64
+ opt_type: option type.
65
+ strict: strict type for type casting.
66
+ nullable: if set True value can be None
67
+ required: flag for required option.
68
+ env_var: environment variable name.
69
+ default_from: name of plugin class property for getting default value.
70
+ plugin_config_key: name of plugin config key for getting value.
71
+ command_line: command line object.
72
+ hook: hook function for prepare option value.
73
+
74
+ Returns:
75
+ New plugin option object.
76
+ """
77
+ return PluginOption(
78
+ opt_type,
79
+ hook=hook,
80
+ strict=strict,
81
+ env_var=env_var,
82
+ nullable=nullable,
83
+ required=required,
84
+ command_line=command_line,
85
+ default_from=default_from,
86
+ plugin_config_key=plugin_config_key,
87
+ )
pluginator/pytest.py ADDED
@@ -0,0 +1,339 @@
1
+ import os
2
+ import typing as t
3
+ from copy import deepcopy
4
+ from dataclasses import dataclass, field
5
+ from functools import cached_property
6
+
7
+ import yaml
8
+ from _pytest.config import Config
9
+ from _pytest.config.argparsing import OptionGroup, Parser
10
+ from _pytest.main import Session
11
+ from qools.funcutils import called_once
12
+
13
+ from .actions import (
14
+ Action,
15
+ ActionContext,
16
+ ActionManager,
17
+ )
18
+ from .utils import call_context
19
+
20
+ PLUGINATOR_OPTIONS_ATTRIBUTE = "__pluginator_options__"
21
+ EXCLUDED_TYPES = (list, tuple, set, frozenset)
22
+
23
+
24
+ class CommandLine:
25
+ """
26
+ Define a command line settings for pytest.
27
+ """
28
+
29
+ def __init__(self, opt: str, /, *args, **kwargs):
30
+ self.opt = opt
31
+
32
+ self.args = args
33
+ self.kwargs = kwargs
34
+
35
+ def register_once(self, opt_type: type, parser: Parser, *, group: OptionGroup | None = None):
36
+ """
37
+ Register the option once in pytest option parser.
38
+
39
+ Args:
40
+ opt_type: The type of the option.
41
+ parser: The option parser instance.
42
+ group: The option group instance.
43
+ """
44
+ pluginator_options = getattr(parser, PLUGINATOR_OPTIONS_ATTRIBUTE, [])
45
+
46
+ if self.opt not in pluginator_options:
47
+ if "group" in self.kwargs:
48
+ group = parser.getgroup(self.kwargs.pop("group"))
49
+
50
+ if (
51
+ "type" not in self.kwargs
52
+ and self.kwargs.get("action") not in ("store_true", "store_false")
53
+ and opt_type not in EXCLUDED_TYPES
54
+ ):
55
+ self.kwargs["type"] = opt_type
56
+
57
+ (group or parser).addoption(self.opt, *self.args, **self.kwargs)
58
+ pluginator_options.append(self.opt)
59
+
60
+ setattr(parser, PLUGINATOR_OPTIONS_ATTRIBUTE, pluginator_options)
61
+
62
+
63
+ class PluginOption:
64
+ """
65
+ A plugin option.
66
+ """
67
+
68
+ # pylint: disable=used-before-assignment
69
+ def __init__(
70
+ self,
71
+ opt_type: type,
72
+ /,
73
+ *,
74
+ nullable: bool = False,
75
+ required: bool = False,
76
+ env_var: str | None = None,
77
+ default_from: str | None = None,
78
+ plugin_config_key: str | None = None,
79
+ command_line: CommandLine | None = None,
80
+ hook: t.Callable[[t.Any], t.Any] | None = None,
81
+ strict: bool = True,
82
+ ):
83
+ self._type = opt_type
84
+
85
+ self._env_var = env_var
86
+ self._nullable = nullable
87
+ self._required = required
88
+ self._default_from = default_from
89
+ self._command_line = command_line
90
+ self._hook = hook
91
+ self._plugin_config_key = plugin_config_key
92
+ self._strict = strict
93
+
94
+ self._name: str | None = None
95
+
96
+ @property
97
+ def type(self):
98
+ return self._type
99
+
100
+ @property
101
+ def command_line(self):
102
+ return self._command_line
103
+
104
+ def __set_name__(self, _, name: str):
105
+ self._name = name
106
+
107
+ def _prepare_value(self, value: t.Any) -> t.Any:
108
+ if callable(self._hook):
109
+ return self._type(self._hook(value)) if self._strict else self._hook(value)
110
+ return self._type(value) if self._strict else value
111
+
112
+ def __get__(self, instance: "BasePlugin", _):
113
+ if instance is None:
114
+ return self
115
+
116
+ if self._plugin_config_key is not None:
117
+ plugin_config_value = instance.plugin_config.get(self._plugin_config_key, object)
118
+
119
+ if plugin_config_value is not object:
120
+ return self._prepare_value(plugin_config_value)
121
+
122
+ if self._env_var is not None:
123
+ env_var_value = os.getenv(self._env_var)
124
+
125
+ if env_var_value is not None:
126
+ return self._prepare_value(env_var_value)
127
+
128
+ if self._command_line is not None:
129
+ cli_opt_value = instance.pytest_config.getoption(self._command_line.opt)
130
+
131
+ if cli_opt_value is not None:
132
+ return self._prepare_value(cli_opt_value)
133
+
134
+ if self._default_from is not None:
135
+ default_from_value = getattr(instance, self._default_from)
136
+
137
+ if default_from_value is not None:
138
+ return self._prepare_value(default_from_value)
139
+
140
+ if self._required:
141
+ raise ValueError(f'{instance.__class__}: option "{self._name}" is required')
142
+
143
+ return None if self._nullable else self._type()
144
+
145
+ def init_command_line(self, parser: Parser, *, group: OptionGroup | None = None):
146
+ """
147
+ Init command line settings for the option.
148
+
149
+ Args:
150
+ parser: The option parser instance.
151
+ group: The option group instance.
152
+ """
153
+ if self._command_line is not None:
154
+ self._command_line.register_once(self._type, parser, group=group)
155
+
156
+
157
+ @dataclass
158
+ class PluginMeta:
159
+ """
160
+ Plugin meta information.
161
+ """
162
+
163
+ name: str
164
+ actions: list[Action] = field(kw_only=True, default=None)
165
+ config_file: str | None = field(kw_only=True, default=None)
166
+ default_config: dict | None = field(kw_only=True, default=None)
167
+ dependencies: t.Iterable[str] | None = field(kw_only=True, default=None)
168
+
169
+
170
+ class BasePluginMeta(type):
171
+ def __new__(mcs, name, bases, attrs):
172
+ cls = type.__new__(mcs, name, bases, attrs)
173
+
174
+ if hasattr(cls, "pytest_addoption"):
175
+ cls.pytest_addoption = called_once(cls.pytest_addoption)
176
+
177
+ return cls
178
+
179
+
180
+ class BasePlugin(metaclass=BasePluginMeta):
181
+ """
182
+ Base plugin class.
183
+ """
184
+
185
+ __meta__: PluginMeta
186
+
187
+ def __init__(self):
188
+ assert hasattr(self, "__meta__"), f'meta object does not defined for "{self.__class__.__name__}"'
189
+
190
+ self.__pytest_config: Config | None = None
191
+ self._actions: ActionManager = ActionManager()
192
+
193
+ if self.__meta__.actions is not None:
194
+ for action in self.__meta__.actions:
195
+ self._actions.add_action(action, self.plugin_config)
196
+
197
+ def __repr__(self):
198
+ return f"<{self.__class__.__name__}: {self.meta.name}>"
199
+
200
+ def action(self, name: str, context: ActionContext, *, lazy: bool = False):
201
+ if lazy:
202
+
203
+ def wrapper(**kwargs):
204
+ ctx = deepcopy(context)
205
+ ctx.update(**kwargs)
206
+ return self._actions(name, ctx)
207
+
208
+ return wrapper
209
+ return self._actions(name, context)
210
+
211
+ @property
212
+ def meta(self) -> PluginMeta:
213
+ return self.__meta__
214
+
215
+ @property
216
+ def pytest_config(self) -> Config:
217
+ if not self.__pytest_config:
218
+ raise AssertionError(
219
+ f'Config is not initialized yet in "{self.__class__.__name__}"',
220
+ )
221
+ return self.__pytest_config
222
+
223
+ @cached_property
224
+ def plugin_config(self) -> dict:
225
+ if self.meta.config_file is None:
226
+ return {}
227
+
228
+ default_config = self.meta.default_config or {}
229
+
230
+ if not os.path.exists(self.meta.config_file):
231
+ return default_config
232
+
233
+ with open(self.meta.config_file, encoding="utf-8") as f:
234
+ config_data = yaml.full_load(f)
235
+
236
+ if isinstance(config_data, dict):
237
+ return default_config | config_data
238
+
239
+ return default_config
240
+
241
+ @property
242
+ def plugin_options(self) -> t.Iterable[PluginOption]:
243
+ for attr in dir(self.__class__):
244
+ value = getattr(self.__class__, attr, None)
245
+
246
+ if isinstance(value, PluginOption):
247
+ yield value
248
+
249
+ def init_plugin_options(self, parser: Parser) -> None:
250
+ group = parser.getgroup(self.meta.name)
251
+
252
+ for plugin_option in self.plugin_options:
253
+ plugin_option.init_command_line(parser, group=group)
254
+
255
+ def init_pytest_config(self, config: Config) -> None:
256
+ self.__pytest_config = config
257
+
258
+ def install(self):
259
+ if not self.__pytest_config:
260
+ raise AssertionError(
261
+ f'Plugin "{self.__class__.__name__}" is not ready to install, '
262
+ f'please call to "init_options" and "init_config" before that',
263
+ )
264
+
265
+ name = self.__pytest_config.pluginmanager.register(self, name=self.meta.name)
266
+ self.__pytest_config.add_cleanup(lambda: self.__pytest_config.pluginmanager.unregister(name=name))
267
+
268
+
269
+ def install_pytest_plugins(
270
+ *plugins,
271
+ check_deps: bool = True, # TODO: remove flag for new minor version
272
+ context: dict[str, t.Any] | None = None,
273
+ ) -> None:
274
+ """
275
+ Install pytest plugins in context.
276
+
277
+ Args:
278
+ plugins: The plugins to install.
279
+ check_deps: Check dependencies if True else no check.
280
+ context: The context to install plugins in.
281
+ """
282
+ context = call_context() if context is None else context
283
+
284
+ ctx_pytest_addoption = context.get("pytest_addoption", lambda parser: None)
285
+ ctx_pytest_configure = context.get("pytest_configure", lambda config: None)
286
+ ctx_pytest_collection_finish = context.get("pytest_collection_finish", lambda session: None)
287
+
288
+ failed_deps = []
289
+ plugin_name_to_failed_deps = {}
290
+
291
+ def pytest_addoption(parser: Parser):
292
+ ctx_pytest_addoption(parser)
293
+
294
+ for plugin in plugins:
295
+ plugin.init_plugin_options(parser)
296
+
297
+ def pytest_configure(config: Config):
298
+ ctx_pytest_configure(config)
299
+
300
+ for plugin in plugins:
301
+ plugin.init_pytest_config(config)
302
+ plugin.install()
303
+
304
+ configure_callback = getattr(plugin, "configure", None)
305
+
306
+ if configure_callback is not None:
307
+ configure_callback()
308
+
309
+ def pytest_collection_finish(session: Session):
310
+ ctx_pytest_collection_finish(session)
311
+
312
+ if check_deps:
313
+ for plugin in plugins:
314
+ for dep_name in plugin.__meta__.dependencies or []:
315
+ if not session.config.pluginmanager.get_plugin(dep_name):
316
+ failed_deps.append(dep_name)
317
+
318
+ if failed_deps:
319
+ plugin_name_to_failed_deps[plugin.meta.name] = failed_deps
320
+
321
+ if plugin_name_to_failed_deps:
322
+ fails = plugin_name_to_failed_deps.items()
323
+ message = 'Plugin "{}" needs "{}" dependencies, but does not installed'
324
+
325
+ raise AssertionError(*map(lambda i: message.format(i[0], ", ".join(i[1])), fails))
326
+
327
+ context["pytest_addoption"] = pytest_addoption
328
+ context["pytest_configure"] = pytest_configure
329
+ context["pytest_collection_finish"] = pytest_collection_finish
330
+
331
+
332
+ __all__ = [
333
+ "BasePlugin",
334
+ "PluginMeta",
335
+ "CommandLine",
336
+ "PluginOption",
337
+ "BasePluginMeta",
338
+ "install_pytest_plugins",
339
+ ]
pluginator/utils.py ADDED
@@ -0,0 +1,8 @@
1
+ import inspect
2
+
3
+
4
+ def call_context():
5
+ """
6
+ Returns the context of the caller.
7
+ """
8
+ return dict(inspect.getmembers(inspect.stack()[2][0]))["f_globals"]
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: pluginator
3
+ Version: 0.0.0
4
+ Summary: Plugin management system
5
+ License: MIT
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: pyyaml>=6.0
16
+ Requires-Dist: qools>=0.0.0
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=8.0; extra == "test"
19
+ Requires-Dist: pytest-cov>=5.0; extra == "test"
20
+ Requires-Dist: pytest-mock>=3.0; extra == "test"
21
+ Requires-Dist: ruff>=0.15.0; extra == "test"
22
+ Requires-Dist: pyhamcrest>=2.0; extra == "test"
23
+ Provides-Extra: docs
24
+ Requires-Dist: mkdocs>=1.5; extra == "docs"
25
+ Requires-Dist: mkdocs-material>=9.0; extra == "docs"
26
+ Requires-Dist: mkdocstrings[python]>=0.24; extra == "docs"
27
+
28
+ # pluginator
29
+
30
+ Plugin management system
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install pluginator
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ ```python
41
+ from pluginator import define
42
+
43
+ @define.plugin('my-plugin', config='config.yml')
44
+ class MyPlugin:
45
+ name = define.option(str, required=True, env_var='PLUGIN_NAME')
46
+
47
+ def configure(self):
48
+ print(f'Configured: {self.name}')
49
+ ```
50
+
51
+ ## Documentation
52
+
53
+ Full documentation: https://qarium.github.io/pluginator/
@@ -0,0 +1,9 @@
1
+ pluginator/__init__.py,sha256=CMJv2uG2SZbbhyUwaFuAVtysBSxaqIc14_vo23xkQSo,298
2
+ pluginator/actions.py,sha256=ReiEwnnLYBz-RMUc7oNS4UZ3Ck9rtqLE5ATfMXAA_00,2865
3
+ pluginator/define.py,sha256=mweHrj9WDovj3fS_WUE9mlDB3XgMh-RsAqCj1Rpja9s,2253
4
+ pluginator/pytest.py,sha256=4PNurKW1kL69rWohv17ksShuRFd70aGdtAUS20pAlI0,10434
5
+ pluginator/utils.py,sha256=skuT5HKY6fSNiczD0GYbWmDJexmGQ2fsROhOgSEuYdA,164
6
+ pluginator-0.0.0.dist-info/METADATA,sha256=UU2RZukmKNtnZz541GOn3mRQJfnrernI8kEvKPDfcjY,1424
7
+ pluginator-0.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ pluginator-0.0.0.dist-info/top_level.txt,sha256=s-5LW15-ww9J184xm1Nq3gYSg3g0-0jj0KpnZuqB4dI,11
9
+ pluginator-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ pluginator