mcp-souschef 3.2.0__py3-none-any.whl → 3.5.2__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.
- {mcp_souschef-3.2.0.dist-info → mcp_souschef-3.5.2.dist-info}/METADATA +159 -30
- {mcp_souschef-3.2.0.dist-info → mcp_souschef-3.5.2.dist-info}/RECORD +19 -14
- {mcp_souschef-3.2.0.dist-info → mcp_souschef-3.5.2.dist-info}/WHEEL +1 -1
- souschef/assessment.py +81 -25
- souschef/cli.py +265 -6
- souschef/converters/playbook.py +413 -156
- souschef/converters/template.py +122 -5
- souschef/core/ai_schemas.py +81 -0
- souschef/core/http_client.py +394 -0
- souschef/core/logging.py +344 -0
- souschef/core/metrics.py +73 -6
- souschef/core/url_validation.py +230 -0
- souschef/server.py +130 -0
- souschef/ui/app.py +20 -6
- souschef/ui/pages/ai_settings.py +151 -30
- souschef/ui/pages/chef_server_settings.py +300 -0
- souschef/ui/pages/cookbook_analysis.py +66 -10
- {mcp_souschef-3.2.0.dist-info → mcp_souschef-3.5.2.dist-info}/entry_points.txt +0 -0
- {mcp_souschef-3.2.0.dist-info → mcp_souschef-3.5.2.dist-info}/licenses/LICENSE +0 -0
souschef/converters/template.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Chef ERB template to Jinja2 converter."""
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
from souschef.parsers.template import (
|
|
6
7
|
_convert_erb_to_jinja2,
|
|
@@ -168,10 +169,126 @@ def convert_template_with_ai(erb_path: str, ai_service=None) -> dict:
|
|
|
168
169
|
# Add conversion method metadata
|
|
169
170
|
result["conversion_method"] = "rule-based"
|
|
170
171
|
|
|
171
|
-
#
|
|
172
|
-
if ai_service is not None:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
172
|
+
# Use AI service to validate and improve complex conversions
|
|
173
|
+
if ai_service is not None and result.get("success"):
|
|
174
|
+
try:
|
|
175
|
+
# Enhanced AI validation for template conversion
|
|
176
|
+
result = _enhance_template_with_ai(result, erb_path, ai_service)
|
|
177
|
+
result["conversion_method"] = "ai-enhanced"
|
|
178
|
+
except Exception as e:
|
|
179
|
+
# If AI enhancement fails, return the rule-based result
|
|
180
|
+
result["ai_enhancement_error"] = str(e)
|
|
181
|
+
result["conversion_method"] = "rule-based-fallback"
|
|
176
182
|
|
|
177
183
|
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _enhance_template_with_ai(
|
|
187
|
+
rule_based_result: dict, erb_path: str, ai_service: Any
|
|
188
|
+
) -> dict:
|
|
189
|
+
"""
|
|
190
|
+
Enhance rule-based template conversion using AI validation.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
rule_based_result: Result from rule-based conversion
|
|
194
|
+
erb_path: Path to original ERB template
|
|
195
|
+
ai_service: AI service instance (Anthropic, OpenAI, etc.)
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Enhanced conversion result with AI improvements
|
|
199
|
+
|
|
200
|
+
"""
|
|
201
|
+
# Read original ERB content
|
|
202
|
+
file_path = Path(erb_path)
|
|
203
|
+
with file_path.open(encoding="utf-8") as f:
|
|
204
|
+
erb_content = f.read()
|
|
205
|
+
|
|
206
|
+
jinja2_content = rule_based_result.get("jinja2_template", "")
|
|
207
|
+
|
|
208
|
+
# Create validation prompt
|
|
209
|
+
prompt = f"""You are an expert in both Chef ERB templates and \
|
|
210
|
+
Ansible Jinja2 templates.
|
|
211
|
+
|
|
212
|
+
Review the following ERB to Jinja2 conversion and provide feedback:
|
|
213
|
+
|
|
214
|
+
**Original ERB Template:**
|
|
215
|
+
```erb
|
|
216
|
+
{erb_content}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**Converted Jinja2 Template:**
|
|
220
|
+
```jinja2
|
|
221
|
+
{jinja2_content}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Analyse the conversion and provide:
|
|
225
|
+
1. Validation: Is the conversion accurate and complete?
|
|
226
|
+
2. Issues: Any Ruby logic that wasn't properly converted?
|
|
227
|
+
3. Improvements: Suggested improvements for better Jinja2 syntax
|
|
228
|
+
4. Security: Any security concerns in the template
|
|
229
|
+
|
|
230
|
+
Respond in JSON format:
|
|
231
|
+
{{
|
|
232
|
+
"valid": true/false,
|
|
233
|
+
"issues": ["list of issues"],
|
|
234
|
+
"improvements": ["list of improvements"],
|
|
235
|
+
"security_concerns": ["list of security concerns"],
|
|
236
|
+
"improved_template": "improved Jinja2 template if applicable"
|
|
237
|
+
}}
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
# Call AI service based on type
|
|
241
|
+
response_text = ""
|
|
242
|
+
|
|
243
|
+
if hasattr(ai_service, "messages") and hasattr(ai_service.messages, "create"):
|
|
244
|
+
# Anthropic API
|
|
245
|
+
response = ai_service.messages.create(
|
|
246
|
+
model="claude-3-5-sonnet-20241022",
|
|
247
|
+
max_tokens=2000,
|
|
248
|
+
messages=[{"role": "user", "content": prompt}],
|
|
249
|
+
)
|
|
250
|
+
response_text = response.content[0].text
|
|
251
|
+
elif hasattr(ai_service, "chat") and hasattr(ai_service.chat, "completions"):
|
|
252
|
+
# OpenAI API
|
|
253
|
+
response = ai_service.chat.completions.create(
|
|
254
|
+
model="gpt-4o",
|
|
255
|
+
messages=[{"role": "user", "content": prompt}],
|
|
256
|
+
max_tokens=2000,
|
|
257
|
+
)
|
|
258
|
+
response_text = response.choices[0].message.content
|
|
259
|
+
else:
|
|
260
|
+
# Unsupported AI service
|
|
261
|
+
return rule_based_result
|
|
262
|
+
|
|
263
|
+
# Parse AI response
|
|
264
|
+
import json
|
|
265
|
+
import re
|
|
266
|
+
|
|
267
|
+
# Extract JSON from response (may be wrapped in markdown code blocks)
|
|
268
|
+
json_match = re.search(r"```json\s*(\{[^\}]*\})\s*```", response_text, re.DOTALL)
|
|
269
|
+
if json_match:
|
|
270
|
+
response_text = json_match.group(1)
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
ai_feedback = json.loads(response_text)
|
|
274
|
+
|
|
275
|
+
# Enhance result with AI feedback
|
|
276
|
+
enhanced_result = rule_based_result.copy()
|
|
277
|
+
enhanced_result["ai_validation"] = {
|
|
278
|
+
"valid": ai_feedback.get("valid", True),
|
|
279
|
+
"issues": ai_feedback.get("issues", []),
|
|
280
|
+
"improvements": ai_feedback.get("improvements", []),
|
|
281
|
+
"security_concerns": ai_feedback.get("security_concerns", []),
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
# Use improved template if provided and valid
|
|
285
|
+
if ai_feedback.get("improved_template") and ai_feedback.get("valid"):
|
|
286
|
+
enhanced_result["jinja2_template"] = ai_feedback["improved_template"]
|
|
287
|
+
enhanced_result["ai_improved"] = True
|
|
288
|
+
|
|
289
|
+
return enhanced_result
|
|
290
|
+
|
|
291
|
+
except json.JSONDecodeError:
|
|
292
|
+
# If AI response isn't valid JSON, add raw feedback
|
|
293
|
+
rule_based_result["ai_feedback_raw"] = response_text
|
|
294
|
+
return rule_based_result
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pydantic schemas for AI structured outputs.
|
|
3
|
+
|
|
4
|
+
This module defines schemas for structured AI responses, enabling
|
|
5
|
+
reliable parsing of AI-generated content with type safety.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AnsibleTask(BaseModel):
|
|
14
|
+
"""Schema for a single Ansible task."""
|
|
15
|
+
|
|
16
|
+
name: str = Field(..., description="Human-readable task name")
|
|
17
|
+
module: str = Field(
|
|
18
|
+
..., description="Ansible module name (e.g., 'package', 'service')"
|
|
19
|
+
)
|
|
20
|
+
parameters: dict[str, Any] = Field(
|
|
21
|
+
default_factory=dict, description="Module parameters as key-value pairs"
|
|
22
|
+
)
|
|
23
|
+
when: str | None = Field(
|
|
24
|
+
None, description="Conditional expression for task execution"
|
|
25
|
+
)
|
|
26
|
+
notify: list[str] | None = Field(
|
|
27
|
+
None, description="List of handlers to notify on change"
|
|
28
|
+
)
|
|
29
|
+
tags: list[str] | None = Field(
|
|
30
|
+
None, description="List of tags for task categorization"
|
|
31
|
+
)
|
|
32
|
+
become: bool | None = Field(None, description="Whether to use privilege escalation")
|
|
33
|
+
register: str | None = Field(None, description="Variable name to store task result")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AnsibleHandler(BaseModel):
|
|
37
|
+
"""Schema for an Ansible handler."""
|
|
38
|
+
|
|
39
|
+
name: str = Field(..., description="Handler name")
|
|
40
|
+
module: str = Field(..., description="Ansible module name")
|
|
41
|
+
parameters: dict[str, Any] = Field(
|
|
42
|
+
default_factory=dict, description="Module parameters"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AnsiblePlaybook(BaseModel):
|
|
47
|
+
"""Schema for a complete Ansible playbook."""
|
|
48
|
+
|
|
49
|
+
name: str = Field(..., description="Playbook name")
|
|
50
|
+
hosts: str = Field(default="all", description="Target hosts pattern")
|
|
51
|
+
become: bool | None = Field(None, description="Whether to use privilege escalation")
|
|
52
|
+
vars: dict[str, Any] | None = Field(None, description="Playbook variables")
|
|
53
|
+
tasks: list[AnsibleTask] = Field(default_factory=list, description="List of tasks")
|
|
54
|
+
handlers: list[AnsibleHandler] | None = Field(None, description="List of handlers")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ConversionResult(BaseModel):
|
|
58
|
+
"""Schema for AI conversion results."""
|
|
59
|
+
|
|
60
|
+
playbook: AnsiblePlaybook = Field(..., description="Generated Ansible playbook")
|
|
61
|
+
notes: list[str] | None = Field(
|
|
62
|
+
None,
|
|
63
|
+
description="Conversion notes, warnings, or manual steps required",
|
|
64
|
+
)
|
|
65
|
+
confidence: float | None = Field(
|
|
66
|
+
None,
|
|
67
|
+
description="Confidence score (0.0-1.0) for the conversion accuracy",
|
|
68
|
+
ge=0.0,
|
|
69
|
+
le=1.0,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TemplateConversion(BaseModel):
|
|
74
|
+
"""Schema for template conversion results."""
|
|
75
|
+
|
|
76
|
+
jinja2_template: str = Field(..., description="Converted Jinja2 template content")
|
|
77
|
+
variable_mappings: dict[str, str] | None = Field(
|
|
78
|
+
None,
|
|
79
|
+
description="Mapping of Chef variables to Ansible variables",
|
|
80
|
+
)
|
|
81
|
+
notes: list[str] | None = Field(None, description="Conversion notes or warnings")
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP client abstraction for SousChef.
|
|
3
|
+
|
|
4
|
+
Provides a unified interface for making HTTP requests with consistent
|
|
5
|
+
error handling, authentication, and retry logic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from requests.adapters import HTTPAdapter
|
|
13
|
+
from requests.exceptions import (
|
|
14
|
+
ConnectionError as RequestsConnectionError,
|
|
15
|
+
)
|
|
16
|
+
from requests.exceptions import (
|
|
17
|
+
HTTPError as RequestsHTTPError,
|
|
18
|
+
)
|
|
19
|
+
from requests.exceptions import (
|
|
20
|
+
RequestException,
|
|
21
|
+
)
|
|
22
|
+
from requests.exceptions import (
|
|
23
|
+
Timeout as RequestsTimeout,
|
|
24
|
+
)
|
|
25
|
+
from urllib3.util.retry import Retry
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import requests
|
|
29
|
+
from requests.adapters import HTTPAdapter
|
|
30
|
+
from requests.exceptions import (
|
|
31
|
+
ConnectionError as RequestsConnectionError,
|
|
32
|
+
)
|
|
33
|
+
from requests.exceptions import (
|
|
34
|
+
HTTPError as RequestsHTTPError,
|
|
35
|
+
)
|
|
36
|
+
from requests.exceptions import (
|
|
37
|
+
RequestException,
|
|
38
|
+
)
|
|
39
|
+
from requests.exceptions import (
|
|
40
|
+
Timeout as RequestsTimeout,
|
|
41
|
+
)
|
|
42
|
+
from urllib3.util.retry import Retry
|
|
43
|
+
|
|
44
|
+
REQUESTS_AVAILABLE = True
|
|
45
|
+
except ImportError: # pragma: no cover
|
|
46
|
+
# Fallback when requests is not installed
|
|
47
|
+
requests = None # type: ignore[assignment]
|
|
48
|
+
HTTPAdapter = None # type: ignore[assignment,misc]
|
|
49
|
+
Retry = None # type: ignore[assignment,misc]
|
|
50
|
+
RequestsHTTPError = Exception # type: ignore[misc,assignment]
|
|
51
|
+
RequestsTimeout = Exception # type: ignore[misc,assignment]
|
|
52
|
+
RequestsConnectionError = Exception # type: ignore[misc,assignment]
|
|
53
|
+
RequestException = Exception # type: ignore[misc,assignment]
|
|
54
|
+
REQUESTS_AVAILABLE = False
|
|
55
|
+
|
|
56
|
+
from souschef.core.errors import SousChefError
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class HTTPError(SousChefError):
|
|
60
|
+
"""Raised when an HTTP request fails."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
status_code: int,
|
|
65
|
+
message: str,
|
|
66
|
+
response_text: str | None = None,
|
|
67
|
+
):
|
|
68
|
+
"""
|
|
69
|
+
Initialise HTTP error.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
status_code: HTTP status code.
|
|
73
|
+
message: Error message.
|
|
74
|
+
response_text: Optional response body text.
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
self.status_code = status_code
|
|
78
|
+
self.response_text = response_text
|
|
79
|
+
|
|
80
|
+
full_message = f"HTTP {status_code}: {message}"
|
|
81
|
+
if response_text:
|
|
82
|
+
full_message += f"\nResponse: {response_text[:500]}"
|
|
83
|
+
|
|
84
|
+
suggestion = self._get_suggestion(status_code)
|
|
85
|
+
super().__init__(full_message, suggestion)
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _get_suggestion(status_code: int) -> str:
|
|
89
|
+
"""Get helpful suggestion based on status code."""
|
|
90
|
+
if status_code == 401:
|
|
91
|
+
return "Check that your API key is valid and properly configured."
|
|
92
|
+
elif status_code == 403:
|
|
93
|
+
return (
|
|
94
|
+
"Your API key doesn't have permission for this operation. "
|
|
95
|
+
"Verify the key has the required scopes."
|
|
96
|
+
)
|
|
97
|
+
elif status_code == 404:
|
|
98
|
+
return "The requested API endpoint was not found. Check the base URL."
|
|
99
|
+
elif status_code == 429:
|
|
100
|
+
return "Rate limit exceeded. Wait a moment and try again."
|
|
101
|
+
elif 500 <= status_code < 600:
|
|
102
|
+
return "The API service is experiencing issues. Try again later."
|
|
103
|
+
else:
|
|
104
|
+
return "Check the API documentation for this error code."
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class HTTPClient:
|
|
108
|
+
"""
|
|
109
|
+
HTTP client with authentication and error handling.
|
|
110
|
+
|
|
111
|
+
Provides a simple interface for making authenticated HTTP requests
|
|
112
|
+
with automatic retries and consistent error handling.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
base_url: str,
|
|
118
|
+
api_key: str | None = None,
|
|
119
|
+
timeout: int = 60,
|
|
120
|
+
max_retries: int = 3,
|
|
121
|
+
backoff_factor: float = 0.5,
|
|
122
|
+
user_agent: str = "SousChef/1.0",
|
|
123
|
+
):
|
|
124
|
+
"""
|
|
125
|
+
Initialise HTTP client.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
base_url: Base URL for API requests.
|
|
129
|
+
api_key: Optional API key for authentication.
|
|
130
|
+
timeout: Request timeout in seconds.
|
|
131
|
+
max_retries: Maximum number of retry attempts.
|
|
132
|
+
backoff_factor: Exponential backoff multiplier for retries.
|
|
133
|
+
user_agent: User-Agent header value.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
SousChefError: If base_url does not use HTTPS.
|
|
137
|
+
|
|
138
|
+
"""
|
|
139
|
+
if not REQUESTS_AVAILABLE or Retry is None or HTTPAdapter is None:
|
|
140
|
+
raise SousChefError(
|
|
141
|
+
"requests library not available",
|
|
142
|
+
"Install with: pip install requests",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
assert requests is not None
|
|
146
|
+
|
|
147
|
+
self.base_url = base_url.rstrip("/")
|
|
148
|
+
|
|
149
|
+
# Validate HTTPS usage for security
|
|
150
|
+
parsed_url = urlparse(self.base_url)
|
|
151
|
+
if parsed_url.scheme != "https":
|
|
152
|
+
raise SousChefError(
|
|
153
|
+
"Insecure HTTP connection not allowed",
|
|
154
|
+
"Use HTTPS for secure communication with the API.",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
self.api_key = api_key
|
|
158
|
+
self.timeout = timeout
|
|
159
|
+
self.user_agent = user_agent
|
|
160
|
+
|
|
161
|
+
# Create session with retry configuration
|
|
162
|
+
self.session = requests.Session()
|
|
163
|
+
|
|
164
|
+
# Configure retries for transient errors
|
|
165
|
+
retry_strategy = Retry(
|
|
166
|
+
total=max_retries,
|
|
167
|
+
backoff_factor=backoff_factor,
|
|
168
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
169
|
+
allowed_methods=["GET", "POST", "PUT", "DELETE"],
|
|
170
|
+
)
|
|
171
|
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
172
|
+
|
|
173
|
+
https_scheme = "https://"
|
|
174
|
+
self.session.mount(https_scheme, adapter)
|
|
175
|
+
|
|
176
|
+
def _get_headers(
|
|
177
|
+
self,
|
|
178
|
+
auth_type: Literal["bearer", "api_key"] = "bearer",
|
|
179
|
+
extra_headers: dict[str, str] | None = None,
|
|
180
|
+
) -> dict[str, str]:
|
|
181
|
+
"""
|
|
182
|
+
Build request headers with authentication.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
auth_type: Authentication type ("bearer" or "api_key").
|
|
186
|
+
extra_headers: Additional headers to include.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Complete headers dictionary.
|
|
190
|
+
|
|
191
|
+
"""
|
|
192
|
+
headers = {
|
|
193
|
+
"Content-Type": "application/json",
|
|
194
|
+
"User-Agent": self.user_agent,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if self.api_key:
|
|
198
|
+
if auth_type == "bearer":
|
|
199
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
200
|
+
elif auth_type == "api_key":
|
|
201
|
+
headers["X-API-Key"] = self.api_key
|
|
202
|
+
|
|
203
|
+
if extra_headers:
|
|
204
|
+
headers.update(extra_headers)
|
|
205
|
+
|
|
206
|
+
return headers
|
|
207
|
+
|
|
208
|
+
def post(
|
|
209
|
+
self,
|
|
210
|
+
endpoint: str,
|
|
211
|
+
json_data: dict[str, Any] | None = None,
|
|
212
|
+
auth_type: Literal["bearer", "api_key"] = "bearer",
|
|
213
|
+
extra_headers: dict[str, str] | None = None,
|
|
214
|
+
timeout: int | None = None,
|
|
215
|
+
) -> dict[str, Any]:
|
|
216
|
+
"""
|
|
217
|
+
Make a POST request.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
endpoint: API endpoint (relative to base_url).
|
|
221
|
+
json_data: JSON request body.
|
|
222
|
+
auth_type: Authentication type.
|
|
223
|
+
extra_headers: Additional headers.
|
|
224
|
+
timeout: Request timeout (overrides default).
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
JSON response data.
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
HTTPError: If request fails.
|
|
231
|
+
|
|
232
|
+
"""
|
|
233
|
+
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
|
234
|
+
headers = self._get_headers(auth_type, extra_headers)
|
|
235
|
+
timeout_value = timeout if timeout is not None else self.timeout
|
|
236
|
+
|
|
237
|
+
response = None
|
|
238
|
+
try:
|
|
239
|
+
response = self.session.post(
|
|
240
|
+
url,
|
|
241
|
+
json=json_data,
|
|
242
|
+
headers=headers,
|
|
243
|
+
timeout=timeout_value,
|
|
244
|
+
)
|
|
245
|
+
response.raise_for_status()
|
|
246
|
+
|
|
247
|
+
# Parse and validate JSON response
|
|
248
|
+
json_response = response.json()
|
|
249
|
+
if not isinstance(json_response, dict):
|
|
250
|
+
response_type = type(json_response).__name__
|
|
251
|
+
raise SousChefError(
|
|
252
|
+
f"Expected JSON object response, got {response_type}",
|
|
253
|
+
"The API returned an unexpected response format. "
|
|
254
|
+
"Check the API documentation and endpoint.",
|
|
255
|
+
)
|
|
256
|
+
return json_response
|
|
257
|
+
except RequestsHTTPError as e:
|
|
258
|
+
if response is not None:
|
|
259
|
+
raise HTTPError(
|
|
260
|
+
response.status_code,
|
|
261
|
+
str(e),
|
|
262
|
+
response.text,
|
|
263
|
+
) from e
|
|
264
|
+
else:
|
|
265
|
+
raise SousChefError(
|
|
266
|
+
f"HTTP request failed: {e}",
|
|
267
|
+
"Check the API endpoint and your network connection.",
|
|
268
|
+
) from e
|
|
269
|
+
except RequestsTimeout as e:
|
|
270
|
+
raise SousChefError(
|
|
271
|
+
f"Request timed out after {timeout_value} seconds",
|
|
272
|
+
"The API service may be slow or unresponsive. Try increasing "
|
|
273
|
+
"the timeout value or try again later.",
|
|
274
|
+
) from e
|
|
275
|
+
except RequestsConnectionError as e:
|
|
276
|
+
raise SousChefError(
|
|
277
|
+
f"Failed to connect to {url}",
|
|
278
|
+
"Check your network connection and verify the base URL is correct.",
|
|
279
|
+
) from e
|
|
280
|
+
except RequestException as e:
|
|
281
|
+
raise SousChefError(
|
|
282
|
+
f"Request failed: {e}",
|
|
283
|
+
"Check the API documentation and your request parameters.",
|
|
284
|
+
) from e
|
|
285
|
+
|
|
286
|
+
def get(
|
|
287
|
+
self,
|
|
288
|
+
endpoint: str,
|
|
289
|
+
params: dict[str, Any] | None = None,
|
|
290
|
+
auth_type: Literal["bearer", "api_key"] = "bearer",
|
|
291
|
+
extra_headers: dict[str, str] | None = None,
|
|
292
|
+
timeout: int | None = None,
|
|
293
|
+
) -> dict[str, Any]:
|
|
294
|
+
"""
|
|
295
|
+
Make a GET request.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
endpoint: API endpoint (relative to base_url).
|
|
299
|
+
params: Query parameters.
|
|
300
|
+
auth_type: Authentication type.
|
|
301
|
+
extra_headers: Additional headers.
|
|
302
|
+
timeout: Request timeout (overrides default).
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
JSON response data.
|
|
306
|
+
|
|
307
|
+
Raises:
|
|
308
|
+
HTTPError: If request fails.
|
|
309
|
+
|
|
310
|
+
"""
|
|
311
|
+
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
|
312
|
+
headers = self._get_headers(auth_type, extra_headers)
|
|
313
|
+
timeout_value = timeout if timeout is not None else self.timeout
|
|
314
|
+
|
|
315
|
+
response = None
|
|
316
|
+
try:
|
|
317
|
+
response = self.session.get(
|
|
318
|
+
url,
|
|
319
|
+
params=params,
|
|
320
|
+
headers=headers,
|
|
321
|
+
timeout=timeout_value,
|
|
322
|
+
)
|
|
323
|
+
response.raise_for_status()
|
|
324
|
+
|
|
325
|
+
# Parse and validate JSON response
|
|
326
|
+
json_response = response.json()
|
|
327
|
+
if not isinstance(json_response, dict):
|
|
328
|
+
response_type = type(json_response).__name__
|
|
329
|
+
raise SousChefError(
|
|
330
|
+
f"Expected JSON object response, got {response_type}",
|
|
331
|
+
"The API returned an unexpected response format. "
|
|
332
|
+
"Check the API documentation and endpoint.",
|
|
333
|
+
)
|
|
334
|
+
return json_response
|
|
335
|
+
except RequestsHTTPError as e:
|
|
336
|
+
if response is not None:
|
|
337
|
+
raise HTTPError(
|
|
338
|
+
response.status_code,
|
|
339
|
+
str(e),
|
|
340
|
+
response.text,
|
|
341
|
+
) from e
|
|
342
|
+
else:
|
|
343
|
+
raise SousChefError(
|
|
344
|
+
f"HTTP request failed: {e}",
|
|
345
|
+
"Check the API endpoint and your network connection.",
|
|
346
|
+
) from e
|
|
347
|
+
except RequestsTimeout as e:
|
|
348
|
+
raise SousChefError(
|
|
349
|
+
f"Request timed out after {timeout_value} seconds",
|
|
350
|
+
"The API service may be slow or unresponsive. Try increasing "
|
|
351
|
+
"the timeout value or try again later.",
|
|
352
|
+
) from e
|
|
353
|
+
except RequestsConnectionError as e:
|
|
354
|
+
raise SousChefError(
|
|
355
|
+
f"Failed to connect to {url}",
|
|
356
|
+
"Check your network connection and verify the base URL is correct.",
|
|
357
|
+
) from e
|
|
358
|
+
except RequestException as e:
|
|
359
|
+
raise SousChefError(
|
|
360
|
+
f"Request failed: {e}",
|
|
361
|
+
"Check the API documentation and your request parameters.",
|
|
362
|
+
) from e
|
|
363
|
+
|
|
364
|
+
def close(self) -> None:
|
|
365
|
+
"""Close the HTTP session."""
|
|
366
|
+
self.session.close()
|
|
367
|
+
|
|
368
|
+
def __enter__(self) -> "HTTPClient":
|
|
369
|
+
"""Enter context manager."""
|
|
370
|
+
return self
|
|
371
|
+
|
|
372
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
373
|
+
"""Exit context manager and close session."""
|
|
374
|
+
self.close()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def create_client(
|
|
378
|
+
base_url: str,
|
|
379
|
+
api_key: str | None = None,
|
|
380
|
+
**kwargs: Any,
|
|
381
|
+
) -> HTTPClient:
|
|
382
|
+
"""
|
|
383
|
+
Create an HTTP client with default settings.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
base_url: Base URL for API requests.
|
|
387
|
+
api_key: Optional API key for authentication.
|
|
388
|
+
**kwargs: Additional HTTPClient arguments.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
Configured HTTP client.
|
|
392
|
+
|
|
393
|
+
"""
|
|
394
|
+
return HTTPClient(base_url=base_url, api_key=api_key, **kwargs)
|