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.
@@ -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()