spaceforge 0.0.2__py3-none-any.whl → 0.0.4__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 +2 -2
  5. spaceforge/cls.py +16 -12
  6. spaceforge/conftest.py +89 -0
  7. spaceforge/generator.py +119 -54
  8. spaceforge/plugin.py +106 -12
  9. spaceforge/runner.py +0 -12
  10. spaceforge/schema.json +38 -29
  11. spaceforge/templates/binary_install.sh.j2 +23 -0
  12. spaceforge/templates/ensure_spaceforge_and_run.sh.j2 +22 -0
  13. spaceforge/{generator_test.py → test_generator.py} +263 -51
  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} +2 -65
  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-0.0.4.dist-info/METADATA +605 -0
  27. spaceforge-0.0.4.dist-info/RECORD +33 -0
  28. spaceforge/plugin_test.py +0 -621
  29. spaceforge-0.0.2.dist-info/METADATA +0 -163
  30. spaceforge-0.0.2.dist-info/RECORD +0 -20
  31. /spaceforge/{cls_test.py → test_cls.py} +0 -0
  32. {spaceforge-0.0.2.dist-info → spaceforge-0.0.4.dist-info}/WHEEL +0 -0
  33. {spaceforge-0.0.2.dist-info → spaceforge-0.0.4.dist-info}/entry_points.txt +0 -0
  34. {spaceforge-0.0.2.dist-info → spaceforge-0.0.4.dist-info}/licenses/LICENSE +0 -0
  35. {spaceforge-0.0.2.dist-info → spaceforge-0.0.4.dist-info}/top_level.txt +0 -0
spaceforge/__init__.py CHANGED
@@ -4,10 +4,18 @@ Spaceforge - Spacelift Plugin Framework
4
4
  A Python framework for building Spacelift plugins with hook-based functionality.
5
5
  """
6
6
 
7
- from ._version import get_version
8
- from .cls import Binary, Context, MountedFile, Parameter, Policy, Variable, Webhook
9
- from .plugin import SpaceforgePlugin
10
- from .runner import PluginRunner
7
+ from spaceforge._version import get_version
8
+ from spaceforge.cls import (
9
+ Binary,
10
+ Context,
11
+ MountedFile,
12
+ Parameter,
13
+ Policy,
14
+ Variable,
15
+ Webhook,
16
+ )
17
+ from spaceforge.plugin import SpaceforgePlugin
18
+ from spaceforge.runner import PluginRunner
11
19
 
12
20
  __version__ = get_version()
13
21
  __all__ = [
spaceforge/__main__.py CHANGED
@@ -4,9 +4,9 @@ Main entry point for spaceforge module.
4
4
 
5
5
  import click
6
6
 
7
- from ._version import get_version
8
- from .generator import generate_command
9
- from .runner import runner_command
7
+ from spaceforge._version import get_version
8
+ from spaceforge.generator import generate_command
9
+ from spaceforge.runner import runner_command
10
10
 
11
11
 
12
12
  @click.group()
spaceforge/_version.py CHANGED
@@ -3,7 +3,6 @@ Dynamic version detection from git tags.
3
3
  """
4
4
 
5
5
  import subprocess
6
- import sys
7
6
  from typing import Optional
8
7
 
9
8
 
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.0.2'
32
- __version_tuple__ = version_tuple = (0, 0, 2)
31
+ __version__ = version = '0.0.4'
32
+ __version_tuple__ = version_tuple = (0, 0, 4)
33
33
 
34
34
  __commit_id__ = commit_id = None
spaceforge/cls.py CHANGED
@@ -1,3 +1,4 @@
1
+ import uuid
1
2
  from typing import Dict, List, Literal, Optional
2
3
 
3
4
  from pydantic import Field
@@ -19,8 +20,7 @@ class Binary:
19
20
 
20
21
  Attributes:
21
22
  name (str): The name of the binary file.
22
- path (str): The path to the binary file.
23
- sensitive (bool): Whether the binary file is sensitive.
23
+ download_urls (Dict[BinaryType, str]): A dictionary mapping binary types to their download URLs.
24
24
  """
25
25
 
26
26
  name: str
@@ -38,6 +38,7 @@ class Parameter:
38
38
  sensitive (bool): Whether the parameter contains sensitive information.
39
39
  required (bool): Whether the parameter is required.
40
40
  default (Optional[str]): The default value of the parameter, if any. (required if sensitive is False)
41
+ id (str): Unique identifier for the parameter.
41
42
  """
42
43
 
43
44
  name: str
@@ -45,6 +46,7 @@ class Parameter:
45
46
  sensitive: bool = False
46
47
  required: bool = False
47
48
  default: Optional[str] = None
49
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
48
50
 
49
51
  def __post_init__(self) -> None:
50
52
  if not self.required and self.default is None:
@@ -116,7 +118,7 @@ class Context:
116
118
  Attributes:
117
119
  name_prefix (str): The name of the context, will be appended with a unique ID.
118
120
  description (str): A description of the context.
119
- labels (dict): Labels associated with the context.
121
+ labels (Optional[List[str]]): Labels associated with the context.
120
122
  env (list): List of variables associated with the context.
121
123
  hooks (dict): Hooks associated with the context.
122
124
  """
@@ -126,7 +128,7 @@ class Context:
126
128
  env: Optional[List[Variable]] = optional_field
127
129
  mounted_files: Optional[List[MountedFile]] = optional_field
128
130
  hooks: Optional[Dict[HookType, List[str]]] = optional_field
129
- labels: Optional[Dict[str, str]] = optional_field
131
+ labels: Optional[List[str]] = optional_field
130
132
 
131
133
 
132
134
  @pydantic_dataclass
@@ -137,14 +139,14 @@ class Webhook:
137
139
  Attributes:
138
140
  name_prefix (str): The name of the webhook, will be appended with a unique ID.
139
141
  endpoint (str): The URL endpoint for the webhook.
140
- labels (Optional[dict]): Labels associated with the webhook.
141
- secrets (Optional[list[Variable]]): List of secrets associated with the webhook.
142
+ labels (Optional[List[str]]): Labels associated with the webhook.
143
+ secret (str): the ID of the parameter where the webhook secret is retrieved from
142
144
  """
143
145
 
144
146
  name_prefix: str
145
147
  endpoint: str
146
- labels: Optional[Dict[str, str]] = optional_field
147
- secrets: Optional[List[Variable]] = optional_field
148
+ secretFromParameter: str
149
+ labels: Optional[List[str]] = optional_field
148
150
 
149
151
 
150
152
  @pydantic_dataclass
@@ -156,13 +158,13 @@ class Policy:
156
158
  name_prefix (str): The name of the policy, will be appended with a unique ID.
157
159
  type (str): The type of the policy (e.g., "terraform", "kubernetes").
158
160
  body (str): The body of the policy, typically a configuration or script.
159
- labels (Optional[dict[str, str]]): Labels associated with the policy.
161
+ labels (Optional[List[str]]): Labels associated with the policy.
160
162
  """
161
163
 
162
164
  name_prefix: str
163
165
  type: str
164
166
  body: str
165
- labels: Optional[Dict[str, str]] = optional_field
167
+ labels: Optional[List[str]] = optional_field
166
168
 
167
169
 
168
170
  @pydantic_dataclass
@@ -171,19 +173,21 @@ class PluginManifest:
171
173
  A class to represent the manifest of a Spacelift plugin.
172
174
 
173
175
  Attributes:
174
- name_prefix (str): The name of the plugin, will be appended with a unique ID.
176
+ name (str): The name of the plugin, will be appended with a unique ID.
175
177
  description (str): A description of the plugin.
176
178
  author (str): The author of the plugin.
179
+ labels (list[str]): List of labels for the plugin.
177
180
  parameters (list[Parameter]): List of parameters for the plugin.
178
181
  contexts (list[Context]): List of contexts for the plugin.
179
182
  webhooks (list[Webhook]): List of webhooks for the plugin.
180
183
  policies (list[Policy]): List of policies for the plugin.
181
184
  """
182
185
 
183
- name_prefix: str
186
+ name: str
184
187
  version: str
185
188
  description: str
186
189
  author: str
190
+ labels: Optional[List[str]] = optional_field
187
191
  parameters: Optional[List[Parameter]] = optional_field
188
192
  contexts: Optional[List[Context]] = optional_field
189
193
  webhooks: Optional[List[Webhook]] = optional_field
spaceforge/conftest.py ADDED
@@ -0,0 +1,89 @@
1
+ """Shared test fixtures for spaceforge tests."""
2
+
3
+ import os
4
+ import tempfile
5
+ from typing import Dict, Generator, List
6
+ from unittest.mock import Mock
7
+
8
+ import pytest
9
+
10
+ from spaceforge.plugin import SpaceforgePlugin
11
+
12
+
13
+ @pytest.fixture
14
+ def temp_dir() -> Generator[str, None, None]:
15
+ """Provide a temporary directory for test files."""
16
+ temp_dir = tempfile.mkdtemp()
17
+ yield temp_dir
18
+ import shutil
19
+
20
+ shutil.rmtree(temp_dir, ignore_errors=True)
21
+
22
+
23
+ @pytest.fixture
24
+ def test_plugin_content() -> str:
25
+ """Basic test plugin content."""
26
+ return """
27
+ from spaceforge import SpaceforgePlugin, Parameter
28
+
29
+ class TestPlugin(SpaceforgePlugin):
30
+ __plugin_name__ = "test"
31
+ __version__ = "1.0.0"
32
+ __author__ = "Test Author"
33
+
34
+ __parameters__ = [
35
+ Parameter(
36
+ name="test_param",
37
+ description="Test parameter",
38
+ required=False,
39
+ default="default_value"
40
+ )
41
+ ]
42
+
43
+ def after_plan(self) -> None:
44
+ pass
45
+ """
46
+
47
+
48
+ @pytest.fixture
49
+ def test_plugin_file(temp_dir: str, test_plugin_content: str) -> str:
50
+ """Create a test plugin file."""
51
+ plugin_path = os.path.join(temp_dir, "plugin.py")
52
+ with open(plugin_path, "w") as f:
53
+ f.write(test_plugin_content)
54
+ return plugin_path
55
+
56
+
57
+ @pytest.fixture
58
+ def mock_env() -> Dict[str, str]:
59
+ """Common test environment variables."""
60
+ return {
61
+ "SPACELIFT_API_TOKEN": "test_token",
62
+ "TF_VAR_spacelift_graphql_endpoint": "https://test.spacelift.io",
63
+ }
64
+
65
+
66
+ @pytest.fixture
67
+ def mock_api_response() -> Mock:
68
+ """Mock API response for testing."""
69
+ mock_response = Mock()
70
+ mock_response.read.return_value = b'{"data": {"test": "result"}}'
71
+ return mock_response
72
+
73
+
74
+ class ExampleTestPlugin(SpaceforgePlugin):
75
+ """Reusable test plugin class."""
76
+
77
+ __plugin_name__ = "example"
78
+ __version__ = "1.0.0"
79
+ __author__ = "Test"
80
+
81
+ def __init__(self) -> None:
82
+ super().__init__()
83
+ self.hook_calls: List[str] = []
84
+
85
+ def after_plan(self) -> None:
86
+ self.hook_calls.append("after_plan")
87
+
88
+ def before_apply(self) -> None:
89
+ self.hook_calls.append("before_apply")
spaceforge/generator.py CHANGED
@@ -4,9 +4,10 @@ YAML generator for Spacelift plugins.
4
4
 
5
5
  import importlib.util
6
6
  import os
7
- from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type
7
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union
8
8
 
9
9
  import yaml
10
+ from jinja2 import Environment, PackageLoader, select_autoescape
10
11
 
11
12
  if TYPE_CHECKING:
12
13
  from .plugin import SpaceforgePlugin
@@ -46,6 +47,10 @@ class PluginGenerator:
46
47
  self.plugin_class: Optional[Type[SpaceforgePlugin]] = None
47
48
  self.plugin_instance: Optional[SpaceforgePlugin] = None
48
49
  self.plugin_working_directory: Optional[str] = None
50
+ self.config: Optional[Dict[str, Any]] = None
51
+ self.jinja = Environment(
52
+ loader=PackageLoader("spaceforge"), autoescape=select_autoescape()
53
+ )
49
54
 
50
55
  def load_plugin(self) -> None:
51
56
  """Load the plugin class from the specified path."""
@@ -80,8 +85,15 @@ class PluginGenerator:
80
85
  self.plugin_working_directory = (
81
86
  "/mnt/workspace/plugins/" + plugin_class.__plugin_name__.lower()
82
87
  )
88
+ self.config = {
89
+ "setup_virtual_env": (
90
+ f"cd {self.plugin_working_directory} && python -m venv ./venv && "
91
+ + "source venv/bin/activate && (command -v spaceforge &> /dev/null || pip install spaceforge)"
92
+ ),
93
+ "plugin_mounted_path": f"{self.plugin_working_directory}/{os.path.basename(self.plugin_path)}",
94
+ }
83
95
 
84
- def get_plugin_metadata(self) -> Dict[str, str]:
96
+ def get_plugin_metadata(self) -> Dict[str, Union[str, List[str]]]:
85
97
  """Extract metadata from the plugin class."""
86
98
  if self.plugin_class is None:
87
99
  raise ValueError("Plugin class not loaded. Call load_plugin() first.")
@@ -97,6 +109,7 @@ class PluginGenerator:
97
109
  self.plugin_class.__name__.lower().replace("plugin", ""),
98
110
  ),
99
111
  "version": getattr(self.plugin_class, "__version__", "1.0.0"),
112
+ "labels": getattr(self.plugin_class, "__labels__", []),
100
113
  "description": doc
101
114
  or f"A Spacelift plugin built with {self.plugin_class.__name__}",
102
115
  "author": getattr(self.plugin_class, "__author__", "Unknown"),
@@ -128,22 +141,27 @@ class PluginGenerator:
128
141
 
129
142
  return hook_methods
130
143
 
131
- def get_plugin_contexts(self) -> List[Context]:
132
- """Get context definitions from the plugin class."""
133
-
134
- change_to_working_directory = f"cd {self.plugin_working_directory}"
135
- hooks = {
136
- "before_init": [
137
- f"mkdir -p {self.plugin_working_directory}",
138
- ],
139
- }
140
- mounted_files = []
141
-
142
- # Add a virtual environment before_init hook if requirements.txt exists
143
- if os.path.exists("requirements.txt"):
144
- hooks["before_init"].append(
145
- f"{change_to_working_directory} && python -m venv ./venv && source venv/bin/activate && pip install -r requirements.txt"
144
+ def _add_to_mounted_files(
145
+ self,
146
+ hooks: Dict[str, List[str]],
147
+ mounted_files: List[MountedFile],
148
+ phase: str,
149
+ filepath: str,
150
+ filecontent: str,
151
+ ) -> None:
152
+ file = f"{self.plugin_working_directory}/{filepath}"
153
+ hooks[phase].append(f"chmod +x {file} && {file}")
154
+ mounted_files.append(
155
+ MountedFile(
156
+ path=f"{self.plugin_working_directory}/{filepath}",
157
+ content=filecontent,
158
+ sensitive=False,
146
159
  )
160
+ )
161
+
162
+ def _update_with_requirements(self, mounted_files: List[MountedFile]) -> None:
163
+ """Update the plugin hooks if there is a requirements.txt"""
164
+ if os.path.exists("requirements.txt") and self.config is not None:
147
165
  # read the requirements.txt file
148
166
  with open("requirements.txt", "r") as f:
149
167
  mounted_files.append(
@@ -154,33 +172,80 @@ class PluginGenerator:
154
172
  )
155
173
  )
156
174
 
157
- # Ensure the plugin file itself is mounted
158
- plugin_mounted_path = (
159
- f"{self.plugin_working_directory}/{os.path.basename(self.plugin_path)}"
160
- )
161
- if os.path.exists(self.plugin_path):
175
+ def _update_with_python_file(self, mounted_files: List[MountedFile]) -> None:
176
+ """Ensure the plugin file itself is mounted."""
177
+ if os.path.exists(self.plugin_path) and self.config is not None:
162
178
  with open(self.plugin_path, "r") as f:
163
179
  mounted_files.append(
164
180
  MountedFile(
165
- path=plugin_mounted_path,
181
+ path=self.config["plugin_mounted_path"],
166
182
  content=f.read(),
167
183
  sensitive=False,
168
184
  )
169
185
  )
170
186
 
171
- binary_cmd = self.generate_binary_install_command()
172
- if binary_cmd != "":
173
- hooks["before_init"].append(binary_cmd)
174
-
187
+ def _add_spaceforge_hooks(
188
+ self, hooks: Dict[str, List[str]], mounted_files: List[MountedFile]
189
+ ) -> None:
175
190
  # Add the spaceforge hook to actually run the plugin
191
+ if self.config is None:
192
+ raise ValueError("Plugin config not set. Call load_plugin() first.")
193
+
176
194
  available_hooks = self.get_available_hooks()
177
195
  for hook in available_hooks:
178
196
  # Ensure the hook exists in the first context
179
197
  if hook not in hooks:
180
198
  hooks[hook] = []
181
- hooks[hook].append(
182
- f"{change_to_working_directory} && python -m spaceforge runner --plugin-file {plugin_mounted_path} {hook}"
199
+
200
+ directory = os.path.dirname(self.config["plugin_mounted_path"])
201
+ template = self.jinja.get_template("ensure_spaceforge_and_run.sh.j2")
202
+ render = template.render(
203
+ plugin_path=directory,
204
+ plugin_file=self.config["plugin_mounted_path"],
205
+ phase=hook,
183
206
  )
207
+ self._add_to_mounted_files(hooks, mounted_files, hook, f"{hook}.sh", render)
208
+
209
+ def _map_variables_to_parameters(self, contexts: List[Context]) -> None:
210
+ for context in contexts:
211
+ # Get the variables from the plugin and change the value_from_parameter to the ID of the parameter
212
+ # based on its name.
213
+ if context.env is None:
214
+ continue
215
+
216
+ for variable in context.env:
217
+ if variable.value_from_parameter:
218
+ parameter_name = variable.value_from_parameter
219
+ parameters = self.get_plugin_parameters()
220
+ if parameters:
221
+ parameter = next(
222
+ (
223
+ p
224
+ for p in parameters
225
+ if p.name == parameter_name or p.id == parameter_name
226
+ ),
227
+ None,
228
+ )
229
+ if parameter:
230
+ variable.value_from_parameter = parameter.id
231
+ else:
232
+ raise ValueError(
233
+ f"Parameter {parameter_name} not found for variable {variable.key}"
234
+ )
235
+
236
+ def get_plugin_contexts(self) -> List[Context]:
237
+ """Get context definitions from the plugin class."""
238
+
239
+ f"cd {self.plugin_working_directory}"
240
+ hooks: Dict[str, List[str]] = {
241
+ "before_init": [f"mkdir -p {self.plugin_working_directory}"]
242
+ }
243
+ mounted_files: List[MountedFile] = []
244
+
245
+ self._update_with_requirements(mounted_files)
246
+ self._update_with_python_file(mounted_files)
247
+ self._generate_binary_install_command(hooks, mounted_files)
248
+ self._add_spaceforge_hooks(hooks, mounted_files)
184
249
 
185
250
  # Get the contexts and append the hooks and mounted files to it.
186
251
  if self.plugin_class is None:
@@ -208,42 +273,41 @@ class PluginGenerator:
208
273
  contexts[0].hooks.update(hooks)
209
274
  contexts[0].mounted_files.extend(mounted_files)
210
275
 
276
+ self._map_variables_to_parameters(contexts)
277
+
211
278
  return contexts
212
279
 
213
- def generate_binary_install_command(self) -> str:
280
+ def _generate_binary_install_command(
281
+ self, hooks: Dict[str, List[str]], mounted_files: List[MountedFile]
282
+ ) -> None:
214
283
  binaries = self.get_plugin_binaries()
215
284
  if binaries is None:
216
- return ""
285
+ return None
217
286
 
218
- binary_cmd = ""
219
- if len(binaries) > 0:
220
- binary_cmd = f"mkdir -p {static_binary_directory} && cd {static_binary_directory} && "
221
287
  for i, binary in enumerate(binaries):
222
288
  amd64_url = binary.download_urls.get("amd64", None)
223
289
  arm64_url = binary.download_urls.get("arm64", None)
290
+ binary_path = f"{static_binary_directory}/{binary.name}"
224
291
  if amd64_url is None and arm64_url is None:
225
292
  raise ValueError(
226
293
  f"Binary {binary.name} must have at least one download URL defined (amd64 or arm64)"
227
294
  )
228
295
 
229
- binary_path = f"{static_binary_directory}/{binary.name}"
230
- amd64_download_command = (
231
- f"curl {amd64_url} -o {binary_path} -L && chmod +x {binary_path}"
232
- if amd64_url is not None
233
- else "echo 'amd64 binary not available' && exit 1"
234
- )
235
- arm64_download_command = (
236
- f"curl {arm64_url} -o {binary_path} -L && chmod +x {binary_path}"
237
- if arm64_url is not None
238
- else "echo 'arm64 binary not available' && exit 1"
296
+ template = self.jinja.get_template("binary_install.sh.j2")
297
+ render = template.render(
298
+ binary=binary,
299
+ amd64_url=amd64_url,
300
+ arm64_url=arm64_url,
301
+ binary_path=binary_path,
302
+ static_binary_directory=static_binary_directory,
239
303
  )
240
-
241
- binary_cmd += '([[ "$(echo "$(arch)")" == "x86_64" ]] && {} || {})'.format(
242
- amd64_download_command, arm64_download_command
304
+ self._add_to_mounted_files(
305
+ hooks,
306
+ mounted_files,
307
+ "before_init",
308
+ f"binary_install_{binary.name}.sh",
309
+ render,
243
310
  )
244
- if i < len(binaries) - 1:
245
- binary_cmd += " && "
246
- return binary_cmd
247
311
 
248
312
  def get_plugin_binaries(self) -> Optional[List[Binary]]:
249
313
  """Get binary definitions from the plugin class."""
@@ -265,10 +329,11 @@ class PluginGenerator:
265
329
  metadata = self.get_plugin_metadata()
266
330
 
267
331
  return PluginManifest(
268
- name_prefix=metadata.get("name_prefix", "unknown"),
269
- version=metadata.get("version", "1.0.0"),
270
- description=metadata.get("description", ""),
271
- author=metadata.get("author", "Unknown"),
332
+ name=str(metadata.get("name_prefix", "unknown")),
333
+ version=str(metadata.get("version", "1.0.0")),
334
+ description=str(metadata.get("description", "")),
335
+ author=str(metadata.get("author", "Unknown")),
336
+ labels=list(metadata.get("labels", [])),
272
337
  parameters=self.get_plugin_parameters(),
273
338
  contexts=self.get_plugin_contexts(),
274
339
  webhooks=self.get_plugin_webhooks(),