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.
Files changed (35) hide show
  1. spaceforge/__init__.py +12 -4
  2. spaceforge/__main__.py +3 -3
  3. spaceforge/_version.py +0 -1
  4. spaceforge/_version_scm.py +34 -0
  5. spaceforge/cls.py +24 -14
  6. spaceforge/conftest.py +89 -0
  7. spaceforge/generator.py +129 -56
  8. spaceforge/plugin.py +199 -22
  9. spaceforge/runner.py +3 -15
  10. spaceforge/schema.json +45 -22
  11. spaceforge/templates/binary_install.sh.j2 +24 -0
  12. spaceforge/templates/ensure_spaceforge_and_run.sh.j2 +24 -0
  13. spaceforge/{generator_test.py → test_generator.py} +265 -53
  14. spaceforge/test_generator_binaries.py +194 -0
  15. spaceforge/test_generator_core.py +180 -0
  16. spaceforge/test_generator_hooks.py +90 -0
  17. spaceforge/test_generator_parameters.py +59 -0
  18. spaceforge/test_plugin.py +357 -0
  19. spaceforge/test_plugin_file_operations.py +118 -0
  20. spaceforge/test_plugin_hooks.py +100 -0
  21. spaceforge/test_plugin_inheritance.py +102 -0
  22. spaceforge/{runner_test.py → test_runner.py} +5 -68
  23. spaceforge/test_runner_cli.py +69 -0
  24. spaceforge/test_runner_core.py +124 -0
  25. spaceforge/test_runner_execution.py +169 -0
  26. spaceforge-1.0.1.dist-info/METADATA +606 -0
  27. spaceforge-1.0.1.dist-info/RECORD +33 -0
  28. spaceforge/plugin_test.py +0 -621
  29. spaceforge-0.1.0.dev0.dist-info/METADATA +0 -163
  30. spaceforge-0.1.0.dev0.dist-info/RECORD +0 -19
  31. /spaceforge/{cls_test.py → test_cls.py} +0 -0
  32. {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.1.dist-info}/WHEEL +0 -0
  33. {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.1.dist-info}/entry_points.txt +0 -0
  34. {spaceforge-0.1.0.dev0.dist-info → spaceforge-1.0.1.dist-info}/licenses/LICENSE +0 -0
  35. {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 Any, Optional
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, main, runner_command
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("[SPACEPY] Running hook: after_plan")
175
- mock_print.assert_any_call("[SPACEPY] Hook completed: after_plan")
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
- "[SPACEPY] Error running hook 'error_hook': Test error from hook"
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()