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.
spaceforge/README.md ADDED
@@ -0,0 +1,279 @@
1
+ # Spaceforge Framework
2
+
3
+ A Python framework for building Spacelift plugins with hook-based functionality.
4
+
5
+ ## Overview
6
+
7
+ Spaceforge provides a simple, declarative way to create Spacelift plugins by inheriting from the `SpaceforgePlugin` base class and implementing hook methods. The framework automatically handles parameter loading, logging, and YAML generation.
8
+
9
+ ## Architecture
10
+
11
+ ### Core Components
12
+
13
+ - **SpaceforgePlugin** (`plugin.py`) - Base class with hook methods and utilities
14
+ - **PluginRunner** (`runner.py`) - Executes hook methods, loads parameters from environment
15
+ - **PluginGenerator** (`generator.py`) - Analyzes Python plugins and generates `plugin.yaml`
16
+ - **CLI Interface** (`__main__.py`) - Click-based CLI with `generate` and `runner` subcommands
17
+ - **Pydantic Dataclasses** (`cls.py`) - Type-safe data structures with validation
18
+
19
+ ### Data Validation
20
+
21
+ The framework uses pydantic dataclasses for all plugin definitions, providing:
22
+ - **Type safety**: All plugin components are strongly typed
23
+ - **Automatic validation**: Data structures are validated during object creation
24
+ - **JSON schema generation**: Plugin manifests include JSON schema for validation
25
+ - **Runtime checks**: For example, Variables must have either `value` or `value_from_parameter`
26
+
27
+ ### Plugin Structure
28
+
29
+ ```python
30
+ from spaceforge import SpaceforgePlugin, Parameter, Variable, Context, Webhook, Policy, MountedFile
31
+
32
+ class MyPlugin(SpaceforgePlugin):
33
+ # Plugin metadata
34
+ __plugin_name__ = "my-plugin"
35
+ __version__ = "1.0.0"
36
+ __author__ = "Your Name"
37
+
38
+ # Parameter definitions using pydantic dataclasses
39
+ __parameters__ = [
40
+ Parameter(
41
+ name="api_key",
42
+ description="API key for authentication",
43
+ required=True,
44
+ sensitive=True
45
+ )
46
+ ]
47
+
48
+ # Context definitions using pydantic dataclasses
49
+ __contexts__ = [
50
+ Context(
51
+ name="main",
52
+ description="Main plugin context",
53
+ env=[
54
+ Variable(
55
+ key="API_KEY",
56
+ value_from_parameter="api_key",
57
+ sensitive=True
58
+ )
59
+ ],
60
+ hooks={
61
+ "after_plan": ["echo 'Custom command here'"]
62
+ },
63
+ mounted_files=[
64
+ MountedFile(
65
+ path="config.yaml",
66
+ content="key: value",
67
+ sensitive=False
68
+ )
69
+ ]
70
+ )
71
+ ]
72
+
73
+ # Webhook definitions using pydantic dataclasses
74
+ __webhooks__ = [
75
+ Webhook(
76
+ name="my_webhook",
77
+ endpoint="https://example.com/webhook",
78
+ secrets=[
79
+ Variable(
80
+ key="WEBHOOK_SECRET",
81
+ value_from_parameter="api_key"
82
+ )
83
+ ]
84
+ )
85
+ ]
86
+
87
+ # Policy definitions using pydantic dataclasses
88
+ __policies__ = [
89
+ Policy(
90
+ name="my_policy",
91
+ type="notification",
92
+ body="package spacelift\n# Policy content here",
93
+ labels={"type": "security"}
94
+ )
95
+ ]
96
+
97
+ def after_plan(self):
98
+ self.logger.info("Running after plan")
99
+ # Your plugin logic here
100
+ ```
101
+
102
+ ## Available Hooks
103
+
104
+ Override these methods in your plugin:
105
+
106
+ - `before_init()` - Before Terraform init
107
+ - `after_init()` - After Terraform init
108
+ - `before_plan()` - Before Terraform plan
109
+ - `after_plan()` - After Terraform plan
110
+ - `before_apply()` - Before Terraform apply
111
+ - `after_apply()` - After Terraform apply
112
+ - `before_perform()` - Before the run performs
113
+ - `after_perform()` - After the run performs
114
+ - `before_destroy()` - Before Terraform destroy
115
+ - `after_destroy()` - After Terraform destroy
116
+ - `after_run()` - After the run completes
117
+
118
+ ## Plugin Features
119
+
120
+ ### Logging
121
+
122
+ Built-in colored logging with run ID and plugin name:
123
+
124
+ ```python
125
+ self.logger.info("Information message")
126
+ self.logger.debug("Debug message") # Only shown when SPACELIFT_DEBUG=true
127
+ self.logger.warning("Warning message")
128
+ self.logger.error("Error message")
129
+ ```
130
+
131
+ ### CLI Execution
132
+
133
+ Run external commands with logging:
134
+
135
+ ```python
136
+ self.run_cli("terraform", "plan", "-out=tfplan")
137
+ ```
138
+
139
+ ### Spacelift API Integration
140
+
141
+ Query the Spacelift GraphQL API:
142
+
143
+ ```python
144
+ # Requires SPACELIFT_API_TOKEN and SPACELIFT_DOMAIN environment variables
145
+ result = self.query_api("""
146
+ query {
147
+ stack(id: "stack-id") {
148
+ name
149
+ state
150
+ }
151
+ }
152
+ """)
153
+ ```
154
+
155
+ ### Plan and State Access
156
+
157
+ Access Terraform plan and state data:
158
+
159
+ ```python
160
+ plan = self.get_plan_json() # Returns parsed spacelift.plan.json
161
+ state = self.get_state_before_json() # Returns parsed spacelift.state.before.json
162
+ ```
163
+
164
+ ## Parameter System
165
+
166
+ Parameters are defined using the `Parameter` pydantic dataclass and automatically loaded from environment variables:
167
+
168
+ ```python
169
+ from spaceforge import Parameter
170
+
171
+ __parameters__ = [
172
+ Parameter(
173
+ name="database_url",
174
+ description="Database connection URL",
175
+ required=True,
176
+ sensitive=True,
177
+ default="postgresql://localhost:5432/mydb"
178
+ )
179
+ ]
180
+ ```
181
+
182
+ Parameters are interpolated by spacelift at install time, allowing you to reference them in contexts and hooks using `${param.name}` syntax.
183
+
184
+ ## Context System
185
+
186
+ Contexts define Spacelift environments using the `Context` pydantic dataclass:
187
+
188
+ ```python
189
+ from spaceforge import Context, Variable, MountedFile
190
+
191
+ __contexts__ = [
192
+ Context(
193
+ name="production",
194
+ description="Production environment",
195
+ labels={
196
+ "environment": "production"
197
+ },
198
+ env=[
199
+ Variable(
200
+ key="DATABASE_URL",
201
+ value_from_parameter="database_url",
202
+ sensitive=True
203
+ )
204
+ ],
205
+ hooks={
206
+ "after_plan": [
207
+ "echo 'Running production validation'"
208
+ ]
209
+ },
210
+ mounted_files=[
211
+ MountedFile(
212
+ path="app.conf",
213
+ content="production config",
214
+ sensitive=False
215
+ )
216
+ ]
217
+ )
218
+ ]
219
+ ```
220
+
221
+ **Important**: Variables must have either a `value` or `value_from_parameter` field. The framework automatically validates this during plugin generation.
222
+
223
+ ## CLI Usage
224
+
225
+ ### Generate Plugin YAML
226
+
227
+ ```bash
228
+ # Generate from plugin.py (default)
229
+ python -m spaceforge generate
230
+
231
+ # Generate from specific file
232
+ python -m spaceforge generate my_plugin.py
233
+
234
+ # Specify output file
235
+ python -m spaceforge generate my_plugin.py -o my_plugin.yaml
236
+ ```
237
+
238
+ ### Test Plugin Hooks
239
+
240
+ ```bash
241
+ # Set plugin parameters
242
+ export API_KEY="your-key"
243
+
244
+ # Run specific hook
245
+ python -m spaceforge runner after_plan
246
+
247
+ # Run with specific plugin file
248
+ python -m spaceforge runner --plugin-file my_plugin.py before_apply
249
+ ```
250
+
251
+ ### Get Help
252
+
253
+ ```bash
254
+ python -m spaceforge --help
255
+ python -m spaceforge generate --help
256
+ python -m spaceforge runner --help
257
+ ```
258
+
259
+ ## Generated YAML Structure
260
+
261
+ The framework automatically generates standard Spacelift plugin YAML:
262
+
263
+ ## Development Tips
264
+
265
+ 1. **Requirements**: If your plugin has dependencies, create a `requirements.txt` file. The generator will automatically add a `before_init` hook to install them.
266
+
267
+ 2. **Testing**: Use the runner command to test individual hooks during development.
268
+
269
+ 3. **Debugging**: Set `SPACELIFT_DEBUG=true` to enable debug logging.
270
+
271
+ 4. **API Access**: Export `SPACELIFT_API_TOKEN` and `SPACELIFT_DOMAIN` to enable Spacelift API queries.
272
+
273
+ ## Error Handling
274
+
275
+ The framework provides built-in error handling:
276
+ - Failed CLI commands are logged with return codes
277
+ - API errors are logged and returned in the response
278
+ - Missing files and import errors are handled gracefully
279
+ - Hook execution errors are caught and re-raised with context
spaceforge/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """
2
+ Spaceforge - Spacelift Plugin Framework
3
+
4
+ A Python framework for building Spacelift plugins with hook-based functionality.
5
+ """
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
11
+
12
+ __version__ = get_version()
13
+ __all__ = [
14
+ "SpaceforgePlugin",
15
+ "PluginRunner",
16
+ "Parameter",
17
+ "Variable",
18
+ "Context",
19
+ "Webhook",
20
+ "Policy",
21
+ "MountedFile",
22
+ "Binary",
23
+ ]
spaceforge/__main__.py ADDED
@@ -0,0 +1,33 @@
1
+ """
2
+ Main entry point for spaceforge module.
3
+ """
4
+
5
+ import click
6
+
7
+ from ._version import get_version
8
+ from .generator import generate_command
9
+ from .runner import runner_command
10
+
11
+
12
+ @click.group()
13
+ @click.version_option(version=get_version(), prog_name="spaceforge")
14
+ def cli() -> None:
15
+ """Spaceforge - Spacelift Plugin Framework
16
+
17
+ A Python framework for building Spacelift plugins with hook-based functionality.
18
+ """
19
+ pass
20
+
21
+
22
+ # Add subcommands
23
+ cli.add_command(generate_command)
24
+ cli.add_command(runner_command)
25
+
26
+
27
+ def main() -> None:
28
+ """Main entry point."""
29
+ cli()
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
spaceforge/_version.py ADDED
@@ -0,0 +1,81 @@
1
+ """
2
+ Dynamic version detection from git tags.
3
+ """
4
+
5
+ import subprocess
6
+ import sys
7
+ from typing import Optional
8
+
9
+
10
+ def get_git_version() -> Optional[str]:
11
+ """
12
+ Get version from git tags.
13
+
14
+ Returns:
15
+ Version string (without 'v' prefix) or None if not available
16
+ """
17
+ try:
18
+ # Try to get the current tag
19
+ result = subprocess.run(
20
+ ["git", "describe", "--tags", "--exact-match"],
21
+ capture_output=True,
22
+ text=True,
23
+ check=True,
24
+ )
25
+ tag = result.stdout.strip()
26
+ # Remove 'v' prefix if present
27
+ return tag[1:] if tag.startswith("v") else tag
28
+ except (subprocess.CalledProcessError, FileNotFoundError):
29
+ # Fall back to describe with commit info
30
+ try:
31
+ result = subprocess.run(
32
+ ["git", "describe", "--tags", "--always"],
33
+ capture_output=True,
34
+ text=True,
35
+ check=True,
36
+ )
37
+ tag = result.stdout.strip()
38
+ # Remove 'v' prefix if present
39
+ return tag[1:] if tag.startswith("v") else tag
40
+ except (subprocess.CalledProcessError, FileNotFoundError):
41
+ return None
42
+
43
+
44
+ def get_version() -> str:
45
+ """
46
+ Get the package version.
47
+
48
+ Tries git tags first, then setuptools-scm, falls back to default version.
49
+
50
+ Returns:
51
+ Version string
52
+ """
53
+ # Try git version first
54
+ git_version = get_git_version()
55
+ if git_version:
56
+ return git_version
57
+
58
+ # Try setuptools-scm generated version file
59
+ try:
60
+ from ._version_scm import version # type: ignore[import-not-found]
61
+
62
+ return str(version)
63
+ except ImportError:
64
+ pass
65
+
66
+ # Try setuptools-scm directly
67
+ try:
68
+ from setuptools_scm import (
69
+ get_version as scm_get_version, # type: ignore[import-untyped]
70
+ )
71
+
72
+ result = scm_get_version(root="..", relative_to=__file__)
73
+ return str(result)
74
+ except ImportError:
75
+ pass
76
+ except Exception:
77
+ # setuptools_scm might fail in various ways, ignore
78
+ pass
79
+
80
+ # Fall back to default version for development
81
+ return "0.1.0-dev"
spaceforge/cls.py ADDED
@@ -0,0 +1,198 @@
1
+ from typing import Dict, List, Literal, Optional
2
+
3
+ from pydantic import Field
4
+ from pydantic.dataclasses import dataclass as pydantic_dataclass
5
+
6
+ # For truly optional fields without default: null in schema
7
+ optional_field = Field(default_factory=lambda: None, exclude=True)
8
+
9
+ BinaryType = Literal[
10
+ "amd64",
11
+ "arm64",
12
+ ]
13
+
14
+
15
+ @pydantic_dataclass
16
+ class Binary:
17
+ """
18
+ A class to represent a binary file.
19
+
20
+ Attributes:
21
+ 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.
24
+ """
25
+
26
+ name: str
27
+ download_urls: Dict[BinaryType, str]
28
+
29
+
30
+ @pydantic_dataclass
31
+ class Parameter:
32
+ """
33
+ A class to represent a parameter with a name and value.
34
+
35
+ Attributes:
36
+ name (str): The name of the parameter.
37
+ description (str): A description of the parameter.
38
+ sensitive (bool): Whether the parameter contains sensitive information.
39
+ required (bool): Whether the parameter is required.
40
+ default (Optional[str]): The default value of the parameter, if any. (required if sensitive is False)
41
+ """
42
+
43
+ name: str
44
+ description: str
45
+ sensitive: bool = False
46
+ required: bool = False
47
+ default: Optional[str] = None
48
+
49
+ def __post_init__(self) -> None:
50
+ if not self.required and self.default is None:
51
+ raise ValueError(
52
+ f"Default value for parameter {self.name} should be set if parameter is optional."
53
+ )
54
+
55
+
56
+ @pydantic_dataclass
57
+ class Variable:
58
+ """
59
+ A class to represent an environment variable.
60
+
61
+ Attributes:
62
+ key (str): The key of the environment variable.
63
+ value (Optional[str]): The value of the environment variable, if set.
64
+ value_from_parameter (Optional[str]): The name of the plugin variable to use as the value.
65
+ sensitive (bool): Whether the environment variable is sensitive.
66
+ """
67
+
68
+ key: str
69
+ value: Optional[str] = optional_field
70
+ value_from_parameter: Optional[str] = optional_field
71
+ sensitive: bool = False
72
+
73
+ def __post_init__(self) -> None:
74
+ if self.value is None and self.value_from_parameter is None:
75
+ raise ValueError(
76
+ "Either value or value_from_parameter must be set for EnvVariable."
77
+ )
78
+
79
+
80
+ @pydantic_dataclass
81
+ class MountedFile:
82
+ """
83
+ A class to represent a mounted file.
84
+
85
+ Attributes:
86
+ path (str): The path of the mounted file.
87
+ content (str): The content of the mounted file.
88
+ sensitive (bool): Whether the content of the file is sensitive.
89
+ """
90
+
91
+ path: str
92
+ content: str
93
+ sensitive: bool = False
94
+
95
+
96
+ HookType = Literal[
97
+ "before_init",
98
+ "after_init",
99
+ "before_plan",
100
+ "after_plan",
101
+ "before_apply",
102
+ "after_apply",
103
+ "before_perform",
104
+ "after_perform",
105
+ "before_destroy",
106
+ "after_destroy",
107
+ "after_run",
108
+ ]
109
+
110
+
111
+ @pydantic_dataclass
112
+ class Context:
113
+ """
114
+ A class to represent a context for a plugin.
115
+
116
+ Attributes:
117
+ name_prefix (str): The name of the context, will be appended with a unique ID.
118
+ description (str): A description of the context.
119
+ labels (dict): Labels associated with the context.
120
+ env (list): List of variables associated with the context.
121
+ hooks (dict): Hooks associated with the context.
122
+ """
123
+
124
+ name_prefix: str
125
+ description: str
126
+ env: Optional[List[Variable]] = optional_field
127
+ mounted_files: Optional[List[MountedFile]] = optional_field
128
+ hooks: Optional[Dict[HookType, List[str]]] = optional_field
129
+ labels: Optional[Dict[str, str]] = optional_field
130
+
131
+
132
+ @pydantic_dataclass
133
+ class Webhook:
134
+ """
135
+ A class to represent a webhook configuration.
136
+
137
+ Attributes:
138
+ name_prefix (str): The name of the webhook, will be appended with a unique ID.
139
+ 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
+ """
143
+
144
+ name_prefix: str
145
+ endpoint: str
146
+ labels: Optional[Dict[str, str]] = optional_field
147
+ secrets: Optional[List[Variable]] = optional_field
148
+
149
+
150
+ @pydantic_dataclass
151
+ class Policy:
152
+ """
153
+ A class to represent a policy configuration.
154
+
155
+ Attributes:
156
+ name_prefix (str): The name of the policy, will be appended with a unique ID.
157
+ type (str): The type of the policy (e.g., "terraform", "kubernetes").
158
+ body (str): The body of the policy, typically a configuration or script.
159
+ labels (Optional[dict[str, str]]): Labels associated with the policy.
160
+ """
161
+
162
+ name_prefix: str
163
+ type: str
164
+ body: str
165
+ labels: Optional[Dict[str, str]] = optional_field
166
+
167
+
168
+ @pydantic_dataclass
169
+ class PluginManifest:
170
+ """
171
+ A class to represent the manifest of a Spacelift plugin.
172
+
173
+ Attributes:
174
+ name_prefix (str): The name of the plugin, will be appended with a unique ID.
175
+ description (str): A description of the plugin.
176
+ author (str): The author of the plugin.
177
+ parameters (list[Parameter]): List of parameters for the plugin.
178
+ contexts (list[Context]): List of contexts for the plugin.
179
+ webhooks (list[Webhook]): List of webhooks for the plugin.
180
+ policies (list[Policy]): List of policies for the plugin.
181
+ """
182
+
183
+ name_prefix: str
184
+ version: str
185
+ description: str
186
+ author: str
187
+ parameters: Optional[List[Parameter]] = optional_field
188
+ contexts: Optional[List[Context]] = optional_field
189
+ webhooks: Optional[List[Webhook]] = optional_field
190
+ policies: Optional[List[Policy]] = optional_field
191
+
192
+
193
+ if __name__ == "__main__":
194
+ import json
195
+
196
+ from pydantic import TypeAdapter
197
+
198
+ print(json.dumps(TypeAdapter(PluginManifest).json_schema(), indent=2))
spaceforge/cls_test.py ADDED
@@ -0,0 +1,17 @@
1
+ import pytest
2
+
3
+ from spaceforge import Parameter, Variable
4
+
5
+
6
+ def test_ensure_optional_parameters_require_default_values() -> None:
7
+ with pytest.raises(ValueError):
8
+ Parameter(
9
+ name="optional_default",
10
+ description="default value",
11
+ required=False,
12
+ )
13
+
14
+
15
+ def test_ensure_variables_have_either_value_or_value_from_parameter() -> None:
16
+ with pytest.raises(ValueError):
17
+ Variable(key="test_var", sensitive=False)