xenfra-sdk 0.2.2__py3-none-any.whl → 0.2.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.
@@ -1,248 +1,278 @@
1
- import json
2
- import logging
3
- from typing import Iterator
4
-
5
- # Import Deployment model when it's defined in models.py
6
- # from ..models import Deployment
7
- from ..exceptions import XenfraAPIError, XenfraError # Add XenfraError
8
- from ..utils import safe_get_json_field, safe_json_parse
9
- from .base import BaseManager
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- class DeploymentsManager(BaseManager):
15
- def create(self, project_name: str, git_repo: str, branch: str, framework: str, region: str = None, size_slug: str = None, is_dockerized: bool = True) -> dict:
16
- """Creates a new deployment."""
17
- try:
18
- payload = {
19
- "project_name": project_name,
20
- "git_repo": git_repo,
21
- "branch": branch,
22
- "framework": framework,
23
- }
24
- if region:
25
- payload["region"] = region
26
- if size_slug:
27
- payload["size_slug"] = size_slug
28
- if is_dockerized is not None:
29
- payload["is_dockerized"] = is_dockerized
30
-
31
- response = self._client._request("POST", "/deployments", json=payload)
32
- # Safe JSON parsing
33
- return safe_json_parse(response)
34
- except XenfraAPIError:
35
- raise
36
- except Exception as e:
37
- raise XenfraError(f"Failed to create deployment: {e}")
38
-
39
- def get_status(self, deployment_id: str) -> dict:
40
- """Get status for a specific deployment.
41
-
42
- Args:
43
- deployment_id: The unique identifier for the deployment.
44
-
45
- Returns:
46
- dict: Deployment status information including state, progress, etc.
47
-
48
- Raises:
49
- XenfraAPIError: If the API returns an error (e.g., 404 not found).
50
- XenfraError: If there's a network or parsing error.
51
- """
52
- try:
53
- response = self._client._request("GET", f"/deployments/{deployment_id}/status")
54
- logger.debug(
55
- f"DeploymentsManager.get_status({deployment_id}) response: {response.status_code}"
56
- )
57
- # Safe JSON parsing - _request() already handles status codes
58
- return safe_json_parse(response)
59
- except XenfraAPIError:
60
- raise # Re-raise API errors
61
- except Exception as e:
62
- raise XenfraError(f"Failed to get status for deployment {deployment_id}: {e}")
63
-
64
- def get_logs(self, deployment_id: str) -> str:
65
- """Get logs for a specific deployment.
66
-
67
- Args:
68
- deployment_id: The unique identifier for the deployment.
69
-
70
- Returns:
71
- str: The deployment logs as plain text.
72
-
73
- Raises:
74
- XenfraAPIError: If the API returns an error (e.g., 404 not found).
75
- XenfraError: If there's a network or parsing error.
76
- """
77
- try:
78
- response = self._client._request("GET", f"/deployments/{deployment_id}/logs")
79
- logger.debug(
80
- f"DeploymentsManager.get_logs({deployment_id}) response: {response.status_code}"
81
- )
82
-
83
- # Safe JSON parsing with structure validation - _request() already handles status codes
84
- data = safe_json_parse(response)
85
- if not isinstance(data, dict):
86
- raise XenfraError(f"Expected dictionary response, got {type(data).__name__}")
87
-
88
- logs = safe_get_json_field(data, "logs", "")
89
-
90
- if not logs:
91
- logger.warning(f"No logs found for deployment {deployment_id}")
92
-
93
- return logs
94
-
95
- except XenfraAPIError:
96
- raise # Re-raise API errors
97
- except Exception as e:
98
- raise XenfraError(f"Failed to get logs for deployment {deployment_id}: {e}")
99
-
100
- def create_stream(self, project_name: str, git_repo: str, branch: str, framework: str, region: str = None, size_slug: str = None, is_dockerized: bool = True, port: int = None, command: str = None, entrypoint: str = None, database: str = None, package_manager: str = None, dependency_file: str = None, file_manifest: list = None) -> Iterator[dict]:
101
- """
102
- Creates a new deployment with real-time SSE log streaming.
103
-
104
- Yields SSE events as dictionaries with 'event' and 'data' keys.
105
-
106
- Args:
107
- project_name: Name of the project
108
- git_repo: Git repository URL (optional if file_manifest provided)
109
- branch: Git branch to deploy
110
- framework: Framework type (fastapi, flask, django)
111
- region: DigitalOcean region (optional)
112
- size_slug: DigitalOcean droplet size (optional)
113
- is_dockerized: Whether to use Docker (optional)
114
- port: Application port (optional, default 8000)
115
- command: Start command (optional, auto-detected if not provided)
116
- entrypoint: Application entrypoint (optional, e.g. 'todo.main:app')
117
- database: Database type (optional, e.g. 'postgres')
118
- package_manager: Package manager (optional, e.g. 'pip', 'uv')
119
- dependency_file: Dependency file (optional, e.g. 'requirements.txt')
120
- file_manifest: List of files for delta upload [{path, sha, size}, ...]
121
-
122
- Yields:
123
- dict: SSE events with 'event' and 'data' fields
124
-
125
- Example:
126
- for event in client.deployments.create_stream(...):
127
- if event['event'] == 'log':
128
- print(event['data'])
129
- elif event['event'] == 'deployment_complete':
130
- print("Done!")
131
- """
132
- payload = {
133
- "project_name": project_name,
134
- "git_repo": git_repo,
135
- "branch": branch,
136
- "framework": framework,
137
- }
138
- if region:
139
- payload["region"] = region
140
- if size_slug:
141
- payload["size_slug"] = size_slug
142
- if is_dockerized is not None:
143
- payload["is_dockerized"] = is_dockerized
144
- if port:
145
- payload["port"] = port
146
- if command:
147
- payload["command"] = command
148
- if entrypoint:
149
- payload["entrypoint"] = entrypoint
150
- if database:
151
- payload["database"] = database
152
- if package_manager:
153
- payload["package_manager"] = package_manager
154
- if dependency_file:
155
- payload["dependency_file"] = dependency_file
156
- if file_manifest:
157
- payload["file_manifest"] = file_manifest
158
-
159
-
160
- try:
161
- # Use httpx to stream the SSE response
162
- import httpx
163
- import os
164
-
165
- headers = {
166
- "Authorization": f"Bearer {self._client._token}",
167
- "Accept": "text/event-stream",
168
- "Content-Type": "application/json",
169
- }
170
-
171
- # Use streaming API URL if available (bypasses Cloudflare timeout)
172
- # Otherwise fall back to regular API URL
173
- streaming_api_url = os.getenv("XENFRA_STREAMING_API_URL")
174
- if streaming_api_url:
175
- base_url = streaming_api_url
176
- else:
177
- # Local/dev/production: use regular API URL
178
- base_url = self._client.api_url
179
-
180
- url = f"{base_url}/deployments/stream"
181
-
182
- with httpx.stream(
183
- "POST",
184
- url,
185
- json=payload,
186
- headers=headers,
187
- timeout=600.0, # 10 minute timeout for deployments
188
- ) as response:
189
- # Check status before consuming stream
190
- if response.status_code not in [200, 201, 202]:
191
- # For error responses from streaming endpoint, read via iteration
192
- error_text = ""
193
- try:
194
- for chunk in response.iter_bytes():
195
- error_text += chunk.decode('utf-8', errors='ignore')
196
- if len(error_text) > 1000: # Limit error message size
197
- break
198
- if not error_text:
199
- error_text = "Unknown error"
200
- except Exception as e:
201
- error_text = f"Could not read error response: {e}"
202
-
203
- raise XenfraAPIError(
204
- status_code=response.status_code,
205
- detail=f"Deployment failed: {error_text}"
206
- )
207
-
208
- # Parse SSE events
209
- current_event = None # Initialize before loop
210
- for line in response.iter_lines():
211
- # No need to explicitly decode if iter_lines is used on a decoded response,
212
- # but if it returns bytes, we decode it.
213
- if isinstance(line, bytes):
214
- line = line.decode('utf-8', errors='ignore')
215
-
216
- line = line.strip()
217
- if not line:
218
- continue
219
-
220
- # SSE format: "event: eventname" or "data: eventdata"
221
- if line.startswith("event:"):
222
- current_event = line[6:].strip()
223
- elif line.startswith("data:"):
224
- data = line[5:].strip()
225
-
226
- # Get event type (default to "message" if no event line was sent)
227
- event_type = current_event if current_event is not None else "message"
228
-
229
- # Skip keep-alive events (used to prevent proxy timeouts)
230
- if event_type == "keep-alive":
231
- current_event = None
232
- continue
233
-
234
- try:
235
- # Try to parse as JSON
236
- data_parsed = json.loads(data)
237
- yield {"event": event_type, "data": data_parsed}
238
- except json.JSONDecodeError:
239
- # If not JSON, yield as plain text
240
- yield {"event": event_type, "data": data}
241
-
242
- # Reset current_event after yielding
243
- current_event = None
244
-
245
- except httpx.HTTPError as e:
246
- raise XenfraError(f"HTTP error during streaming deployment: {e}")
247
- except Exception as e:
248
- raise XenfraError(f"Failed to create streaming deployment: {e}")
1
+ import json
2
+ import logging
3
+ from typing import Iterator
4
+
5
+ # Import Deployment model when it's defined in models.py
6
+ # from ..models import Deployment
7
+ from ..exceptions import XenfraAPIError, XenfraError # Add XenfraError
8
+ from ..utils import safe_get_json_field, safe_json_parse
9
+ from .base import BaseManager
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class DeploymentsManager(BaseManager):
15
+ def create(self, project_name: str, git_repo: str, branch: str, framework: str, region: str = None, size_slug: str = None, is_dockerized: bool = True, port: int = None, command: str = None, entrypoint: str = None, database: str = None, package_manager: str = None, dependency_file: str = None, file_manifest: list = None, cleanup_on_failure: bool = False, services: list = None, mode: str = None) -> dict:
16
+ """Creates a new deployment."""
17
+ try:
18
+ payload = {
19
+ "project_name": project_name,
20
+ "git_repo": git_repo,
21
+ "branch": branch,
22
+ "framework": framework,
23
+ }
24
+ if region:
25
+ payload["region"] = region
26
+ if size_slug:
27
+ payload["size_slug"] = size_slug
28
+ if is_dockerized is not None:
29
+ payload["is_dockerized"] = is_dockerized
30
+ if port:
31
+ payload["port"] = port
32
+ if command:
33
+ payload["command"] = command
34
+ if entrypoint:
35
+ payload["entrypoint"] = entrypoint
36
+ if database:
37
+ payload["database"] = database
38
+ if package_manager:
39
+ payload["package_manager"] = package_manager
40
+ if dependency_file:
41
+ payload["dependency_file"] = dependency_file
42
+ if file_manifest:
43
+ payload["file_manifest"] = file_manifest
44
+ if cleanup_on_failure:
45
+ payload["cleanup_on_failure"] = True
46
+ # Microservices support
47
+ if services:
48
+ payload["services"] = services
49
+ if mode:
50
+ payload["mode"] = mode
51
+
52
+ response = self._client._request("POST", "/deployments", json=payload)
53
+ # Safe JSON parsing
54
+ return safe_json_parse(response)
55
+ except XenfraAPIError:
56
+ raise
57
+ except Exception as e:
58
+ raise XenfraError(f"Failed to create deployment: {e}")
59
+
60
+ def get_status(self, deployment_id: str) -> dict:
61
+ """Get status for a specific deployment.
62
+
63
+ Args:
64
+ deployment_id: The unique identifier for the deployment.
65
+
66
+ Returns:
67
+ dict: Deployment status information including state, progress, etc.
68
+
69
+ Raises:
70
+ XenfraAPIError: If the API returns an error (e.g., 404 not found).
71
+ XenfraError: If there's a network or parsing error.
72
+ """
73
+ try:
74
+ response = self._client._request("GET", f"/deployments/{deployment_id}/status")
75
+ logger.debug(
76
+ f"DeploymentsManager.get_status({deployment_id}) response: {response.status_code}"
77
+ )
78
+ # Safe JSON parsing - _request() already handles status codes
79
+ return safe_json_parse(response)
80
+ except XenfraAPIError:
81
+ raise # Re-raise API errors
82
+ except Exception as e:
83
+ raise XenfraError(f"Failed to get status for deployment {deployment_id}: {e}")
84
+
85
+ def get_logs(self, deployment_id: str) -> str:
86
+ """Get logs for a specific deployment.
87
+
88
+ Args:
89
+ deployment_id: The unique identifier for the deployment.
90
+
91
+ Returns:
92
+ str: The deployment logs as plain text.
93
+
94
+ Raises:
95
+ XenfraAPIError: If the API returns an error (e.g., 404 not found).
96
+ XenfraError: If there's a network or parsing error.
97
+ """
98
+ try:
99
+ response = self._client._request("GET", f"/deployments/{deployment_id}/logs")
100
+ logger.debug(
101
+ f"DeploymentsManager.get_logs({deployment_id}) response: {response.status_code}"
102
+ )
103
+
104
+ # Safe JSON parsing with structure validation - _request() already handles status codes
105
+ data = safe_json_parse(response)
106
+ if not isinstance(data, dict):
107
+ raise XenfraError(f"Expected dictionary response, got {type(data).__name__}")
108
+
109
+ logs = safe_get_json_field(data, "logs", "")
110
+
111
+ if not logs:
112
+ logger.warning(f"No logs found for deployment {deployment_id}")
113
+
114
+ return logs
115
+
116
+ except XenfraAPIError:
117
+ raise # Re-raise API errors
118
+ except Exception as e:
119
+ raise XenfraError(f"Failed to get logs for deployment {deployment_id}: {e}")
120
+
121
+ def create_stream(self, project_name: str, git_repo: str, branch: str, framework: str, region: str = None, size_slug: str = None, is_dockerized: bool = True, port: int = None, command: str = None, entrypoint: str = None, database: str = None, package_manager: str = None, dependency_file: str = None, file_manifest: list = None, cleanup_on_failure: bool = False, services: list = None, mode: str = None) -> Iterator[dict]:
122
+ """
123
+ Creates a new deployment with real-time SSE log streaming.
124
+
125
+ Yields SSE events as dictionaries with 'event' and 'data' keys.
126
+
127
+ Args:
128
+ project_name: Name of the project
129
+ git_repo: Git repository URL (optional if file_manifest provided)
130
+ branch: Git branch to deploy
131
+ framework: Framework type (fastapi, flask, django)
132
+ region: DigitalOcean region (optional)
133
+ size_slug: DigitalOcean droplet size (optional)
134
+ is_dockerized: Whether to use Docker (optional)
135
+ port: Application port (optional, default 8000)
136
+ command: Start command (optional, auto-detected if not provided)
137
+ entrypoint: Application entrypoint (optional, e.g. 'todo.main:app')
138
+ database: Database type (optional, e.g. 'postgres')
139
+ package_manager: Package manager (optional, e.g. 'pip', 'uv')
140
+ dependency_file: Dependency file (optional, e.g. 'requirements.txt')
141
+ file_manifest: List of files for delta upload [{path, sha, size}, ...]
142
+ cleanup_on_failure: Automatically cleanup resources if deployment fails (optional)
143
+ services: List of service definitions for multi-service deployments (optional)
144
+ mode: Deployment mode - 'monolithic', 'single-droplet', or 'multi-droplet' (optional)
145
+
146
+ Yields:
147
+ dict: SSE events with 'event' and 'data' fields
148
+
149
+ Example:
150
+ for event in client.deployments.create_stream(...):
151
+ if event['event'] == 'log':
152
+ print(event['data'])
153
+ elif event['event'] == 'deployment_complete':
154
+ print("Done!")
155
+ """
156
+ payload = {
157
+ "project_name": project_name,
158
+ "git_repo": git_repo,
159
+ "branch": branch,
160
+ "framework": framework,
161
+ }
162
+ if region:
163
+ payload["region"] = region
164
+ if size_slug:
165
+ payload["size_slug"] = size_slug
166
+ if is_dockerized is not None:
167
+ payload["is_dockerized"] = is_dockerized
168
+ if port:
169
+ payload["port"] = port
170
+ if command:
171
+ payload["command"] = command
172
+ if entrypoint:
173
+ payload["entrypoint"] = entrypoint
174
+ if database:
175
+ payload["database"] = database
176
+ if package_manager:
177
+ payload["package_manager"] = package_manager
178
+ if dependency_file:
179
+ payload["dependency_file"] = dependency_file
180
+ if file_manifest:
181
+ payload["file_manifest"] = file_manifest
182
+ if cleanup_on_failure:
183
+ payload["cleanup_on_failure"] = True
184
+ # Microservices support
185
+ if services:
186
+ payload["services"] = services
187
+ if mode:
188
+ payload["mode"] = mode
189
+
190
+ try:
191
+ # Use httpx to stream the SSE response
192
+ import httpx
193
+ import os
194
+
195
+ headers = {
196
+ "Authorization": f"Bearer {self._client._token}",
197
+ "Accept": "text/event-stream",
198
+ "Content-Type": "application/json",
199
+ }
200
+
201
+ # Use streaming API URL if available (bypasses Cloudflare timeout)
202
+ # Otherwise fall back to regular API URL
203
+ streaming_api_url = os.getenv("XENFRA_STREAMING_API_URL")
204
+ if streaming_api_url:
205
+ base_url = streaming_api_url
206
+ else:
207
+ # Local/dev/production: use regular API URL
208
+ base_url = self._client.api_url
209
+
210
+ url = f"{base_url}/deployments/stream"
211
+
212
+ with httpx.stream(
213
+ "POST",
214
+ url,
215
+ json=payload,
216
+ headers=headers,
217
+ timeout=600.0, # 10 minute timeout for deployments
218
+ ) as response:
219
+ # Check status before consuming stream
220
+ if response.status_code not in [200, 201, 202]:
221
+ # For error responses from streaming endpoint, read via iteration
222
+ error_text = ""
223
+ try:
224
+ for chunk in response.iter_bytes():
225
+ error_text += chunk.decode('utf-8', errors='ignore')
226
+ if len(error_text) > 1000: # Limit error message size
227
+ break
228
+ if not error_text:
229
+ error_text = "Unknown error"
230
+ except Exception as e:
231
+ error_text = f"Could not read error response: {e}"
232
+
233
+ raise XenfraAPIError(
234
+ status_code=response.status_code,
235
+ detail=f"Deployment failed: {error_text}"
236
+ )
237
+
238
+ # Parse SSE events
239
+ current_event = None # Initialize before loop
240
+ for line in response.iter_lines():
241
+ # No need to explicitly decode if iter_lines is used on a decoded response,
242
+ # but if it returns bytes, we decode it.
243
+ if isinstance(line, bytes):
244
+ line = line.decode('utf-8', errors='ignore')
245
+
246
+ line = line.strip()
247
+ if not line:
248
+ continue
249
+
250
+ # SSE format: "event: eventname" or "data: eventdata"
251
+ if line.startswith("event:"):
252
+ current_event = line[6:].strip()
253
+ elif line.startswith("data:"):
254
+ data = line[5:].strip()
255
+
256
+ # Get event type (default to "message" if no event line was sent)
257
+ event_type = current_event if current_event is not None else "message"
258
+
259
+ # Skip keep-alive events (used to prevent proxy timeouts)
260
+ if event_type == "keep-alive":
261
+ current_event = None
262
+ continue
263
+
264
+ try:
265
+ # Try to parse as JSON
266
+ data_parsed = json.loads(data)
267
+ yield {"event": event_type, "data": data_parsed}
268
+ except json.JSONDecodeError:
269
+ # If not JSON, yield as plain text
270
+ yield {"event": event_type, "data": data}
271
+
272
+ # Reset current_event after yielding
273
+ current_event = None
274
+
275
+ except httpx.HTTPError as e:
276
+ raise XenfraError(f"HTTP error during streaming deployment: {e}")
277
+ except Exception as e:
278
+ raise XenfraError(f"Failed to create streaming deployment: {e}")