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
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from unittest.mock import Mock, mock_open, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from spaceforge.cls import (
|
|
9
|
+
Binary,
|
|
10
|
+
Context,
|
|
11
|
+
MountedFile,
|
|
12
|
+
Parameter,
|
|
13
|
+
PluginManifest,
|
|
14
|
+
Policy,
|
|
15
|
+
Variable,
|
|
16
|
+
Webhook,
|
|
17
|
+
)
|
|
18
|
+
from spaceforge.generator import PluginGenerator
|
|
19
|
+
from spaceforge.plugin import SpaceforgePlugin
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PluginExample(SpaceforgePlugin):
|
|
23
|
+
"""Test plugin for generator testing."""
|
|
24
|
+
|
|
25
|
+
__plugin_name__ = "test_plugin"
|
|
26
|
+
__version__ = "2.0.0"
|
|
27
|
+
__author__ = "Test Author"
|
|
28
|
+
|
|
29
|
+
__parameters__ = [
|
|
30
|
+
Parameter(
|
|
31
|
+
name="api_key",
|
|
32
|
+
description="API key for authentication",
|
|
33
|
+
required=True,
|
|
34
|
+
sensitive=True,
|
|
35
|
+
),
|
|
36
|
+
Parameter(
|
|
37
|
+
name="endpoint",
|
|
38
|
+
description="API endpoint URL",
|
|
39
|
+
required=False,
|
|
40
|
+
default="https://api.example.com",
|
|
41
|
+
),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
__binaries__ = [
|
|
45
|
+
Binary(
|
|
46
|
+
name="test-cli",
|
|
47
|
+
download_urls={
|
|
48
|
+
"amd64": "https://example.com/test-cli-amd64",
|
|
49
|
+
"arm64": "https://example.com/test-cli-arm64",
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
__contexts__ = [
|
|
55
|
+
Context(
|
|
56
|
+
name_prefix="test_context",
|
|
57
|
+
description="Test context",
|
|
58
|
+
labels={"env": "test"},
|
|
59
|
+
env=[Variable(key="TEST_VAR", value="test_value")],
|
|
60
|
+
)
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
__webhooks__ = [
|
|
64
|
+
Webhook(
|
|
65
|
+
name_prefix="test_webhook",
|
|
66
|
+
endpoint="https://webhook.example.com",
|
|
67
|
+
secrets=[Variable(key="SECRET_KEY", value_from_parameter="api_key")],
|
|
68
|
+
)
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
__policies__ = [
|
|
72
|
+
Policy(
|
|
73
|
+
name_prefix="test_policy",
|
|
74
|
+
type="notification",
|
|
75
|
+
body="package test",
|
|
76
|
+
labels={"type": "security"},
|
|
77
|
+
)
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
def after_plan(self) -> None:
|
|
81
|
+
"""Override hook method."""
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
def before_apply(self) -> None:
|
|
85
|
+
"""Override hook method."""
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestPluginGenerator:
|
|
90
|
+
|
|
91
|
+
def setup_method(self) -> None:
|
|
92
|
+
"""Setup test fixtures."""
|
|
93
|
+
self.temp_dir = tempfile.mkdtemp()
|
|
94
|
+
self.test_plugin_path = os.path.join(self.temp_dir, "plugin.py")
|
|
95
|
+
self.test_output_path = os.path.join(self.temp_dir, "plugin.yaml")
|
|
96
|
+
|
|
97
|
+
# Create a test plugin file
|
|
98
|
+
with open(self.test_plugin_path, "w") as f:
|
|
99
|
+
f.write(
|
|
100
|
+
"""
|
|
101
|
+
from spaceforge import SpaceforgePlugin, Parameter
|
|
102
|
+
|
|
103
|
+
class TestPlugin(SpaceforgePlugin):
|
|
104
|
+
__plugin_name__ = "test"
|
|
105
|
+
__version__ = "1.0.0"
|
|
106
|
+
__author__ = "Test"
|
|
107
|
+
|
|
108
|
+
__parameters__ = [
|
|
109
|
+
Parameter(name="test_param", description="Test parameter", required=False, default="default_value")
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
def after_plan(self) -> None:
|
|
113
|
+
pass
|
|
114
|
+
"""
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def teardown_method(self) -> None:
|
|
118
|
+
"""Cleanup test fixtures."""
|
|
119
|
+
import shutil
|
|
120
|
+
|
|
121
|
+
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
|
122
|
+
|
|
123
|
+
def test_plugin_generator_init(self) -> None:
|
|
124
|
+
"""Test PluginGenerator initialization."""
|
|
125
|
+
generator = PluginGenerator("plugin_test.py", "output_test.yaml")
|
|
126
|
+
|
|
127
|
+
assert generator.plugin_path == "plugin_test.py"
|
|
128
|
+
assert generator.output_path == "output_test.yaml"
|
|
129
|
+
assert generator.plugin_class is None
|
|
130
|
+
assert generator.plugin_instance is None
|
|
131
|
+
assert generator.plugin_working_directory is None
|
|
132
|
+
|
|
133
|
+
def test_plugin_generator_init_defaults(self) -> None:
|
|
134
|
+
"""Test PluginGenerator initialization with default values."""
|
|
135
|
+
generator = PluginGenerator()
|
|
136
|
+
|
|
137
|
+
assert generator.plugin_path == "plugin.py"
|
|
138
|
+
assert generator.output_path == "plugin.yaml"
|
|
139
|
+
|
|
140
|
+
def test_load_plugin_file_not_found(self) -> None:
|
|
141
|
+
"""Test loading plugin when file doesn't exist."""
|
|
142
|
+
generator = PluginGenerator("nonexistent.py")
|
|
143
|
+
|
|
144
|
+
with pytest.raises(FileNotFoundError, match="Plugin file not found"):
|
|
145
|
+
generator.load_plugin()
|
|
146
|
+
|
|
147
|
+
def test_load_plugin_invalid_module(self) -> None:
|
|
148
|
+
"""Test loading invalid Python module."""
|
|
149
|
+
invalid_path = os.path.join(self.temp_dir, "invalid.py")
|
|
150
|
+
with open(invalid_path, "w") as f:
|
|
151
|
+
f.write("invalid python syntax }")
|
|
152
|
+
|
|
153
|
+
generator = PluginGenerator(invalid_path)
|
|
154
|
+
|
|
155
|
+
with pytest.raises(Exception): # Could be syntax error or import error
|
|
156
|
+
generator.load_plugin()
|
|
157
|
+
|
|
158
|
+
def test_load_plugin_no_spacepy_subclass(self) -> None:
|
|
159
|
+
"""Test loading plugin with no SpaceforgePlugin subclass."""
|
|
160
|
+
no_plugin_path = os.path.join(self.temp_dir, "no_plugin.py")
|
|
161
|
+
with open(no_plugin_path, "w") as f:
|
|
162
|
+
f.write(
|
|
163
|
+
"""
|
|
164
|
+
class NotAPlugin:
|
|
165
|
+
pass
|
|
166
|
+
"""
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
generator = PluginGenerator(no_plugin_path)
|
|
170
|
+
|
|
171
|
+
with pytest.raises(ValueError, match="No SpaceforgePlugin subclass found"):
|
|
172
|
+
generator.load_plugin()
|
|
173
|
+
|
|
174
|
+
def test_load_plugin_success(self) -> None:
|
|
175
|
+
"""Test successful plugin loading."""
|
|
176
|
+
generator = PluginGenerator(self.test_plugin_path)
|
|
177
|
+
generator.load_plugin()
|
|
178
|
+
|
|
179
|
+
assert generator.plugin_class is not None
|
|
180
|
+
assert generator.plugin_instance is not None
|
|
181
|
+
assert generator.plugin_class.__name__ == "TestPlugin"
|
|
182
|
+
assert generator.plugin_working_directory == "/mnt/workspace/plugins/test"
|
|
183
|
+
|
|
184
|
+
@patch("spaceforge.generator.importlib.util.spec_from_file_location")
|
|
185
|
+
def test_load_plugin_spec_none(self, mock_spec: Mock) -> None:
|
|
186
|
+
"""Test plugin loading when spec is None."""
|
|
187
|
+
mock_spec.return_value = None
|
|
188
|
+
|
|
189
|
+
generator = PluginGenerator(self.test_plugin_path)
|
|
190
|
+
|
|
191
|
+
with pytest.raises(ImportError, match="Could not load plugin"):
|
|
192
|
+
generator.load_plugin()
|
|
193
|
+
|
|
194
|
+
def test_get_plugin_metadata_with_all_attributes(self) -> None:
|
|
195
|
+
"""Test metadata extraction with all attributes present."""
|
|
196
|
+
generator = PluginGenerator()
|
|
197
|
+
generator.plugin_class = PluginExample
|
|
198
|
+
|
|
199
|
+
metadata = generator.get_plugin_metadata()
|
|
200
|
+
|
|
201
|
+
assert metadata["name_prefix"] == "test_plugin"
|
|
202
|
+
assert metadata["version"] == "2.0.0"
|
|
203
|
+
assert metadata["author"] == "Test Author"
|
|
204
|
+
assert metadata["description"] == "Test plugin for generator testing."
|
|
205
|
+
|
|
206
|
+
def test_get_plugin_metadata_with_defaults(self) -> None:
|
|
207
|
+
"""Test metadata extraction with missing attributes."""
|
|
208
|
+
|
|
209
|
+
class MinimalPlugin(SpaceforgePlugin):
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
generator = PluginGenerator()
|
|
213
|
+
generator.plugin_class = MinimalPlugin
|
|
214
|
+
|
|
215
|
+
metadata = generator.get_plugin_metadata()
|
|
216
|
+
|
|
217
|
+
# MinimalPlugin inherits __plugin_name__ from SpaceforgePlugin
|
|
218
|
+
assert metadata["name_prefix"] == "SpaceforgePlugin"
|
|
219
|
+
assert metadata["version"] == "1.0.0" # inherited from base
|
|
220
|
+
assert metadata["author"] == "Spacelift Team" # inherited from base
|
|
221
|
+
assert "MinimalPlugin" in metadata["description"]
|
|
222
|
+
|
|
223
|
+
def test_get_plugin_metadata_class_name_fallback(self) -> None:
|
|
224
|
+
"""Test metadata extraction using class name when __plugin_name__ not set."""
|
|
225
|
+
|
|
226
|
+
# Test the fallback behavior by mocking the plugin class itself
|
|
227
|
+
class MinimalPlugin: # Don't inherit from SpaceforgePlugin
|
|
228
|
+
__name__ = "MinimalPlugin"
|
|
229
|
+
|
|
230
|
+
generator = PluginGenerator()
|
|
231
|
+
generator.plugin_class = MinimalPlugin # type: ignore[assignment]
|
|
232
|
+
|
|
233
|
+
metadata = generator.get_plugin_metadata()
|
|
234
|
+
|
|
235
|
+
# Should use class name fallback logic
|
|
236
|
+
assert (
|
|
237
|
+
metadata["name_prefix"] == "minimal"
|
|
238
|
+
) # class name lowercased with 'plugin' removed
|
|
239
|
+
assert metadata["version"] == "1.0.0" # default
|
|
240
|
+
assert metadata["author"] == "Unknown" # default
|
|
241
|
+
assert "MinimalPlugin" in metadata["description"]
|
|
242
|
+
|
|
243
|
+
def test_get_plugin_parameters(self) -> None:
|
|
244
|
+
"""Test parameter extraction."""
|
|
245
|
+
generator = PluginGenerator()
|
|
246
|
+
generator.plugin_class = PluginExample
|
|
247
|
+
|
|
248
|
+
parameters = generator.get_plugin_parameters()
|
|
249
|
+
|
|
250
|
+
assert parameters is not None
|
|
251
|
+
assert len(parameters) == 2
|
|
252
|
+
assert parameters[0].name == "api_key"
|
|
253
|
+
assert parameters[0].sensitive is True
|
|
254
|
+
assert parameters[1].name == "endpoint"
|
|
255
|
+
assert parameters[1].default == "https://api.example.com"
|
|
256
|
+
|
|
257
|
+
def test_get_plugin_parameters_none(self) -> None:
|
|
258
|
+
"""Test parameter extraction when no parameters defined."""
|
|
259
|
+
|
|
260
|
+
class NoParamsPlugin(SpaceforgePlugin):
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
generator = PluginGenerator()
|
|
264
|
+
generator.plugin_class = NoParamsPlugin
|
|
265
|
+
|
|
266
|
+
parameters = generator.get_plugin_parameters()
|
|
267
|
+
assert parameters is None
|
|
268
|
+
|
|
269
|
+
def test_get_available_hooks(self) -> None:
|
|
270
|
+
"""Test hook method detection."""
|
|
271
|
+
generator = PluginGenerator()
|
|
272
|
+
generator.plugin_class = PluginExample
|
|
273
|
+
|
|
274
|
+
hooks = generator.get_available_hooks()
|
|
275
|
+
|
|
276
|
+
assert "after_plan" in hooks
|
|
277
|
+
assert "before_apply" in hooks
|
|
278
|
+
assert len(hooks) == 2
|
|
279
|
+
|
|
280
|
+
def test_get_available_hooks_no_overrides(self) -> None:
|
|
281
|
+
"""Test hook detection with no overridden methods."""
|
|
282
|
+
|
|
283
|
+
class NoHooksPlugin(SpaceforgePlugin):
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
generator = PluginGenerator()
|
|
287
|
+
generator.plugin_class = NoHooksPlugin
|
|
288
|
+
|
|
289
|
+
hooks = generator.get_available_hooks()
|
|
290
|
+
assert hooks == []
|
|
291
|
+
|
|
292
|
+
def test_get_plugin_binaries(self) -> None:
|
|
293
|
+
"""Test binary extraction."""
|
|
294
|
+
generator = PluginGenerator()
|
|
295
|
+
generator.plugin_class = PluginExample
|
|
296
|
+
|
|
297
|
+
binaries = generator.get_plugin_binaries()
|
|
298
|
+
|
|
299
|
+
assert binaries is not None
|
|
300
|
+
assert len(binaries) == 1
|
|
301
|
+
assert binaries[0].name == "test-cli"
|
|
302
|
+
assert "amd64" in binaries[0].download_urls
|
|
303
|
+
assert "arm64" in binaries[0].download_urls
|
|
304
|
+
|
|
305
|
+
def test_get_plugin_binaries_none(self) -> None:
|
|
306
|
+
"""Test binary extraction when no binaries defined."""
|
|
307
|
+
|
|
308
|
+
class NoBinariesPlugin(SpaceforgePlugin):
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
generator = PluginGenerator()
|
|
312
|
+
generator.plugin_class = NoBinariesPlugin
|
|
313
|
+
|
|
314
|
+
binaries = generator.get_plugin_binaries()
|
|
315
|
+
assert binaries is None
|
|
316
|
+
|
|
317
|
+
def test_generate_binary_install_command(self) -> None:
|
|
318
|
+
"""Test binary installation command generation."""
|
|
319
|
+
generator = PluginGenerator()
|
|
320
|
+
generator.plugin_class = PluginExample
|
|
321
|
+
|
|
322
|
+
command = generator.generate_binary_install_command()
|
|
323
|
+
|
|
324
|
+
assert "mkdir -p /mnt/workspace/plugins/plugin_binaries" in command
|
|
325
|
+
assert "curl https://example.com/test-cli-amd64" in command
|
|
326
|
+
assert "curl https://example.com/test-cli-arm64" in command
|
|
327
|
+
assert "arch" in command
|
|
328
|
+
assert "x86_64" in command
|
|
329
|
+
|
|
330
|
+
def test_generate_binary_install_command_no_binaries(self) -> None:
|
|
331
|
+
"""Test binary command generation when no binaries."""
|
|
332
|
+
|
|
333
|
+
class NoBinariesPlugin(SpaceforgePlugin):
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
generator = PluginGenerator()
|
|
337
|
+
generator.plugin_class = NoBinariesPlugin
|
|
338
|
+
|
|
339
|
+
command = generator.generate_binary_install_command()
|
|
340
|
+
assert command == ""
|
|
341
|
+
|
|
342
|
+
def test_generate_binary_install_command_missing_urls(self) -> None:
|
|
343
|
+
"""Test binary command generation with missing URLs."""
|
|
344
|
+
|
|
345
|
+
class InvalidBinaryPlugin(SpaceforgePlugin):
|
|
346
|
+
__binaries__ = [Binary(name="invalid", download_urls={})]
|
|
347
|
+
|
|
348
|
+
generator = PluginGenerator()
|
|
349
|
+
generator.plugin_class = InvalidBinaryPlugin
|
|
350
|
+
|
|
351
|
+
with pytest.raises(ValueError, match="must have at least one download URL"):
|
|
352
|
+
generator.generate_binary_install_command()
|
|
353
|
+
|
|
354
|
+
def test_get_plugin_policies(self) -> None:
|
|
355
|
+
"""Test policy extraction."""
|
|
356
|
+
generator = PluginGenerator()
|
|
357
|
+
generator.plugin_class = PluginExample
|
|
358
|
+
|
|
359
|
+
policies = generator.get_plugin_policies()
|
|
360
|
+
|
|
361
|
+
assert policies is not None
|
|
362
|
+
assert len(policies) == 1
|
|
363
|
+
assert policies[0].name_prefix == "test_policy"
|
|
364
|
+
assert policies[0].type == "notification"
|
|
365
|
+
assert policies[0].body == "package test"
|
|
366
|
+
|
|
367
|
+
def test_get_plugin_webhooks(self) -> None:
|
|
368
|
+
"""Test webhook extraction."""
|
|
369
|
+
generator = PluginGenerator()
|
|
370
|
+
generator.plugin_class = PluginExample
|
|
371
|
+
|
|
372
|
+
webhooks = generator.get_plugin_webhooks()
|
|
373
|
+
|
|
374
|
+
assert webhooks is not None
|
|
375
|
+
assert len(webhooks) == 1
|
|
376
|
+
assert webhooks[0].name_prefix == "test_webhook"
|
|
377
|
+
assert webhooks[0].endpoint == "https://webhook.example.com"
|
|
378
|
+
|
|
379
|
+
@patch("os.path.exists")
|
|
380
|
+
@patch("builtins.open", new_callable=mock_open, read_data="requirements content")
|
|
381
|
+
def test_get_plugin_contexts_with_requirements(
|
|
382
|
+
self, mock_file: Mock, mock_exists: Mock
|
|
383
|
+
) -> None:
|
|
384
|
+
"""Test context generation with requirements.txt."""
|
|
385
|
+
mock_exists.side_effect = (
|
|
386
|
+
lambda path: path == "requirements.txt" or "plugin.py" in path
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
generator = PluginGenerator(self.test_plugin_path)
|
|
390
|
+
generator.plugin_class = PluginExample
|
|
391
|
+
generator.plugin_working_directory = "/mnt/workspace/plugins/test_plugin"
|
|
392
|
+
|
|
393
|
+
contexts = generator.get_plugin_contexts()
|
|
394
|
+
|
|
395
|
+
assert len(contexts) == 1
|
|
396
|
+
context = contexts[0]
|
|
397
|
+
|
|
398
|
+
# Should have before_init hooks for venv setup
|
|
399
|
+
assert context.hooks is not None
|
|
400
|
+
assert "before_init" in context.hooks
|
|
401
|
+
venv_command = None
|
|
402
|
+
for cmd in context.hooks["before_init"]:
|
|
403
|
+
if "python -m venv" in cmd:
|
|
404
|
+
venv_command = cmd
|
|
405
|
+
break
|
|
406
|
+
assert venv_command is not None
|
|
407
|
+
|
|
408
|
+
# Should have requirements.txt as mounted file
|
|
409
|
+
assert context.mounted_files is not None
|
|
410
|
+
req_file = None
|
|
411
|
+
for mf in context.mounted_files:
|
|
412
|
+
if "requirements.txt" in mf.path:
|
|
413
|
+
req_file = mf
|
|
414
|
+
break
|
|
415
|
+
assert req_file is not None
|
|
416
|
+
assert req_file.content == "requirements content"
|
|
417
|
+
|
|
418
|
+
@patch("os.path.exists")
|
|
419
|
+
@patch("builtins.open", new_callable=mock_open, read_data="plugin content")
|
|
420
|
+
def test_get_plugin_contexts_basic(
|
|
421
|
+
self, mock_file: Mock, mock_exists: Mock
|
|
422
|
+
) -> None:
|
|
423
|
+
"""Test basic context generation."""
|
|
424
|
+
mock_exists.side_effect = lambda path: "plugin.py" in path
|
|
425
|
+
|
|
426
|
+
generator = PluginGenerator(self.test_plugin_path)
|
|
427
|
+
generator.plugin_class = PluginExample
|
|
428
|
+
generator.plugin_working_directory = "/mnt/workspace/plugins/test_plugin"
|
|
429
|
+
|
|
430
|
+
with patch.object(
|
|
431
|
+
generator, "get_available_hooks", return_value=["after_plan"]
|
|
432
|
+
):
|
|
433
|
+
contexts = generator.get_plugin_contexts()
|
|
434
|
+
|
|
435
|
+
assert len(contexts) == 1
|
|
436
|
+
context = contexts[0]
|
|
437
|
+
|
|
438
|
+
# Should have plugin file mounted
|
|
439
|
+
assert context.mounted_files is not None
|
|
440
|
+
plugin_file = None
|
|
441
|
+
for mf in context.mounted_files:
|
|
442
|
+
if "plugin.py" in mf.path:
|
|
443
|
+
plugin_file = mf
|
|
444
|
+
break
|
|
445
|
+
assert plugin_file is not None
|
|
446
|
+
|
|
447
|
+
# Should have spacepy runner hooks
|
|
448
|
+
assert context.hooks is not None
|
|
449
|
+
assert "after_plan" in context.hooks
|
|
450
|
+
runner_command = context.hooks["after_plan"][0]
|
|
451
|
+
assert "python -m spaceforge runner" in runner_command
|
|
452
|
+
assert "after_plan" in runner_command
|
|
453
|
+
|
|
454
|
+
def test_generate_manifest(self) -> None:
|
|
455
|
+
"""Test complete manifest generation."""
|
|
456
|
+
generator = PluginGenerator(self.test_plugin_path)
|
|
457
|
+
|
|
458
|
+
with patch.object(generator, "load_plugin"):
|
|
459
|
+
generator.plugin_class = PluginExample
|
|
460
|
+
manifest = generator.generate_manifest()
|
|
461
|
+
|
|
462
|
+
assert isinstance(manifest, PluginManifest)
|
|
463
|
+
assert manifest.version == "2.0.0"
|
|
464
|
+
assert manifest.author == "Test Author"
|
|
465
|
+
assert manifest.description == "Test plugin for generator testing."
|
|
466
|
+
assert manifest.parameters is not None
|
|
467
|
+
assert manifest.contexts is not None
|
|
468
|
+
assert manifest.webhooks is not None
|
|
469
|
+
assert manifest.policies is not None
|
|
470
|
+
|
|
471
|
+
@patch("builtins.open", new_callable=mock_open)
|
|
472
|
+
@patch("spaceforge.generator.yaml.dump")
|
|
473
|
+
def test_write_yaml(self, mock_yaml_dump: Mock, mock_file: Mock) -> None:
|
|
474
|
+
"""Test YAML writing functionality."""
|
|
475
|
+
generator = PluginGenerator(output_path=self.test_output_path)
|
|
476
|
+
manifest = PluginManifest(
|
|
477
|
+
name_prefix="test",
|
|
478
|
+
version="1.0.0",
|
|
479
|
+
description="Test",
|
|
480
|
+
author="Test Author",
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
generator.write_yaml(manifest)
|
|
484
|
+
|
|
485
|
+
mock_file.assert_called_once_with(self.test_output_path, "w")
|
|
486
|
+
mock_yaml_dump.assert_called_once()
|
|
487
|
+
|
|
488
|
+
# Verify yaml.dump was called with correct parameters
|
|
489
|
+
args, kwargs = mock_yaml_dump.call_args
|
|
490
|
+
assert args[0] == manifest
|
|
491
|
+
assert kwargs["default_flow_style"] is False
|
|
492
|
+
assert kwargs["sort_keys"] is False
|
|
493
|
+
assert kwargs["indent"] == 2
|
|
494
|
+
|
|
495
|
+
@patch.object(PluginGenerator, "write_yaml")
|
|
496
|
+
@patch.object(PluginGenerator, "generate_manifest")
|
|
497
|
+
def test_generate(
|
|
498
|
+
self, mock_generate_manifest: Mock, mock_write_yaml: Mock
|
|
499
|
+
) -> None:
|
|
500
|
+
"""Test complete generate method."""
|
|
501
|
+
generator = PluginGenerator()
|
|
502
|
+
mock_manifest = PluginManifest(
|
|
503
|
+
name_prefix="test", version="1.0.0", description="Test", author="Test"
|
|
504
|
+
)
|
|
505
|
+
mock_generate_manifest.return_value = mock_manifest
|
|
506
|
+
|
|
507
|
+
generator.generate()
|
|
508
|
+
|
|
509
|
+
mock_generate_manifest.assert_called_once()
|
|
510
|
+
mock_write_yaml.assert_called_once_with(mock_manifest)
|
|
511
|
+
|
|
512
|
+
def test_integration_full_workflow(self) -> None:
|
|
513
|
+
"""Integration test for complete workflow."""
|
|
514
|
+
# Create a complete test plugin file
|
|
515
|
+
full_plugin_path = os.path.join(self.temp_dir, "full_plugin.py")
|
|
516
|
+
with open(full_plugin_path, "w") as f:
|
|
517
|
+
f.write(
|
|
518
|
+
'''
|
|
519
|
+
from spaceforge import SpaceforgePlugin, Parameter
|
|
520
|
+
|
|
521
|
+
class FullTestPlugin(SpaceforgePlugin):
|
|
522
|
+
"""A full test plugin."""
|
|
523
|
+
|
|
524
|
+
__plugin_name__ = "full_test"
|
|
525
|
+
__version__ = "1.5.0"
|
|
526
|
+
__author__ = "Integration Test"
|
|
527
|
+
|
|
528
|
+
__parameters__ = [
|
|
529
|
+
Parameter(
|
|
530
|
+
name="test_param",
|
|
531
|
+
description="Test parameter",
|
|
532
|
+
required=False,
|
|
533
|
+
default="test_value"
|
|
534
|
+
)
|
|
535
|
+
]
|
|
536
|
+
|
|
537
|
+
def after_plan(self):
|
|
538
|
+
"""Override after_plan hook."""
|
|
539
|
+
pass
|
|
540
|
+
'''
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
generator = PluginGenerator(full_plugin_path, self.test_output_path)
|
|
544
|
+
|
|
545
|
+
# This should work end-to-end
|
|
546
|
+
generator.load_plugin()
|
|
547
|
+
assert generator.plugin_class is not None
|
|
548
|
+
assert generator.plugin_class.__name__ == "FullTestPlugin"
|
|
549
|
+
|
|
550
|
+
metadata = generator.get_plugin_metadata()
|
|
551
|
+
assert metadata["name_prefix"] == "full_test"
|
|
552
|
+
assert metadata["version"] == "1.5.0"
|
|
553
|
+
|
|
554
|
+
hooks = generator.get_available_hooks()
|
|
555
|
+
assert "after_plan" in hooks
|
|
556
|
+
|
|
557
|
+
manifest = generator.generate_manifest()
|
|
558
|
+
assert manifest.version == "1.5.0"
|
|
559
|
+
assert manifest.description == "A full test plugin."
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
class TestPluginGeneratorEdgeCases:
|
|
563
|
+
"""Test edge cases and error conditions."""
|
|
564
|
+
|
|
565
|
+
def test_plugin_with_docstring_multiline(self) -> None:
|
|
566
|
+
"""Test plugin with multiline docstring."""
|
|
567
|
+
|
|
568
|
+
class MultilineDocPlugin(SpaceforgePlugin):
|
|
569
|
+
"""
|
|
570
|
+
This is a multiline
|
|
571
|
+
docstring with multiple
|
|
572
|
+
lines of description.
|
|
573
|
+
"""
|
|
574
|
+
|
|
575
|
+
pass
|
|
576
|
+
|
|
577
|
+
generator = PluginGenerator()
|
|
578
|
+
generator.plugin_class = MultilineDocPlugin
|
|
579
|
+
|
|
580
|
+
metadata = generator.get_plugin_metadata()
|
|
581
|
+
assert "multiline" in metadata["description"]
|
|
582
|
+
assert "multiple" in metadata["description"]
|
|
583
|
+
|
|
584
|
+
def test_plugin_class_name_with_plugin_suffix(self) -> None:
|
|
585
|
+
"""Test plugin class name ending with 'Plugin'."""
|
|
586
|
+
|
|
587
|
+
class MyAwesomePlugin: # Don't inherit from SpaceforgePlugin
|
|
588
|
+
__name__ = "MyAwesomePlugin"
|
|
589
|
+
|
|
590
|
+
generator = PluginGenerator()
|
|
591
|
+
generator.plugin_class = MyAwesomePlugin # type: ignore[assignment]
|
|
592
|
+
|
|
593
|
+
metadata = generator.get_plugin_metadata()
|
|
594
|
+
assert metadata["name_prefix"] == "myawesome" # 'plugin' removed and lowercased
|
|
595
|
+
|
|
596
|
+
def test_binary_install_single_arch(self) -> None:
|
|
597
|
+
"""Test binary installation with only one architecture."""
|
|
598
|
+
|
|
599
|
+
class SingleArchPlugin(SpaceforgePlugin):
|
|
600
|
+
__binaries__ = [
|
|
601
|
+
Binary(
|
|
602
|
+
name="single-arch",
|
|
603
|
+
download_urls={"amd64": "https://example.com/binary-amd64"},
|
|
604
|
+
)
|
|
605
|
+
]
|
|
606
|
+
|
|
607
|
+
generator = PluginGenerator()
|
|
608
|
+
generator.plugin_class = SingleArchPlugin
|
|
609
|
+
|
|
610
|
+
command = generator.generate_binary_install_command()
|
|
611
|
+
|
|
612
|
+
assert "https://example.com/binary-amd64" in command
|
|
613
|
+
assert "arm64 binary not available" in command
|
|
614
|
+
|
|
615
|
+
def test_context_merging_with_existing(self) -> None:
|
|
616
|
+
"""Test that generated hooks are merged with existing context hooks."""
|
|
617
|
+
|
|
618
|
+
class ExistingHooksPlugin(SpaceforgePlugin):
|
|
619
|
+
__plugin_name__ = "existing_hooks"
|
|
620
|
+
|
|
621
|
+
__contexts__ = [
|
|
622
|
+
Context(
|
|
623
|
+
name_prefix="existing",
|
|
624
|
+
description="Existing context",
|
|
625
|
+
hooks={"before_init": ["echo 'existing hook'"]},
|
|
626
|
+
mounted_files=[
|
|
627
|
+
MountedFile(
|
|
628
|
+
path="/existing", content="existing", sensitive=False
|
|
629
|
+
)
|
|
630
|
+
],
|
|
631
|
+
env=[Variable(key="EXISTING", value="existing")],
|
|
632
|
+
)
|
|
633
|
+
]
|
|
634
|
+
|
|
635
|
+
def after_plan(self) -> None:
|
|
636
|
+
pass
|
|
637
|
+
|
|
638
|
+
generator = PluginGenerator("/fake/path")
|
|
639
|
+
generator.plugin_class = ExistingHooksPlugin
|
|
640
|
+
generator.plugin_working_directory = "/mnt/workspace/plugins/existing_hooks"
|
|
641
|
+
|
|
642
|
+
with patch("os.path.exists") as mock_exists:
|
|
643
|
+
# Only plugin file exists, not requirements.txt
|
|
644
|
+
mock_exists.side_effect = lambda path: path == "/fake/path"
|
|
645
|
+
with patch("builtins.open", mock_open(read_data="fake content")):
|
|
646
|
+
contexts = generator.get_plugin_contexts()
|
|
647
|
+
|
|
648
|
+
context = contexts[0]
|
|
649
|
+
|
|
650
|
+
# The update() method replaces the existing hooks completely
|
|
651
|
+
assert context.hooks is not None
|
|
652
|
+
assert "after_plan" in context.hooks
|
|
653
|
+
assert any("mkdir -p" in cmd for cmd in context.hooks["before_init"])
|
|
654
|
+
|
|
655
|
+
# Should have both existing and generated mounted files
|
|
656
|
+
assert context.mounted_files is not None
|
|
657
|
+
existing_files = [mf for mf in context.mounted_files if mf.path == "/existing"]
|
|
658
|
+
assert len(existing_files) == 1
|
|
659
|
+
|
|
660
|
+
# Plugin file should be mounted (path contains the working directory)
|
|
661
|
+
plugin_files = [
|
|
662
|
+
mf
|
|
663
|
+
for mf in context.mounted_files
|
|
664
|
+
if "/mnt/workspace/plugins/existing_hooks" in mf.path
|
|
665
|
+
]
|
|
666
|
+
assert len(plugin_files) == 1
|
|
667
|
+
|
|
668
|
+
# Should have existing env vars
|
|
669
|
+
assert context.env is not None
|
|
670
|
+
existing_vars = [var for var in context.env if var.key == "EXISTING"]
|
|
671
|
+
assert len(existing_vars) == 1
|