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,605 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import tempfile
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
from unittest.mock import Mock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from spaceforge.plugin import SpaceforgePlugin
|
|
10
|
+
from spaceforge.runner import PluginRunner, main, runner_command
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PluginForTesting(SpaceforgePlugin):
|
|
14
|
+
"""Test plugin for runner testing."""
|
|
15
|
+
|
|
16
|
+
__plugin_name__ = "test_plugin"
|
|
17
|
+
__version__ = "1.0.0"
|
|
18
|
+
__author__ = "Test Author"
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
super().__init__()
|
|
22
|
+
self.hook_called = False
|
|
23
|
+
self.hook_args: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
def after_plan(self) -> None:
|
|
26
|
+
"""Test hook method."""
|
|
27
|
+
self.hook_called = True
|
|
28
|
+
self.hook_args = "after_plan"
|
|
29
|
+
|
|
30
|
+
def before_apply(self) -> None:
|
|
31
|
+
"""Another test hook method."""
|
|
32
|
+
self.hook_called = True
|
|
33
|
+
self.hook_args = "before_apply"
|
|
34
|
+
|
|
35
|
+
def failing_hook(self) -> None:
|
|
36
|
+
"""Hook that raises an exception."""
|
|
37
|
+
raise RuntimeError("Test error")
|
|
38
|
+
|
|
39
|
+
def not_a_method(self) -> None:
|
|
40
|
+
"""Regular method that's not a hook."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestPluginRunner:
|
|
45
|
+
|
|
46
|
+
def setup_method(self) -> None:
|
|
47
|
+
"""Setup test fixtures."""
|
|
48
|
+
self.temp_dir = tempfile.mkdtemp()
|
|
49
|
+
self.test_plugin_path = os.path.join(self.temp_dir, "plugin.py")
|
|
50
|
+
|
|
51
|
+
# Create a test plugin file
|
|
52
|
+
with open(self.test_plugin_path, "w") as f:
|
|
53
|
+
f.write(
|
|
54
|
+
"""
|
|
55
|
+
from spaceforge import SpaceforgePlugin
|
|
56
|
+
|
|
57
|
+
class TestRunnerPlugin(SpaceforgePlugin):
|
|
58
|
+
__plugin_name__ = "test_runner"
|
|
59
|
+
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
super().__init__()
|
|
62
|
+
self.executed_hooks = []
|
|
63
|
+
|
|
64
|
+
def after_plan(self) -> None:
|
|
65
|
+
self.executed_hooks.append('after_plan')
|
|
66
|
+
|
|
67
|
+
def before_apply(self) -> None:
|
|
68
|
+
self.executed_hooks.append('before_apply')
|
|
69
|
+
|
|
70
|
+
def error_hook(self) -> None:
|
|
71
|
+
raise ValueError("Test error from hook")
|
|
72
|
+
"""
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def teardown_method(self) -> None:
|
|
76
|
+
"""Cleanup test fixtures."""
|
|
77
|
+
import shutil
|
|
78
|
+
|
|
79
|
+
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
|
80
|
+
|
|
81
|
+
def test_plugin_runner_init(self) -> None:
|
|
82
|
+
"""Test PluginRunner initialization."""
|
|
83
|
+
runner = PluginRunner("test_plugin.py")
|
|
84
|
+
|
|
85
|
+
assert runner.plugin_path == "test_plugin.py"
|
|
86
|
+
assert runner.plugin_instance is None
|
|
87
|
+
|
|
88
|
+
def test_plugin_runner_init_defaults(self) -> None:
|
|
89
|
+
"""Test PluginRunner initialization with default values."""
|
|
90
|
+
runner = PluginRunner()
|
|
91
|
+
|
|
92
|
+
assert runner.plugin_path == "plugin.py"
|
|
93
|
+
assert runner.plugin_instance is None
|
|
94
|
+
|
|
95
|
+
def test_load_plugin_file_not_found(self) -> None:
|
|
96
|
+
"""Test loading plugin when file doesn't exist."""
|
|
97
|
+
runner = PluginRunner("nonexistent.py")
|
|
98
|
+
|
|
99
|
+
with pytest.raises(FileNotFoundError, match="Plugin file not found"):
|
|
100
|
+
runner.load_plugin()
|
|
101
|
+
|
|
102
|
+
def test_load_plugin_invalid_module(self) -> None:
|
|
103
|
+
"""Test loading invalid Python module."""
|
|
104
|
+
invalid_path = os.path.join(self.temp_dir, "invalid.py")
|
|
105
|
+
with open(invalid_path, "w") as f:
|
|
106
|
+
f.write("invalid python syntax }")
|
|
107
|
+
|
|
108
|
+
runner = PluginRunner(invalid_path)
|
|
109
|
+
|
|
110
|
+
with pytest.raises(Exception): # Could be syntax error
|
|
111
|
+
runner.load_plugin()
|
|
112
|
+
|
|
113
|
+
def test_load_plugin_no_spacepy_subclass(self) -> None:
|
|
114
|
+
"""Test loading plugin with no SpaceforgePlugin subclass."""
|
|
115
|
+
no_plugin_path = os.path.join(self.temp_dir, "no_plugin.py")
|
|
116
|
+
with open(no_plugin_path, "w") as f:
|
|
117
|
+
f.write(
|
|
118
|
+
"""
|
|
119
|
+
class NotAPlugin:
|
|
120
|
+
pass
|
|
121
|
+
"""
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
runner = PluginRunner(no_plugin_path)
|
|
125
|
+
|
|
126
|
+
with pytest.raises(ValueError, match="No SpaceforgePlugin subclass found"):
|
|
127
|
+
runner.load_plugin()
|
|
128
|
+
|
|
129
|
+
def test_load_plugin_success(self) -> None:
|
|
130
|
+
"""Test successful plugin loading."""
|
|
131
|
+
runner = PluginRunner(self.test_plugin_path)
|
|
132
|
+
runner.load_plugin()
|
|
133
|
+
|
|
134
|
+
assert runner.plugin_instance is not None
|
|
135
|
+
assert runner.plugin_instance.__class__.__name__ == "TestRunnerPlugin"
|
|
136
|
+
|
|
137
|
+
@patch("spaceforge.runner.importlib.util.spec_from_file_location")
|
|
138
|
+
def test_load_plugin_spec_none(self, mock_spec: Mock) -> None:
|
|
139
|
+
"""Test plugin loading when spec is None."""
|
|
140
|
+
mock_spec.return_value = None
|
|
141
|
+
|
|
142
|
+
runner = PluginRunner(self.test_plugin_path)
|
|
143
|
+
|
|
144
|
+
with pytest.raises(ImportError, match="Could not load plugin"):
|
|
145
|
+
runner.load_plugin()
|
|
146
|
+
|
|
147
|
+
def test_run_hook_loads_plugin_if_needed(self) -> None:
|
|
148
|
+
"""Test that run_hook loads plugin if not already loaded."""
|
|
149
|
+
runner = PluginRunner(self.test_plugin_path)
|
|
150
|
+
|
|
151
|
+
with patch.object(runner, "load_plugin") as mock_load:
|
|
152
|
+
mock_instance = Mock()
|
|
153
|
+
mock_instance.after_plan = Mock()
|
|
154
|
+
runner.plugin_instance = None
|
|
155
|
+
|
|
156
|
+
# Should call load_plugin when plugin_instance is None
|
|
157
|
+
runner.run_hook("after_plan")
|
|
158
|
+
mock_load.assert_called_once()
|
|
159
|
+
|
|
160
|
+
def test_run_hook_success(self) -> None:
|
|
161
|
+
"""Test successful hook execution."""
|
|
162
|
+
runner = PluginRunner(self.test_plugin_path)
|
|
163
|
+
runner.load_plugin()
|
|
164
|
+
|
|
165
|
+
with patch("builtins.print") as mock_print:
|
|
166
|
+
runner.run_hook("after_plan")
|
|
167
|
+
|
|
168
|
+
# Verify the hook was called
|
|
169
|
+
assert runner.plugin_instance is not None
|
|
170
|
+
assert hasattr(runner.plugin_instance, "executed_hooks")
|
|
171
|
+
assert "after_plan" in getattr(runner.plugin_instance, "executed_hooks")
|
|
172
|
+
|
|
173
|
+
# Verify print statements
|
|
174
|
+
mock_print.assert_any_call("[SPACEPY] Running hook: after_plan")
|
|
175
|
+
mock_print.assert_any_call("[SPACEPY] Hook completed: after_plan")
|
|
176
|
+
|
|
177
|
+
def test_run_hook_not_found(self) -> None:
|
|
178
|
+
"""Test running a hook that doesn't exist."""
|
|
179
|
+
runner = PluginRunner(self.test_plugin_path)
|
|
180
|
+
runner.load_plugin()
|
|
181
|
+
|
|
182
|
+
with patch("builtins.print") as mock_print:
|
|
183
|
+
runner.run_hook("nonexistent_hook")
|
|
184
|
+
|
|
185
|
+
mock_print.assert_called_with(
|
|
186
|
+
"Hook method 'nonexistent_hook' not found in plugin"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def test_run_hook_not_callable(self) -> None:
|
|
190
|
+
"""Test running a hook that exists but is not callable."""
|
|
191
|
+
runner = PluginRunner(self.test_plugin_path)
|
|
192
|
+
runner.load_plugin()
|
|
193
|
+
|
|
194
|
+
# Add a non-callable attribute
|
|
195
|
+
assert runner.plugin_instance is not None
|
|
196
|
+
setattr(runner.plugin_instance, "not_callable", "not a method")
|
|
197
|
+
|
|
198
|
+
with patch("builtins.print") as mock_print:
|
|
199
|
+
runner.run_hook("not_callable")
|
|
200
|
+
|
|
201
|
+
mock_print.assert_called_with("'not_callable' is not a callable method")
|
|
202
|
+
|
|
203
|
+
def test_run_hook_with_exception(self) -> None:
|
|
204
|
+
"""Test hook execution that raises an exception."""
|
|
205
|
+
runner = PluginRunner(self.test_plugin_path)
|
|
206
|
+
runner.load_plugin()
|
|
207
|
+
|
|
208
|
+
with patch("builtins.print") as mock_print:
|
|
209
|
+
with pytest.raises(ValueError, match="Test error from hook"):
|
|
210
|
+
runner.run_hook("error_hook")
|
|
211
|
+
|
|
212
|
+
# Should print error message
|
|
213
|
+
mock_print.assert_any_call(
|
|
214
|
+
"[SPACEPY] Error running hook 'error_hook': Test error from hook"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def test_run_hook_multiple_hooks(self) -> None:
|
|
218
|
+
"""Test running multiple different hooks."""
|
|
219
|
+
runner = PluginRunner(self.test_plugin_path)
|
|
220
|
+
runner.load_plugin()
|
|
221
|
+
|
|
222
|
+
with patch("builtins.print"):
|
|
223
|
+
runner.run_hook("after_plan")
|
|
224
|
+
runner.run_hook("before_apply")
|
|
225
|
+
|
|
226
|
+
# Both hooks should have been executed
|
|
227
|
+
assert runner.plugin_instance is not None
|
|
228
|
+
assert hasattr(runner.plugin_instance, "executed_hooks")
|
|
229
|
+
executed = getattr(runner.plugin_instance, "executed_hooks")
|
|
230
|
+
assert "after_plan" in executed
|
|
231
|
+
assert "before_apply" in executed
|
|
232
|
+
assert len(executed) == 2
|
|
233
|
+
|
|
234
|
+
def test_integration_full_workflow(self) -> None:
|
|
235
|
+
"""Integration test for complete runner workflow."""
|
|
236
|
+
# Create a complete test plugin file
|
|
237
|
+
full_plugin_path = os.path.join(self.temp_dir, "full_plugin.py")
|
|
238
|
+
with open(full_plugin_path, "w") as f:
|
|
239
|
+
f.write(
|
|
240
|
+
'''
|
|
241
|
+
from spaceforge import SpaceforgePlugin
|
|
242
|
+
|
|
243
|
+
class FullTestPlugin(SpaceforgePlugin):
|
|
244
|
+
"""A full test plugin for integration testing."""
|
|
245
|
+
|
|
246
|
+
__plugin_name__ = "full_test"
|
|
247
|
+
|
|
248
|
+
def __init__(self) -> None:
|
|
249
|
+
super().__init__()
|
|
250
|
+
self.integration_test_passed = False
|
|
251
|
+
|
|
252
|
+
def after_plan(self) -> str:
|
|
253
|
+
"""Integration test hook."""
|
|
254
|
+
self.integration_test_passed = True
|
|
255
|
+
return "success"
|
|
256
|
+
'''
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
runner = PluginRunner(full_plugin_path)
|
|
260
|
+
|
|
261
|
+
# This should work end-to-end
|
|
262
|
+
with patch("builtins.print"):
|
|
263
|
+
runner.run_hook("after_plan")
|
|
264
|
+
|
|
265
|
+
assert runner.plugin_instance is not None
|
|
266
|
+
assert runner.plugin_instance.__class__.__name__ == "FullTestPlugin"
|
|
267
|
+
assert hasattr(runner.plugin_instance, "integration_test_passed")
|
|
268
|
+
assert getattr(runner.plugin_instance, "integration_test_passed") is True
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class TestRunnerCommand:
|
|
272
|
+
"""Test the Click command interface."""
|
|
273
|
+
|
|
274
|
+
def setup_method(self) -> None:
|
|
275
|
+
"""Setup test fixtures."""
|
|
276
|
+
self.temp_dir = tempfile.mkdtemp()
|
|
277
|
+
self.test_plugin_path = os.path.join(self.temp_dir, "plugin.py")
|
|
278
|
+
|
|
279
|
+
# Create a test plugin file
|
|
280
|
+
with open(self.test_plugin_path, "w") as f:
|
|
281
|
+
f.write(
|
|
282
|
+
"""
|
|
283
|
+
from spaceforge import SpaceforgePlugin
|
|
284
|
+
|
|
285
|
+
class ClickTestPlugin(SpaceforgePlugin):
|
|
286
|
+
def after_plan(self) -> None:
|
|
287
|
+
print("Hook executed via click")
|
|
288
|
+
"""
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def teardown_method(self) -> None:
|
|
292
|
+
"""Cleanup test fixtures."""
|
|
293
|
+
import shutil
|
|
294
|
+
|
|
295
|
+
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
|
296
|
+
|
|
297
|
+
def test_runner_command(self) -> None:
|
|
298
|
+
"""Test the Click runner command."""
|
|
299
|
+
from click.testing import CliRunner
|
|
300
|
+
|
|
301
|
+
cli_runner = CliRunner()
|
|
302
|
+
|
|
303
|
+
with patch("spaceforge.runner.PluginRunner") as mock_runner_class:
|
|
304
|
+
mock_runner = Mock()
|
|
305
|
+
mock_runner_class.return_value = mock_runner
|
|
306
|
+
|
|
307
|
+
# Test the command using Click's test runner
|
|
308
|
+
result = cli_runner.invoke(
|
|
309
|
+
runner_command, ["after_plan", "--plugin-file", self.test_plugin_path]
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
assert result.exit_code == 0
|
|
313
|
+
|
|
314
|
+
# Verify PluginRunner was instantiated with correct path
|
|
315
|
+
mock_runner_class.assert_called_once_with(self.test_plugin_path)
|
|
316
|
+
|
|
317
|
+
# Verify run_hook was called with correct hook name
|
|
318
|
+
mock_runner.run_hook.assert_called_once_with("after_plan")
|
|
319
|
+
|
|
320
|
+
def test_runner_command_default_plugin_file(self) -> None:
|
|
321
|
+
"""Test runner command with default plugin file."""
|
|
322
|
+
from click.testing import CliRunner
|
|
323
|
+
|
|
324
|
+
cli_runner = CliRunner()
|
|
325
|
+
|
|
326
|
+
with patch("spaceforge.runner.PluginRunner") as mock_runner_class:
|
|
327
|
+
mock_runner = Mock()
|
|
328
|
+
mock_runner_class.return_value = mock_runner
|
|
329
|
+
|
|
330
|
+
# Test with explicit plugin file path
|
|
331
|
+
result = cli_runner.invoke(
|
|
332
|
+
runner_command, ["before_apply", "--plugin-file", self.test_plugin_path]
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
assert result.exit_code == 0
|
|
336
|
+
mock_runner_class.assert_called_once_with(self.test_plugin_path)
|
|
337
|
+
mock_runner.run_hook.assert_called_once_with("before_apply")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class TestMainFunction:
|
|
341
|
+
"""Test the legacy main function."""
|
|
342
|
+
|
|
343
|
+
def setup_method(self) -> None:
|
|
344
|
+
"""Setup test fixtures."""
|
|
345
|
+
self.temp_dir = tempfile.mkdtemp()
|
|
346
|
+
self.test_plugin_path = os.path.join(self.temp_dir, "plugin.py")
|
|
347
|
+
|
|
348
|
+
# Create a test plugin file
|
|
349
|
+
with open(self.test_plugin_path, "w") as f:
|
|
350
|
+
f.write(
|
|
351
|
+
"""
|
|
352
|
+
from spaceforge import SpaceforgePlugin
|
|
353
|
+
|
|
354
|
+
class MainTestPlugin(SpaceforgePlugin):
|
|
355
|
+
def after_plan(self) -> None:
|
|
356
|
+
pass
|
|
357
|
+
"""
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def teardown_method(self) -> None:
|
|
361
|
+
"""Cleanup test fixtures."""
|
|
362
|
+
import shutil
|
|
363
|
+
|
|
364
|
+
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
|
365
|
+
|
|
366
|
+
@patch("spaceforge.runner.PluginRunner")
|
|
367
|
+
@patch("builtins.print")
|
|
368
|
+
def test_main_insufficient_args(
|
|
369
|
+
self, mock_print: Mock, mock_runner_class: Mock
|
|
370
|
+
) -> None:
|
|
371
|
+
"""Test main function with insufficient arguments."""
|
|
372
|
+
original_argv = sys.argv
|
|
373
|
+
try:
|
|
374
|
+
sys.argv = ["runner.py"] # Missing hook_name
|
|
375
|
+
|
|
376
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
377
|
+
main()
|
|
378
|
+
|
|
379
|
+
assert exc_info.value.code == 1
|
|
380
|
+
mock_print.assert_called_with(
|
|
381
|
+
"Usage: python -m spaceforge.runner <hook_name>"
|
|
382
|
+
)
|
|
383
|
+
mock_runner_class.assert_not_called()
|
|
384
|
+
|
|
385
|
+
finally:
|
|
386
|
+
sys.argv = original_argv
|
|
387
|
+
|
|
388
|
+
@patch("spaceforge.runner.PluginRunner")
|
|
389
|
+
@patch("builtins.print")
|
|
390
|
+
def test_main_too_many_args(
|
|
391
|
+
self, mock_print: Mock, mock_runner_class: Mock
|
|
392
|
+
) -> None:
|
|
393
|
+
"""Test main function with too many arguments."""
|
|
394
|
+
original_argv = sys.argv
|
|
395
|
+
try:
|
|
396
|
+
sys.argv = ["runner.py", "after_plan", "extra_arg"]
|
|
397
|
+
|
|
398
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
399
|
+
main()
|
|
400
|
+
|
|
401
|
+
assert exc_info.value.code == 1
|
|
402
|
+
mock_print.assert_called_with(
|
|
403
|
+
"Usage: python -m spaceforge.runner <hook_name>"
|
|
404
|
+
)
|
|
405
|
+
mock_runner_class.assert_not_called()
|
|
406
|
+
|
|
407
|
+
finally:
|
|
408
|
+
sys.argv = original_argv
|
|
409
|
+
|
|
410
|
+
@patch("spaceforge.runner.PluginRunner")
|
|
411
|
+
def test_main_success(self, mock_runner_class: Mock) -> None:
|
|
412
|
+
"""Test successful main function execution."""
|
|
413
|
+
mock_runner = Mock()
|
|
414
|
+
mock_runner_class.return_value = mock_runner
|
|
415
|
+
|
|
416
|
+
original_argv = sys.argv
|
|
417
|
+
try:
|
|
418
|
+
sys.argv = ["runner.py", "after_plan"]
|
|
419
|
+
|
|
420
|
+
main()
|
|
421
|
+
|
|
422
|
+
mock_runner_class.assert_called_once_with()
|
|
423
|
+
mock_runner.run_hook.assert_called_once_with("after_plan")
|
|
424
|
+
|
|
425
|
+
finally:
|
|
426
|
+
sys.argv = original_argv
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class TestRunnerEdgeCases:
|
|
430
|
+
"""Test edge cases and error conditions."""
|
|
431
|
+
|
|
432
|
+
def setup_method(self) -> None:
|
|
433
|
+
"""Setup test fixtures."""
|
|
434
|
+
self.temp_dir = tempfile.mkdtemp()
|
|
435
|
+
|
|
436
|
+
def teardown_method(self) -> None:
|
|
437
|
+
"""Cleanup test fixtures."""
|
|
438
|
+
import shutil
|
|
439
|
+
|
|
440
|
+
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
|
441
|
+
|
|
442
|
+
def test_plugin_with_multiple_subclasses(self) -> None:
|
|
443
|
+
"""Test plugin file with multiple SpaceforgePlugin subclasses."""
|
|
444
|
+
multi_plugin_path = os.path.join(self.temp_dir, "multi_plugin.py")
|
|
445
|
+
with open(multi_plugin_path, "w") as f:
|
|
446
|
+
f.write(
|
|
447
|
+
"""
|
|
448
|
+
from spaceforge import SpaceforgePlugin
|
|
449
|
+
|
|
450
|
+
class FirstPlugin(SpaceforgePlugin):
|
|
451
|
+
def after_plan(self) -> None:
|
|
452
|
+
pass
|
|
453
|
+
|
|
454
|
+
class SecondPlugin(SpaceforgePlugin):
|
|
455
|
+
def before_apply(self) -> None:
|
|
456
|
+
pass
|
|
457
|
+
"""
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
runner = PluginRunner(multi_plugin_path)
|
|
461
|
+
runner.load_plugin()
|
|
462
|
+
|
|
463
|
+
# Should load one of the plugins (implementation loads the first one found)
|
|
464
|
+
assert runner.plugin_instance is not None
|
|
465
|
+
assert runner.plugin_instance.__class__.__name__ in [
|
|
466
|
+
"FirstPlugin",
|
|
467
|
+
"SecondPlugin",
|
|
468
|
+
]
|
|
469
|
+
|
|
470
|
+
def test_plugin_with_inheritance_hierarchy(self) -> None:
|
|
471
|
+
"""Test plugin with complex inheritance hierarchy."""
|
|
472
|
+
hierarchy_plugin_path = os.path.join(self.temp_dir, "hierarchy_plugin.py")
|
|
473
|
+
with open(hierarchy_plugin_path, "w") as f:
|
|
474
|
+
f.write(
|
|
475
|
+
"""
|
|
476
|
+
from spaceforge import SpaceforgePlugin
|
|
477
|
+
|
|
478
|
+
class BaseCustomPlugin(SpaceforgePlugin):
|
|
479
|
+
def base_method(self) -> None:
|
|
480
|
+
pass
|
|
481
|
+
|
|
482
|
+
class DerivedPlugin(BaseCustomPlugin):
|
|
483
|
+
def after_plan(self) -> None:
|
|
484
|
+
self.base_method()
|
|
485
|
+
"""
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
runner = PluginRunner(hierarchy_plugin_path)
|
|
489
|
+
runner.load_plugin()
|
|
490
|
+
|
|
491
|
+
assert runner.plugin_instance is not None
|
|
492
|
+
# The runner loads the first SpaceforgePlugin subclass it finds
|
|
493
|
+
# which could be either BaseCustomPlugin or DerivedPlugin
|
|
494
|
+
assert runner.plugin_instance.__class__.__name__ in [
|
|
495
|
+
"BaseCustomPlugin",
|
|
496
|
+
"DerivedPlugin",
|
|
497
|
+
]
|
|
498
|
+
|
|
499
|
+
# Should be able to run hooks from the loaded class
|
|
500
|
+
with patch("builtins.print"):
|
|
501
|
+
if hasattr(runner.plugin_instance, "after_plan"):
|
|
502
|
+
runner.run_hook("after_plan")
|
|
503
|
+
|
|
504
|
+
def test_hook_execution_with_return_value(self) -> None:
|
|
505
|
+
"""Test hook execution that returns a value."""
|
|
506
|
+
return_plugin_path = os.path.join(self.temp_dir, "return_plugin.py")
|
|
507
|
+
with open(return_plugin_path, "w") as f:
|
|
508
|
+
f.write(
|
|
509
|
+
"""
|
|
510
|
+
from spaceforge import SpaceforgePlugin
|
|
511
|
+
|
|
512
|
+
class ReturnPlugin(SpaceforgePlugin):
|
|
513
|
+
def after_plan(self) -> dict[str, str]:
|
|
514
|
+
return {"status": "success", "data": "test"}
|
|
515
|
+
"""
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
runner = PluginRunner(return_plugin_path)
|
|
519
|
+
runner.load_plugin()
|
|
520
|
+
|
|
521
|
+
# Hook execution should work even if it returns a value
|
|
522
|
+
with patch("builtins.print"):
|
|
523
|
+
runner.run_hook("after_plan")
|
|
524
|
+
|
|
525
|
+
def test_hook_with_arguments_fails_gracefully(self) -> None:
|
|
526
|
+
"""Test hook that expects arguments (should fail gracefully)."""
|
|
527
|
+
args_plugin_path = os.path.join(self.temp_dir, "args_plugin.py")
|
|
528
|
+
with open(args_plugin_path, "w") as f:
|
|
529
|
+
f.write(
|
|
530
|
+
"""
|
|
531
|
+
from spaceforge import SpaceforgePlugin
|
|
532
|
+
|
|
533
|
+
class ArgsPlugin(SpaceforgePlugin):
|
|
534
|
+
def after_plan(self, required_arg) -> None:
|
|
535
|
+
pass
|
|
536
|
+
"""
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
runner = PluginRunner(args_plugin_path)
|
|
540
|
+
runner.load_plugin()
|
|
541
|
+
|
|
542
|
+
# Should raise TypeError due to missing required argument
|
|
543
|
+
with patch("builtins.print") as mock_print:
|
|
544
|
+
with pytest.raises(TypeError):
|
|
545
|
+
runner.run_hook("after_plan")
|
|
546
|
+
|
|
547
|
+
# Should print error message
|
|
548
|
+
assert any(
|
|
549
|
+
"Error running hook" in str(call) for call in mock_print.call_args_list
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
def test_plugin_loading_with_import_errors(self) -> None:
|
|
553
|
+
"""Test plugin loading when plugin imports fail."""
|
|
554
|
+
import_error_path = os.path.join(self.temp_dir, "import_error_plugin.py")
|
|
555
|
+
with open(import_error_path, "w") as f:
|
|
556
|
+
f.write(
|
|
557
|
+
"""
|
|
558
|
+
from nonexistent_module import SomeClass
|
|
559
|
+
from spaceforge import SpaceforgePlugin
|
|
560
|
+
|
|
561
|
+
class ImportErrorPlugin(SpaceforgePlugin):
|
|
562
|
+
def after_plan(self) -> None:
|
|
563
|
+
pass
|
|
564
|
+
"""
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
runner = PluginRunner(import_error_path)
|
|
568
|
+
|
|
569
|
+
with pytest.raises(ModuleNotFoundError):
|
|
570
|
+
runner.load_plugin()
|
|
571
|
+
|
|
572
|
+
def test_run_hook_preserves_plugin_state(self) -> None:
|
|
573
|
+
"""Test that running hooks preserves plugin instance state."""
|
|
574
|
+
state_plugin_path = os.path.join(self.temp_dir, "state_plugin.py")
|
|
575
|
+
with open(state_plugin_path, "w") as f:
|
|
576
|
+
f.write(
|
|
577
|
+
"""
|
|
578
|
+
from spaceforge import SpaceforgePlugin
|
|
579
|
+
|
|
580
|
+
class StatePlugin(SpaceforgePlugin):
|
|
581
|
+
def __init__(self) -> None:
|
|
582
|
+
super().__init__()
|
|
583
|
+
self.counter = 0
|
|
584
|
+
|
|
585
|
+
def increment_hook(self) -> None:
|
|
586
|
+
self.counter += 1
|
|
587
|
+
|
|
588
|
+
def get_counter_hook(self) -> int:
|
|
589
|
+
return self.counter
|
|
590
|
+
"""
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
runner = PluginRunner(state_plugin_path)
|
|
594
|
+
runner.load_plugin()
|
|
595
|
+
|
|
596
|
+
# Run increment hook multiple times
|
|
597
|
+
with patch("builtins.print"):
|
|
598
|
+
runner.run_hook("increment_hook")
|
|
599
|
+
runner.run_hook("increment_hook")
|
|
600
|
+
runner.run_hook("increment_hook")
|
|
601
|
+
|
|
602
|
+
# State should be preserved
|
|
603
|
+
assert runner.plugin_instance is not None
|
|
604
|
+
assert hasattr(runner.plugin_instance, "counter")
|
|
605
|
+
assert getattr(runner.plugin_instance, "counter") == 3
|