mcp-souschef 2.1.2__py3-none-any.whl → 2.5.3__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.
@@ -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
  ]
@@ -0,0 +1,275 @@
1
+ """Enhanced error handling with actionable messages and recovery suggestions."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ class SousChefError(Exception):
7
+ """Base exception for SousChef with enhanced error messages."""
8
+
9
+ def __init__(self, message: str, suggestion: str | None = None):
10
+ """
11
+ Initialize with message and optional recovery suggestion.
12
+
13
+ Args:
14
+ message: The error message describing what went wrong.
15
+ suggestion: Optional suggestion for how to fix the error.
16
+
17
+ """
18
+ self.message = message
19
+ self.suggestion = suggestion
20
+ full_message = message
21
+ if suggestion:
22
+ full_message = f"{message}\n\nSuggestion: {suggestion}"
23
+ super().__init__(full_message)
24
+
25
+
26
+ class ChefFileNotFoundError(SousChefError):
27
+ """Raised when a required file cannot be found."""
28
+
29
+ def __init__(self, path: str, file_type: str = "file"):
30
+ """
31
+ Initialize file not found error.
32
+
33
+ Args:
34
+ path: The path that was not found.
35
+ file_type: Type of file (e.g., 'cookbook', 'recipe', 'template').
36
+
37
+ """
38
+ message = f"Could not find {file_type}: {path}"
39
+ suggestion = (
40
+ "Check that the path exists and you have read permissions. "
41
+ "For cookbooks, ensure you're pointing to the cookbook root "
42
+ "directory containing metadata.rb."
43
+ )
44
+ super().__init__(message, suggestion)
45
+
46
+
47
+ class InvalidCookbookError(SousChefError):
48
+ """Raised when a cookbook is invalid or malformed."""
49
+
50
+ def __init__(self, path: str, reason: str):
51
+ """
52
+ Initialize invalid cookbook error.
53
+
54
+ Args:
55
+ path: The cookbook path.
56
+ reason: Why the cookbook is invalid.
57
+
58
+ """
59
+ message = f"Invalid cookbook at {path}: {reason}"
60
+ suggestion = (
61
+ "Ensure the directory contains a valid Chef cookbook with "
62
+ "metadata.rb. Run 'knife cookbook test' to validate the "
63
+ "cookbook structure."
64
+ )
65
+ super().__init__(message, suggestion)
66
+
67
+
68
+ class ParseError(SousChefError):
69
+ """Raised when parsing Chef code fails."""
70
+
71
+ def __init__(
72
+ self, file_path: str, line_number: int | None = None, detail: str = ""
73
+ ):
74
+ """
75
+ Initialize parse error.
76
+
77
+ Args:
78
+ file_path: The file that failed to parse.
79
+ line_number: Optional line number where parsing failed.
80
+ detail: Additional detail about the parse failure.
81
+
82
+ """
83
+ location = f" at line {line_number}" if line_number else ""
84
+ message = f"Failed to parse {file_path}{location}"
85
+ if detail:
86
+ message += f": {detail}"
87
+ suggestion = (
88
+ "Check that the file contains valid Chef Ruby DSL syntax. "
89
+ "Complex Ruby code may require manual review."
90
+ )
91
+ super().__init__(message, suggestion)
92
+
93
+
94
+ class ConversionError(SousChefError):
95
+ """Raised when conversion from Chef to Ansible fails."""
96
+
97
+ def __init__(self, resource_type: str, reason: str):
98
+ """
99
+ Initialize conversion error.
100
+
101
+ Args:
102
+ resource_type: The Chef resource type that failed to convert.
103
+ reason: Why the conversion failed.
104
+
105
+ """
106
+ message = f"Cannot convert Chef resource '{resource_type}': {reason}"
107
+ suggestion = (
108
+ "This resource may require manual conversion. Check the Ansible "
109
+ "module documentation for equivalent modules, or consider using "
110
+ "the 'command' or 'shell' module as a fallback."
111
+ )
112
+ super().__init__(message, suggestion)
113
+
114
+
115
+ class ValidationError(SousChefError):
116
+ """Raised when validation of converted content fails."""
117
+
118
+ def __init__(self, validation_type: str, issues: list[str]):
119
+ """
120
+ Initialize validation error.
121
+
122
+ Args:
123
+ validation_type: Type of validation that failed.
124
+ issues: List of validation issues found.
125
+
126
+ """
127
+ issue_list = "\n - ".join(issues)
128
+ message = f"{validation_type} validation failed:\n - {issue_list}"
129
+ suggestion = (
130
+ "Review the validation issues above and fix them in the "
131
+ "generated output. Run the validation again after making "
132
+ "corrections."
133
+ )
134
+ super().__init__(message, suggestion)
135
+
136
+
137
+ def validate_file_exists(path: str, file_type: str = "file") -> Path:
138
+ """
139
+ Validate that a file exists and is readable.
140
+
141
+ Args:
142
+ path: Path to validate.
143
+ file_type: Type of file for error messages.
144
+
145
+ Returns:
146
+ Path object if validation succeeds.
147
+
148
+ Raises:
149
+ FileNotFoundError: If file doesn't exist or isn't readable.
150
+
151
+ """
152
+ file_path = Path(path)
153
+ if not file_path.exists():
154
+ raise ChefFileNotFoundError(path, file_type)
155
+ if not file_path.is_file():
156
+ raise ChefFileNotFoundError(path, file_type)
157
+ try:
158
+ # Test readability
159
+ with file_path.open() as f:
160
+ f.read(1)
161
+ except PermissionError as e:
162
+ raise SousChefError(
163
+ f"Permission denied reading {file_type}: {path}",
164
+ "Ensure you have read permissions on the file. On Unix systems, "
165
+ "try 'chmod +r' on the file.",
166
+ ) from e
167
+ return file_path
168
+
169
+
170
+ def validate_directory_exists(path: str, dir_type: str = "directory") -> Path:
171
+ """
172
+ Validate that a directory exists and is readable.
173
+
174
+ Args:
175
+ path: Path to validate.
176
+ dir_type: Type of directory for error messages.
177
+
178
+ Returns:
179
+ Path object if validation succeeds.
180
+
181
+ Raises:
182
+ FileNotFoundError: If directory doesn't exist or isn't readable.
183
+
184
+ """
185
+ dir_path = Path(path)
186
+ if not dir_path.exists():
187
+ raise ChefFileNotFoundError(path, dir_type)
188
+ if not dir_path.is_dir():
189
+ raise SousChefError(
190
+ f"Path is not a {dir_type}: {path}",
191
+ f"Expected a directory but found a file. Check that you're "
192
+ f"pointing to the {dir_type} directory, not a file within it.",
193
+ )
194
+ try:
195
+ # Test readability
196
+ list(dir_path.iterdir())
197
+ except PermissionError as e:
198
+ raise SousChefError(
199
+ f"Permission denied reading {dir_type}: {path}",
200
+ "Ensure you have read and execute permissions on the directory. "
201
+ "On Unix systems, try 'chmod +rx' on the directory.",
202
+ ) from e
203
+ return dir_path
204
+
205
+
206
+ def validate_cookbook_structure(path: str) -> Path:
207
+ """
208
+ Validate that a path contains a valid Chef cookbook.
209
+
210
+ Args:
211
+ path: Path to the cookbook root directory.
212
+
213
+ Returns:
214
+ Path object if validation succeeds.
215
+
216
+ Raises:
217
+ InvalidCookbookError: If the directory isn't a valid cookbook.
218
+
219
+ """
220
+ cookbook_path = validate_directory_exists(path, "cookbook")
221
+
222
+ # Check for metadata.rb or metadata.json
223
+ has_metadata = (cookbook_path / "metadata.rb").exists() or (
224
+ cookbook_path / "metadata.json"
225
+ ).exists()
226
+
227
+ if not has_metadata:
228
+ raise InvalidCookbookError(
229
+ path, "No metadata.rb or metadata.json found in cookbook root"
230
+ )
231
+
232
+ return cookbook_path
233
+
234
+
235
+ def format_error_with_context(
236
+ error: Exception, operation: str, file_path: str | None = None
237
+ ) -> str:
238
+ """
239
+ Format an error message with operation context.
240
+
241
+ Args:
242
+ error: The exception that occurred.
243
+ operation: Description of the operation that failed.
244
+ file_path: Optional path to the file being processed.
245
+
246
+ Returns:
247
+ Formatted error message with context and suggestions.
248
+
249
+ """
250
+ if isinstance(error, SousChefError):
251
+ # Already has good formatting
252
+ return str(error)
253
+
254
+ context = f"Error during {operation}"
255
+ if file_path:
256
+ context += f" for {file_path}"
257
+
258
+ if isinstance(error, FileNotFoundError):
259
+ return str(ChefFileNotFoundError(file_path or "unknown", "file"))
260
+ elif isinstance(error, PermissionError):
261
+ return (
262
+ f"{context}: Permission denied\n\nSuggestion: Check "
263
+ "file/directory permissions and ensure you have read access."
264
+ )
265
+ elif isinstance(error, (ValueError, TypeError)):
266
+ return (
267
+ f"{context}: {error}\n\nSuggestion: Check that input "
268
+ "values are in the correct format and type."
269
+ )
270
+ else:
271
+ return (
272
+ f"{context}: {error}\n\nSuggestion: If this error persists, "
273
+ "please report it with the full error message at "
274
+ "https://github.com/kpeacocke/souschef/issues"
275
+ )
@@ -234,10 +234,43 @@ class ValidationEngine:
234
234
  if "import pytest" in result:
235
235
  # Testinfra format
236
236
  self._validate_python_syntax(result)
237
- elif "---" in result:
238
- # Ansible assert format
237
+ elif "require 'serverspec'" in result:
238
+ # ServerSpec format (Ruby)
239
+ self._validate_ruby_syntax(result)
240
+ elif "---" in result or ("package:" in result and "service:" in result):
241
+ # Ansible assert or Goss YAML format
239
242
  self._validate_yaml_syntax(result)
240
243
 
244
+ def _validate_ruby_syntax(self, ruby_content: str) -> None:
245
+ """
246
+ Validate Ruby syntax.
247
+
248
+ Args:
249
+ ruby_content: Ruby content to validate.
250
+
251
+ """
252
+ # Basic Ruby syntax checks
253
+ if not ruby_content.strip():
254
+ self._add_result(
255
+ ValidationLevel.ERROR,
256
+ ValidationCategory.SYNTAX,
257
+ "Empty Ruby content",
258
+ suggestion="Ensure the conversion produced valid Ruby code",
259
+ )
260
+ return
261
+
262
+ # Check for balanced blocks (describe/do/end)
263
+ do_count = len(re.findall(r"\bdo\b", ruby_content))
264
+ end_count = len(re.findall(r"\bend\b", ruby_content))
265
+
266
+ if do_count != end_count:
267
+ self._add_result(
268
+ ValidationLevel.ERROR,
269
+ ValidationCategory.SYNTAX,
270
+ f"Unbalanced Ruby blocks: {do_count} 'do' but {end_count} 'end'",
271
+ suggestion="Check that all 'do' blocks have matching 'end' keywords",
272
+ )
273
+
241
274
  def _validate_yaml_syntax(self, yaml_content: str) -> None:
242
275
  """
243
276
  Validate YAML syntax.