spaceforge 0.1.0.dev0__py3-none-any.whl → 1.0.1__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/__init__.py +12 -4
- spaceforge/__main__.py +3 -3
- spaceforge/_version.py +0 -1
- spaceforge/_version_scm.py +34 -0
- spaceforge/cls.py +24 -14
- spaceforge/conftest.py +89 -0
- spaceforge/generator.py +129 -56
- spaceforge/plugin.py +199 -22
- spaceforge/runner.py +3 -15
- spaceforge/schema.json +45 -22
- spaceforge/templates/binary_install.sh.j2 +24 -0
- spaceforge/templates/ensure_spaceforge_and_run.sh.j2 +24 -0
- spaceforge/{generator_test.py → test_generator.py} +265 -53
- spaceforge/test_generator_binaries.py +194 -0
- spaceforge/test_generator_core.py +180 -0
- spaceforge/test_generator_hooks.py +90 -0
- spaceforge/test_generator_parameters.py +59 -0
- spaceforge/test_plugin.py +357 -0
- spaceforge/test_plugin_file_operations.py +118 -0
- spaceforge/test_plugin_hooks.py +100 -0
- spaceforge/test_plugin_inheritance.py +102 -0
- spaceforge/{runner_test.py → test_runner.py} +5 -68
- spaceforge/test_runner_cli.py +69 -0
- spaceforge/test_runner_core.py +124 -0
- spaceforge/test_runner_execution.py +169 -0
- spaceforge-1.0.1.dist-info/METADATA +606 -0
- spaceforge-1.0.1.dist-info/RECORD +33 -0
- spaceforge/plugin_test.py +0 -621
- spaceforge-0.1.0.dev0.dist-info/METADATA +0 -163
- spaceforge-0.1.0.dev0.dist-info/RECORD +0 -19
- /spaceforge/{cls_test.py → test_cls.py} +0 -0
- {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.1.dist-info}/WHEEL +0 -0
- {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.1.dist-info}/entry_points.txt +0 -0
- {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.1.dist-info}/licenses/LICENSE +0 -0
- {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Tests for SpaceforgePlugin file operation methods."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from spaceforge.plugin import SpaceforgePlugin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestSpaceforgePluginFileOperations:
|
|
14
|
+
"""Test file reading and parsing functionality."""
|
|
15
|
+
|
|
16
|
+
def test_should_return_plan_data_when_plan_file_exists(self, temp_dir: str) -> None:
|
|
17
|
+
"""Should parse and return plan JSON when file exists and is valid."""
|
|
18
|
+
# Arrange
|
|
19
|
+
plugin = SpaceforgePlugin()
|
|
20
|
+
plugin._workspace_root = temp_dir
|
|
21
|
+
plan_data = {"resource_changes": [{"type": "create"}]}
|
|
22
|
+
plan_path = os.path.join(temp_dir, "spacelift.plan.json")
|
|
23
|
+
|
|
24
|
+
with open(plan_path, "w") as f:
|
|
25
|
+
json.dump(plan_data, f)
|
|
26
|
+
|
|
27
|
+
# Act
|
|
28
|
+
result = plugin.get_plan_json()
|
|
29
|
+
|
|
30
|
+
# Assert
|
|
31
|
+
assert result == plan_data
|
|
32
|
+
|
|
33
|
+
def test_should_return_none_and_log_error_when_plan_file_missing(
|
|
34
|
+
self, temp_dir: str
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Should return None and log error when plan file doesn't exist."""
|
|
37
|
+
# Arrange
|
|
38
|
+
plugin = SpaceforgePlugin()
|
|
39
|
+
plugin._workspace_root = temp_dir
|
|
40
|
+
|
|
41
|
+
# Act
|
|
42
|
+
with patch.object(plugin.logger, "error") as mock_error:
|
|
43
|
+
result = plugin.get_plan_json()
|
|
44
|
+
|
|
45
|
+
# Assert
|
|
46
|
+
assert result is None
|
|
47
|
+
mock_error.assert_called_with("spacelift.plan.json does not exist.")
|
|
48
|
+
|
|
49
|
+
def test_should_raise_json_decode_error_when_plan_file_invalid(
|
|
50
|
+
self, temp_dir: str
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Should raise JSONDecodeError when plan file contains invalid JSON."""
|
|
53
|
+
# Arrange
|
|
54
|
+
plugin = SpaceforgePlugin()
|
|
55
|
+
plugin._workspace_root = temp_dir
|
|
56
|
+
plan_path = os.path.join(temp_dir, "spacelift.plan.json")
|
|
57
|
+
|
|
58
|
+
with open(plan_path, "w") as f:
|
|
59
|
+
f.write("invalid json {")
|
|
60
|
+
|
|
61
|
+
# Act & Assert
|
|
62
|
+
with pytest.raises(json.JSONDecodeError):
|
|
63
|
+
plugin.get_plan_json()
|
|
64
|
+
|
|
65
|
+
def test_should_return_state_data_when_state_file_exists(
|
|
66
|
+
self, temp_dir: str
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Should parse and return state JSON when file exists and is valid."""
|
|
69
|
+
# Arrange
|
|
70
|
+
plugin = SpaceforgePlugin()
|
|
71
|
+
plugin._workspace_root = temp_dir
|
|
72
|
+
state_data: Dict[str, Any] = {"values": {"root_module": {}}}
|
|
73
|
+
state_path = os.path.join(temp_dir, "spacelift.state.before.json")
|
|
74
|
+
|
|
75
|
+
with open(state_path, "w") as f:
|
|
76
|
+
json.dump(state_data, f)
|
|
77
|
+
|
|
78
|
+
# Act
|
|
79
|
+
result = plugin.get_state_before_json()
|
|
80
|
+
|
|
81
|
+
# Assert
|
|
82
|
+
assert result == state_data
|
|
83
|
+
|
|
84
|
+
def test_should_return_none_and_log_error_when_state_file_missing(
|
|
85
|
+
self, temp_dir: str
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Should return None and log error when state file doesn't exist."""
|
|
88
|
+
# Arrange
|
|
89
|
+
plugin = SpaceforgePlugin()
|
|
90
|
+
plugin._workspace_root = temp_dir
|
|
91
|
+
|
|
92
|
+
# Act
|
|
93
|
+
with patch.object(plugin.logger, "error") as mock_error:
|
|
94
|
+
result = plugin.get_state_before_json()
|
|
95
|
+
|
|
96
|
+
# Assert
|
|
97
|
+
assert result is None
|
|
98
|
+
mock_error.assert_called_with("spacelift.state.before.json does not exist.")
|
|
99
|
+
|
|
100
|
+
def test_custom_policy_input(
|
|
101
|
+
self, temp_dir: str, monkeypatch: pytest.MonkeyPatch
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Should return custom policy input when file exists."""
|
|
104
|
+
# Arrange
|
|
105
|
+
monkeypatch.chdir(temp_dir)
|
|
106
|
+
plugin = SpaceforgePlugin()
|
|
107
|
+
plugin._is_local = False
|
|
108
|
+
plugin._workspace_root = temp_dir
|
|
109
|
+
custom_policy_data = {"test": "input"}
|
|
110
|
+
|
|
111
|
+
# Act
|
|
112
|
+
plugin.add_to_policy_input("test", custom_policy_data)
|
|
113
|
+
|
|
114
|
+
with open(temp_dir + "/test.custom.spacelift.json", "r") as f:
|
|
115
|
+
policy_data = json.load(f)
|
|
116
|
+
|
|
117
|
+
# Assert
|
|
118
|
+
assert policy_data == custom_policy_data
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Tests for SpaceforgePlugin hook system."""
|
|
2
|
+
|
|
3
|
+
from spaceforge.plugin import SpaceforgePlugin
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestSpaceforgePluginHooks:
|
|
7
|
+
"""Test hook method detection and execution."""
|
|
8
|
+
|
|
9
|
+
def test_should_provide_all_expected_hook_methods(self) -> None:
|
|
10
|
+
"""Should define all standard Spacelift hook methods as callable."""
|
|
11
|
+
# Arrange
|
|
12
|
+
plugin = SpaceforgePlugin()
|
|
13
|
+
expected_hooks = [
|
|
14
|
+
"before_init",
|
|
15
|
+
"after_init",
|
|
16
|
+
"before_plan",
|
|
17
|
+
"after_plan",
|
|
18
|
+
"before_apply",
|
|
19
|
+
"after_apply",
|
|
20
|
+
"before_perform",
|
|
21
|
+
"after_perform",
|
|
22
|
+
"before_destroy",
|
|
23
|
+
"after_destroy",
|
|
24
|
+
"after_run",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# Act & Assert
|
|
28
|
+
for hook_name in expected_hooks:
|
|
29
|
+
assert hasattr(plugin, hook_name)
|
|
30
|
+
hook_method = getattr(plugin, hook_name)
|
|
31
|
+
assert callable(hook_method)
|
|
32
|
+
# Should be able to call without error (default implementation is pass)
|
|
33
|
+
hook_method()
|
|
34
|
+
|
|
35
|
+
def test_should_return_all_available_hooks_for_base_class(self) -> None:
|
|
36
|
+
"""Should detect and return all hook methods defined in base class."""
|
|
37
|
+
# Arrange
|
|
38
|
+
plugin = SpaceforgePlugin()
|
|
39
|
+
expected_hooks = [
|
|
40
|
+
"before_init",
|
|
41
|
+
"after_init",
|
|
42
|
+
"before_plan",
|
|
43
|
+
"after_plan",
|
|
44
|
+
"before_apply",
|
|
45
|
+
"after_apply",
|
|
46
|
+
"before_perform",
|
|
47
|
+
"after_perform",
|
|
48
|
+
"before_destroy",
|
|
49
|
+
"after_destroy",
|
|
50
|
+
"after_run",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
# Act
|
|
54
|
+
hooks = plugin.get_available_hooks()
|
|
55
|
+
|
|
56
|
+
# Assert
|
|
57
|
+
for expected_hook in expected_hooks:
|
|
58
|
+
assert expected_hook in hooks
|
|
59
|
+
|
|
60
|
+
def test_should_detect_overridden_hooks_in_custom_plugin(self) -> None:
|
|
61
|
+
"""Should include all hooks including overridden ones in custom plugins."""
|
|
62
|
+
|
|
63
|
+
# Arrange
|
|
64
|
+
class TestPluginWithHooks(SpaceforgePlugin):
|
|
65
|
+
def after_plan(self) -> None:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
def before_apply(self) -> None:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
def custom_method(self) -> None: # Not a hook
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
plugin = TestPluginWithHooks()
|
|
75
|
+
|
|
76
|
+
# Act
|
|
77
|
+
hooks = plugin.get_available_hooks()
|
|
78
|
+
|
|
79
|
+
# Assert
|
|
80
|
+
assert "after_plan" in hooks
|
|
81
|
+
assert "before_apply" in hooks
|
|
82
|
+
assert "custom_method" not in hooks # Not a recognized hook
|
|
83
|
+
|
|
84
|
+
# Should still have all the expected hooks from the base class
|
|
85
|
+
expected_hooks = [
|
|
86
|
+
"before_init",
|
|
87
|
+
"after_init",
|
|
88
|
+
"before_plan",
|
|
89
|
+
"after_plan",
|
|
90
|
+
"before_apply",
|
|
91
|
+
"after_apply",
|
|
92
|
+
"before_perform",
|
|
93
|
+
"after_perform",
|
|
94
|
+
"before_destroy",
|
|
95
|
+
"after_destroy",
|
|
96
|
+
"after_run",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
for expected_hook in expected_hooks:
|
|
100
|
+
assert expected_hook in hooks
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Tests for SpaceforgePlugin inheritance and customization."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Dict
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
from spaceforge.plugin import SpaceforgePlugin
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestSpaceforgePluginInheritance:
|
|
11
|
+
"""Test custom plugin creation and inheritance patterns."""
|
|
12
|
+
|
|
13
|
+
def test_should_support_custom_plugin_with_metadata(self) -> None:
|
|
14
|
+
"""Should allow creation of custom plugins with metadata attributes."""
|
|
15
|
+
|
|
16
|
+
# Arrange & Act
|
|
17
|
+
class CustomPlugin(SpaceforgePlugin):
|
|
18
|
+
__plugin_name__ = "custom"
|
|
19
|
+
__version__ = "2.0.0"
|
|
20
|
+
__author__ = "Custom Author"
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.custom_state = "initialized"
|
|
25
|
+
|
|
26
|
+
def after_plan(self) -> None:
|
|
27
|
+
self.custom_state = "plan_executed"
|
|
28
|
+
|
|
29
|
+
def custom_method(self) -> str:
|
|
30
|
+
return "custom_result"
|
|
31
|
+
|
|
32
|
+
plugin = CustomPlugin()
|
|
33
|
+
|
|
34
|
+
# Assert
|
|
35
|
+
assert plugin.__plugin_name__ == "custom"
|
|
36
|
+
assert plugin.__version__ == "2.0.0"
|
|
37
|
+
assert plugin.__author__ == "Custom Author"
|
|
38
|
+
assert plugin.custom_state == "initialized"
|
|
39
|
+
assert plugin.custom_method() == "custom_result"
|
|
40
|
+
|
|
41
|
+
# Test hook override
|
|
42
|
+
plugin.after_plan()
|
|
43
|
+
assert plugin.custom_state == "plan_executed"
|
|
44
|
+
|
|
45
|
+
# Test inherited functionality
|
|
46
|
+
assert hasattr(plugin, "logger")
|
|
47
|
+
assert hasattr(plugin, "run_cli")
|
|
48
|
+
|
|
49
|
+
def test_should_support_complex_initialization_logic(self) -> None:
|
|
50
|
+
"""Should allow complex initialization patterns in custom plugins."""
|
|
51
|
+
|
|
52
|
+
# Arrange & Act
|
|
53
|
+
class ComplexPlugin(SpaceforgePlugin):
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
super().__init__()
|
|
56
|
+
self.config = self._load_config()
|
|
57
|
+
self.initialized = True
|
|
58
|
+
|
|
59
|
+
def _load_config(self) -> Dict[str, str]:
|
|
60
|
+
return {"setting1": "value1", "setting2": "value2"}
|
|
61
|
+
|
|
62
|
+
def after_plan(self) -> None:
|
|
63
|
+
self.config_info = f"Config loaded: {self.config}"
|
|
64
|
+
|
|
65
|
+
plugin = ComplexPlugin()
|
|
66
|
+
|
|
67
|
+
# Assert
|
|
68
|
+
assert plugin.initialized is True
|
|
69
|
+
assert plugin.config == {"setting1": "value1", "setting2": "value2"}
|
|
70
|
+
|
|
71
|
+
plugin.after_plan()
|
|
72
|
+
assert hasattr(plugin, "config_info")
|
|
73
|
+
assert "Config loaded:" in plugin.config_info
|
|
74
|
+
|
|
75
|
+
def test_should_support_environment_variable_access_in_custom_plugins(self) -> None:
|
|
76
|
+
"""Should allow custom plugins to access environment variables."""
|
|
77
|
+
|
|
78
|
+
# Arrange
|
|
79
|
+
class EnvPlugin(SpaceforgePlugin):
|
|
80
|
+
def get_custom_env(self) -> str:
|
|
81
|
+
return os.environ.get("CUSTOM_ENV", "default_value")
|
|
82
|
+
|
|
83
|
+
plugin = EnvPlugin()
|
|
84
|
+
|
|
85
|
+
# Act & Assert - no environment variable
|
|
86
|
+
assert plugin.get_custom_env() == "default_value"
|
|
87
|
+
|
|
88
|
+
# Act & Assert - with environment variable set
|
|
89
|
+
with patch.dict(os.environ, {"CUSTOM_ENV": "custom_value"}):
|
|
90
|
+
assert plugin.get_custom_env() == "custom_value"
|
|
91
|
+
|
|
92
|
+
def test_should_share_logger_instances_across_plugin_instances(self) -> None:
|
|
93
|
+
"""Should use the same logger instance for multiple plugin instances of same class."""
|
|
94
|
+
# Arrange & Act
|
|
95
|
+
plugin1 = SpaceforgePlugin()
|
|
96
|
+
plugin2 = SpaceforgePlugin()
|
|
97
|
+
|
|
98
|
+
# Assert
|
|
99
|
+
# Python loggers are singletons by name, so they should be the same instance
|
|
100
|
+
assert plugin1.logger is plugin2.logger
|
|
101
|
+
assert plugin1.logger.name == "spaceforge.SpaceforgePlugin"
|
|
102
|
+
assert plugin2.logger.name == "spaceforge.SpaceforgePlugin"
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import os
|
|
2
|
-
import sys
|
|
3
2
|
import tempfile
|
|
4
|
-
from typing import
|
|
3
|
+
from typing import Optional
|
|
5
4
|
from unittest.mock import Mock, patch
|
|
6
5
|
|
|
7
6
|
import pytest
|
|
8
7
|
|
|
9
8
|
from spaceforge.plugin import SpaceforgePlugin
|
|
10
|
-
from spaceforge.runner import PluginRunner,
|
|
9
|
+
from spaceforge.runner import PluginRunner, runner_command
|
|
11
10
|
|
|
12
11
|
|
|
13
12
|
class PluginForTesting(SpaceforgePlugin):
|
|
@@ -171,8 +170,8 @@ class NotAPlugin:
|
|
|
171
170
|
assert "after_plan" in getattr(runner.plugin_instance, "executed_hooks")
|
|
172
171
|
|
|
173
172
|
# Verify print statements
|
|
174
|
-
mock_print.assert_any_call("[
|
|
175
|
-
mock_print.assert_any_call("[
|
|
173
|
+
mock_print.assert_any_call("[SpaceForge] Running hook: after_plan")
|
|
174
|
+
mock_print.assert_any_call("[SpaceForge] Hook completed: after_plan")
|
|
176
175
|
|
|
177
176
|
def test_run_hook_not_found(self) -> None:
|
|
178
177
|
"""Test running a hook that doesn't exist."""
|
|
@@ -211,7 +210,7 @@ class NotAPlugin:
|
|
|
211
210
|
|
|
212
211
|
# Should print error message
|
|
213
212
|
mock_print.assert_any_call(
|
|
214
|
-
"[
|
|
213
|
+
"[SpaceForge] Error running hook 'error_hook': Test error from hook"
|
|
215
214
|
)
|
|
216
215
|
|
|
217
216
|
def test_run_hook_multiple_hooks(self) -> None:
|
|
@@ -363,68 +362,6 @@ class MainTestPlugin(SpaceforgePlugin):
|
|
|
363
362
|
|
|
364
363
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
|
365
364
|
|
|
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
365
|
|
|
429
366
|
class TestRunnerEdgeCases:
|
|
430
367
|
"""Test edge cases and error conditions."""
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Tests for PluginRunner CLI interface."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from unittest.mock import Mock, patch
|
|
5
|
+
|
|
6
|
+
from spaceforge.runner import runner_command
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestRunnerClickCommand:
|
|
10
|
+
"""Test Click command interface."""
|
|
11
|
+
|
|
12
|
+
def test_should_execute_hook_via_click_command(self, temp_dir: str) -> None:
|
|
13
|
+
"""Should execute hook through Click command interface."""
|
|
14
|
+
# Arrange
|
|
15
|
+
click_plugin_path = os.path.join(temp_dir, "click_plugin.py")
|
|
16
|
+
with open(click_plugin_path, "w") as f:
|
|
17
|
+
f.write(
|
|
18
|
+
"""
|
|
19
|
+
from spaceforge import SpaceforgePlugin
|
|
20
|
+
|
|
21
|
+
class ClickTestPlugin(SpaceforgePlugin):
|
|
22
|
+
def after_plan(self):
|
|
23
|
+
print("Hook executed via click")
|
|
24
|
+
"""
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Act
|
|
28
|
+
from click.testing import CliRunner
|
|
29
|
+
|
|
30
|
+
cli_runner = CliRunner()
|
|
31
|
+
|
|
32
|
+
with patch("spaceforge.runner.PluginRunner") as mock_runner_class:
|
|
33
|
+
mock_runner = Mock()
|
|
34
|
+
mock_runner_class.return_value = mock_runner
|
|
35
|
+
|
|
36
|
+
result = cli_runner.invoke(
|
|
37
|
+
runner_command, ["after_plan", "--plugin-file", click_plugin_path]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Assert
|
|
41
|
+
assert result.exit_code == 0
|
|
42
|
+
mock_runner_class.assert_called_once_with(click_plugin_path)
|
|
43
|
+
mock_runner.run_hook.assert_called_once_with("after_plan")
|
|
44
|
+
|
|
45
|
+
def test_should_use_custom_plugin_file_when_specified(self, temp_dir: str) -> None:
|
|
46
|
+
"""Should use specified plugin file path instead of default."""
|
|
47
|
+
# Arrange
|
|
48
|
+
custom_plugin_path = os.path.join(temp_dir, "custom_plugin.py")
|
|
49
|
+
# Create the file since Click validates existence
|
|
50
|
+
with open(custom_plugin_path, "w") as f:
|
|
51
|
+
f.write("# dummy plugin file for testing")
|
|
52
|
+
|
|
53
|
+
# Act
|
|
54
|
+
from click.testing import CliRunner
|
|
55
|
+
|
|
56
|
+
cli_runner = CliRunner()
|
|
57
|
+
|
|
58
|
+
with patch("spaceforge.runner.PluginRunner") as mock_runner_class:
|
|
59
|
+
mock_runner = Mock()
|
|
60
|
+
mock_runner_class.return_value = mock_runner
|
|
61
|
+
|
|
62
|
+
result = cli_runner.invoke(
|
|
63
|
+
runner_command, ["before_apply", "--plugin-file", custom_plugin_path]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Assert
|
|
67
|
+
assert result.exit_code == 0
|
|
68
|
+
mock_runner_class.assert_called_once_with(custom_plugin_path)
|
|
69
|
+
mock_runner.run_hook.assert_called_once_with("before_apply")
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Tests for PluginRunner core functionality."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import List
|
|
5
|
+
from unittest.mock import Mock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from spaceforge.plugin import SpaceforgePlugin
|
|
10
|
+
from spaceforge.runner import PluginRunner
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RunnerTestPlugin(SpaceforgePlugin):
|
|
14
|
+
"""Reusable test plugin for runner tests."""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
super().__init__()
|
|
18
|
+
self.executed_hooks: List[str] = []
|
|
19
|
+
|
|
20
|
+
def after_plan(self) -> None:
|
|
21
|
+
self.executed_hooks.append("after_plan")
|
|
22
|
+
|
|
23
|
+
def before_apply(self) -> None:
|
|
24
|
+
self.executed_hooks.append("before_apply")
|
|
25
|
+
|
|
26
|
+
def error_hook(self) -> None:
|
|
27
|
+
raise ValueError("Test error from hook")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestPluginRunnerInitialization:
|
|
31
|
+
"""Test PluginRunner initialization and configuration."""
|
|
32
|
+
|
|
33
|
+
def test_should_initialize_with_custom_plugin_path(self) -> None:
|
|
34
|
+
"""Should accept and store custom plugin file path."""
|
|
35
|
+
# Arrange & Act
|
|
36
|
+
runner = PluginRunner("custom_plugin.py")
|
|
37
|
+
|
|
38
|
+
# Assert
|
|
39
|
+
assert runner.plugin_path == "custom_plugin.py"
|
|
40
|
+
assert runner.plugin_instance is None
|
|
41
|
+
|
|
42
|
+
def test_should_use_default_plugin_path_when_none_provided(self) -> None:
|
|
43
|
+
"""Should use 'plugin.py' as default when no path specified."""
|
|
44
|
+
# Arrange & Act
|
|
45
|
+
runner = PluginRunner()
|
|
46
|
+
|
|
47
|
+
# Assert
|
|
48
|
+
assert runner.plugin_path == "plugin.py"
|
|
49
|
+
assert runner.plugin_instance is None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestPluginRunnerLoading:
|
|
53
|
+
"""Test plugin loading functionality."""
|
|
54
|
+
|
|
55
|
+
def test_should_raise_file_not_found_when_plugin_file_missing(self) -> None:
|
|
56
|
+
"""Should raise FileNotFoundError when plugin file doesn't exist."""
|
|
57
|
+
# Arrange
|
|
58
|
+
runner = PluginRunner("nonexistent.py")
|
|
59
|
+
|
|
60
|
+
# Act & Assert
|
|
61
|
+
with pytest.raises(FileNotFoundError, match="Plugin file not found"):
|
|
62
|
+
runner.load_plugin()
|
|
63
|
+
|
|
64
|
+
def test_should_raise_exception_when_plugin_file_has_syntax_errors(
|
|
65
|
+
self, temp_dir: str
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Should raise exception when plugin file has invalid Python syntax."""
|
|
68
|
+
# Arrange
|
|
69
|
+
invalid_path = os.path.join(temp_dir, "invalid.py")
|
|
70
|
+
with open(invalid_path, "w") as f:
|
|
71
|
+
f.write("invalid python syntax }")
|
|
72
|
+
|
|
73
|
+
runner = PluginRunner(invalid_path)
|
|
74
|
+
|
|
75
|
+
# Act & Assert
|
|
76
|
+
with pytest.raises(Exception): # Could be syntax error
|
|
77
|
+
runner.load_plugin()
|
|
78
|
+
|
|
79
|
+
def test_should_raise_value_error_when_no_spaceforge_plugin_found(
|
|
80
|
+
self, temp_dir: str
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Should raise ValueError when file has no SpaceforgePlugin subclass."""
|
|
83
|
+
# Arrange
|
|
84
|
+
no_plugin_path = os.path.join(temp_dir, "no_plugin.py")
|
|
85
|
+
with open(no_plugin_path, "w") as f:
|
|
86
|
+
f.write(
|
|
87
|
+
"""
|
|
88
|
+
class NotAPlugin:
|
|
89
|
+
pass
|
|
90
|
+
"""
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
runner = PluginRunner(no_plugin_path)
|
|
94
|
+
|
|
95
|
+
# Act & Assert
|
|
96
|
+
with pytest.raises(ValueError, match="No SpaceforgePlugin subclass found"):
|
|
97
|
+
runner.load_plugin()
|
|
98
|
+
|
|
99
|
+
def test_should_load_plugin_successfully_when_valid_file_provided(
|
|
100
|
+
self, test_plugin_file: str
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Should successfully load plugin from valid file."""
|
|
103
|
+
# Arrange
|
|
104
|
+
runner = PluginRunner(test_plugin_file)
|
|
105
|
+
|
|
106
|
+
# Act
|
|
107
|
+
runner.load_plugin()
|
|
108
|
+
|
|
109
|
+
# Assert
|
|
110
|
+
assert runner.plugin_instance is not None
|
|
111
|
+
assert runner.plugin_instance.__class__.__name__ == "TestPlugin"
|
|
112
|
+
|
|
113
|
+
@patch("spaceforge.runner.importlib.util.spec_from_file_location")
|
|
114
|
+
def test_should_raise_import_error_when_spec_is_none(
|
|
115
|
+
self, mock_spec: Mock, test_plugin_file: str
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Should raise ImportError when importlib spec creation fails."""
|
|
118
|
+
# Arrange
|
|
119
|
+
mock_spec.return_value = None
|
|
120
|
+
runner = PluginRunner(test_plugin_file)
|
|
121
|
+
|
|
122
|
+
# Act & Assert
|
|
123
|
+
with pytest.raises(ImportError, match="Could not load plugin"):
|
|
124
|
+
runner.load_plugin()
|