spaceforge 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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