forked from MakazhanAlpamys/Soup
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path__init__.py
More file actions
318 lines (269 loc) · 9.7 KB
/
Copy path__init__.py
File metadata and controls
318 lines (269 loc) · 9.7 KB
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
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
"""v0.45.0 Part A — Plugin / hook system.
Public API for third-party plugins. Plugins register themselves at module
import time via ``register_plugin(...)`` and provide hooks the trainer fires
at well-known points (``pre_train`` / ``post_train`` / ``pre_step`` /
``post_step``). Plugins may also register chat templates and model groups
via ``register_template`` / ``register_model_group``.
This release ships the registry and CLI surface; live trainer-callback
wiring lands in v0.45.1 (mirrors the v0.27.0 MII / v0.37.0 multipack
stub-then-live pattern).
"""
from __future__ import annotations
import importlib
import logging
import pkgutil
import re
from dataclasses import dataclass
from threading import RLock
from types import MappingProxyType
from typing import (
Any,
Callable,
Dict,
List,
Mapping,
Optional,
Protocol,
Tuple,
runtime_checkable,
)
logger = logging.getLogger(__name__)
# Plugin name: kebab-case, alphanumeric + hyphens; 1..40 chars, leading alnum.
_PLUGIN_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{0,39}$")
# Semver-ish: MAJOR.MINOR.PATCH with optional ``-tag`` / ``+build`` suffix.
_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[\-+][A-Za-z0-9.\-]{1,32})?$")
_MAX_PLUGINS = 64
_MAX_DESCRIPTION = 256
_MAX_TEMPLATES_PER_PLUGIN = 32
_MAX_MODEL_GROUPS_PER_PLUGIN = 32
_MAX_NAME_ENTRY_LEN = 128
_HOOK_NAMES: Tuple[str, ...] = (
"pre_train",
"post_train",
"pre_step",
"post_step",
)
@runtime_checkable
class BasePlugin(Protocol):
"""Duck-typed plugin protocol.
Plugins are objects (instance or class) carrying any subset of the
four hook methods. Each hook accepts a single ``context`` argument
(an opaque dict the trainer fills in) and returns ``None``.
"""
def pre_train(self, context: Dict[str, Any]) -> None: ...
def post_train(self, context: Dict[str, Any]) -> None: ...
def pre_step(self, context: Dict[str, Any]) -> None: ...
def post_step(self, context: Dict[str, Any]) -> None: ...
@dataclass(frozen=True)
class PluginSpec:
"""One registered plugin."""
name: str
version: str
plugin: Any
description: str = ""
enabled: bool = True
templates: Tuple[str, ...] = ()
model_groups: Tuple[str, ...] = ()
_PLUGINS: Dict[str, PluginSpec] = {}
_LOCK = RLock()
def _validate_name(name: str) -> None:
if not isinstance(name, str):
raise TypeError("plugin name must be a string")
if not _PLUGIN_NAME_RE.match(name):
raise ValueError(
"plugin name must be kebab-case ([a-z0-9][a-z0-9-]{0,39})"
)
def _validate_version(version: str) -> None:
if not isinstance(version, str):
raise TypeError("plugin version must be a string")
if not _VERSION_RE.match(version):
raise ValueError(
"plugin version must match MAJOR.MINOR.PATCH (semver)"
)
def _validate_description(description: str) -> None:
if not isinstance(description, str):
raise TypeError("description must be a string")
if "\x00" in description:
raise ValueError("description must not contain null bytes")
if len(description) > _MAX_DESCRIPTION:
raise ValueError(f"description exceeds {_MAX_DESCRIPTION} chars")
def list_hook_names() -> Tuple[str, ...]:
"""Return the canonical hook names recognised by the trainer."""
return _HOOK_NAMES
def discover_hooks(plugin: Any) -> Dict[str, Callable[[Dict[str, Any]], None]]:
"""Return the subset of canonical hooks the plugin actually implements.
A hook is considered implemented when ``getattr(plugin, name)`` is a
callable. Missing or non-callable attributes are silently skipped —
plugins are not required to implement every hook.
"""
found: Dict[str, Callable[[Dict[str, Any]], None]] = {}
for hook in _HOOK_NAMES:
candidate = getattr(plugin, hook, None)
if callable(candidate):
found[hook] = candidate
return found
def register_plugin(
*,
name: str,
version: str,
plugin: Any,
description: str = "",
templates: Optional[List[str]] = None,
model_groups: Optional[List[str]] = None,
) -> PluginSpec:
"""Register a plugin. Idempotent for an identical spec; rejects
re-registration with a different version or plugin object."""
_validate_name(name)
_validate_version(version)
_validate_description(description)
if plugin is None:
raise ValueError("plugin object must not be None")
# Hook discovery is best-effort: we don't require any hook, but at
# least one of {hooks, templates, model_groups} must be non-empty so a
# totally-empty plugin is rejected loudly.
hooks = discover_hooks(plugin)
tpls = tuple(templates or ())
grps = tuple(model_groups or ())
if len(tpls) > _MAX_TEMPLATES_PER_PLUGIN:
raise ValueError(
f"templates exceeds {_MAX_TEMPLATES_PER_PLUGIN} entries"
)
if len(grps) > _MAX_MODEL_GROUPS_PER_PLUGIN:
raise ValueError(
f"model_groups exceeds {_MAX_MODEL_GROUPS_PER_PLUGIN} entries"
)
for tpl in tpls:
if not isinstance(tpl, str) or not tpl or "\x00" in tpl:
raise ValueError("template name must be non-empty NUL-free str")
if len(tpl) > _MAX_NAME_ENTRY_LEN:
raise ValueError(
f"template name exceeds {_MAX_NAME_ENTRY_LEN} chars"
)
for grp in grps:
if not isinstance(grp, str) or not grp or "\x00" in grp:
raise ValueError("model_group name must be non-empty NUL-free str")
if len(grp) > _MAX_NAME_ENTRY_LEN:
raise ValueError(
f"model_group name exceeds {_MAX_NAME_ENTRY_LEN} chars"
)
if not hooks and not tpls and not grps:
raise ValueError(
"plugin must implement at least one hook OR register a template "
"OR register a model group"
)
spec = PluginSpec(
name=name,
version=version,
plugin=plugin,
description=description,
templates=tpls,
model_groups=grps,
)
with _LOCK:
if len(_PLUGINS) >= _MAX_PLUGINS and name not in _PLUGINS:
raise RuntimeError(f"too many plugins (max {_MAX_PLUGINS})")
existing = _PLUGINS.get(name)
if existing is not None:
if (
existing.version != version
or existing.plugin is not plugin
or existing.templates != tpls
or existing.model_groups != grps
or existing.description != description
):
raise ValueError(
f"plugin {name!r} already registered with a different spec"
)
# Identical re-register: keep enabled state.
return existing
_PLUGINS[name] = spec
return spec
def list_plugins() -> Mapping[str, PluginSpec]:
"""Return an immutable view of registered plugins."""
with _LOCK:
return MappingProxyType(dict(_PLUGINS))
def get_plugin(name: str) -> Optional[PluginSpec]:
"""Return the registered plugin spec for ``name``, or ``None``."""
if not isinstance(name, str):
return None
with _LOCK:
return _PLUGINS.get(name)
def enable_plugin(name: str) -> bool:
"""Mark a registered plugin enabled. Returns True iff it changed state."""
_validate_name(name)
with _LOCK:
existing = _PLUGINS.get(name)
if existing is None:
raise KeyError(name)
if existing.enabled:
return False
_PLUGINS[name] = PluginSpec(
name=existing.name,
version=existing.version,
plugin=existing.plugin,
description=existing.description,
enabled=True,
templates=existing.templates,
model_groups=existing.model_groups,
)
return True
def disable_plugin(name: str) -> bool:
"""Mark a registered plugin disabled. Returns True iff it changed state."""
_validate_name(name)
with _LOCK:
existing = _PLUGINS.get(name)
if existing is None:
raise KeyError(name)
if not existing.enabled:
return False
_PLUGINS[name] = PluginSpec(
name=existing.name,
version=existing.version,
plugin=existing.plugin,
description=existing.description,
enabled=False,
templates=existing.templates,
model_groups=existing.model_groups,
)
return True
def is_enabled(name: str) -> bool:
"""Return True iff ``name`` is registered and enabled."""
spec = get_plugin(name)
return bool(spec and spec.enabled)
def clear_plugins() -> None:
"""Remove all registered plugins. Used by tests."""
with _LOCK:
_PLUGINS.clear()
def load_plugins() -> int:
"""Import every ``soup_cli.plugins.*`` submodule. Returns count loaded.
Plugin failures are caught and logged at WARNING — one bad plugin
must not crash ``soup`` startup (mirrors the v0.44.0 Web UI plugin
loader policy).
"""
count = 0
pkg = importlib.import_module(__name__)
for module_info in pkgutil.iter_modules(pkg.__path__):
if module_info.name.startswith("_"):
continue
try:
importlib.import_module(f"{__name__}.{module_info.name}")
count += 1
except Exception: # noqa: BLE001 — plugin failure must not crash CLI
logger.exception(
"Failed to load Soup plugin: %s", module_info.name
)
return count
__all__ = [
"BasePlugin",
"PluginSpec",
"discover_hooks",
"list_hook_names",
"register_plugin",
"list_plugins",
"get_plugin",
"enable_plugin",
"disable_plugin",
"is_enabled",
"clear_plugins",
"load_plugins",
]