spaceforge 0.1.0.dev0__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.
- spaceforge/README.md +279 -0
- spaceforge/__init__.py +23 -0
- spaceforge/__main__.py +33 -0
- spaceforge/_version.py +81 -0
- spaceforge/cls.py +198 -0
- spaceforge/cls_test.py +17 -0
- spaceforge/generator.py +362 -0
- spaceforge/generator_test.py +671 -0
- spaceforge/plugin.py +275 -0
- spaceforge/plugin_test.py +621 -0
- spaceforge/runner.py +115 -0
- spaceforge/runner_test.py +605 -0
- spaceforge/schema.json +371 -0
- spaceforge-0.1.0.dev0.dist-info/METADATA +163 -0
- spaceforge-0.1.0.dev0.dist-info/RECORD +19 -0
- spaceforge-0.1.0.dev0.dist-info/WHEEL +5 -0
- spaceforge-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- spaceforge-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- spaceforge-0.1.0.dev0.dist-info/top_level.txt +1 -0
spaceforge/generator.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""
|
|
2
|
+
YAML generator for Spacelift plugins.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import os
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .plugin import SpaceforgePlugin
|
|
13
|
+
|
|
14
|
+
from dataclasses import fields
|
|
15
|
+
|
|
16
|
+
from .cls import (
|
|
17
|
+
Binary,
|
|
18
|
+
Context,
|
|
19
|
+
MountedFile,
|
|
20
|
+
Parameter,
|
|
21
|
+
PluginManifest,
|
|
22
|
+
Policy,
|
|
23
|
+
Variable,
|
|
24
|
+
Webhook,
|
|
25
|
+
)
|
|
26
|
+
from .plugin import SpaceforgePlugin
|
|
27
|
+
|
|
28
|
+
static_binary_directory = "/mnt/workspace/plugins/plugin_binaries"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PluginGenerator:
|
|
32
|
+
"""Generates plugin.yaml from a Python plugin class."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self, plugin_path: str = "plugin.py", output_path: str = "plugin.yaml"
|
|
36
|
+
) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Initialize the generator.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
plugin_path: Path to the plugin Python file
|
|
42
|
+
output_path: Path where to write the generated YAML
|
|
43
|
+
"""
|
|
44
|
+
self.plugin_path = plugin_path
|
|
45
|
+
self.output_path = output_path
|
|
46
|
+
self.plugin_class: Optional[Type[SpaceforgePlugin]] = None
|
|
47
|
+
self.plugin_instance: Optional[SpaceforgePlugin] = None
|
|
48
|
+
self.plugin_working_directory: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
def load_plugin(self) -> None:
|
|
51
|
+
"""Load the plugin class from the specified path."""
|
|
52
|
+
if not os.path.exists(self.plugin_path):
|
|
53
|
+
raise FileNotFoundError(f"Plugin file not found: {self.plugin_path}")
|
|
54
|
+
|
|
55
|
+
# Load the module
|
|
56
|
+
spec = importlib.util.spec_from_file_location("plugin_module", self.plugin_path)
|
|
57
|
+
if spec is None or spec.loader is None:
|
|
58
|
+
raise ImportError(f"Could not load plugin from {self.plugin_path}")
|
|
59
|
+
|
|
60
|
+
plugin_module = importlib.util.module_from_spec(spec)
|
|
61
|
+
spec.loader.exec_module(plugin_module)
|
|
62
|
+
|
|
63
|
+
# Find the plugin class (should inherit from SpaceforgePlugin)
|
|
64
|
+
plugin_class = None
|
|
65
|
+
for attr_name in dir(plugin_module):
|
|
66
|
+
attr = getattr(plugin_module, attr_name)
|
|
67
|
+
if (
|
|
68
|
+
isinstance(attr, type)
|
|
69
|
+
and issubclass(attr, SpaceforgePlugin)
|
|
70
|
+
and attr != SpaceforgePlugin
|
|
71
|
+
):
|
|
72
|
+
plugin_class = attr
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
if plugin_class is None:
|
|
76
|
+
raise ValueError("No SpaceforgePlugin subclass found in plugin file")
|
|
77
|
+
|
|
78
|
+
self.plugin_class = plugin_class
|
|
79
|
+
self.plugin_instance = plugin_class()
|
|
80
|
+
self.plugin_working_directory = (
|
|
81
|
+
"/mnt/workspace/plugins/" + plugin_class.__plugin_name__.lower()
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def get_plugin_metadata(self) -> Dict[str, str]:
|
|
85
|
+
"""Extract metadata from the plugin class."""
|
|
86
|
+
if self.plugin_class is None:
|
|
87
|
+
raise ValueError("Plugin class not loaded. Call load_plugin() first.")
|
|
88
|
+
|
|
89
|
+
doc = getattr(self.plugin_class, "__doc__", "")
|
|
90
|
+
if doc:
|
|
91
|
+
doc = doc.strip()
|
|
92
|
+
|
|
93
|
+
metadata = {
|
|
94
|
+
"name_prefix": getattr(
|
|
95
|
+
self.plugin_class,
|
|
96
|
+
"__plugin_name__",
|
|
97
|
+
self.plugin_class.__name__.lower().replace("plugin", ""),
|
|
98
|
+
),
|
|
99
|
+
"version": getattr(self.plugin_class, "__version__", "1.0.0"),
|
|
100
|
+
"description": doc
|
|
101
|
+
or f"A Spacelift plugin built with {self.plugin_class.__name__}",
|
|
102
|
+
"author": getattr(self.plugin_class, "__author__", "Unknown"),
|
|
103
|
+
}
|
|
104
|
+
return metadata
|
|
105
|
+
|
|
106
|
+
def get_plugin_parameters(self) -> Optional[List[Parameter]]:
|
|
107
|
+
"""Extract parameter definitions from the plugin class."""
|
|
108
|
+
return getattr(self.plugin_class, "__parameters__", None)
|
|
109
|
+
|
|
110
|
+
def get_available_hooks(self) -> List[str]:
|
|
111
|
+
"""Get list of hook methods implemented in the plugin."""
|
|
112
|
+
hook_methods = []
|
|
113
|
+
|
|
114
|
+
for method_name in dir(self.plugin_class):
|
|
115
|
+
if method_name.startswith(
|
|
116
|
+
("after_", "before_")
|
|
117
|
+
) and not method_name.startswith("_"):
|
|
118
|
+
method = getattr(self.plugin_class, method_name)
|
|
119
|
+
base_method = getattr(SpaceforgePlugin, method_name, None)
|
|
120
|
+
|
|
121
|
+
# Check if the method is overridden (different from base implementation)
|
|
122
|
+
if (
|
|
123
|
+
callable(method)
|
|
124
|
+
and base_method
|
|
125
|
+
and method.__qualname__ != base_method.__qualname__
|
|
126
|
+
):
|
|
127
|
+
hook_methods.append(method_name)
|
|
128
|
+
|
|
129
|
+
return hook_methods
|
|
130
|
+
|
|
131
|
+
def get_plugin_contexts(self) -> List[Context]:
|
|
132
|
+
"""Get context definitions from the plugin class."""
|
|
133
|
+
|
|
134
|
+
change_to_working_directory = f"cd {self.plugin_working_directory}"
|
|
135
|
+
hooks = {
|
|
136
|
+
"before_init": [
|
|
137
|
+
f"mkdir -p {self.plugin_working_directory}",
|
|
138
|
+
],
|
|
139
|
+
}
|
|
140
|
+
mounted_files = []
|
|
141
|
+
|
|
142
|
+
# Add a virtual environment before_init hook if requirements.txt exists
|
|
143
|
+
if os.path.exists("requirements.txt"):
|
|
144
|
+
hooks["before_init"].append(
|
|
145
|
+
f"{change_to_working_directory} && python -m venv ./venv && source venv/bin/activate && pip install -r requirements.txt"
|
|
146
|
+
)
|
|
147
|
+
# read the requirements.txt file
|
|
148
|
+
with open("requirements.txt", "r") as f:
|
|
149
|
+
mounted_files.append(
|
|
150
|
+
MountedFile(
|
|
151
|
+
path=f"{self.plugin_working_directory}/requirements.txt",
|
|
152
|
+
content=f.read(),
|
|
153
|
+
sensitive=False,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Ensure the plugin file itself is mounted
|
|
158
|
+
plugin_mounted_path = (
|
|
159
|
+
f"{self.plugin_working_directory}/{os.path.basename(self.plugin_path)}"
|
|
160
|
+
)
|
|
161
|
+
if os.path.exists(self.plugin_path):
|
|
162
|
+
with open(self.plugin_path, "r") as f:
|
|
163
|
+
mounted_files.append(
|
|
164
|
+
MountedFile(
|
|
165
|
+
path=plugin_mounted_path,
|
|
166
|
+
content=f.read(),
|
|
167
|
+
sensitive=False,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
binary_cmd = self.generate_binary_install_command()
|
|
172
|
+
if binary_cmd != "":
|
|
173
|
+
hooks["before_init"].append(binary_cmd)
|
|
174
|
+
|
|
175
|
+
# Add the spaceforge hook to actually run the plugin
|
|
176
|
+
available_hooks = self.get_available_hooks()
|
|
177
|
+
for hook in available_hooks:
|
|
178
|
+
# Ensure the hook exists in the first context
|
|
179
|
+
if hook not in hooks:
|
|
180
|
+
hooks[hook] = []
|
|
181
|
+
hooks[hook].append(
|
|
182
|
+
f"{change_to_working_directory} && python -m spaceforge runner --plugin-file {plugin_mounted_path} {hook}"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Get the contexts and append the hooks and mounted files to it.
|
|
186
|
+
if self.plugin_class is None:
|
|
187
|
+
raise ValueError("Plugin class not loaded. Call load_plugin() first.")
|
|
188
|
+
|
|
189
|
+
contexts = getattr(
|
|
190
|
+
self.plugin_class,
|
|
191
|
+
"__contexts__",
|
|
192
|
+
[
|
|
193
|
+
Context(
|
|
194
|
+
name_prefix=self.plugin_class.__plugin_name__.lower(),
|
|
195
|
+
description=f"Main context for {self.plugin_class.__plugin_name__}",
|
|
196
|
+
)
|
|
197
|
+
],
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if contexts[0].hooks is None:
|
|
201
|
+
contexts[0].hooks = {}
|
|
202
|
+
if contexts[0].mounted_files is None:
|
|
203
|
+
contexts[0].mounted_files = []
|
|
204
|
+
if contexts[0].env is None:
|
|
205
|
+
contexts[0].env = []
|
|
206
|
+
|
|
207
|
+
# Add the hooks and mounted files to the first context
|
|
208
|
+
contexts[0].hooks.update(hooks)
|
|
209
|
+
contexts[0].mounted_files.extend(mounted_files)
|
|
210
|
+
|
|
211
|
+
return contexts
|
|
212
|
+
|
|
213
|
+
def generate_binary_install_command(self) -> str:
|
|
214
|
+
binaries = self.get_plugin_binaries()
|
|
215
|
+
if binaries is None:
|
|
216
|
+
return ""
|
|
217
|
+
|
|
218
|
+
binary_cmd = ""
|
|
219
|
+
if len(binaries) > 0:
|
|
220
|
+
binary_cmd = f"mkdir -p {static_binary_directory} && cd {static_binary_directory} && "
|
|
221
|
+
for i, binary in enumerate(binaries):
|
|
222
|
+
amd64_url = binary.download_urls.get("amd64", None)
|
|
223
|
+
arm64_url = binary.download_urls.get("arm64", None)
|
|
224
|
+
if amd64_url is None and arm64_url is None:
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"Binary {binary.name} must have at least one download URL defined (amd64 or arm64)"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
binary_path = f"{static_binary_directory}/{binary.name}"
|
|
230
|
+
amd64_download_command = (
|
|
231
|
+
f"curl {amd64_url} -o {binary_path} -L && chmod +x {binary_path}"
|
|
232
|
+
if amd64_url is not None
|
|
233
|
+
else "echo 'amd64 binary not available' && exit 1"
|
|
234
|
+
)
|
|
235
|
+
arm64_download_command = (
|
|
236
|
+
f"curl {arm64_url} -o {binary_path} -L && chmod +x {binary_path}"
|
|
237
|
+
if arm64_url is not None
|
|
238
|
+
else "echo 'arm64 binary not available' && exit 1"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
binary_cmd += '([[ "$(echo "$(arch)")" == "x86_64" ]] && {} || {})'.format(
|
|
242
|
+
amd64_download_command, arm64_download_command
|
|
243
|
+
)
|
|
244
|
+
if i < len(binaries) - 1:
|
|
245
|
+
binary_cmd += " && "
|
|
246
|
+
return binary_cmd
|
|
247
|
+
|
|
248
|
+
def get_plugin_binaries(self) -> Optional[List[Binary]]:
|
|
249
|
+
"""Get binary definitions from the plugin class."""
|
|
250
|
+
return getattr(self.plugin_class, "__binaries__", None)
|
|
251
|
+
|
|
252
|
+
def get_plugin_policies(self) -> Optional[List[Policy]]:
|
|
253
|
+
"""Get policy definitions from the plugin class."""
|
|
254
|
+
return getattr(self.plugin_class, "__policies__", None)
|
|
255
|
+
|
|
256
|
+
def get_plugin_webhooks(self) -> Optional[List[Webhook]]:
|
|
257
|
+
"""Get webhook definitions from the plugin class."""
|
|
258
|
+
return getattr(self.plugin_class, "__webhooks__", None)
|
|
259
|
+
|
|
260
|
+
def generate_manifest(self) -> PluginManifest:
|
|
261
|
+
"""Generate the complete plugin YAML structure."""
|
|
262
|
+
if self.plugin_class is None:
|
|
263
|
+
self.load_plugin()
|
|
264
|
+
|
|
265
|
+
metadata = self.get_plugin_metadata()
|
|
266
|
+
|
|
267
|
+
return PluginManifest(
|
|
268
|
+
name_prefix=metadata.get("name_prefix", "unknown"),
|
|
269
|
+
version=metadata.get("version", "1.0.0"),
|
|
270
|
+
description=metadata.get("description", ""),
|
|
271
|
+
author=metadata.get("author", "Unknown"),
|
|
272
|
+
parameters=self.get_plugin_parameters(),
|
|
273
|
+
contexts=self.get_plugin_contexts(),
|
|
274
|
+
webhooks=self.get_plugin_webhooks(),
|
|
275
|
+
policies=self.get_plugin_policies(),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def write_yaml(self, manifest: PluginManifest) -> None:
|
|
279
|
+
"""Write the YAML data to file."""
|
|
280
|
+
|
|
281
|
+
class CustomDumper(yaml.Dumper):
|
|
282
|
+
def choose_scalar_style(self) -> str:
|
|
283
|
+
"""Override scalar style selection to prefer literal for multiline code."""
|
|
284
|
+
style = super().choose_scalar_style()
|
|
285
|
+
if (
|
|
286
|
+
hasattr(self, "event")
|
|
287
|
+
and self.event
|
|
288
|
+
and hasattr(self.event, "value")
|
|
289
|
+
):
|
|
290
|
+
if self.event.value.count("\n") > 0 and len(self.event.value) > 100:
|
|
291
|
+
return "|"
|
|
292
|
+
return style if isinstance(style, str) else ""
|
|
293
|
+
|
|
294
|
+
def represent_str(self, data: str) -> Any:
|
|
295
|
+
"""Override string representation for multiline strings."""
|
|
296
|
+
if data.count("\n") > 0:
|
|
297
|
+
data = data.strip()
|
|
298
|
+
data = "\n".join([line.rstrip() for line in data.splitlines()])
|
|
299
|
+
return self.represent_scalar(
|
|
300
|
+
"tag:yaml.org,2002:str", data, style="|"
|
|
301
|
+
)
|
|
302
|
+
return self.represent_scalar("tag:yaml.org,2002:str", data)
|
|
303
|
+
|
|
304
|
+
def represent_dataclass(self, data: Any) -> Any:
|
|
305
|
+
"""Override dataclass representation to exclude None values."""
|
|
306
|
+
filtered_dict = {
|
|
307
|
+
field.name: getattr(data, field.name)
|
|
308
|
+
for field in fields(data)
|
|
309
|
+
if getattr(data, field.name) is not None
|
|
310
|
+
}
|
|
311
|
+
return self.represent_dict(filtered_dict)
|
|
312
|
+
|
|
313
|
+
# Register representers using the instance methods
|
|
314
|
+
CustomDumper.add_representer(str, CustomDumper.represent_str)
|
|
315
|
+
CustomDumper.add_representer(Context, CustomDumper.represent_dataclass)
|
|
316
|
+
CustomDumper.add_representer(Parameter, CustomDumper.represent_dataclass)
|
|
317
|
+
CustomDumper.add_representer(Variable, CustomDumper.represent_dataclass)
|
|
318
|
+
CustomDumper.add_representer(Webhook, CustomDumper.represent_dataclass)
|
|
319
|
+
CustomDumper.add_representer(Policy, CustomDumper.represent_dataclass)
|
|
320
|
+
CustomDumper.add_representer(MountedFile, CustomDumper.represent_dataclass)
|
|
321
|
+
CustomDumper.add_representer(PluginManifest, CustomDumper.represent_dataclass)
|
|
322
|
+
|
|
323
|
+
with open(self.output_path, "w") as f:
|
|
324
|
+
yaml.dump(
|
|
325
|
+
manifest,
|
|
326
|
+
f,
|
|
327
|
+
Dumper=CustomDumper,
|
|
328
|
+
default_flow_style=False,
|
|
329
|
+
sort_keys=False,
|
|
330
|
+
indent=2,
|
|
331
|
+
width=float("inf"),
|
|
332
|
+
)
|
|
333
|
+
print(f"Generated plugin.yaml at: {self.output_path}")
|
|
334
|
+
|
|
335
|
+
def generate(self) -> None:
|
|
336
|
+
"""Generate the plugin.yaml file."""
|
|
337
|
+
self.write_yaml(self.generate_manifest())
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
import click
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@click.command(name="generate")
|
|
344
|
+
@click.argument(
|
|
345
|
+
"plugin_file",
|
|
346
|
+
default="plugin.py",
|
|
347
|
+
type=click.Path(exists=True, dir_okay=False, readable=True),
|
|
348
|
+
)
|
|
349
|
+
@click.option(
|
|
350
|
+
"-o",
|
|
351
|
+
"--output",
|
|
352
|
+
default="plugin.yaml",
|
|
353
|
+
type=click.Path(dir_okay=False, writable=True),
|
|
354
|
+
help="Output YAML file path (default: plugin.yaml)",
|
|
355
|
+
)
|
|
356
|
+
def generate_command(plugin_file: str, output: str) -> None:
|
|
357
|
+
"""Generate plugin.yaml from Python plugin.
|
|
358
|
+
|
|
359
|
+
PLUGIN_FILE: Path to the plugin Python file (default: plugin.py)
|
|
360
|
+
"""
|
|
361
|
+
generator = PluginGenerator(plugin_file, output)
|
|
362
|
+
generator.generate()
|