mcp-souschef 2.1.2__py3-none-any.whl → 2.2.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.
souschef/cli.py CHANGED
@@ -11,6 +11,10 @@ from typing import NoReturn
11
11
 
12
12
  import click
13
13
 
14
+ from souschef.profiling import (
15
+ generate_cookbook_performance_report,
16
+ profile_function,
17
+ )
14
18
  from souschef.server import (
15
19
  convert_inspec_to_test,
16
20
  convert_resource_to_task,
@@ -425,6 +429,92 @@ def _output_result(result: str, output_format: str) -> None:
425
429
  _output_text_format(result)
426
430
 
427
431
 
432
+ @cli.command()
433
+ @click.argument("cookbook_path", type=click.Path(exists=True))
434
+ @click.option(
435
+ "--output",
436
+ "-o",
437
+ type=click.Path(),
438
+ help="Save report to file instead of printing to stdout",
439
+ )
440
+ def profile(cookbook_path: str, output: str | None) -> None:
441
+ """
442
+ Profile cookbook parsing performance and generate optimization report.
443
+
444
+ COOKBOOK_PATH: Path to the Chef cookbook to profile
445
+
446
+ This command analyzes the performance of parsing all cookbook components
447
+ (recipes, attributes, resources, templates) and provides recommendations
448
+ for optimization.
449
+ """
450
+ try:
451
+ click.echo(f"Profiling cookbook: {cookbook_path}")
452
+ click.echo("This may take a moment for large cookbooks...")
453
+
454
+ report = generate_cookbook_performance_report(cookbook_path)
455
+ report_text = str(report)
456
+
457
+ if output:
458
+ Path(output).write_text(report_text)
459
+ click.echo(f"✓ Performance report saved to: {output}")
460
+ else:
461
+ click.echo(report_text)
462
+
463
+ except Exception as e:
464
+ click.echo(f"Error profiling cookbook: {e}", err=True)
465
+ sys.exit(1)
466
+
467
+
468
+ @cli.command()
469
+ @click.argument(
470
+ "operation",
471
+ type=click.Choice(["recipe", "attributes", "resource", "template"]),
472
+ )
473
+ @click.argument("path", type=click.Path(exists=True))
474
+ @click.option(
475
+ "--detailed",
476
+ is_flag=True,
477
+ help="Show detailed function call statistics",
478
+ )
479
+ def profile_operation(operation: str, path: str, detailed: bool) -> None:
480
+ """
481
+ Profile a single parsing operation in detail.
482
+
483
+ OPERATION: Type of operation to profile
484
+ PATH: Path to the file to parse
485
+
486
+ This command profiles a single parsing operation and shows
487
+ execution time, memory usage, and optionally detailed function statistics.
488
+ """
489
+ operation_map = {
490
+ "recipe": parse_recipe,
491
+ "attributes": parse_attributes,
492
+ "resource": parse_custom_resource,
493
+ "template": parse_template,
494
+ }
495
+
496
+ func = operation_map[operation]
497
+
498
+ try:
499
+ click.echo(f"Profiling {operation} parsing: {path}")
500
+
501
+ if detailed:
502
+ from souschef.profiling import detailed_profile_function
503
+
504
+ _, profile_result = detailed_profile_function(func, path)
505
+ click.echo(str(profile_result))
506
+ if profile_result.function_stats.get("top_functions"):
507
+ click.echo("\nDetailed Function Statistics:")
508
+ click.echo(profile_result.function_stats["top_functions"])
509
+ else:
510
+ _, profile_result = profile_function(func, path)
511
+ click.echo(str(profile_result))
512
+
513
+ except Exception as e:
514
+ click.echo(f"Error profiling operation: {e}", err=True)
515
+ sys.exit(1)
516
+
517
+
428
518
  def main() -> NoReturn:
429
519
  """Run the CLI."""
430
520
  cli()
@@ -1441,8 +1441,40 @@ def _extract_chef_guards(resource: dict[str, str], raw_content: str) -> dict[str
1441
1441
  return guards
1442
1442
 
1443
1443
 
1444
+ def _is_opening_delimiter(char: str, in_quotes: bool) -> bool:
1445
+ """Check if character is an opening delimiter."""
1446
+ return char == "{" and not in_quotes
1447
+
1448
+
1449
+ def _is_closing_delimiter(char: str, in_quotes: bool) -> bool:
1450
+ """Check if character is a closing delimiter."""
1451
+ return char == "}" and not in_quotes
1452
+
1453
+
1454
+ def _is_quote_character(char: str) -> bool:
1455
+ """Check if character is a quote."""
1456
+ return char in ['"', "'"]
1457
+
1458
+
1459
+ def _should_split_here(char: str, in_quotes: bool, in_block: int) -> bool:
1460
+ """Determine if we should split at this comma."""
1461
+ return char == "," and not in_quotes and in_block == 0
1462
+
1463
+
1444
1464
  def _split_guard_array_parts(array_content: str) -> list[str]:
1445
- """Split array content by commas, respecting quotes and blocks."""
1465
+ """
1466
+ Split array content by commas, respecting quotes and blocks.
1467
+
1468
+ Handles Chef guard arrays like: ['test -f /file', { block }, "string"]
1469
+ Tracks quote state and brace nesting to avoid splitting inside strings or blocks.
1470
+
1471
+ Args:
1472
+ array_content: Raw array content string
1473
+
1474
+ Returns:
1475
+ List of array parts split by commas
1476
+
1477
+ """
1446
1478
  parts = []
1447
1479
  current_part = ""
1448
1480
  in_quotes = False
@@ -1450,24 +1482,30 @@ def _split_guard_array_parts(array_content: str) -> list[str]:
1450
1482
  quote_char = None
1451
1483
 
1452
1484
  for char in array_content:
1453
- if char in ['"', "'"] and not in_block:
1485
+ # Handle quote transitions
1486
+ if _is_quote_character(char) and not in_block:
1454
1487
  if not in_quotes:
1455
1488
  in_quotes = True
1456
1489
  quote_char = char
1457
1490
  elif char == quote_char:
1458
1491
  in_quotes = False
1459
1492
  quote_char = None
1460
- elif char == "{" and not in_quotes:
1493
+
1494
+ # Handle block nesting
1495
+ elif _is_opening_delimiter(char, in_quotes):
1461
1496
  in_block += 1
1462
- elif char == "}" and not in_quotes:
1497
+ elif _is_closing_delimiter(char, in_quotes):
1463
1498
  in_block -= 1
1464
- elif char == "," and not in_quotes and in_block == 0:
1499
+
1500
+ # Handle splits at commas
1501
+ elif _should_split_here(char, in_quotes, in_block):
1465
1502
  parts.append(current_part.strip())
1466
1503
  current_part = ""
1467
1504
  continue
1468
1505
 
1469
1506
  current_part += char
1470
1507
 
1508
+ # Add final part if not empty
1471
1509
  if current_part.strip():
1472
1510
  parts.append(current_part.strip())
1473
1511
 
@@ -2,10 +2,14 @@
2
2
 
3
3
  import ast
4
4
  import json
5
+ from collections.abc import Callable
5
6
  from typing import Any
6
7
 
7
8
  from souschef.core.constants import ACTION_TO_STATE, RESOURCE_MAPPINGS
8
9
 
10
+ # Type alias for parameter builder functions
11
+ ParamBuilder = Callable[[str, str, dict[str, Any]], dict[str, Any]]
12
+
9
13
 
10
14
  def _parse_properties(properties_str: str) -> dict[str, Any]:
11
15
  """
@@ -87,6 +91,39 @@ def _get_service_params(resource_name: str, action: str) -> dict[str, Any]:
87
91
  return params
88
92
 
89
93
 
94
+ def _get_template_file_params(resource_name: str, action: str) -> dict[str, Any]:
95
+ """Get parameters for template resources."""
96
+ params = {
97
+ "src": resource_name,
98
+ "dest": resource_name.replace(".erb", ""),
99
+ }
100
+ if action == "create":
101
+ params["mode"] = "0644"
102
+ return params
103
+
104
+
105
+ def _get_regular_file_params(resource_name: str, action: str) -> dict[str, Any]:
106
+ """Get parameters for regular file resources."""
107
+ params: dict[str, Any] = {"path": resource_name}
108
+ if action == "create":
109
+ params["state"] = "file"
110
+ params["mode"] = "0644"
111
+ else:
112
+ params["state"] = ACTION_TO_STATE.get(action, action)
113
+ return params
114
+
115
+
116
+ def _get_directory_params(resource_name: str, action: str) -> dict[str, Any]:
117
+ """Get parameters for directory resources."""
118
+ params: dict[str, Any] = {
119
+ "path": resource_name,
120
+ "state": "directory",
121
+ }
122
+ if action == "create":
123
+ params["mode"] = "0755"
124
+ return params
125
+
126
+
90
127
  def _get_file_params(
91
128
  resource_name: str, action: str, resource_type: str
92
129
  ) -> dict[str, Any]:
@@ -102,34 +139,84 @@ def _get_file_params(
102
139
  Dictionary of Ansible file parameters.
103
140
 
104
141
  """
105
- params: dict[str, Any] = {}
106
-
107
142
  if resource_type == "template":
108
- params["src"] = resource_name
109
- params["dest"] = resource_name.replace(".erb", "")
110
- if action == "create":
111
- params["mode"] = "0644"
143
+ return _get_template_file_params(resource_name, action)
112
144
  elif resource_type == "file":
113
- params["path"] = resource_name
114
- if action == "create":
115
- params["state"] = "file"
116
- params["mode"] = "0644"
117
- else:
118
- params["state"] = ACTION_TO_STATE.get(action, action)
145
+ return _get_regular_file_params(resource_name, action)
119
146
  elif resource_type == "directory":
120
- params["path"] = resource_name
121
- params["state"] = "directory"
122
- if action == "create":
123
- params["mode"] = "0755"
147
+ return _get_directory_params(resource_name, action)
148
+ return {}
149
+
150
+
151
+ def _get_package_params(
152
+ resource_name: str, action: str, props: dict[str, Any]
153
+ ) -> dict[str, Any]:
154
+ """Build parameters for package resources."""
155
+ return {"name": resource_name, "state": ACTION_TO_STATE.get(action, action)}
156
+
157
+
158
+ def _get_execute_params(
159
+ resource_name: str, action: str, props: dict[str, Any]
160
+ ) -> dict[str, Any]:
161
+ """Build parameters for execute/bash resources."""
162
+ return {"cmd": resource_name}
163
+
164
+
165
+ def _get_user_group_params(
166
+ resource_name: str, action: str, props: dict[str, Any]
167
+ ) -> dict[str, Any]:
168
+ """Build parameters for user/group resources."""
169
+ return {"name": resource_name, "state": ACTION_TO_STATE.get(action, "present")}
170
+
171
+
172
+ def _get_remote_file_params(
173
+ resource_name: str, action: str, props: dict[str, Any]
174
+ ) -> dict[str, Any]:
175
+ """Build parameters for remote_file resources."""
176
+ params = {"dest": resource_name}
177
+ # Map Chef properties to Ansible parameters
178
+ prop_mappings = {
179
+ "source": "url",
180
+ "mode": "mode",
181
+ "owner": "owner",
182
+ "group": "group",
183
+ "checksum": "checksum",
184
+ }
185
+ for chef_prop, ansible_param in prop_mappings.items():
186
+ if chef_prop in props:
187
+ params[ansible_param] = props[chef_prop]
188
+ return params
124
189
 
190
+
191
+ def _get_default_params(resource_name: str, action: str) -> dict[str, Any]:
192
+ """Build default parameters for unknown resource types."""
193
+ params = {"name": resource_name}
194
+ if action in ACTION_TO_STATE:
195
+ params["state"] = ACTION_TO_STATE[action]
125
196
  return params
126
197
 
127
198
 
199
+ # Resource type to parameter builder mappings
200
+ RESOURCE_PARAM_BUILDERS: dict[str, ParamBuilder | str] = {
201
+ "package": _get_package_params,
202
+ "service": "service", # Uses _get_service_params
203
+ "systemd_unit": "service",
204
+ "template": "file", # Uses _get_file_params
205
+ "file": "file",
206
+ "directory": "file",
207
+ "execute": _get_execute_params,
208
+ "bash": _get_execute_params,
209
+ "user": _get_user_group_params,
210
+ "group": _get_user_group_params,
211
+ "remote_file": _get_remote_file_params,
212
+ }
213
+
214
+
128
215
  def _convert_chef_resource_to_ansible(
129
216
  resource_type: str, resource_name: str, action: str, properties: str
130
217
  ) -> dict[str, Any]:
131
218
  """
132
- Convert Chef resource to Ansible task dictionary.
219
+ Convert Chef resource to Ansible task dictionary using data-driven approach.
133
220
 
134
221
  Args:
135
222
  resource_type: The Chef resource type.
@@ -149,46 +236,56 @@ def _convert_chef_resource_to_ansible(
149
236
  "name": f"{action.capitalize()} {resource_type} {resource_name}",
150
237
  }
151
238
 
152
- # Build module parameters based on resource type
153
- module_params: dict[str, Any] = {}
154
-
155
- # Parse properties if provided
239
+ # Parse properties
156
240
  props = _parse_properties(properties)
157
241
 
158
- if resource_type == "package":
159
- module_params["name"] = resource_name
160
- module_params["state"] = ACTION_TO_STATE.get(action, action)
161
- elif resource_type in ["service", "systemd_unit"]:
162
- module_params = _get_service_params(resource_name, action)
163
- elif resource_type in ["template", "file", "directory"]:
164
- module_params = _get_file_params(resource_name, action, resource_type)
165
- elif resource_type in ["execute", "bash"]:
166
- module_params["cmd"] = resource_name
242
+ # Build module parameters using appropriate builder
243
+ module_params = _build_module_params(resource_type, resource_name, action, props)
244
+
245
+ # Add special task-level flags for execute/bash resources
246
+ if resource_type in ["execute", "bash"]:
167
247
  task["changed_when"] = "false"
168
- elif resource_type in ["user", "group"]:
169
- module_params["name"] = resource_name
170
- module_params["state"] = ACTION_TO_STATE.get(action, "present")
171
- elif resource_type == "remote_file":
172
- module_params["dest"] = resource_name
173
- if "source" in props:
174
- module_params["url"] = props["source"]
175
- if "mode" in props:
176
- module_params["mode"] = props["mode"]
177
- if "owner" in props:
178
- module_params["owner"] = props["owner"]
179
- if "group" in props:
180
- module_params["group"] = props["group"]
181
- if "checksum" in props:
182
- module_params["checksum"] = props["checksum"]
183
- else:
184
- module_params["name"] = resource_name
185
- if action in ACTION_TO_STATE:
186
- module_params["state"] = ACTION_TO_STATE[action]
187
248
 
188
249
  task[ansible_module] = module_params
189
250
  return task
190
251
 
191
252
 
253
+ def _build_module_params(
254
+ resource_type: str, resource_name: str, action: str, props: dict[str, Any]
255
+ ) -> dict[str, Any]:
256
+ """
257
+ Build Ansible module parameters based on resource type.
258
+
259
+ Args:
260
+ resource_type: The Chef resource type.
261
+ resource_name: The resource name.
262
+ action: The Chef action.
263
+ props: Parsed properties dictionary.
264
+
265
+ Returns:
266
+ Dictionary of Ansible module parameters.
267
+
268
+ """
269
+ # Look up the parameter builder for this resource type
270
+ builder = RESOURCE_PARAM_BUILDERS.get(resource_type)
271
+
272
+ if builder is None:
273
+ # Unknown resource type - use default builder
274
+ return _get_default_params(resource_name, action)
275
+
276
+ if isinstance(builder, str):
277
+ # Special handler reference (service/file)
278
+ if builder == "service":
279
+ return _get_service_params(resource_name, action)
280
+ elif builder == "file":
281
+ return _get_file_params(resource_name, action, resource_type)
282
+ # This shouldn't happen, but handle gracefully
283
+ return _get_default_params(resource_name, action)
284
+
285
+ # Call the parameter builder function
286
+ return builder(resource_name, action, props)
287
+
288
+
192
289
  def _format_yaml_value(value: Any) -> str:
193
290
  """Format a value for YAML output."""
194
291
  if isinstance(value, str):
souschef/core/__init__.py CHANGED
@@ -38,6 +38,18 @@ from souschef.core.constants import (
38
38
  REGEX_WORD_SYMBOLS,
39
39
  RESOURCE_MAPPINGS,
40
40
  )
41
+ from souschef.core.errors import (
42
+ ChefFileNotFoundError,
43
+ ConversionError,
44
+ InvalidCookbookError,
45
+ ParseError,
46
+ SousChefError,
47
+ ValidationError,
48
+ format_error_with_context,
49
+ validate_cookbook_structure,
50
+ validate_directory_exists,
51
+ validate_file_exists,
52
+ )
41
53
  from souschef.core.path_utils import _normalize_path, _safe_join
42
54
  from souschef.core.ruby_utils import _normalize_ruby_value
43
55
  from souschef.core.validation import (
@@ -55,4 +67,14 @@ __all__ = [
55
67
  "ValidationEngine",
56
68
  "ValidationLevel",
57
69
  "ValidationResult",
70
+ "SousChefError",
71
+ "ChefFileNotFoundError",
72
+ "InvalidCookbookError",
73
+ "ParseError",
74
+ "ConversionError",
75
+ "ValidationError",
76
+ "validate_file_exists",
77
+ "validate_directory_exists",
78
+ "validate_cookbook_structure",
79
+ "format_error_with_context",
58
80
  ]