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.
@@ -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
- # Future enhancement: Use AI service to validate/improve complex conversions
172
- if ai_service is not None:
173
- # AI validation/improvement logic deferred to future enhancement
174
- # when AI integration becomes more critical to the template conversion process
175
- pass
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)