xenfra-sdk 0.1.0__tar.gz → 0.1.2__tar.gz
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.
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/PKG-INFO +1 -1
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/pyproject.toml +1 -1
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/client.py +21 -3
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/client_with_hooks.py +32 -7
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/config.py +2 -2
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/db/models.py +1 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/dependencies.py +1 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/models.py +17 -5
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/privacy.py +43 -8
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/resources/deployments.py +17 -11
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/resources/intelligence.py +12 -19
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/resources/projects.py +21 -17
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/utils.py +49 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/README.md +0 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/__init__.py +0 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/cli/__init__.py +0 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/cli/main.py +0 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/db/__init__.py +0 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/db/session.py +0 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/dockerizer.py +0 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/engine.py +0 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/exceptions.py +0 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/mcp_client.py +0 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/patterns.json +0 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/recipes.py +0 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/resources/__init__.py +0 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/resources/base.py +0 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/security.py +0 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/templates/Dockerfile.j2 +0 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/templates/cloud-init.sh.j2 +0 -0
- {xenfra_sdk-0.1.0 → xenfra_sdk-0.1.2}/src/xenfra_sdk/templates/docker-compose.yml.j2 +0 -0
|
@@ -9,7 +9,11 @@ from .resources.projects import ProjectsManager
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class XenfraClient:
|
|
12
|
-
def __init__(self, token: str = None, api_url: str =
|
|
12
|
+
def __init__(self, token: str = None, api_url: str = None):
|
|
13
|
+
# Use provided URL, or fall back to env var, or default to production
|
|
14
|
+
if api_url is None:
|
|
15
|
+
api_url = os.getenv("XENFRA_API_URL", "https://api.xenfra.tech")
|
|
16
|
+
|
|
13
17
|
self.api_url = api_url
|
|
14
18
|
self._token = token or os.getenv("XENFRA_TOKEN")
|
|
15
19
|
if not self._token:
|
|
@@ -42,7 +46,21 @@ class XenfraClient:
|
|
|
42
46
|
return response
|
|
43
47
|
except httpx.HTTPStatusError as e:
|
|
44
48
|
# Convert httpx error to our custom SDK error
|
|
45
|
-
|
|
49
|
+
# Safe JSON parsing with fallback
|
|
50
|
+
try:
|
|
51
|
+
content_type = e.response.headers.get("content-type", "")
|
|
52
|
+
if "application/json" in content_type:
|
|
53
|
+
try:
|
|
54
|
+
error_data = e.response.json()
|
|
55
|
+
detail = error_data.get(
|
|
56
|
+
"detail", e.response.text[:500] if e.response.text else "Unknown error"
|
|
57
|
+
)
|
|
58
|
+
except (ValueError, TypeError):
|
|
59
|
+
detail = e.response.text[:500] if e.response.text else "Unknown error"
|
|
60
|
+
else:
|
|
61
|
+
detail = e.response.text[:500] if e.response.text else "Unknown error"
|
|
62
|
+
except Exception:
|
|
63
|
+
detail = "Unknown error"
|
|
46
64
|
raise XenfraAPIError(status_code=e.response.status_code, detail=detail) from e
|
|
47
65
|
except httpx.RequestError as e:
|
|
48
66
|
# Handle connection errors, timeouts, etc.
|
|
@@ -65,5 +83,5 @@ class XenfraClient:
|
|
|
65
83
|
|
|
66
84
|
def __del__(self):
|
|
67
85
|
"""Destructor - cleanup if not already closed."""
|
|
68
|
-
if hasattr(self,
|
|
86
|
+
if hasattr(self, "_closed") and not self._closed:
|
|
69
87
|
self.close()
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Enhanced XenfraClient with context management and lifecycle hooks.
|
|
3
3
|
"""
|
|
4
|
+
|
|
4
5
|
import logging
|
|
5
6
|
import os
|
|
6
|
-
from typing import
|
|
7
|
+
from typing import Callable
|
|
7
8
|
|
|
8
9
|
import httpx
|
|
9
10
|
|
|
@@ -102,7 +103,7 @@ class XenfraClient:
|
|
|
102
103
|
headers={
|
|
103
104
|
"Authorization": f"Bearer {self._token}",
|
|
104
105
|
"Content-Type": "application/json",
|
|
105
|
-
"User-Agent": "Xenfra-SDK/0.2.
|
|
106
|
+
"User-Agent": "Xenfra-SDK/0.2.4",
|
|
106
107
|
},
|
|
107
108
|
timeout=timeout,
|
|
108
109
|
transport=transport,
|
|
@@ -178,7 +179,25 @@ class XenfraClient:
|
|
|
178
179
|
|
|
179
180
|
except httpx.HTTPStatusError as e:
|
|
180
181
|
# API error (4xx, 5xx)
|
|
181
|
-
|
|
182
|
+
# Safe JSON parsing with fallback
|
|
183
|
+
if e.response:
|
|
184
|
+
try:
|
|
185
|
+
content_type = e.response.headers.get("content-type", "")
|
|
186
|
+
if "application/json" in content_type:
|
|
187
|
+
try:
|
|
188
|
+
error_data = e.response.json()
|
|
189
|
+
detail = error_data.get(
|
|
190
|
+
"detail",
|
|
191
|
+
e.response.text[:500] if e.response.text else "Unknown error",
|
|
192
|
+
)
|
|
193
|
+
except (ValueError, TypeError):
|
|
194
|
+
detail = e.response.text[:500] if e.response.text else "Unknown error"
|
|
195
|
+
else:
|
|
196
|
+
detail = e.response.text[:500] if e.response.text else "Unknown error"
|
|
197
|
+
except Exception:
|
|
198
|
+
detail = "Unknown error"
|
|
199
|
+
else:
|
|
200
|
+
detail = str(e)
|
|
182
201
|
|
|
183
202
|
# Run error hooks
|
|
184
203
|
error_context = {**request_context, "error": e, "response": e.response}
|
|
@@ -189,11 +208,12 @@ class XenfraClient:
|
|
|
189
208
|
logger.warning(f"on_error hook failed: {hook_error}")
|
|
190
209
|
|
|
191
210
|
# Log error
|
|
192
|
-
logger.error(
|
|
211
|
+
logger.error(
|
|
212
|
+
f"{method} {path} failed: {e.response.status_code if e.response else 'unknown'}"
|
|
213
|
+
)
|
|
193
214
|
|
|
194
215
|
raise XenfraAPIError(
|
|
195
|
-
status_code=e.response.status_code if e.response else 500,
|
|
196
|
-
detail=detail
|
|
216
|
+
status_code=e.response.status_code if e.response else 500, detail=detail
|
|
197
217
|
) from e
|
|
198
218
|
|
|
199
219
|
except httpx.RequestError as e:
|
|
@@ -227,7 +247,9 @@ class XenfraClient:
|
|
|
227
247
|
def __del__(self):
|
|
228
248
|
"""Destructor - cleanup if not already closed."""
|
|
229
249
|
if not self._closed:
|
|
230
|
-
logger.warning(
|
|
250
|
+
logger.warning(
|
|
251
|
+
"XenfraClient was not properly closed. Use 'with' statement or call close()."
|
|
252
|
+
)
|
|
231
253
|
self.close()
|
|
232
254
|
|
|
233
255
|
def __repr__(self):
|
|
@@ -238,6 +260,7 @@ class XenfraClient:
|
|
|
238
260
|
|
|
239
261
|
# Example hooks for common use cases
|
|
240
262
|
|
|
263
|
+
|
|
241
264
|
def logging_hook_before(request_context):
|
|
242
265
|
"""Example: Log all requests."""
|
|
243
266
|
print(f"→ {request_context['method']} {request_context['url']}")
|
|
@@ -264,12 +287,14 @@ def rate_limit_tracker_hook(request_context, response):
|
|
|
264
287
|
def request_timing_hook(request_context):
|
|
265
288
|
"""Example: Track request timing."""
|
|
266
289
|
import time
|
|
290
|
+
|
|
267
291
|
request_context["start_time"] = time.time()
|
|
268
292
|
|
|
269
293
|
|
|
270
294
|
def response_timing_hook(request_context, response):
|
|
271
295
|
"""Example: Calculate request duration."""
|
|
272
296
|
import time
|
|
297
|
+
|
|
273
298
|
if "start_time" in request_context:
|
|
274
299
|
duration = time.time() - request_context["start_time"]
|
|
275
300
|
print(f"Request took {duration:.3f}s")
|
|
@@ -15,6 +15,7 @@ class Project(SQLModel, table=True):
|
|
|
15
15
|
In a microservices architecture, we store the ID but don't enforce
|
|
16
16
|
a foreign key constraint across service boundaries.
|
|
17
17
|
"""
|
|
18
|
+
|
|
18
19
|
id: Optional[int] = Field(default=None, primary_key=True)
|
|
19
20
|
droplet_id: int = Field(unique=True, index=True)
|
|
20
21
|
name: str
|
|
@@ -119,12 +119,15 @@ class ProjectRead(BaseModel):
|
|
|
119
119
|
|
|
120
120
|
# Intelligence Service Models
|
|
121
121
|
|
|
122
|
+
|
|
122
123
|
class PatchObject(BaseModel):
|
|
123
124
|
"""
|
|
124
125
|
Represents a structured patch for a configuration file.
|
|
125
126
|
"""
|
|
126
127
|
|
|
127
|
-
file: str | None = Field(
|
|
128
|
+
file: str | None = Field(
|
|
129
|
+
None, description="The name of the file to be patched (e.g., 'requirements.txt')"
|
|
130
|
+
)
|
|
128
131
|
operation: str | None = Field(None, description="The patch operation (e.g., 'add', 'replace')")
|
|
129
132
|
path: str | None = Field(None, description="A JSON-like path to the field to be changed")
|
|
130
133
|
value: str | None = Field(None, description="The new value to apply")
|
|
@@ -160,11 +163,20 @@ class CodebaseAnalysisResponse(BaseModel):
|
|
|
160
163
|
cache: str | None = Field(None, description="Detected cache (redis, memcached, none)")
|
|
161
164
|
workers: list[str] | None = Field(None, description="Detected background workers (celery, rq)")
|
|
162
165
|
env_vars: list[str] | None = Field(None, description="Required environment variables")
|
|
163
|
-
package_manager: str = Field(
|
|
164
|
-
|
|
166
|
+
package_manager: str = Field(
|
|
167
|
+
..., description="Detected package manager (uv, pip, poetry, npm, pnpm, yarn, go, bundler)"
|
|
168
|
+
)
|
|
169
|
+
dependency_file: str = Field(
|
|
170
|
+
...,
|
|
171
|
+
description="Dependency manifest file (pyproject.toml, requirements.txt, package.json, go.mod, Gemfile)",
|
|
172
|
+
)
|
|
165
173
|
has_conflict: bool = Field(False, description="True if multiple package managers detected")
|
|
166
|
-
detected_package_managers: list[PackageManagerOption] | None = Field(
|
|
167
|
-
|
|
174
|
+
detected_package_managers: list[PackageManagerOption] | None = Field(
|
|
175
|
+
None, description="All detected package managers (if conflict)"
|
|
176
|
+
)
|
|
177
|
+
instance_size: str = Field(
|
|
178
|
+
..., description="Recommended instance size (basic, standard, premium)"
|
|
179
|
+
)
|
|
168
180
|
estimated_cost_monthly: float = Field(..., description="Estimated monthly cost in USD")
|
|
169
181
|
confidence: float = Field(..., description="Confidence score (0.0-1.0)")
|
|
170
182
|
notes: str | None = Field(None, description="Additional observations")
|
|
@@ -5,12 +5,16 @@ before it is sent to diagnostic endpoints, upholding privacy-first principles.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
8
10
|
import re
|
|
9
11
|
from pathlib import Path
|
|
10
12
|
from typing import List, Optional
|
|
11
13
|
|
|
12
14
|
import httpx # For fetching patterns from URL
|
|
13
15
|
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
14
18
|
# Path to the patterns file within the SDK
|
|
15
19
|
_PATTERNS_FILE_PATH = Path(__file__).parent / "patterns.json"
|
|
16
20
|
_REDACTION_PLACEHOLDER = "[REDACTED]"
|
|
@@ -20,8 +24,8 @@ _CACHED_PATTERNS: List[re.Pattern] = []
|
|
|
20
24
|
def _load_patterns_from_file(file_path: Path) -> List[str]:
|
|
21
25
|
"""Loads raw regex patterns from a JSON file."""
|
|
22
26
|
if not file_path.exists():
|
|
23
|
-
|
|
24
|
-
f"
|
|
27
|
+
logger.warning(
|
|
28
|
+
f"Patterns file not found at {file_path}. No patterns will be used for scrubbing."
|
|
25
29
|
)
|
|
26
30
|
return []
|
|
27
31
|
try:
|
|
@@ -29,7 +33,7 @@ def _load_patterns_from_file(file_path: Path) -> List[str]:
|
|
|
29
33
|
config = json.load(f)
|
|
30
34
|
return config.get("redaction_patterns", [])
|
|
31
35
|
except json.JSONDecodeError as e:
|
|
32
|
-
|
|
36
|
+
logger.error(f"Error decoding patterns.json: {e}. Falling back to empty patterns.")
|
|
33
37
|
return []
|
|
34
38
|
|
|
35
39
|
|
|
@@ -38,16 +42,47 @@ async def _refresh_patterns_from_url(url: str) -> Optional[List[str]]:
|
|
|
38
42
|
Fetches updated patterns from a URL asynchronously.
|
|
39
43
|
"""
|
|
40
44
|
try:
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
# Configure timeout from environment or default to 30 seconds
|
|
46
|
+
timeout_seconds = float(os.getenv("XENFRA_SDK_TIMEOUT", "30.0"))
|
|
47
|
+
timeout = httpx.Timeout(timeout_seconds, connect=10.0)
|
|
48
|
+
|
|
49
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
50
|
+
response = await client.get(url)
|
|
43
51
|
response.raise_for_status()
|
|
44
|
-
|
|
52
|
+
|
|
53
|
+
# Safe JSON parsing with content-type check
|
|
54
|
+
content_type = response.headers.get("content-type", "")
|
|
55
|
+
if "application/json" not in content_type:
|
|
56
|
+
logger.warning(
|
|
57
|
+
f"Expected JSON response from {url}, got {content_type}. "
|
|
58
|
+
"Skipping pattern refresh."
|
|
59
|
+
)
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
config = response.json()
|
|
64
|
+
except (ValueError, TypeError) as e:
|
|
65
|
+
logger.error(f"Failed to parse JSON from patterns URL {url}: {e}")
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
if not isinstance(config, dict):
|
|
69
|
+
logger.error(
|
|
70
|
+
f"Expected dictionary from patterns URL {url}, got {type(config).__name__}"
|
|
71
|
+
)
|
|
72
|
+
return None
|
|
73
|
+
|
|
45
74
|
return config.get("redaction_patterns", [])
|
|
75
|
+
except httpx.TimeoutException as e:
|
|
76
|
+
logger.warning(f"Timeout fetching patterns from {url}: {e}")
|
|
77
|
+
return None
|
|
46
78
|
except httpx.RequestError as e:
|
|
47
|
-
|
|
79
|
+
logger.warning(f"Error fetching patterns from {url}: {e}")
|
|
48
80
|
return None
|
|
49
81
|
except json.JSONDecodeError as e:
|
|
50
|
-
|
|
82
|
+
logger.error(f"Error decoding JSON from patterns URL {url}: {e}")
|
|
83
|
+
return None
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error(f"Unexpected error fetching patterns from {url}: {e}")
|
|
51
86
|
return None
|
|
52
87
|
|
|
53
88
|
|
|
@@ -3,6 +3,7 @@ import logging
|
|
|
3
3
|
# Import Deployment model when it's defined in models.py
|
|
4
4
|
# from ..models import Deployment
|
|
5
5
|
from ..exceptions import XenfraAPIError, XenfraError # Add XenfraError
|
|
6
|
+
from ..utils import safe_get_json_field, safe_json_parse
|
|
6
7
|
from .base import BaseManager
|
|
7
8
|
|
|
8
9
|
logger = logging.getLogger(__name__)
|
|
@@ -19,9 +20,8 @@ class DeploymentsManager(BaseManager):
|
|
|
19
20
|
"framework": framework,
|
|
20
21
|
}
|
|
21
22
|
response = self._client._request("POST", "/deployments", json=payload)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return response.json()
|
|
23
|
+
# Safe JSON parsing
|
|
24
|
+
return safe_json_parse(response)
|
|
25
25
|
except XenfraAPIError:
|
|
26
26
|
raise
|
|
27
27
|
except Exception as e:
|
|
@@ -42,9 +42,11 @@ class DeploymentsManager(BaseManager):
|
|
|
42
42
|
"""
|
|
43
43
|
try:
|
|
44
44
|
response = self._client._request("GET", f"/deployments/{deployment_id}/status")
|
|
45
|
-
logger.debug(
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
logger.debug(
|
|
46
|
+
f"DeploymentsManager.get_status({deployment_id}) response: {response.status_code}"
|
|
47
|
+
)
|
|
48
|
+
# Safe JSON parsing - _request() already handles status codes
|
|
49
|
+
return safe_json_parse(response)
|
|
48
50
|
except XenfraAPIError:
|
|
49
51
|
raise # Re-raise API errors
|
|
50
52
|
except Exception as e:
|
|
@@ -65,12 +67,16 @@ class DeploymentsManager(BaseManager):
|
|
|
65
67
|
"""
|
|
66
68
|
try:
|
|
67
69
|
response = self._client._request("GET", f"/deployments/{deployment_id}/logs")
|
|
68
|
-
logger.debug(
|
|
69
|
-
|
|
70
|
+
logger.debug(
|
|
71
|
+
f"DeploymentsManager.get_logs({deployment_id}) response: {response.status_code}"
|
|
72
|
+
)
|
|
70
73
|
|
|
71
|
-
#
|
|
72
|
-
data = response
|
|
73
|
-
|
|
74
|
+
# Safe JSON parsing with structure validation - _request() already handles status codes
|
|
75
|
+
data = safe_json_parse(response)
|
|
76
|
+
if not isinstance(data, dict):
|
|
77
|
+
raise XenfraError(f"Expected dictionary response, got {type(data).__name__}")
|
|
78
|
+
|
|
79
|
+
logs = safe_get_json_field(data, "logs", "")
|
|
74
80
|
|
|
75
81
|
if not logs:
|
|
76
82
|
logger.warning(f"No logs found for deployment {deployment_id}")
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
Intelligence resource manager for Xenfra SDK.
|
|
3
3
|
Provides AI-powered deployment diagnosis and codebase analysis.
|
|
4
4
|
"""
|
|
5
|
+
|
|
5
6
|
import logging
|
|
6
7
|
|
|
7
8
|
from ..exceptions import XenfraAPIError, XenfraError
|
|
8
9
|
from ..models import CodebaseAnalysisResponse, DiagnosisResponse
|
|
10
|
+
from ..utils import safe_json_parse
|
|
9
11
|
from .base import BaseManager
|
|
10
12
|
|
|
11
13
|
logger = logging.getLogger(__name__)
|
|
@@ -21,10 +23,7 @@ class IntelligenceManager(BaseManager):
|
|
|
21
23
|
"""
|
|
22
24
|
|
|
23
25
|
def diagnose(
|
|
24
|
-
self,
|
|
25
|
-
logs: str,
|
|
26
|
-
package_manager: str | None = None,
|
|
27
|
-
dependency_file: str | None = None
|
|
26
|
+
self, logs: str, package_manager: str | None = None, dependency_file: str | None = None
|
|
28
27
|
) -> DiagnosisResponse:
|
|
29
28
|
"""
|
|
30
29
|
Diagnose deployment failure from logs using AI.
|
|
@@ -51,18 +50,13 @@ class IntelligenceManager(BaseManager):
|
|
|
51
50
|
if dependency_file:
|
|
52
51
|
payload["dependency_file"] = dependency_file
|
|
53
52
|
|
|
54
|
-
response = self._client._request(
|
|
55
|
-
"POST",
|
|
56
|
-
"/intelligence/diagnose",
|
|
57
|
-
json=payload
|
|
58
|
-
)
|
|
53
|
+
response = self._client._request("POST", "/intelligence/diagnose", json=payload)
|
|
59
54
|
|
|
60
|
-
logger.debug(
|
|
61
|
-
f"IntelligenceManager.diagnose response: status={response.status_code}"
|
|
62
|
-
)
|
|
55
|
+
logger.debug(f"IntelligenceManager.diagnose response: status={response.status_code}")
|
|
63
56
|
|
|
64
|
-
|
|
65
|
-
|
|
57
|
+
# Safe JSON parsing
|
|
58
|
+
data = safe_json_parse(response)
|
|
59
|
+
return DiagnosisResponse(**data)
|
|
66
60
|
except XenfraAPIError:
|
|
67
61
|
raise
|
|
68
62
|
except Exception as e:
|
|
@@ -85,17 +79,16 @@ class IntelligenceManager(BaseManager):
|
|
|
85
79
|
"""
|
|
86
80
|
try:
|
|
87
81
|
response = self._client._request(
|
|
88
|
-
"POST",
|
|
89
|
-
"/intelligence/analyze-codebase",
|
|
90
|
-
json={"code_snippets": code_snippets}
|
|
82
|
+
"POST", "/intelligence/analyze-codebase", json={"code_snippets": code_snippets}
|
|
91
83
|
)
|
|
92
84
|
|
|
93
85
|
logger.debug(
|
|
94
86
|
f"IntelligenceManager.analyze_codebase response: status={response.status_code}"
|
|
95
87
|
)
|
|
96
88
|
|
|
97
|
-
|
|
98
|
-
|
|
89
|
+
# Safe JSON parsing
|
|
90
|
+
data = safe_json_parse(response)
|
|
91
|
+
return CodebaseAnalysisResponse(**data)
|
|
99
92
|
except XenfraAPIError:
|
|
100
93
|
raise
|
|
101
94
|
except Exception as e:
|
|
@@ -2,6 +2,7 @@ import logging
|
|
|
2
2
|
|
|
3
3
|
from ..exceptions import XenfraAPIError, XenfraError # Add XenfraError
|
|
4
4
|
from ..models import ProjectRead
|
|
5
|
+
from ..utils import safe_get_json_field, safe_json_parse
|
|
5
6
|
from .base import BaseManager
|
|
6
7
|
|
|
7
8
|
logger = logging.getLogger(__name__)
|
|
@@ -18,8 +19,16 @@ class ProjectsManager(BaseManager):
|
|
|
18
19
|
f"body={response.text[:200]}..." # Truncate long responses
|
|
19
20
|
)
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
# Safe JSON parsing with structure validation
|
|
23
|
+
data = safe_json_parse(response)
|
|
24
|
+
projects = safe_get_json_field(data, "projects", [])
|
|
25
|
+
|
|
26
|
+
if not isinstance(projects, list):
|
|
27
|
+
raise XenfraError(
|
|
28
|
+
f"Expected 'projects' to be a list, got {type(projects).__name__}"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return [ProjectRead(**p) for p in projects]
|
|
23
32
|
except XenfraAPIError:
|
|
24
33
|
raise # Re-raise API errors
|
|
25
34
|
except Exception as e:
|
|
@@ -42,18 +51,16 @@ class ProjectsManager(BaseManager):
|
|
|
42
51
|
try:
|
|
43
52
|
response = self._client._request("GET", f"/projects/{project_id}")
|
|
44
53
|
logger.debug(f"ProjectsManager.show({project_id}) response: {response.status_code}")
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
# Safe JSON parsing
|
|
55
|
+
data = safe_json_parse(response)
|
|
56
|
+
return ProjectRead(**data)
|
|
47
57
|
except XenfraAPIError:
|
|
48
58
|
raise # Re-raise API errors
|
|
49
59
|
except Exception as e:
|
|
50
60
|
raise XenfraError(f"Failed to get project {project_id}: {e}")
|
|
51
61
|
|
|
52
62
|
def create(
|
|
53
|
-
self,
|
|
54
|
-
name: str,
|
|
55
|
-
region: str = "nyc3",
|
|
56
|
-
size_slug: str = "s-1vcpu-1gb"
|
|
63
|
+
self, name: str, region: str = "nyc3", size_slug: str = "s-1vcpu-1gb"
|
|
57
64
|
) -> ProjectRead:
|
|
58
65
|
"""Create a new project.
|
|
59
66
|
|
|
@@ -70,15 +77,12 @@ class ProjectsManager(BaseManager):
|
|
|
70
77
|
XenfraError: If there's a network or parsing error.
|
|
71
78
|
"""
|
|
72
79
|
try:
|
|
73
|
-
payload = {
|
|
74
|
-
"name": name,
|
|
75
|
-
"region": region,
|
|
76
|
-
"size_slug": size_slug
|
|
77
|
-
}
|
|
80
|
+
payload = {"name": name, "region": region, "size_slug": size_slug}
|
|
78
81
|
logger.debug(f"ProjectsManager.create payload: {payload}")
|
|
79
82
|
response = self._client._request("POST", "/projects/", json=payload)
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
# Safe JSON parsing
|
|
84
|
+
data = safe_json_parse(response)
|
|
85
|
+
return ProjectRead(**data)
|
|
82
86
|
except XenfraAPIError:
|
|
83
87
|
raise
|
|
84
88
|
except Exception as e:
|
|
@@ -87,8 +91,8 @@ class ProjectsManager(BaseManager):
|
|
|
87
91
|
def delete(self, project_id: str) -> None:
|
|
88
92
|
"""Deletes a project."""
|
|
89
93
|
try:
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
# _request() already handles status codes and raises XenfraAPIError for non-2xx
|
|
95
|
+
self._client._request("DELETE", f"/projects/{project_id}")
|
|
92
96
|
except XenfraAPIError:
|
|
93
97
|
raise
|
|
94
98
|
except Exception as e:
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import tomllib # Python 3.11+
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .exceptions import XenfraError
|
|
3
8
|
|
|
4
9
|
|
|
5
10
|
def get_project_context():
|
|
@@ -68,3 +73,47 @@ def get_project_context():
|
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
return context
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def safe_json_parse(response: httpx.Response) -> Dict[str, Any]:
|
|
79
|
+
"""
|
|
80
|
+
Safely parse JSON from HTTP response with content-type validation and error handling.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
response: HTTP response object
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Parsed JSON dictionary
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
XenfraError: If response is not JSON or parsing fails
|
|
90
|
+
"""
|
|
91
|
+
content_type = response.headers.get("content-type", "")
|
|
92
|
+
if "application/json" not in content_type:
|
|
93
|
+
# Try to get error text for better error messages
|
|
94
|
+
error_text = response.text[:500] if response.text else "Unknown error"
|
|
95
|
+
raise XenfraError(f"Expected JSON response, got {content_type}. Response: {error_text}")
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
return response.json()
|
|
99
|
+
except (ValueError, TypeError) as e:
|
|
100
|
+
error_text = response.text[:500] if response.text else "Unknown error"
|
|
101
|
+
raise XenfraError(f"Failed to parse JSON response: {e}. Response: {error_text}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def safe_get_json_field(data: Dict[str, Any], field: str, default: Any = None) -> Any:
|
|
105
|
+
"""
|
|
106
|
+
Safely get a field from JSON data with validation.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
data: JSON dictionary
|
|
110
|
+
field: Field name to retrieve
|
|
111
|
+
default: Default value if field is missing
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Field value or default
|
|
115
|
+
"""
|
|
116
|
+
if not isinstance(data, dict):
|
|
117
|
+
raise XenfraError(f"Expected dictionary, got {type(data).__name__}")
|
|
118
|
+
|
|
119
|
+
return data.get(field, default)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|