mcp-souschef 2.5.3__py3-none-any.whl → 3.0.0__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.
@@ -2,9 +2,14 @@
2
2
 
3
3
  import ast
4
4
  import json
5
+ import re
5
6
  from collections.abc import Callable
6
7
  from typing import Any
7
8
 
9
+ from souschef.converters.cookbook_specific import (
10
+ build_cookbook_resource_params,
11
+ get_cookbook_package_config,
12
+ )
8
13
  from souschef.core.constants import ACTION_TO_STATE, RESOURCE_MAPPINGS
9
14
 
10
15
  # Type alias for parameter builder functions
@@ -25,20 +30,59 @@ def _parse_properties(properties_str: str) -> dict[str, Any]:
25
30
  if not properties_str:
26
31
  return {}
27
32
  try:
28
- # Try ast.literal_eval first for safety
33
+ # Use ast.literal_eval for safe parsing of Python literals
29
34
  result = ast.literal_eval(properties_str)
30
35
  if isinstance(result, dict):
31
36
  return result
32
37
  return {}
33
38
  except (ValueError, SyntaxError):
34
- # Fallback to eval if needed, but this is less safe
35
- try:
36
- result = eval(properties_str) # noqa: S307
37
- if isinstance(result, dict):
38
- return result
39
- return {}
40
- except Exception:
41
- return {}
39
+ # If parsing fails, return empty dict rather than using unsafe eval
40
+ return {}
41
+
42
+
43
+ def _normalize_template_value(value: Any) -> Any:
44
+ """Normalize Ruby-style attribute references into Jinja templates."""
45
+ if isinstance(value, str):
46
+ # Convert Chef node attributes to Ansible variables dynamically
47
+ def _replace_node_attr(match):
48
+ cookbook = match.group(1)
49
+ attr = match.group(2)
50
+
51
+ # Convert cookbook name to readable format
52
+ def char_to_word(c: str) -> str:
53
+ number_words = {
54
+ "1": "one",
55
+ "2": "two",
56
+ "3": "three",
57
+ "4": "four",
58
+ "5": "five",
59
+ "6": "six",
60
+ "7": "seven",
61
+ "8": "eight",
62
+ "9": "nine",
63
+ "0": "zero",
64
+ }
65
+ return number_words.get(c, c)
66
+
67
+ # Replace non-alphanumeric characters with underscores
68
+ readable_cookbook = "".join(
69
+ char_to_word(c) if c.isdigit() else (c if c.isalnum() else "_")
70
+ for c in cookbook
71
+ )
72
+
73
+ # Ensure we don't have multiple consecutive underscores
74
+ readable_cookbook = re.sub(r"_+", "_", readable_cookbook)
75
+ # Remove leading/trailing underscores
76
+ readable_cookbook = readable_cookbook.strip("_")
77
+
78
+ return f"{{{{ {readable_cookbook}_{attr} }}}}"
79
+
80
+ value = re.sub(r"node\['(\w+)'\]\['(\w+)'\]", _replace_node_attr, value)
81
+
82
+ # Wrap in Jinja if it's a node reference
83
+ if "node[" in value:
84
+ return f"{{{{ {value} }}}}"
85
+ return value
42
86
 
43
87
 
44
88
  def convert_resource_to_task(
@@ -188,6 +232,26 @@ def _get_remote_file_params(
188
232
  return params
189
233
 
190
234
 
235
+ def _get_include_recipe_params(
236
+ resource_name: str, action: str, props: dict[str, Any]
237
+ ) -> dict[str, Any]:
238
+ """
239
+ Build parameters for include_recipe resources.
240
+
241
+ Uses cookbook-specific configurations when available.
242
+ Falls back to include_role for unknown cookbooks.
243
+ """
244
+ cookbook_config = get_cookbook_package_config(resource_name)
245
+ if cookbook_config:
246
+ # Return a copy to prevent callers from mutating the shared mapping.
247
+ return dict(cookbook_config["params"])
248
+
249
+ # For unknown cookbooks, use include_role with the cookbook name
250
+ # Extract cookbook name from "cookbook::recipe" format
251
+ cookbook_name = resource_name.split("::")[0]
252
+ return {"name": cookbook_name}
253
+
254
+
191
255
  def _get_default_params(resource_name: str, action: str) -> dict[str, Any]:
192
256
  """Build default parameters for unknown resource types."""
193
257
  params = {"name": resource_name}
@@ -209,6 +273,7 @@ RESOURCE_PARAM_BUILDERS: dict[str, ParamBuilder | str] = {
209
273
  "user": _get_user_group_params,
210
274
  "group": _get_user_group_params,
211
275
  "remote_file": _get_remote_file_params,
276
+ "include_recipe": _get_include_recipe_params,
212
277
  }
213
278
 
214
279
 
@@ -229,7 +294,26 @@ def _convert_chef_resource_to_ansible(
229
294
 
230
295
  """
231
296
  # Get Ansible module name
232
- ansible_module = RESOURCE_MAPPINGS.get(resource_type, f"# Unknown: {resource_type}")
297
+ ansible_module = RESOURCE_MAPPINGS.get(resource_type)
298
+
299
+ # Check for cookbook-specific include_recipe configurations
300
+ if resource_type == "include_recipe":
301
+ cookbook_config = get_cookbook_package_config(resource_name)
302
+ if cookbook_config:
303
+ ansible_module = cookbook_config["module"]
304
+ else:
305
+ # For include_recipe without specific mapping, use include_role
306
+ ansible_module = "ansible.builtin.include_role"
307
+
308
+ # Handle unknown resource types
309
+ if ansible_module is None:
310
+ # Return a task with just a comment for unknown resources
311
+ return {
312
+ "name": f"Create {resource_type} {resource_name}",
313
+ "# Unknown": f"{resource_type}:",
314
+ "resource_name": resource_name,
315
+ "state": "present",
316
+ }
233
317
 
234
318
  # Start building the task
235
319
  task: dict[str, Any] = {
@@ -242,6 +326,12 @@ def _convert_chef_resource_to_ansible(
242
326
  # Build module parameters using appropriate builder
243
327
  module_params = _build_module_params(resource_type, resource_name, action, props)
244
328
 
329
+ # Override with cookbook-specific params for include_recipe
330
+ if resource_type == "include_recipe":
331
+ cookbook_config = get_cookbook_package_config(resource_name)
332
+ if cookbook_config:
333
+ module_params = cookbook_config["params"].copy()
334
+
245
335
  # Add special task-level flags for execute/bash resources
246
336
  if resource_type in ["execute", "bash"]:
247
337
  task["changed_when"] = "false"
@@ -266,6 +356,13 @@ def _build_module_params(
266
356
  Dictionary of Ansible module parameters.
267
357
 
268
358
  """
359
+ # First check for cookbook-specific resource types
360
+ cookbook_params = build_cookbook_resource_params(
361
+ resource_type, resource_name, action, props
362
+ )
363
+ if cookbook_params is not None:
364
+ return cookbook_params
365
+
269
366
  # Look up the parameter builder for this resource type
270
367
  builder = RESOURCE_PARAM_BUILDERS.get(resource_type)
271
368
 
@@ -297,6 +394,8 @@ def _format_dict_value(key: str, value: dict[str, Any]) -> list[str]:
297
394
  """Format a dictionary value for YAML output."""
298
395
  lines = [f" {key}:"]
299
396
  for param_key, param_value in value.items():
397
+ # Indent nested params by four spaces so downstream formatting nests
398
+ # module parameters under the module key.
300
399
  lines.append(f" {param_key}: {_format_yaml_value(param_value)}")
301
400
  return lines
302
401
 
@@ -317,6 +416,10 @@ def _format_ansible_task(task: dict[str, Any]) -> str:
317
416
  for key, value in task.items():
318
417
  if key == "name":
319
418
  continue
419
+ if key == "# Unknown":
420
+ # Handle unknown resources with a comment
421
+ result.append(f" # {value}")
422
+ continue
320
423
  if isinstance(value, dict):
321
424
  result.extend(_format_dict_value(key, value))
322
425
  else:
@@ -0,0 +1,177 @@
1
+ """Chef ERB template to Jinja2 converter."""
2
+
3
+ from pathlib import Path
4
+
5
+ from souschef.parsers.template import (
6
+ _convert_erb_to_jinja2,
7
+ _extract_template_variables,
8
+ )
9
+
10
+
11
+ def convert_template_file(erb_path: str) -> dict:
12
+ """
13
+ Convert an ERB template file to Jinja2 format.
14
+
15
+ Args:
16
+ erb_path: Path to the ERB template file.
17
+
18
+ Returns:
19
+ Dictionary containing:
20
+ - success: bool, whether conversion succeeded
21
+ - original_file: str, path to original ERB file
22
+ - jinja2_file: str, suggested path for .j2 file
23
+ - jinja2_content: str, converted Jinja2 template content
24
+ - variables: list, variables found in template
25
+ - error: str (optional), error message if conversion failed
26
+
27
+ """
28
+ try:
29
+ file_path = Path(erb_path).resolve()
30
+
31
+ if not file_path.exists():
32
+ return {
33
+ "success": False,
34
+ "error": f"File not found: {erb_path}",
35
+ "original_file": erb_path,
36
+ }
37
+
38
+ if not file_path.is_file():
39
+ return {
40
+ "success": False,
41
+ "error": f"Path is not a file: {erb_path}",
42
+ "original_file": erb_path,
43
+ }
44
+
45
+ # Read ERB template
46
+ try:
47
+ content = file_path.read_text(encoding="utf-8")
48
+ except UnicodeDecodeError:
49
+ return {
50
+ "success": False,
51
+ "error": f"Unable to decode {erb_path} as UTF-8 text",
52
+ "original_file": str(file_path),
53
+ }
54
+
55
+ # Extract variables
56
+ variables = _extract_template_variables(content)
57
+
58
+ # Convert ERB to Jinja2
59
+ jinja2_content = _convert_erb_to_jinja2(content)
60
+
61
+ # Determine output file name
62
+ jinja2_file = str(file_path).replace(".erb", ".j2")
63
+
64
+ return {
65
+ "success": True,
66
+ "original_file": str(file_path),
67
+ "jinja2_file": jinja2_file,
68
+ "jinja2_content": jinja2_content,
69
+ "variables": sorted(variables),
70
+ }
71
+
72
+ except Exception as e:
73
+ return {
74
+ "success": False,
75
+ "error": f"Conversion failed: {e}",
76
+ "original_file": erb_path,
77
+ }
78
+
79
+
80
+ def convert_cookbook_templates(cookbook_path: str) -> dict:
81
+ """
82
+ Convert all ERB templates in a cookbook to Jinja2.
83
+
84
+ Args:
85
+ cookbook_path: Path to the cookbook directory.
86
+
87
+ Returns:
88
+ Dictionary containing:
89
+ - success: bool, whether all conversions succeeded
90
+ - templates_converted: int, number of templates successfully converted
91
+ - templates_failed: int, number of templates that failed conversion
92
+ - results: list of dict, individual template conversion results
93
+ - error: str (optional), error message if cookbook not found
94
+
95
+ """
96
+ try:
97
+ cookbook_dir = Path(cookbook_path).resolve()
98
+
99
+ if not cookbook_dir.exists():
100
+ return {
101
+ "success": False,
102
+ "error": f"Cookbook directory not found: {cookbook_path}",
103
+ "templates_converted": 0,
104
+ "templates_failed": 0,
105
+ "results": [],
106
+ }
107
+
108
+ # Find all .erb files in the cookbook
109
+ erb_files = list(cookbook_dir.glob("**/*.erb"))
110
+
111
+ if not erb_files:
112
+ return {
113
+ "success": True,
114
+ "templates_converted": 0,
115
+ "templates_failed": 0,
116
+ "results": [],
117
+ "message": "No ERB templates found in cookbook",
118
+ }
119
+
120
+ results = []
121
+ templates_converted = 0
122
+ templates_failed = 0
123
+
124
+ for erb_file in erb_files:
125
+ result = convert_template_file(str(erb_file))
126
+ results.append(result)
127
+
128
+ if result["success"]:
129
+ templates_converted += 1
130
+ else:
131
+ templates_failed += 1
132
+
133
+ return {
134
+ "success": templates_failed == 0,
135
+ "templates_converted": templates_converted,
136
+ "templates_failed": templates_failed,
137
+ "results": results,
138
+ }
139
+
140
+ except Exception as e:
141
+ return {
142
+ "success": False,
143
+ "error": f"Failed to convert cookbook templates: {e}",
144
+ "templates_converted": 0,
145
+ "templates_failed": 0,
146
+ "results": [],
147
+ }
148
+
149
+
150
+ def convert_template_with_ai(erb_path: str, ai_service=None) -> dict:
151
+ """
152
+ Convert an ERB template to Jinja2 using AI assistance for complex conversions.
153
+
154
+ This function first attempts rule-based conversion, then optionally uses AI
155
+ for validation or complex Ruby logic that can't be automatically converted.
156
+
157
+ Args:
158
+ erb_path: Path to the ERB template file.
159
+ ai_service: Optional AI service instance for complex conversions.
160
+
161
+ Returns:
162
+ Dictionary with conversion results (same format as convert_template_file).
163
+
164
+ """
165
+ # Start with rule-based conversion
166
+ result = convert_template_file(erb_path)
167
+
168
+ # Add conversion method metadata
169
+ result["conversion_method"] = "rule-based"
170
+
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
176
+
177
+ return result
@@ -1,10 +1,13 @@
1
1
  """Constants used throughout SousChef."""
2
2
 
3
3
  __all__ = [
4
+ "VERSION",
4
5
  "ANSIBLE_SERVICE_MODULE",
5
6
  "METADATA_FILENAME",
6
7
  "ERROR_PREFIX",
7
8
  "NODE_PREFIX",
9
+ "ATTRIBUTE_PREFIX",
10
+ "VALUE_PREFIX",
8
11
  "CHEF_RECIPE_PREFIX",
9
12
  "CHEF_ROLE_PREFIX",
10
13
  "REGEX_WHITESPACE_QUOTE",
@@ -39,6 +42,9 @@ __all__ = [
39
42
  "ACTION_TO_STATE",
40
43
  ]
41
44
 
45
+ # Version
46
+ VERSION = "2.6.0"
47
+
42
48
  # Ansible module names
43
49
  ANSIBLE_SERVICE_MODULE = "ansible.builtin.service"
44
50
 
@@ -48,6 +54,8 @@ METADATA_FILENAME = "metadata.rb"
48
54
  # Common prefixes
49
55
  ERROR_PREFIX = "Error:"
50
56
  NODE_PREFIX = "node["
57
+ ATTRIBUTE_PREFIX = "Attribute: "
58
+ VALUE_PREFIX = "Value: "
51
59
  CHEF_RECIPE_PREFIX = "recipe["
52
60
  CHEF_ROLE_PREFIX = "role["
53
61
 
@@ -126,6 +134,11 @@ RESOURCE_MAPPINGS = {
126
134
  "mount": "ansible.builtin.mount",
127
135
  "git": "ansible.builtin.git",
128
136
  "remote_file": "ansible.builtin.get_url",
137
+ "nodejs_npm": "community.general.npm",
138
+ # Map include_recipe to a generic package install so well-known recipes
139
+ # (like nodejs) can be materialised into concrete tasks instead of
140
+ # producing stubbed import_role calls.
141
+ "include_recipe": "ansible.builtin.package",
129
142
  }
130
143
 
131
144
  # Chef action to Ansible state mappings