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.
@@ -4,9 +4,11 @@ import re
4
4
  from typing import Any
5
5
 
6
6
  from souschef.core.constants import (
7
+ ATTRIBUTE_PREFIX,
7
8
  ERROR_FILE_NOT_FOUND,
8
9
  ERROR_IS_DIRECTORY,
9
10
  ERROR_PERMISSION_DENIED,
11
+ VALUE_PREFIX,
10
12
  )
11
13
  from souschef.core.path_utils import _normalize_path
12
14
  from souschef.parsers.template import _strip_ruby_comments
@@ -64,7 +66,351 @@ def parse_attributes(path: str, resolve_precedence: bool = True) -> str:
64
66
  return f"An error occurred: {e}"
65
67
 
66
68
 
67
- def _extract_attributes(content: str) -> list[dict[str, str]]:
69
+ def _extract_precedence_and_path(line: str) -> tuple[str, str, str] | None:
70
+ """Extract precedence and attribute path from a line."""
71
+ precedence_types = (
72
+ "default",
73
+ "force_default",
74
+ "normal",
75
+ "override",
76
+ "force_override",
77
+ "automatic",
78
+ )
79
+ if not (line.startswith(precedence_types) and "[" in line):
80
+ return None
81
+
82
+ # Extract precedence
83
+ if line.startswith("default"):
84
+ precedence = "default"
85
+ attr_part = line[7:].strip()
86
+ elif line.startswith("force_default"):
87
+ precedence = "force_default"
88
+ attr_part = line[13:].strip()
89
+ elif line.startswith("normal"):
90
+ precedence = "normal"
91
+ attr_part = line[6:].strip()
92
+ elif line.startswith("override"):
93
+ precedence = "override"
94
+ attr_part = line[8:].strip()
95
+ elif line.startswith("force_override"):
96
+ precedence = "force_override"
97
+ attr_part = line[14:].strip()
98
+ elif line.startswith("automatic"):
99
+ precedence = "automatic"
100
+ attr_part = line[9:].strip()
101
+ else:
102
+ return None
103
+
104
+ # Find the attribute path and value
105
+ equals_pos = attr_part.find("=")
106
+ if equals_pos == -1:
107
+ return None
108
+
109
+ attr_path_part = attr_part[:equals_pos].strip()
110
+ value_start = attr_part[equals_pos + 1 :].strip()
111
+
112
+ # Clean up the path
113
+ attr_path = (
114
+ attr_path_part.replace("']['", ".")
115
+ .replace('"]["', ".")
116
+ .replace("['", "")
117
+ .replace("']", "")
118
+ .replace('["', "")
119
+ .replace('"]', "")
120
+ )
121
+
122
+ return precedence, attr_path, value_start
123
+
124
+
125
+ def _is_ruby_array_syntax(value: str) -> bool:
126
+ """Check if value uses Ruby array syntax."""
127
+ stripped = value.strip()
128
+ return stripped.startswith("%w") or (
129
+ stripped.startswith("[") and stripped.endswith("]")
130
+ )
131
+
132
+
133
+ def _should_stop_collecting(stripped: str, precedence_types: tuple[str, ...]) -> bool:
134
+ """Check if we should stop collecting multiline value based on line content."""
135
+ # Stop if we hit another attribute declaration
136
+ if stripped.startswith(precedence_types) and "[" in stripped:
137
+ return True
138
+
139
+ # Stop if we hit Ruby control structures that indicate end of attribute
140
+ return stripped.startswith(
141
+ (
142
+ "case ",
143
+ "if ",
144
+ "unless ",
145
+ "when ",
146
+ "else",
147
+ "end",
148
+ "def ",
149
+ "class ",
150
+ "module ",
151
+ )
152
+ )
153
+
154
+
155
+ def _update_string_state(
156
+ char: str,
157
+ in_string: bool,
158
+ string_char: str | None,
159
+ line: str,
160
+ value_lines: list[str],
161
+ ) -> tuple[bool, str | None]:
162
+ """Update string parsing state for a single character."""
163
+ if not in_string and char in ('"', "'"):
164
+ return True, char
165
+ elif (
166
+ in_string
167
+ and string_char is not None
168
+ and char == string_char
169
+ and (
170
+ not value_lines
171
+ or line[value_lines[-1].rfind(string_char) + 1 :].count("\\") % 2 == 0
172
+ )
173
+ ):
174
+ return False, None
175
+ return in_string, string_char
176
+
177
+
178
+ def _update_bracket_depths(
179
+ char: str,
180
+ brace_depth: int,
181
+ bracket_depth: int,
182
+ paren_depth: int,
183
+ ) -> tuple[int, int, int]:
184
+ """Update bracket/braces/parentheses depth counters for a single character."""
185
+ if char == "{":
186
+ return brace_depth + 1, bracket_depth, paren_depth
187
+ elif char == "}":
188
+ return brace_depth - 1, bracket_depth, paren_depth
189
+ elif char == "[":
190
+ return brace_depth, bracket_depth + 1, paren_depth
191
+ elif char == "]":
192
+ return brace_depth, bracket_depth - 1, paren_depth
193
+ elif char == "(":
194
+ return brace_depth, bracket_depth, paren_depth + 1
195
+ elif char == ")":
196
+ return brace_depth, bracket_depth, paren_depth - 1
197
+ return brace_depth, bracket_depth, paren_depth
198
+
199
+
200
+ def _update_parsing_state(
201
+ line: str,
202
+ in_string: bool,
203
+ string_char: str | None,
204
+ brace_depth: int,
205
+ bracket_depth: int,
206
+ paren_depth: int,
207
+ value_lines: list[str],
208
+ ) -> tuple[bool, str | None, int, int, int]:
209
+ """Update parsing state for string literals and bracket/braces depth."""
210
+ for char in line:
211
+ if in_string:
212
+ in_string, string_char = _update_string_state(
213
+ char,
214
+ in_string,
215
+ string_char,
216
+ line,
217
+ value_lines,
218
+ )
219
+ else:
220
+ # Check for entering string
221
+ if char in ('"', "'"):
222
+ in_string, string_char = True, char
223
+ else:
224
+ # Update bracket depths
225
+ brace_depth, bracket_depth, paren_depth = _update_bracket_depths(
226
+ char, brace_depth, bracket_depth, paren_depth
227
+ )
228
+
229
+ return in_string, string_char, brace_depth, bracket_depth, paren_depth
230
+
231
+
232
+ def _is_value_complete(
233
+ in_string: bool,
234
+ brace_depth: int,
235
+ bracket_depth: int,
236
+ paren_depth: int,
237
+ value_lines: list[str],
238
+ line: str,
239
+ next_line: str,
240
+ precedence_types: tuple[str, ...],
241
+ ) -> bool:
242
+ """Check if the multiline value collection is complete."""
243
+ # Must be outside strings and have balanced brackets/braces
244
+ if in_string or brace_depth > 0 or bracket_depth > 0 or paren_depth > 0:
245
+ return False
246
+
247
+ # For arrays like %w(...), check if we have the closing paren
248
+ if value_lines and value_lines[0].strip().startswith("%w"):
249
+ return ")" in line
250
+
251
+ # For regular arrays/hashes, break when brackets are balanced
252
+ return (
253
+ brace_depth == 0
254
+ and bracket_depth == 0
255
+ and paren_depth == 0
256
+ and (
257
+ not next_line
258
+ or next_line.startswith(precedence_types)
259
+ or next_line.startswith(("case ", "if ", "unless "))
260
+ )
261
+ )
262
+
263
+
264
+ def _collect_multiline_value(lines: list[str], start_idx: int) -> tuple[str, int]:
265
+ """Collect multiline attribute value."""
266
+ value_lines: list[str] = []
267
+ i = start_idx
268
+ brace_depth = 0
269
+ bracket_depth = 0
270
+ paren_depth = 0
271
+ in_string = False
272
+ string_char = None
273
+
274
+ precedence_types = (
275
+ "default",
276
+ "force_default",
277
+ "normal",
278
+ "override",
279
+ "force_override",
280
+ "automatic",
281
+ )
282
+
283
+ while i < len(lines):
284
+ line = lines[i]
285
+ stripped = line.strip()
286
+
287
+ # Skip empty lines at the beginning
288
+ if not value_lines and not stripped:
289
+ i += 1
290
+ continue
291
+
292
+ # Check if we should stop collecting
293
+ if _should_stop_collecting(stripped, precedence_types):
294
+ break
295
+
296
+ # Update parsing state for strings and brackets
297
+ (
298
+ in_string,
299
+ string_char,
300
+ brace_depth,
301
+ bracket_depth,
302
+ paren_depth,
303
+ ) = _update_parsing_state(
304
+ line,
305
+ in_string,
306
+ string_char,
307
+ brace_depth,
308
+ bracket_depth,
309
+ paren_depth,
310
+ value_lines,
311
+ )
312
+
313
+ value_lines.append(line)
314
+
315
+ # Check if the value is complete
316
+ next_line = lines[i + 1].strip() if i + 1 < len(lines) else ""
317
+ if _is_value_complete(
318
+ in_string,
319
+ brace_depth,
320
+ bracket_depth,
321
+ paren_depth,
322
+ value_lines,
323
+ line,
324
+ next_line,
325
+ precedence_types,
326
+ ):
327
+ break
328
+
329
+ i += 1
330
+
331
+ return "\n".join(value_lines).strip(), i
332
+
333
+
334
+ def _convert_ruby_word_array(content: str) -> str:
335
+ """Convert Ruby %w(...) array syntax to YAML list."""
336
+ # Extract the array content
337
+ match = re.match(r"%w\s*\((.*?)\)", content, re.DOTALL)
338
+ if not match:
339
+ return content
340
+
341
+ array_content = match.group(1)
342
+ # Split on whitespace and newlines, clean up
343
+ items = []
344
+ for item in re.split(r"\s+", array_content):
345
+ item = item.strip()
346
+ if item and not item.startswith("#"): # Skip comments
347
+ items.append(f" - {item}")
348
+ return "\n".join(items) if items else "[]"
349
+
350
+
351
+ def _convert_ruby_array(content: str) -> str:
352
+ """Convert Ruby [item1, item2] array syntax to YAML list."""
353
+ # Remove brackets and strip
354
+ array_content = content.strip()[1:-1]
355
+ if not array_content.strip():
356
+ return "[]"
357
+
358
+ items = []
359
+ # Split on commas, but be careful with nested structures
360
+ for item in array_content.split(","):
361
+ item = item.strip()
362
+ if item:
363
+ items.append(f" - {item}")
364
+ return "\n".join(items) if items else "[]"
365
+
366
+
367
+ def _convert_ruby_hash(content: str) -> str:
368
+ """Convert Ruby {key: value} hash syntax to YAML mapping."""
369
+ # Remove braces and strip
370
+ hash_content = content.strip()[1:-1]
371
+ if not hash_content.strip():
372
+ return "{}"
373
+
374
+ lines = []
375
+ # Split on commas
376
+ for pair in hash_content.split(","):
377
+ pair = pair.strip()
378
+ if ":" in pair:
379
+ key, val = pair.split(":", 1)
380
+ lines.append(f" {key.strip()}: {val.strip()}")
381
+ return "\n".join(lines) if lines else "{}"
382
+
383
+
384
+ def _convert_ruby_value_to_yaml(value: str) -> str:
385
+ """
386
+ Convert Ruby value syntax to YAML-compatible format.
387
+
388
+ Args:
389
+ value: Raw Ruby value string.
390
+
391
+ Returns:
392
+ YAML-compatible value string.
393
+
394
+ """
395
+ stripped = value.strip()
396
+
397
+ # Handle %w(...) array syntax
398
+ if stripped.startswith("%w"):
399
+ return _convert_ruby_word_array(stripped)
400
+
401
+ # Handle regular arrays [item1, item2]
402
+ if stripped.startswith("[") and stripped.endswith("]"):
403
+ return _convert_ruby_array(stripped)
404
+
405
+ # Handle hashes {key: value, key2: value2}
406
+ if stripped.startswith("{") and stripped.endswith("}"):
407
+ return _convert_ruby_hash(stripped)
408
+
409
+ # Return as-is for other values
410
+ return value
411
+
412
+
413
+ def _extract_attributes(content: str) -> list[dict[str, str]]: # noqa: C901
68
414
  """
69
415
  Extract Chef attributes from attributes file content.
70
416
 
@@ -79,36 +425,55 @@ def _extract_attributes(content: str) -> list[dict[str, str]]:
79
425
  # Strip comments first
80
426
  clean_content = _strip_ruby_comments(content)
81
427
 
82
- # Match attribute declarations with all precedence levels
83
- # Chef precedence levels (lowest to highest):
84
- # default < force_default < normal < override < force_override < automatic
85
- pattern = (
86
- r"(default|force_default|normal|override|force_override|automatic)"
87
- r"((?:\[[^\]]+\])+)\s*=\s*([^\n]+)"
88
- )
428
+ # Split content into lines for easier processing
429
+ lines = clean_content.split("\n")
430
+ i = 0
89
431
 
90
- for match in re.finditer(pattern, clean_content, re.DOTALL):
91
- precedence = match.group(1)
92
- # Extract the bracket part and clean it up
93
- brackets = match.group(2)
94
- # Clean up the path - remove quotes and brackets, convert to dot notation
95
- attr_path = (
96
- brackets.replace("']['", ".")
97
- .replace('"]["', ".")
98
- .replace("['", "")
99
- .replace("']", "")
100
- .replace('["', "")
101
- .replace('"]', "")
102
- )
103
- value = match.group(3).strip()
104
-
105
- attributes.append(
106
- {
107
- "precedence": precedence,
108
- "path": attr_path,
109
- "value": value,
110
- }
111
- )
432
+ while i < len(lines):
433
+ line = lines[i].strip()
434
+
435
+ # Try to extract precedence and path
436
+ result = _extract_precedence_and_path(line)
437
+ if result is not None:
438
+ precedence, attr_path, value_start = result
439
+
440
+ # Check if this is Ruby array syntax
441
+ is_ruby_array = _is_ruby_array_syntax(value_start)
442
+
443
+ # Collect the value (may span multiple lines)
444
+ value_lines = [value_start]
445
+ i += 1
446
+
447
+ # Continue collecting lines
448
+ full_value, i = _collect_multiline_value(lines, i)
449
+
450
+ if full_value:
451
+ value_lines[0] = full_value
452
+
453
+ # Join value lines and clean up
454
+ value = "\n".join(value_lines).strip()
455
+
456
+ # For Ruby arrays, reconstruct the full syntax for conversion
457
+ if (
458
+ is_ruby_array
459
+ and not value.startswith("%w")
460
+ and (not value.startswith("["))
461
+ ):
462
+ # This was a multiline Ruby array, reconstruct %w syntax
463
+ value = f"%w(\n{value}\n)"
464
+
465
+ # Convert Ruby syntax to YAML-compatible format
466
+ value = _convert_ruby_value_to_yaml(value)
467
+
468
+ attributes.append(
469
+ {
470
+ "precedence": precedence,
471
+ "path": attr_path,
472
+ "value": value,
473
+ }
474
+ )
475
+ else:
476
+ i += 1
112
477
 
113
478
  return attributes
114
479
 
@@ -236,8 +601,8 @@ def _format_resolved_attributes(
236
601
  # Sort by attribute path for consistent output
237
602
  for path in sorted(resolved.keys()):
238
603
  info = resolved[path]
239
- result.append(f"Attribute: {path}")
240
- result.append(f" Value: {info['value']}")
604
+ result.append(f"{ATTRIBUTE_PREFIX}{path}")
605
+ result.append(f" {VALUE_PREFIX}{info['value']}")
241
606
  result.append(
242
607
  f" Precedence: {info['precedence']} (level {info['precedence_level']})"
243
608
  )
@@ -38,11 +38,15 @@ def parse_recipe(path: str) -> str:
38
38
  content = file_path.read_text(encoding="utf-8")
39
39
 
40
40
  resources = _extract_resources(content)
41
+ include_recipes = _extract_include_recipes(content)
41
42
 
42
- if not resources:
43
- return f"Warning: No Chef resources found in {path}"
43
+ # Combine resources and include_recipes
44
+ all_items = resources + include_recipes
44
45
 
45
- return _format_resources(resources)
46
+ if not all_items:
47
+ return f"Warning: No Chef resources or include_recipe calls found in {path}"
48
+
49
+ return _format_resources(all_items)
46
50
 
47
51
  except ValueError as e:
48
52
  return f"Error: {e}"
@@ -114,6 +118,36 @@ def _extract_resources(content: str) -> list[dict[str, str]]:
114
118
  return resources
115
119
 
116
120
 
121
+ def _extract_include_recipes(content: str) -> list[dict[str, str]]:
122
+ """
123
+ Extract include_recipe calls from recipe content.
124
+
125
+ Args:
126
+ content: Raw content of recipe file.
127
+
128
+ Returns:
129
+ List of dictionaries containing include_recipe information.
130
+
131
+ """
132
+ include_recipes = []
133
+ # Strip comments first
134
+ clean_content = _strip_ruby_comments(content)
135
+
136
+ # Match include_recipe calls: include_recipe 'recipe_name'
137
+ pattern = r"include_recipe\s+['\"]([^'\"]+)['\"]"
138
+
139
+ for match in re.finditer(pattern, clean_content):
140
+ recipe_name = match.group(1)
141
+ include_recipes.append(
142
+ {
143
+ "type": "include_recipe",
144
+ "name": recipe_name,
145
+ }
146
+ )
147
+
148
+ return include_recipes
149
+
150
+
117
151
  def _extract_conditionals(content: str) -> list[dict[str, Any]]:
118
152
  """
119
153
  Extract Ruby conditionals from recipe code.
@@ -189,12 +223,16 @@ def _format_resources(resources: list[dict[str, Any]]) -> str:
189
223
  for i, resource in enumerate(resources, 1):
190
224
  if i > 1:
191
225
  result.append("")
192
- result.append(f"Resource {i}:")
193
- result.append(f" Type: {resource['type']}")
194
- result.append(f" Name: {resource['name']}")
195
- if "action" in resource:
196
- result.append(f" Action: {resource['action']}")
197
- if "properties" in resource:
198
- result.append(f" Properties: {resource['properties']}")
226
+ if resource["type"] == "include_recipe":
227
+ result.append(f"Include Recipe {i}:")
228
+ result.append(f" Recipe: {resource['name']}")
229
+ else:
230
+ result.append(f"Resource {i}:")
231
+ result.append(f" Type: {resource['type']}")
232
+ result.append(f" Name: {resource['name']}")
233
+ if "action" in resource:
234
+ result.append(f" Action: {resource['action']}")
235
+ if "properties" in resource:
236
+ result.append(f" Properties: {resource['properties']}")
199
237
 
200
238
  return "\n".join(result)