spaceforge 0.0.2__py3-none-any.whl → 0.0.3__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,59 @@
1
+ """Tests for PluginGenerator parameter handling."""
2
+
3
+ from spaceforge.cls import Parameter
4
+ from spaceforge.generator import PluginGenerator
5
+ from spaceforge.plugin import SpaceforgePlugin
6
+
7
+
8
+ class TestPluginGeneratorParameters:
9
+ """Test parameter extraction and processing."""
10
+
11
+ def test_should_extract_parameters_when_defined(self) -> None:
12
+ """Should extract and return parameter list when plugin defines them."""
13
+
14
+ # Arrange
15
+ class ParameterizedPlugin(SpaceforgePlugin):
16
+ __parameters__ = [
17
+ Parameter(
18
+ name="api_key",
19
+ description="API key for authentication",
20
+ required=True,
21
+ sensitive=True,
22
+ ),
23
+ Parameter(
24
+ name="endpoint",
25
+ description="API endpoint URL",
26
+ required=False,
27
+ default="https://api.example.com",
28
+ ),
29
+ ]
30
+
31
+ generator = PluginGenerator()
32
+ generator.plugin_class = ParameterizedPlugin
33
+
34
+ # Act
35
+ parameters = generator.get_plugin_parameters()
36
+
37
+ # Assert
38
+ assert parameters is not None
39
+ assert len(parameters) == 2
40
+ assert parameters[0].name == "api_key"
41
+ assert parameters[0].sensitive is True
42
+ assert parameters[1].name == "endpoint"
43
+ assert parameters[1].default == "https://api.example.com"
44
+
45
+ def test_should_return_none_when_no_parameters_defined(self) -> None:
46
+ """Should return None when plugin has no parameters."""
47
+
48
+ # Arrange
49
+ class NoParamsPlugin(SpaceforgePlugin):
50
+ pass
51
+
52
+ generator = PluginGenerator()
53
+ generator.plugin_class = NoParamsPlugin
54
+
55
+ # Act
56
+ parameters = generator.get_plugin_parameters()
57
+
58
+ # Assert
59
+ assert parameters is None
@@ -0,0 +1,357 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import subprocess
5
+ from typing import Dict
6
+ from unittest.mock import Mock, patch
7
+
8
+ import pytest
9
+
10
+ from spaceforge.plugin import SpaceforgePlugin
11
+
12
+
13
+ class TestSpaceforgePluginInitialization:
14
+ """Test SpaceforgePlugin initialization and configuration."""
15
+
16
+ def test_should_initialize_with_defaults_when_no_environment_set(self) -> None:
17
+ """Should set default values when no environment variables are provided."""
18
+ # Arrange & Act
19
+ with patch.dict(os.environ, {}, clear=True):
20
+ plugin = SpaceforgePlugin()
21
+
22
+ # Assert
23
+ assert plugin._api_token is False
24
+ assert plugin._spacelift_domain is False
25
+ assert plugin._api_enabled is False
26
+ assert plugin._workspace_root == os.getcwd()
27
+ assert isinstance(plugin.logger, logging.Logger)
28
+
29
+ def test_should_enable_api_when_valid_credentials_provided(
30
+ self, mock_env: Dict[str, str]
31
+ ) -> None:
32
+ """Should enable API access when both token and endpoint are provided."""
33
+ # Arrange & Act
34
+ with patch.dict(os.environ, mock_env, clear=True):
35
+ plugin = SpaceforgePlugin()
36
+
37
+ # Assert
38
+ assert plugin._api_token == "test_token"
39
+ assert plugin._spacelift_domain == "https://test.spacelift.io"
40
+ assert plugin._api_enabled is True
41
+ assert plugin._workspace_root == os.getcwd()
42
+
43
+ def test_should_normalize_domain_with_trailing_slash(self) -> None:
44
+ """Should remove trailing slash from domain URL."""
45
+ # Arrange
46
+ test_env = {
47
+ "SPACELIFT_API_TOKEN": "test_token",
48
+ "TF_VAR_spacelift_graphql_endpoint": "https://test.spacelift.io/",
49
+ }
50
+
51
+ # Act
52
+ with patch.dict(os.environ, test_env, clear=True):
53
+ plugin = SpaceforgePlugin()
54
+
55
+ # Assert
56
+ assert plugin._spacelift_domain == "https://test.spacelift.io"
57
+ assert plugin._api_enabled is True
58
+
59
+ def test_should_disable_api_when_domain_has_no_https_prefix(self) -> None:
60
+ """Should disable API when domain doesn't use HTTPS."""
61
+ # Arrange
62
+ test_env = {
63
+ "SPACELIFT_API_TOKEN": "test_token",
64
+ "TF_VAR_spacelift_graphql_endpoint": "test.spacelift.io",
65
+ }
66
+
67
+ # Act
68
+ with patch.dict(os.environ, test_env, clear=True):
69
+ plugin = SpaceforgePlugin()
70
+
71
+ # Assert
72
+ assert plugin._spacelift_domain == "test.spacelift.io"
73
+ assert plugin._api_enabled is False
74
+
75
+ def test_should_disable_api_when_only_token_provided(self) -> None:
76
+ """Should disable API when only token is provided without domain."""
77
+ # Arrange & Act
78
+ with patch.dict(os.environ, {"SPACELIFT_API_TOKEN": "test_token"}, clear=True):
79
+ plugin = SpaceforgePlugin()
80
+
81
+ # Assert
82
+ assert plugin._api_enabled is False
83
+
84
+ def test_should_disable_api_when_only_domain_provided(self) -> None:
85
+ """Should disable API when only domain is provided without token."""
86
+ # Arrange & Act
87
+ with patch.dict(
88
+ os.environ,
89
+ {"TF_VAR_spacelift_graphql_endpoint": "https://test.spacelift.io"},
90
+ clear=True,
91
+ ):
92
+ plugin = SpaceforgePlugin()
93
+
94
+ # Assert
95
+ assert plugin._api_enabled is False
96
+
97
+
98
+ class TestSpaceforgePluginLogging:
99
+ """Test logging configuration and functionality."""
100
+
101
+ def test_should_configure_logger_with_correct_name_and_level(self) -> None:
102
+ """Should set up logger with proper name and level."""
103
+ # Arrange & Act
104
+ with patch.dict(os.environ, {}, clear=True):
105
+ plugin = SpaceforgePlugin()
106
+
107
+ # Assert
108
+ assert plugin.logger.name == "spaceforge.SpaceforgePlugin"
109
+ assert len(plugin.logger.handlers) >= 1
110
+ assert plugin.logger.getEffectiveLevel() <= logging.INFO
111
+
112
+ def test_should_enable_debug_logging_when_debug_env_set(self) -> None:
113
+ """Should set DEBUG level when SPACELIFT_DEBUG environment variable is true."""
114
+ # Arrange
115
+ test_env = {"SPACELIFT_DEBUG": "true"}
116
+
117
+ # Act
118
+ with patch.dict(os.environ, test_env, clear=True):
119
+ plugin = SpaceforgePlugin()
120
+
121
+ # Assert
122
+ assert plugin.logger.level == logging.DEBUG
123
+
124
+ def test_should_include_run_id_in_log_format(self) -> None:
125
+ """Should include run ID in log message format when available."""
126
+ # Arrange
127
+ test_env = {"TF_VAR_spacelift_run_id": "run-123"}
128
+
129
+ # Act
130
+ with patch.dict(os.environ, test_env, clear=True):
131
+ plugin = SpaceforgePlugin()
132
+ formatter = plugin.logger.handlers[0].formatter
133
+
134
+ record = logging.LogRecord(
135
+ name="test",
136
+ level=logging.INFO,
137
+ pathname="",
138
+ lineno=0,
139
+ msg="test message",
140
+ args=(),
141
+ exc_info=None,
142
+ )
143
+ record.levelname = "INFO"
144
+
145
+ # Assert
146
+ assert formatter is not None
147
+ formatted = formatter.format(record)
148
+ assert "[run-123]" in formatted or "[local]" in formatted
149
+
150
+ def test_logger_color_formatting(self) -> None:
151
+ """Test color formatting for different log levels."""
152
+ plugin = SpaceforgePlugin()
153
+ formatter = plugin.logger.handlers[0].formatter
154
+ assert formatter is not None
155
+
156
+ # Test different log levels
157
+ levels_to_test = [
158
+ (logging.INFO, "INFO"),
159
+ (logging.DEBUG, "DEBUG"),
160
+ (logging.WARNING, "WARNING"),
161
+ (logging.ERROR, "ERROR"),
162
+ ]
163
+
164
+ for level, level_name in levels_to_test:
165
+ record = logging.LogRecord(
166
+ name="test",
167
+ level=level,
168
+ pathname="",
169
+ lineno=0,
170
+ msg="test message",
171
+ args=(),
172
+ exc_info=None,
173
+ )
174
+ record.levelname = level_name
175
+ formatted = formatter.format(record)
176
+
177
+ # Should contain color codes and plugin name
178
+ assert "\033[" in formatted # ANSI color codes
179
+ assert "(SpaceforgePlugin)" in formatted
180
+ assert "test message" in formatted
181
+
182
+
183
+ # Hook tests moved to test_plugin_hooks.py
184
+
185
+
186
+ class TestSpaceforgePluginCLI:
187
+ """Test command-line interface execution functionality."""
188
+
189
+ def test_should_execute_cli_command_and_log_output_on_success(self) -> None:
190
+ """Should run CLI command and log output when execution succeeds."""
191
+ # Arrange
192
+ plugin = SpaceforgePlugin()
193
+ mock_process = Mock()
194
+ mock_process.communicate.return_value = (b"success output\n", None)
195
+ mock_process.returncode = 0
196
+
197
+ # Act
198
+ with patch("subprocess.Popen") as mock_popen:
199
+ with patch.object(plugin.logger, "info") as mock_info:
200
+ mock_popen.return_value = mock_process
201
+ plugin.run_cli("echo", "test")
202
+
203
+ # Assert
204
+ mock_popen.assert_called_once_with(
205
+ ("echo", "test"), stdout=subprocess.PIPE, stderr=subprocess.STDOUT
206
+ )
207
+ mock_info.assert_called_with("success output")
208
+
209
+ def test_should_log_error_when_cli_command_fails(self) -> None:
210
+ """Should log error details when CLI command returns non-zero exit code."""
211
+ # Arrange
212
+ plugin = SpaceforgePlugin()
213
+ mock_process = Mock()
214
+ mock_process.communicate.return_value = (None, b"error output\n")
215
+ mock_process.returncode = 1
216
+
217
+ # Act
218
+ with patch("subprocess.Popen") as mock_popen:
219
+ with patch.object(plugin.logger, "error") as mock_error:
220
+ mock_popen.return_value = mock_process
221
+ plugin.run_cli("false")
222
+
223
+ # Assert
224
+ mock_error.assert_any_call("Command failed with return code 1")
225
+ mock_error.assert_any_call("error output")
226
+
227
+ def test_run_cli_with_multiple_args(self) -> None:
228
+ """Test CLI command with multiple arguments."""
229
+ plugin = SpaceforgePlugin()
230
+
231
+ with patch("subprocess.Popen") as mock_popen:
232
+ mock_process = Mock()
233
+ mock_process.communicate.return_value = (b"", None)
234
+ mock_process.returncode = 0
235
+ mock_popen.return_value = mock_process
236
+
237
+ with patch.object(plugin.logger, "debug") as mock_debug:
238
+ plugin.run_cli("git", "status", "--porcelain")
239
+
240
+ mock_popen.assert_called_once_with(
241
+ ("git", "status", "--porcelain"),
242
+ stdout=subprocess.PIPE,
243
+ stderr=subprocess.STDOUT,
244
+ )
245
+ mock_debug.assert_called_with("Running CLI command: git status --porcelain")
246
+
247
+
248
+ class TestSpaceforgePluginAPI:
249
+ """Test GraphQL API interaction functionality."""
250
+
251
+ def test_should_exit_with_error_when_api_disabled(self) -> None:
252
+ """Should exit with error message when API is not enabled."""
253
+ # Arrange
254
+ plugin = SpaceforgePlugin()
255
+ plugin._api_enabled = False
256
+
257
+ # Act & Assert
258
+ with patch.object(plugin.logger, "error") as mock_error:
259
+ with pytest.raises(SystemExit):
260
+ plugin.query_api("query { test }")
261
+
262
+ mock_error.assert_called_with(
263
+ 'API is not enabled, please export "SPACELIFT_API_TOKEN" and "TF_VAR_spacelift_graphql_endpoint".'
264
+ )
265
+
266
+ def test_should_make_successful_api_request_with_correct_format(
267
+ self, mock_api_response: Mock
268
+ ) -> None:
269
+ """Should execute GraphQL query with proper authentication and format."""
270
+ # Arrange
271
+ plugin = SpaceforgePlugin()
272
+ plugin._api_enabled = True
273
+ plugin._api_token = "test_token"
274
+ plugin._spacelift_domain = "https://test.spacelift.io"
275
+
276
+ expected_data = {"data": {"test": "result"}}
277
+ mock_api_response.read.return_value = json.dumps(expected_data).encode("utf-8")
278
+
279
+ # Act
280
+ with patch("urllib.request.urlopen") as mock_urlopen:
281
+ with patch("urllib.request.Request") as mock_request:
282
+ mock_urlopen.return_value.__enter__ = Mock(
283
+ return_value=mock_api_response
284
+ )
285
+ mock_urlopen.return_value.__exit__ = Mock(return_value=None)
286
+
287
+ result = plugin.query_api("query { test }")
288
+
289
+ # Assert
290
+ mock_request.assert_called_once()
291
+ call_args = mock_request.call_args[0]
292
+ assert call_args[0] == "https://test.spacelift.io"
293
+
294
+ request_data = json.loads(call_args[1].decode("utf-8"))
295
+ assert request_data["query"] == "query { test }"
296
+
297
+ headers = mock_request.call_args[0][2]
298
+ assert headers["Content-Type"] == "application/json"
299
+ assert headers["Authorization"] == "Bearer test_token"
300
+
301
+ assert result == expected_data
302
+
303
+ def test_query_api_with_variables(self) -> None:
304
+ """Test API query with variables."""
305
+ plugin = SpaceforgePlugin()
306
+ plugin._api_enabled = True
307
+ plugin._api_token = "test_token"
308
+ plugin._spacelift_domain = "https://test.spacelift.io"
309
+
310
+ mock_response_data = {"data": {"test": "result"}}
311
+ mock_response = Mock()
312
+ mock_response.read.return_value = json.dumps(mock_response_data).encode("utf-8")
313
+
314
+ variables = {"stackId": "test-stack"}
315
+
316
+ with patch("urllib.request.urlopen") as mock_urlopen:
317
+ with patch("urllib.request.Request") as mock_request:
318
+ mock_urlopen.return_value.__enter__ = Mock(return_value=mock_response)
319
+ mock_urlopen.return_value.__exit__ = Mock(return_value=None)
320
+
321
+ plugin.query_api(
322
+ "query ($stackId: ID!) { stack(id: $stackId) { name } }", variables
323
+ )
324
+
325
+ # Verify request data includes variables
326
+ request_data = json.loads(mock_request.call_args[0][1].decode("utf-8"))
327
+ assert request_data["variables"] == variables
328
+
329
+ def test_query_api_with_errors(self) -> None:
330
+ """Test API query that returns errors."""
331
+ plugin = SpaceforgePlugin()
332
+ plugin._api_enabled = True
333
+ plugin._api_token = "test_token"
334
+ plugin._spacelift_domain = "https://test.spacelift.io"
335
+
336
+ mock_response_data = {"errors": [{"message": "Test error"}]}
337
+ mock_response = Mock()
338
+ mock_response.read.return_value = json.dumps(mock_response_data).encode("utf-8")
339
+
340
+ with patch("urllib.request.urlopen") as mock_urlopen:
341
+ with patch.object(plugin.logger, "error") as mock_error:
342
+ mock_urlopen.return_value.__enter__ = Mock(return_value=mock_response)
343
+ mock_urlopen.return_value.__exit__ = Mock(return_value=None)
344
+
345
+ result = plugin.query_api("query { test }")
346
+
347
+ mock_error.assert_called_with("Error: [{'message': 'Test error'}]")
348
+ assert result == mock_response_data
349
+
350
+
351
+ # File operation tests moved to test_plugin_file_operations.py
352
+
353
+
354
+ # Inheritance tests moved to test_plugin_inheritance.py
355
+
356
+
357
+ # Edge case tests moved to test_plugin_inheritance.py
@@ -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