forgeoptimizer 1.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.
- forgecli/__init__.py +4 -0
- forgecli/build/__init__.py +135 -0
- forgecli/build/apply.py +218 -0
- forgecli/build/caveman_optimize.py +37 -0
- forgecli/build/diff_extract.py +218 -0
- forgecli/build/llm.py +167 -0
- forgecli/build/optimize.py +37 -0
- forgecli/build/pipeline.py +76 -0
- forgecli/build/retrieval.py +157 -0
- forgecli/build/summarize.py +76 -0
- forgecli/build/test_run.py +75 -0
- forgecli/builder/__init__.py +11 -0
- forgecli/builder/builder.py +53 -0
- forgecli/builder/editor.py +36 -0
- forgecli/builder/formatter.py +31 -0
- forgecli/cli/__init__.py +5 -0
- forgecli/cli/bootstrap.py +281 -0
- forgecli/cli/commands_graph.py +137 -0
- forgecli/cli/commands_wrappers.py +63 -0
- forgecli/cli/main.py +122 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +204 -0
- forgecli/config/writer.py +90 -0
- forgecli/core/__init__.py +35 -0
- forgecli/core/container.py +68 -0
- forgecli/core/context.py +54 -0
- forgecli/core/credentials.py +114 -0
- forgecli/core/errors.py +27 -0
- forgecli/core/events.py +67 -0
- forgecli/core/logging.py +47 -0
- forgecli/core/models.py +159 -0
- forgecli/core/plugins.py +56 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +119 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +171 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +526 -0
- forgecli/engine/plugins.py +146 -0
- forgecli/engine/runner.py +155 -0
- forgecli/engine/stages/__init__.py +33 -0
- forgecli/engine/stages/caveman_optimizer.py +47 -0
- forgecli/engine/stages/context_optimizer.py +44 -0
- forgecli/engine/stages/execution_engine_stage.py +108 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +66 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +89 -0
- forgecli/git/__init__.py +9 -0
- forgecli/git/repo.py +55 -0
- forgecli/git/service.py +45 -0
- forgecli/graph/__init__.py +56 -0
- forgecli/graph/backend_graphify.py +453 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +412 -0
- forgecli/graph/indexer.py +82 -0
- forgecli/graph/node.py +47 -0
- forgecli/graph/repository.py +164 -0
- forgecli/memory/__init__.py +12 -0
- forgecli/memory/cache.py +61 -0
- forgecli/memory/history.py +138 -0
- forgecli/memory/store.py +87 -0
- forgecli/optimizer/__init__.py +14 -0
- forgecli/optimizer/caveman/__init__.py +173 -0
- forgecli/optimizer/caveman/cli.py +64 -0
- forgecli/optimizer/caveman/decorator.py +67 -0
- forgecli/optimizer/caveman/factory.py +51 -0
- forgecli/optimizer/caveman/ruleset.py +156 -0
- forgecli/optimizer/caveman/state.py +52 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +183 -0
- forgecli/optimizer/ponytail/cli.py +168 -0
- forgecli/optimizer/ponytail/decorator.py +70 -0
- forgecli/optimizer/ponytail/factory.py +51 -0
- forgecli/optimizer/ponytail/ruleset.py +168 -0
- forgecli/optimizer/ponytail/state.py +53 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +707 -0
- forgecli/planner/__init__.py +56 -0
- forgecli/planner/agent.py +61 -0
- forgecli/planner/plan.py +65 -0
- forgecli/planner/planner.py +17 -0
- forgecli/planner/render.py +271 -0
- forgecli/planner/serialize.py +106 -0
- forgecli/planner/software.py +834 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +354 -0
- forgecli/platform/paths.py +236 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +252 -0
- forgecli/plugins/__init__.py +251 -0
- forgecli/prompts/__init__.py +11 -0
- forgecli/prompts/loader.py +28 -0
- forgecli/prompts/registry.py +32 -0
- forgecli/prompts/renderer.py +23 -0
- forgecli/providers/__init__.py +39 -0
- forgecli/providers/anthropic.py +206 -0
- forgecli/providers/base.py +206 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +78 -0
- forgecli/providers/google.py +295 -0
- forgecli/providers/http_base.py +211 -0
- forgecli/providers/mock.py +113 -0
- forgecli/providers/openai.py +202 -0
- forgecli/providers/openai_compatible.py +728 -0
- forgecli/providers/router.py +345 -0
- forgecli/providers/router_state.py +89 -0
- forgecli/review/__init__.py +45 -0
- forgecli/review/analyzer.py +124 -0
- forgecli/review/analyzers/__init__.py +1 -0
- forgecli/review/analyzers/architecture.py +258 -0
- forgecli/review/analyzers/complexity.py +167 -0
- forgecli/review/analyzers/dead_code.py +262 -0
- forgecli/review/analyzers/duplicates.py +163 -0
- forgecli/review/analyzers/performance.py +165 -0
- forgecli/review/analyzers/security.py +251 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +311 -0
- forgecli/review/repository.py +131 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +77 -0
- forgecli/runtime/prepare.py +199 -0
- forgecli/runtime/wrappers.py +152 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +243 -0
- forgecli/sdk/manager.py +693 -0
- forgecli/sdk/manifest.py +397 -0
- forgecli/sdk/sandbox.py +197 -0
- forgecli/sdk/version.py +316 -0
- forgecli/templates/__init__.py +9 -0
- forgecli/templates/engine.py +37 -0
- forgecli/templates/registry.py +32 -0
- forgecli/utils/__init__.py +19 -0
- forgecli/utils/fs.py +91 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +70 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
- forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
- forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
- forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-1.0.0.dist-info/licenses/LICENSE +21 -0
forgecli/sdk/manager.py
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
"""The :class:`PluginManager` — single source of truth for plugin state.
|
|
2
|
+
|
|
3
|
+
The manager owns:
|
|
4
|
+
|
|
5
|
+
* the :class:`LoadedPlugin` cache (filesystem + entry-point);
|
|
6
|
+
* the persistent **enabled-set** (which plugin names are turned on);
|
|
7
|
+
* the per-plugin **config** dict (validated against the
|
|
8
|
+
manifest's ``config_schema`` if any);
|
|
9
|
+
* the registration channels that plugins use to contribute
|
|
10
|
+
providers, analyzers, optimizers, etc.
|
|
11
|
+
|
|
12
|
+
The manager's lifecycle is::
|
|
13
|
+
|
|
14
|
+
load → install → enable → (configure) → (run) → disable → uninstall
|
|
15
|
+
|
|
16
|
+
All mutations go through the manager so the SDK can fire lifecycle
|
|
17
|
+
events on the :class:`PluginEventBus` and call the appropriate
|
|
18
|
+
:func:`sandbox` for plugin callbacks.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from forgecli.platform.core import (
|
|
34
|
+
current_platform,
|
|
35
|
+
python_version,
|
|
36
|
+
)
|
|
37
|
+
from forgecli.platform.paths import config_dir, data_dir
|
|
38
|
+
from forgecli.sdk.events import (
|
|
39
|
+
HookManager,
|
|
40
|
+
PluginEvent,
|
|
41
|
+
PluginEventBus,
|
|
42
|
+
PluginEventKind,
|
|
43
|
+
)
|
|
44
|
+
from forgecli.sdk.interfaces import HealthIssue, HealthReport
|
|
45
|
+
from forgecli.sdk.loader import (
|
|
46
|
+
LoadedPlugin,
|
|
47
|
+
discover_entry_points,
|
|
48
|
+
discover_filesystem,
|
|
49
|
+
load_filesystem,
|
|
50
|
+
)
|
|
51
|
+
from forgecli.sdk.manifest import (
|
|
52
|
+
Compatibility,
|
|
53
|
+
EntryPointKind,
|
|
54
|
+
PluginManifest,
|
|
55
|
+
is_valid_plugin_name,
|
|
56
|
+
)
|
|
57
|
+
from forgecli.sdk.sandbox import Sandbox
|
|
58
|
+
from forgecli.sdk.version import (
|
|
59
|
+
Requirement,
|
|
60
|
+
Version,
|
|
61
|
+
VersionParseError,
|
|
62
|
+
resolve,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
_logger = logging.getLogger("forgecli.sdk.manager")
|
|
66
|
+
_STATE_FILENAME = "plugins.json"
|
|
67
|
+
_REGISTRY_FILENAME = "registry.json"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Errors
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class PluginError(Exception):
|
|
76
|
+
"""Base class for plugin-manager errors."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PluginNotFoundError(PluginError, LookupError):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class PluginAlreadyInstalledError(PluginError):
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PluginCompatibilityError(PluginError):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Persistent state
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class PluginState:
|
|
98
|
+
"""The persisted state of an installed plugin."""
|
|
99
|
+
|
|
100
|
+
name: str
|
|
101
|
+
version: str
|
|
102
|
+
enabled: bool
|
|
103
|
+
source: str # "filesystem" | "entry-point" | "git"
|
|
104
|
+
install_path: str | None = None
|
|
105
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
106
|
+
installed_at: str = ""
|
|
107
|
+
enabled_at: str | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class PluginRegistryState:
|
|
112
|
+
"""Top-level persisted state of the SDK."""
|
|
113
|
+
|
|
114
|
+
plugins: dict[str, PluginState] = field(default_factory=dict)
|
|
115
|
+
schema_version: int = 1
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Manager
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class PluginManager:
|
|
124
|
+
"""The SDK's central façade."""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
config_root: Path | None = None,
|
|
130
|
+
data_root: Path | None = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
self._config_root = config_root or config_dir()
|
|
133
|
+
self._data_root = data_root or data_dir()
|
|
134
|
+
self._plugins_dir = self._config_root / "plugins"
|
|
135
|
+
self._plugins_dir.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
self._state_file = self._data_root / _STATE_FILENAME
|
|
137
|
+
self._bus = PluginEventBus()
|
|
138
|
+
self._hooks = HookManager()
|
|
139
|
+
self._loaded: dict[str, LoadedPlugin] = {}
|
|
140
|
+
self._state = self._load_state()
|
|
141
|
+
# Registration channels
|
|
142
|
+
self.providers: dict[str, type] = {}
|
|
143
|
+
self.optimizers: dict[str, type] = {}
|
|
144
|
+
self.analyzers: list[type] = []
|
|
145
|
+
self.workflows: list = []
|
|
146
|
+
self.classifiers: list = []
|
|
147
|
+
self.test_runners: dict[str, Callable[..., Any]] = {}
|
|
148
|
+
self.docs_generators: list[Callable[..., Any]] = []
|
|
149
|
+
self.deployment_providers: list[Callable[..., Any]] = []
|
|
150
|
+
self.observability_providers: list[Callable[..., Any]] = []
|
|
151
|
+
self.notification_providers: dict[str, Callable[..., Any]] = {}
|
|
152
|
+
self.git_service: Any = None
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# Accessors
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def state(self) -> PluginRegistryState:
|
|
160
|
+
return self._state
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def bus(self) -> PluginEventBus:
|
|
164
|
+
return self._bus
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def hooks(self) -> HookManager:
|
|
168
|
+
return self._hooks
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def plugins_dir(self) -> Path:
|
|
172
|
+
return self._plugins_dir
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
# Discovery
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def discover(self, *, use_cache: bool = True) -> list[LoadedPlugin]:
|
|
179
|
+
"""Return every plugin available on disk or via entry points."""
|
|
180
|
+
if use_cache and self._loaded:
|
|
181
|
+
return list(self._loaded.values())
|
|
182
|
+
self._loaded = {}
|
|
183
|
+
for plugin in discover_filesystem(self._plugins_dir):
|
|
184
|
+
self._loaded[plugin.name] = plugin
|
|
185
|
+
for plugin in discover_entry_points():
|
|
186
|
+
# On-disk overrides entry-point with the same name.
|
|
187
|
+
self._loaded.setdefault(plugin.name, plugin)
|
|
188
|
+
return list(self._loaded.values())
|
|
189
|
+
|
|
190
|
+
def get(self, name: str) -> LoadedPlugin:
|
|
191
|
+
if name not in self._loaded:
|
|
192
|
+
self.discover()
|
|
193
|
+
if name not in self._loaded:
|
|
194
|
+
raise PluginNotFoundError(name)
|
|
195
|
+
return self._loaded[name]
|
|
196
|
+
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
# Lifecycle
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def install(
|
|
202
|
+
self,
|
|
203
|
+
source: str,
|
|
204
|
+
*,
|
|
205
|
+
from_path: Path | None = None,
|
|
206
|
+
from_git: str | None = None,
|
|
207
|
+
) -> LoadedPlugin:
|
|
208
|
+
"""Install a plugin from a local path or a git URL."""
|
|
209
|
+
if from_path is not None:
|
|
210
|
+
return self._install_from_path(from_path)
|
|
211
|
+
if from_git is not None:
|
|
212
|
+
return self._install_from_git(from_git)
|
|
213
|
+
# Treat ``source`` as a plugin name or path.
|
|
214
|
+
candidate = Path(source)
|
|
215
|
+
if candidate.exists():
|
|
216
|
+
return self._install_from_path(candidate)
|
|
217
|
+
raise PluginError(f"could not resolve plugin source: {source!r}")
|
|
218
|
+
|
|
219
|
+
def _install_from_path(self, source: Path) -> LoadedPlugin:
|
|
220
|
+
if not source.is_dir():
|
|
221
|
+
raise PluginError(f"{source} is not a directory")
|
|
222
|
+
manifest = PluginManifest.load(source / "forgecli-plugin.toml")
|
|
223
|
+
self._validate(manifest)
|
|
224
|
+
target = self._plugins_dir / manifest.name
|
|
225
|
+
if target.exists():
|
|
226
|
+
raise PluginAlreadyInstalledError(manifest.name)
|
|
227
|
+
shutil.copytree(source, target)
|
|
228
|
+
loaded = load_filesystem(target)
|
|
229
|
+
self._loaded[loaded.name] = loaded
|
|
230
|
+
self._state.plugins[loaded.name] = PluginState(
|
|
231
|
+
name=loaded.name,
|
|
232
|
+
version=str(loaded.version),
|
|
233
|
+
enabled=False,
|
|
234
|
+
source="filesystem",
|
|
235
|
+
install_path=str(target),
|
|
236
|
+
installed_at=_now_iso(),
|
|
237
|
+
)
|
|
238
|
+
self._save_state()
|
|
239
|
+
self._bus.publish(
|
|
240
|
+
PluginEvent(
|
|
241
|
+
kind=PluginEventKind.INSTALLED,
|
|
242
|
+
plugin_name=loaded.name,
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
return loaded
|
|
246
|
+
|
|
247
|
+
def _install_from_git(self, url: str) -> LoadedPlugin:
|
|
248
|
+
target = self._plugins_dir / _slugify(url)
|
|
249
|
+
if target.exists():
|
|
250
|
+
shutil.rmtree(target)
|
|
251
|
+
self._run_git_clone(url, target)
|
|
252
|
+
return self._install_from_path(target)
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _run_git_clone(url: str, target: Path) -> None:
|
|
256
|
+
import tempfile
|
|
257
|
+
|
|
258
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
259
|
+
result = subprocess.run(
|
|
260
|
+
["git", "clone", "--depth=1", url, str(target)],
|
|
261
|
+
cwd=tmp,
|
|
262
|
+
capture_output=True,
|
|
263
|
+
text=True,
|
|
264
|
+
check=False,
|
|
265
|
+
)
|
|
266
|
+
if result.returncode != 0:
|
|
267
|
+
raise PluginError(f"git clone {url!r} failed: {result.stderr.strip()}")
|
|
268
|
+
|
|
269
|
+
def uninstall(self, name: str, *, remove_files: bool = True) -> None:
|
|
270
|
+
plugin = self._state.plugins.get(name)
|
|
271
|
+
if plugin is None:
|
|
272
|
+
raise PluginNotFoundError(name)
|
|
273
|
+
if plugin.enabled:
|
|
274
|
+
self.disable(name)
|
|
275
|
+
if remove_files and plugin.install_path:
|
|
276
|
+
path = Path(plugin.install_path)
|
|
277
|
+
if path.exists() and path.is_relative_to(self._plugins_dir):
|
|
278
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
279
|
+
self._state.plugins.pop(name, None)
|
|
280
|
+
self._loaded.pop(name, None)
|
|
281
|
+
self._save_state()
|
|
282
|
+
self._bus.publish(
|
|
283
|
+
PluginEvent(kind=PluginEventKind.UNINSTALLED, plugin_name=name)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def enable(self, name: str) -> None:
|
|
287
|
+
plugin_state = self._state.plugins.get(name)
|
|
288
|
+
if plugin_state is None:
|
|
289
|
+
raise PluginNotFoundError(name)
|
|
290
|
+
if plugin_state.enabled:
|
|
291
|
+
return
|
|
292
|
+
plugin = self._load_for(name, plugin_state)
|
|
293
|
+
self._validate(plugin.manifest)
|
|
294
|
+
self._invoke_entry_points(plugin, enabled=True)
|
|
295
|
+
plugin_state.enabled = True
|
|
296
|
+
plugin_state.enabled_at = _now_iso()
|
|
297
|
+
self._save_state()
|
|
298
|
+
self._bus.publish(
|
|
299
|
+
PluginEvent(kind=PluginEventKind.ENABLED, plugin_name=name)
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def disable(self, name: str) -> None:
|
|
303
|
+
plugin_state = self._state.plugins.get(name)
|
|
304
|
+
if plugin_state is None or not plugin_state.enabled:
|
|
305
|
+
return
|
|
306
|
+
plugin = self._load_for(name, plugin_state)
|
|
307
|
+
self._invoke_entry_points(plugin, enabled=False)
|
|
308
|
+
plugin_state.enabled = False
|
|
309
|
+
plugin_state.enabled_at = None
|
|
310
|
+
self._save_state()
|
|
311
|
+
self._bus.publish(
|
|
312
|
+
PluginEvent(kind=PluginEventKind.DISABLED, plugin_name=name)
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def update(self, name: str, *, source: str | None = None) -> LoadedPlugin:
|
|
316
|
+
plugin_state = self._state.plugins.get(name)
|
|
317
|
+
if plugin_state is None:
|
|
318
|
+
raise PluginNotFoundError(name)
|
|
319
|
+
if plugin_state.source == "filesystem" and source is None:
|
|
320
|
+
raise PluginError(
|
|
321
|
+
f"plugin {name!r} was installed from a path; "
|
|
322
|
+
"pass --source to re-pull it"
|
|
323
|
+
)
|
|
324
|
+
if source is None and plugin_state.source == "git":
|
|
325
|
+
# Re-clone from the original URL.
|
|
326
|
+
assert plugin_state.install_path is not None
|
|
327
|
+
url = self._git_origin(Path(plugin_state.install_path))
|
|
328
|
+
return self._install_from_git(url)
|
|
329
|
+
if source is None and plugin_state.source == "entry-point":
|
|
330
|
+
raise PluginError(
|
|
331
|
+
f"plugin {name!r} is an entry-point plugin; "
|
|
332
|
+
"upgrade the underlying distribution instead"
|
|
333
|
+
)
|
|
334
|
+
return self.install(source) # type: ignore[arg-type]
|
|
335
|
+
|
|
336
|
+
# ------------------------------------------------------------------
|
|
337
|
+
# Configuration
|
|
338
|
+
# ------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def configure(self, name: str, **values: Any) -> None:
|
|
341
|
+
state = self._state.plugins.get(name)
|
|
342
|
+
if state is None:
|
|
343
|
+
raise PluginNotFoundError(name)
|
|
344
|
+
merged = dict(state.config)
|
|
345
|
+
merged.update(values)
|
|
346
|
+
state.config = merged
|
|
347
|
+
self._save_state()
|
|
348
|
+
self._bus.publish(
|
|
349
|
+
PluginEvent(
|
|
350
|
+
kind=PluginEventKind.CONFIG_CHANGED,
|
|
351
|
+
plugin_name=name,
|
|
352
|
+
payload={"config": merged},
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
def get_config(self, name: str) -> dict[str, Any]:
|
|
357
|
+
state = self._state.plugins.get(name)
|
|
358
|
+
if state is None:
|
|
359
|
+
raise PluginNotFoundError(name)
|
|
360
|
+
return dict(state.config)
|
|
361
|
+
|
|
362
|
+
# ------------------------------------------------------------------
|
|
363
|
+
# Health
|
|
364
|
+
# ------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
def doctor(self) -> list[HealthReport]:
|
|
367
|
+
"""Run a health check across every installed plugin."""
|
|
368
|
+
reports: list[HealthReport] = []
|
|
369
|
+
for name, state in sorted(self._state.plugins.items()):
|
|
370
|
+
issues: list[HealthIssue] = []
|
|
371
|
+
try:
|
|
372
|
+
plugin = self._load_for(name, state)
|
|
373
|
+
except Exception as exc:
|
|
374
|
+
issues.append(HealthIssue("error", f"could not load: {exc}"))
|
|
375
|
+
reports.append(HealthReport(name, tuple(issues), healthy=False))
|
|
376
|
+
continue
|
|
377
|
+
issues.extend(_check_compatibility(plugin.manifest))
|
|
378
|
+
issues.extend(_check_dependencies(plugin.manifest))
|
|
379
|
+
if state.enabled and _has_health_check(plugin):
|
|
380
|
+
with Sandbox(plugin_permissions=plugin.manifest.permissions):
|
|
381
|
+
try:
|
|
382
|
+
reported = list(plugin.entry_point_factories[ # type: ignore[attr-defined]
|
|
383
|
+
(EntryPointKind.OBSERVABILITY.value, "health")
|
|
384
|
+
]())
|
|
385
|
+
except Exception:
|
|
386
|
+
reported = []
|
|
387
|
+
for issue in reported:
|
|
388
|
+
issues.append(
|
|
389
|
+
HealthIssue(
|
|
390
|
+
severity=str(issue.get("severity", "warn")),
|
|
391
|
+
message=str(issue.get("message", "")),
|
|
392
|
+
suggestion=issue.get("suggestion"),
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
healthy = not any(i.severity == "error" for i in issues)
|
|
396
|
+
reports.append(HealthReport(name, tuple(issues), healthy=healthy))
|
|
397
|
+
return reports
|
|
398
|
+
|
|
399
|
+
# ------------------------------------------------------------------
|
|
400
|
+
# Listing
|
|
401
|
+
# ------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
def list(self) -> list[tuple[PluginState, LoadedPlugin | None]]:
|
|
404
|
+
"""Return ``(state, loaded_or_none)`` for every installed plugin."""
|
|
405
|
+
result: list[tuple[PluginState, LoadedPlugin | None]] = []
|
|
406
|
+
for state in self._state.plugins.values():
|
|
407
|
+
try:
|
|
408
|
+
loaded = self._load_for(state.name, state)
|
|
409
|
+
except Exception:
|
|
410
|
+
loaded = None
|
|
411
|
+
result.append((state, loaded))
|
|
412
|
+
return result
|
|
413
|
+
|
|
414
|
+
# ------------------------------------------------------------------
|
|
415
|
+
# Registration channels
|
|
416
|
+
# ------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
def register_provider(self, name: str, provider_cls: type) -> None:
|
|
419
|
+
self.providers[name] = provider_cls
|
|
420
|
+
|
|
421
|
+
def register_optimizer(self, name: str, optimizer_cls: type) -> None:
|
|
422
|
+
self.optimizers[name] = optimizer_cls
|
|
423
|
+
|
|
424
|
+
def register_repository_analyzer(self, analyzer_cls: type) -> None:
|
|
425
|
+
self.analyzers.append(analyzer_cls)
|
|
426
|
+
|
|
427
|
+
def register_workflow(self, workflow: Any) -> None:
|
|
428
|
+
self.workflows.append(workflow)
|
|
429
|
+
|
|
430
|
+
def register_classifier(self, classifier: Any) -> None:
|
|
431
|
+
self.classifiers.append(classifier)
|
|
432
|
+
|
|
433
|
+
def register_test_runner(self, name: str, callback: Callable[..., Any]) -> None:
|
|
434
|
+
self.test_runners[name] = callback
|
|
435
|
+
|
|
436
|
+
def register_docs_generator(self, callback: Callable[..., Any]) -> None:
|
|
437
|
+
self.docs_generators.append(callback)
|
|
438
|
+
|
|
439
|
+
def register_deployment_provider(self, callback: Callable[..., Any]) -> None:
|
|
440
|
+
self.deployment_providers.append(callback)
|
|
441
|
+
|
|
442
|
+
def register_observability_provider(self, callback: Callable[..., Any]) -> None:
|
|
443
|
+
self.observability_providers.append(callback)
|
|
444
|
+
|
|
445
|
+
def register_notification_provider(
|
|
446
|
+
self, name: str, callback: Callable[..., Any]
|
|
447
|
+
) -> None:
|
|
448
|
+
self.notification_providers[name] = callback
|
|
449
|
+
|
|
450
|
+
# ------------------------------------------------------------------
|
|
451
|
+
# Internals
|
|
452
|
+
# ------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
def _validate(self, manifest: PluginManifest) -> None:
|
|
455
|
+
if not is_valid_plugin_name(manifest.name):
|
|
456
|
+
raise PluginError(f"invalid plugin name: {manifest.name!r}")
|
|
457
|
+
sdk = Version.parse(_sdk_version())
|
|
458
|
+
if not manifest.compatibility.matches_host(
|
|
459
|
+
sdk, python_version(), current_platform().os.value
|
|
460
|
+
):
|
|
461
|
+
raise PluginCompatibilityError(
|
|
462
|
+
f"plugin {manifest.name!r} {manifest.version} is not "
|
|
463
|
+
f"compatible with the current host "
|
|
464
|
+
f"(forgecli-sdk={sdk}, python={python_version()}, "
|
|
465
|
+
f"os={current_platform().os.value})"
|
|
466
|
+
)
|
|
467
|
+
# Resolve dependencies against the SDK's version + the
|
|
468
|
+
# currently enabled plugins. Missing transitive deps are
|
|
469
|
+
# surfaced as a warning only.
|
|
470
|
+
requirements: list[Requirement] = list(manifest.dependencies)
|
|
471
|
+
candidates: dict[str, tuple[Version, ...]] = {
|
|
472
|
+
"forgecli-sdk": (sdk,)
|
|
473
|
+
}
|
|
474
|
+
for name in self._state.plugins:
|
|
475
|
+
try:
|
|
476
|
+
candidates[name] = (Version.parse(self._state.plugins[name].version),)
|
|
477
|
+
except VersionParseError:
|
|
478
|
+
continue
|
|
479
|
+
try:
|
|
480
|
+
resolve(requirements, candidates)
|
|
481
|
+
except Exception as exc:
|
|
482
|
+
_logger.warning("dependency resolution failed for %s: %s", manifest.name, exc)
|
|
483
|
+
|
|
484
|
+
def _load_for(self, name: str, state: PluginState) -> LoadedPlugin:
|
|
485
|
+
if name in self._loaded:
|
|
486
|
+
return self._loaded[name]
|
|
487
|
+
if (state.source == "filesystem" and state.install_path) or (state.source == "git" and state.install_path):
|
|
488
|
+
loaded = load_filesystem(Path(state.install_path))
|
|
489
|
+
else:
|
|
490
|
+
# Entry-point plugin; fall back to discovery.
|
|
491
|
+
for ep in discover_entry_points():
|
|
492
|
+
if ep.name == name:
|
|
493
|
+
loaded = ep
|
|
494
|
+
break
|
|
495
|
+
else:
|
|
496
|
+
raise PluginNotFoundError(name)
|
|
497
|
+
self._loaded[name] = loaded
|
|
498
|
+
return loaded
|
|
499
|
+
|
|
500
|
+
def _invoke_entry_points(self, plugin: LoadedPlugin, *, enabled: bool) -> None:
|
|
501
|
+
"""Run each entry-point's factory with the sandbox active."""
|
|
502
|
+
for (kind_name, name), factory in plugin.entry_point_factories.items():
|
|
503
|
+
try:
|
|
504
|
+
kind = EntryPointKind(kind_name)
|
|
505
|
+
except ValueError:
|
|
506
|
+
continue
|
|
507
|
+
with Sandbox(plugin_permissions=plugin.manifest.permissions):
|
|
508
|
+
try:
|
|
509
|
+
if enabled:
|
|
510
|
+
factory(self)
|
|
511
|
+
except Exception:
|
|
512
|
+
_logger.exception(
|
|
513
|
+
"plugin %s: %s.%s failed", plugin.name, kind.value, name
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
# ------------------------------------------------------------------
|
|
517
|
+
# Persistence
|
|
518
|
+
# ------------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
def _load_state(self) -> PluginRegistryState:
|
|
521
|
+
if not self._state_file.exists():
|
|
522
|
+
return PluginRegistryState()
|
|
523
|
+
try:
|
|
524
|
+
raw = json.loads(self._state_file.read_text(encoding="utf-8"))
|
|
525
|
+
except (OSError, json.JSONDecodeError):
|
|
526
|
+
return PluginRegistryState()
|
|
527
|
+
plugins: dict[str, PluginState] = {}
|
|
528
|
+
for name, data in raw.get("plugins", {}).items():
|
|
529
|
+
try:
|
|
530
|
+
plugins[name] = PluginState(
|
|
531
|
+
name=name,
|
|
532
|
+
version=str(data.get("version", "0.0.0")),
|
|
533
|
+
enabled=bool(data.get("enabled", False)),
|
|
534
|
+
source=str(data.get("source", "filesystem")),
|
|
535
|
+
install_path=data.get("install_path"),
|
|
536
|
+
config=dict(data.get("config") or {}),
|
|
537
|
+
installed_at=str(data.get("installed_at", "")),
|
|
538
|
+
enabled_at=data.get("enabled_at"),
|
|
539
|
+
)
|
|
540
|
+
except Exception:
|
|
541
|
+
continue
|
|
542
|
+
return PluginRegistryState(plugins=plugins, schema_version=raw.get("schema_version", 1))
|
|
543
|
+
|
|
544
|
+
def _save_state(self) -> None:
|
|
545
|
+
self._state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
546
|
+
payload = {
|
|
547
|
+
"schema_version": 1,
|
|
548
|
+
"plugins": {
|
|
549
|
+
name: {
|
|
550
|
+
"version": state.version,
|
|
551
|
+
"enabled": state.enabled,
|
|
552
|
+
"source": state.source,
|
|
553
|
+
"install_path": state.install_path,
|
|
554
|
+
"config": state.config,
|
|
555
|
+
"installed_at": state.installed_at,
|
|
556
|
+
"enabled_at": state.enabled_at,
|
|
557
|
+
}
|
|
558
|
+
for name, state in self._state.plugins.items()
|
|
559
|
+
},
|
|
560
|
+
}
|
|
561
|
+
with contextlib.suppress(OSError):
|
|
562
|
+
self._state_file.write_text(
|
|
563
|
+
json.dumps(payload, indent=2), encoding="utf-8"
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
def _git_origin(self, path: Path) -> str:
|
|
567
|
+
result = subprocess.run(
|
|
568
|
+
["git", "config", "--get", "remote.origin.url"],
|
|
569
|
+
cwd=path,
|
|
570
|
+
capture_output=True,
|
|
571
|
+
text=True,
|
|
572
|
+
check=False,
|
|
573
|
+
)
|
|
574
|
+
if result.returncode != 0:
|
|
575
|
+
raise PluginError(f"could not read origin URL from {path}")
|
|
576
|
+
return result.stdout.strip()
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# ---------------------------------------------------------------------------
|
|
580
|
+
# Helpers
|
|
581
|
+
# ---------------------------------------------------------------------------
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _now_iso() -> str:
|
|
585
|
+
from datetime import UTC, datetime
|
|
586
|
+
|
|
587
|
+
return datetime.now(UTC).isoformat(timespec="seconds")
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _slugify(value: str) -> str:
|
|
591
|
+
import re
|
|
592
|
+
|
|
593
|
+
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-").lower()
|
|
594
|
+
return slug or "plugin"
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _has_health_check(plugin: LoadedPlugin) -> bool:
|
|
598
|
+
"""Return True if the plugin has a registered health entry point."""
|
|
599
|
+
from forgecli.sdk.manifest import EntryPointKind
|
|
600
|
+
|
|
601
|
+
return (EntryPointKind.OBSERVABILITY.value, "health") in plugin.entry_point_factories
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _check_compatibility(manifest: PluginManifest) -> list[HealthIssue]:
|
|
605
|
+
sdk = Version.parse(_sdk_version())
|
|
606
|
+
issues: list[HealthIssue] = []
|
|
607
|
+
if manifest.compatibility.min_sdk and sdk < manifest.compatibility.min_sdk:
|
|
608
|
+
issues.append(
|
|
609
|
+
HealthIssue(
|
|
610
|
+
severity="error",
|
|
611
|
+
message=(
|
|
612
|
+
f"requires forgecli-sdk>={manifest.compatibility.min_sdk}, "
|
|
613
|
+
f"have {sdk}"
|
|
614
|
+
),
|
|
615
|
+
suggestion="upgrade ForgeCLI",
|
|
616
|
+
)
|
|
617
|
+
)
|
|
618
|
+
if manifest.compatibility.max_sdk and sdk > manifest.compatibility.max_sdk:
|
|
619
|
+
issues.append(
|
|
620
|
+
HealthIssue(
|
|
621
|
+
severity="warn",
|
|
622
|
+
message=(
|
|
623
|
+
f"tested up to forgecli-sdk<={manifest.compatibility.max_sdk}, "
|
|
624
|
+
f"have {sdk}"
|
|
625
|
+
),
|
|
626
|
+
)
|
|
627
|
+
)
|
|
628
|
+
if (
|
|
629
|
+
manifest.compatibility.os_targets
|
|
630
|
+
and current_platform().os.value not in manifest.compatibility.os_targets
|
|
631
|
+
):
|
|
632
|
+
issues.append(
|
|
633
|
+
HealthIssue(
|
|
634
|
+
severity="warn",
|
|
635
|
+
message=(
|
|
636
|
+
f"targets {manifest.compatibility.os_targets}, "
|
|
637
|
+
f"current is {current_platform().os.value}"
|
|
638
|
+
),
|
|
639
|
+
)
|
|
640
|
+
)
|
|
641
|
+
return issues
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _check_dependencies(manifest: PluginManifest) -> list[HealthIssue]:
|
|
645
|
+
issues: list[HealthIssue] = []
|
|
646
|
+
for req in manifest.dependencies:
|
|
647
|
+
if req.name == "python":
|
|
648
|
+
try:
|
|
649
|
+
if not req.matches(Version.parse(python_version())):
|
|
650
|
+
issues.append(
|
|
651
|
+
HealthIssue(
|
|
652
|
+
severity="error",
|
|
653
|
+
message=f"python {req} but have {python_version()}",
|
|
654
|
+
)
|
|
655
|
+
)
|
|
656
|
+
except VersionParseError:
|
|
657
|
+
pass
|
|
658
|
+
continue
|
|
659
|
+
# External plugin / library — best-effort probe.
|
|
660
|
+
if not _sdk_version_known(req.name):
|
|
661
|
+
issues.append(
|
|
662
|
+
HealthIssue(
|
|
663
|
+
severity="info",
|
|
664
|
+
message=f"depends on {req} (not validated locally)",
|
|
665
|
+
)
|
|
666
|
+
)
|
|
667
|
+
return issues
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _sdk_version() -> str:
|
|
671
|
+
from forgecli import __version__ as v
|
|
672
|
+
|
|
673
|
+
return v
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _sdk_version_known(_name: str) -> bool:
|
|
677
|
+
return False
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
__all__ = [
|
|
681
|
+
"Compatibility",
|
|
682
|
+
"PluginAlreadyInstalledError",
|
|
683
|
+
"PluginCompatibilityError",
|
|
684
|
+
"PluginError",
|
|
685
|
+
"PluginManager",
|
|
686
|
+
"PluginNotFoundError",
|
|
687
|
+
"PluginRegistryState",
|
|
688
|
+
"PluginState",
|
|
689
|
+
]
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
# Silence the unused-import warnings for symbols only used in some
|
|
693
|
+
# branches of the public surface.
|