mcp-souschef 2.0.1__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.
@@ -0,0 +1,5 @@
1
+ """Filesystem operations."""
2
+
3
+ from souschef.filesystem.operations import list_directory, read_file
4
+
5
+ __all__ = ["list_directory", "read_file"]
@@ -0,0 +1,67 @@
1
+ """Filesystem operations for Chef cookbook exploration."""
2
+
3
+ from mcp.server.fastmcp import FastMCP
4
+
5
+ from souschef.core.constants import (
6
+ ERROR_FILE_NOT_FOUND,
7
+ ERROR_IS_DIRECTORY,
8
+ ERROR_PERMISSION_DENIED,
9
+ )
10
+ from souschef.core.path_utils import _normalize_path
11
+
12
+ # Get MCP instance to register tools
13
+ mcp = FastMCP("souschef")
14
+
15
+
16
+ @mcp.tool()
17
+ def list_directory(path: str) -> list[str] | str:
18
+ """
19
+ List the contents of a directory.
20
+
21
+ Args:
22
+ path: The path to the directory to list.
23
+
24
+ Returns:
25
+ A list of filenames in the directory, or an error message.
26
+
27
+ """
28
+ try:
29
+ dir_path = _normalize_path(path)
30
+ return [item.name for item in dir_path.iterdir()]
31
+ except ValueError as e:
32
+ return f"Error: {e}"
33
+ except FileNotFoundError:
34
+ return f"Error: Directory not found at {path}"
35
+ except NotADirectoryError:
36
+ return f"Error: {path} is not a directory"
37
+ except PermissionError:
38
+ return ERROR_PERMISSION_DENIED.format(path=path)
39
+ except Exception as e:
40
+ return f"An error occurred: {e}"
41
+
42
+
43
+ @mcp.tool()
44
+ def read_file(path: str) -> str:
45
+ """
46
+ Read the contents of a file.
47
+
48
+ Args:
49
+ path: The path to the file to read.
50
+
51
+ Returns:
52
+ The contents of the file, or an error message.
53
+
54
+ """
55
+ try:
56
+ file_path = _normalize_path(path)
57
+ return file_path.read_text(encoding="utf-8")
58
+ except ValueError as e:
59
+ return f"Error: {e}"
60
+ except FileNotFoundError:
61
+ return ERROR_FILE_NOT_FOUND.format(path=path)
62
+ except IsADirectoryError:
63
+ return ERROR_IS_DIRECTORY.format(path=path)
64
+ except PermissionError:
65
+ return ERROR_PERMISSION_DENIED.format(path=path)
66
+ except Exception as e:
67
+ return f"An error occurred: {e}"
@@ -0,0 +1,36 @@
1
+ """Chef cookbook parsers."""
2
+
3
+ from souschef.core.validation import (
4
+ ValidationCategory,
5
+ ValidationEngine,
6
+ ValidationLevel,
7
+ ValidationResult,
8
+ )
9
+ from souschef.parsers.attributes import parse_attributes
10
+ from souschef.parsers.habitat import parse_habitat_plan
11
+ from souschef.parsers.inspec import (
12
+ convert_inspec_to_test,
13
+ generate_inspec_from_chef,
14
+ parse_inspec_profile,
15
+ )
16
+ from souschef.parsers.metadata import list_cookbook_structure, read_cookbook_metadata
17
+ from souschef.parsers.recipe import parse_recipe
18
+ from souschef.parsers.resource import parse_custom_resource
19
+ from souschef.parsers.template import parse_template
20
+
21
+ __all__ = [
22
+ "parse_template",
23
+ "parse_recipe",
24
+ "parse_attributes",
25
+ "parse_custom_resource",
26
+ "read_cookbook_metadata",
27
+ "list_cookbook_structure",
28
+ "parse_inspec_profile",
29
+ "convert_inspec_to_test",
30
+ "generate_inspec_from_chef",
31
+ "parse_habitat_plan",
32
+ "ValidationCategory",
33
+ "ValidationEngine",
34
+ "ValidationLevel",
35
+ "ValidationResult",
36
+ ]
@@ -0,0 +1,257 @@
1
+ """Chef attributes file parser."""
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from souschef.core.constants import (
7
+ ERROR_FILE_NOT_FOUND,
8
+ ERROR_IS_DIRECTORY,
9
+ ERROR_PERMISSION_DENIED,
10
+ )
11
+ from souschef.core.path_utils import _normalize_path
12
+ from souschef.parsers.template import _strip_ruby_comments
13
+
14
+
15
+ def parse_attributes(path: str, resolve_precedence: bool = True) -> str:
16
+ """
17
+ Parse a Chef attributes file and extract attribute definitions.
18
+
19
+ Analyzes attributes file and extracts all attribute definitions with their
20
+ precedence levels and values. By default, resolves precedence conflicts
21
+ to show the winning value for each attribute path.
22
+
23
+ Chef attribute precedence (lowest to highest):
24
+ 1. default - Normal default value
25
+ 2. force_default - Forced default, higher than regular default
26
+ 3. normal - Normal attribute set by cookbook
27
+ 4. override - Override values
28
+ 5. force_override - Forced override, cannot be overridden
29
+ 6. automatic - Automatically detected by Ohai (highest precedence)
30
+
31
+ Args:
32
+ path: Path to the attributes (.rb) file.
33
+ resolve_precedence: If True (default), resolves precedence conflicts
34
+ and shows only winning values. If False, shows all attributes.
35
+
36
+ Returns:
37
+ Formatted string with extracted attributes.
38
+
39
+ """
40
+ try:
41
+ file_path = _normalize_path(path)
42
+ content = file_path.read_text(encoding="utf-8")
43
+
44
+ attributes = _extract_attributes(content)
45
+
46
+ if not attributes:
47
+ return f"Warning: No attributes found in {path}"
48
+
49
+ if resolve_precedence:
50
+ resolved = _resolve_attribute_precedence(attributes)
51
+ return _format_resolved_attributes(resolved)
52
+ else:
53
+ return _format_attributes(attributes)
54
+
55
+ except ValueError as e:
56
+ return f"Error: {e}"
57
+ except FileNotFoundError:
58
+ return ERROR_FILE_NOT_FOUND.format(path=path)
59
+ except IsADirectoryError:
60
+ return ERROR_IS_DIRECTORY.format(path=path)
61
+ except PermissionError:
62
+ return ERROR_PERMISSION_DENIED.format(path=path)
63
+ except Exception as e:
64
+ return f"An error occurred: {e}"
65
+
66
+
67
+ def _extract_attributes(content: str) -> list[dict[str, str]]:
68
+ """
69
+ Extract Chef attributes from attributes file content.
70
+
71
+ Args:
72
+ content: Raw content of attributes file.
73
+
74
+ Returns:
75
+ List of dictionaries containing attribute information.
76
+
77
+ """
78
+ attributes = []
79
+ # Strip comments first
80
+ clean_content = _strip_ruby_comments(content)
81
+
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
+ )
89
+
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
+ )
112
+
113
+ return attributes
114
+
115
+
116
+ def _get_precedence_level(precedence: str) -> int:
117
+ """
118
+ Get numeric precedence level for Chef attribute precedence.
119
+
120
+ Chef attribute precedence (lowest to highest):
121
+ 1. default
122
+ 2. force_default
123
+ 3. normal
124
+ 4. override
125
+ 5. force_override
126
+ 6. automatic
127
+
128
+ Args:
129
+ precedence: Chef attribute precedence level.
130
+
131
+ Returns:
132
+ Numeric precedence level (1-6).
133
+
134
+ """
135
+ precedence_map = {
136
+ "default": 1,
137
+ "force_default": 2,
138
+ "normal": 3,
139
+ "override": 4,
140
+ "force_override": 5,
141
+ "automatic": 6,
142
+ }
143
+ return precedence_map.get(precedence, 1)
144
+
145
+
146
+ def _resolve_attribute_precedence(
147
+ attributes: list[dict[str, str]],
148
+ ) -> dict[str, dict[str, str | bool | int]]:
149
+ """
150
+ Resolve attribute precedence conflicts based on Chef's precedence rules.
151
+
152
+ When multiple attributes with the same path exist, the one with
153
+ higher precedence wins. Returns the winning value for each path
154
+ along with precedence information.
155
+
156
+ Args:
157
+ attributes: List of attribute dictionaries with precedence, path, and value.
158
+
159
+ Returns:
160
+ Dictionary mapping attribute paths to their resolved values and metadata.
161
+
162
+ """
163
+ # Group attributes by path
164
+ path_groups: dict[str, list[dict[str, str]]] = {}
165
+ for attr in attributes:
166
+ path = attr["path"]
167
+ if path not in path_groups:
168
+ path_groups[path] = []
169
+ path_groups[path].append(attr)
170
+
171
+ # Resolve precedence for each path
172
+ resolved: dict[str, dict[str, Any]] = {}
173
+ for path, attrs in path_groups.items():
174
+ # Find attribute with highest precedence
175
+ winning_attr = max(attrs, key=lambda a: _get_precedence_level(a["precedence"]))
176
+
177
+ # Check for conflicts (multiple values at different precedence levels)
178
+ has_conflict = len(attrs) > 1
179
+ conflict_info = []
180
+ if has_conflict:
181
+ # Sort by precedence for conflict reporting
182
+ sorted_attrs = sorted(
183
+ attrs, key=lambda a: _get_precedence_level(a["precedence"])
184
+ )
185
+ conflict_info = [
186
+ f"{a['precedence']}={a['value']}" for a in sorted_attrs[:-1]
187
+ ]
188
+
189
+ resolved[path] = {
190
+ "value": winning_attr["value"],
191
+ "precedence": winning_attr["precedence"],
192
+ "precedence_level": _get_precedence_level(winning_attr["precedence"]),
193
+ "has_conflict": has_conflict,
194
+ "overridden_values": ", ".join(conflict_info) if conflict_info else "",
195
+ }
196
+
197
+ return resolved
198
+
199
+
200
+ def _format_attributes(attributes: list[dict[str, str]]) -> str:
201
+ """
202
+ Format attributes list as a readable string.
203
+
204
+ Args:
205
+ attributes: List of attribute dictionaries.
206
+
207
+ Returns:
208
+ Formatted string representation.
209
+
210
+ """
211
+ result = []
212
+ for attr in attributes:
213
+ result.append(f"{attr['precedence']}[{attr['path']}] = {attr['value']}")
214
+
215
+ return "\n".join(result)
216
+
217
+
218
+ def _format_resolved_attributes(
219
+ resolved: dict[str, dict[str, str | bool | int]],
220
+ ) -> str:
221
+ """
222
+ Format resolved attributes with precedence information.
223
+
224
+ Args:
225
+ resolved: Dictionary mapping attribute paths to resolved values and metadata.
226
+
227
+ Returns:
228
+ Formatted string showing resolved attributes with precedence details.
229
+
230
+ """
231
+ if not resolved:
232
+ return "No attributes found."
233
+
234
+ result = ["Resolved Attributes (with precedence):", "=" * 50, ""]
235
+
236
+ # Sort by attribute path for consistent output
237
+ for path in sorted(resolved.keys()):
238
+ info = resolved[path]
239
+ result.append(f"Attribute: {path}")
240
+ result.append(f" Value: {info['value']}")
241
+ result.append(
242
+ f" Precedence: {info['precedence']} (level {info['precedence_level']})"
243
+ )
244
+
245
+ if info["has_conflict"]:
246
+ result.append(f" ⚠️ Overridden values: {info['overridden_values']}")
247
+
248
+ result.append("") # Blank line between attributes
249
+
250
+ # Add summary
251
+ conflict_count = sum(1 for info in resolved.values() if info["has_conflict"])
252
+ result.append("=" * 50)
253
+ result.append(f"Total attributes: {len(resolved)}")
254
+ if conflict_count > 0:
255
+ result.append(f"Attributes with precedence conflicts: {conflict_count}")
256
+
257
+ return "\n".join(result)
@@ -0,0 +1,317 @@
1
+ """Chef Habitat plan parser."""
2
+
3
+ import json
4
+ import re
5
+ from typing import Any
6
+
7
+ from souschef.core.constants import (
8
+ ERROR_FILE_NOT_FOUND,
9
+ ERROR_IS_DIRECTORY,
10
+ ERROR_PERMISSION_DENIED,
11
+ )
12
+ from souschef.core.path_utils import _normalize_path
13
+
14
+ # Maximum length for variable values in Habitat plan parsing
15
+ # Prevents ReDoS attacks from extremely long variable assignments
16
+ MAX_PLAN_VALUE_LENGTH = 10000
17
+
18
+
19
+ def parse_habitat_plan(plan_path: str) -> str:
20
+ """
21
+ Parse a Chef Habitat plan file (plan.sh) and extract package metadata.
22
+
23
+ Analyzes Habitat plans to extract package information, dependencies,
24
+ ports, services, and build callbacks for container conversion.
25
+
26
+ Args:
27
+ plan_path: Path to the plan.sh file
28
+
29
+ Returns:
30
+ JSON string with parsed plan metadata
31
+
32
+ """
33
+ try:
34
+ normalized_path = _normalize_path(plan_path)
35
+ if not normalized_path.exists():
36
+ return ERROR_FILE_NOT_FOUND.format(path=normalized_path)
37
+ if normalized_path.is_dir():
38
+ return ERROR_IS_DIRECTORY.format(path=normalized_path)
39
+
40
+ content = normalized_path.read_text(encoding="utf-8")
41
+ metadata: dict[str, Any] = {
42
+ "package": {},
43
+ "dependencies": {"build": [], "runtime": []},
44
+ "ports": [],
45
+ "binds": [],
46
+ "service": {},
47
+ "callbacks": {},
48
+ }
49
+
50
+ # Extract package info
51
+ metadata["package"]["name"] = _extract_plan_var(content, "pkg_name")
52
+ metadata["package"]["origin"] = _extract_plan_var(content, "pkg_origin")
53
+ metadata["package"]["version"] = _extract_plan_var(content, "pkg_version")
54
+ metadata["package"]["maintainer"] = _extract_plan_var(content, "pkg_maintainer")
55
+ metadata["package"]["license"] = _extract_plan_array(content, "pkg_license")
56
+ metadata["package"]["description"] = _extract_plan_var(
57
+ content, "pkg_description"
58
+ )
59
+ metadata["package"]["upstream_url"] = _extract_plan_var(
60
+ content, "pkg_upstream_url"
61
+ )
62
+ metadata["package"]["source"] = _extract_plan_var(content, "pkg_source")
63
+
64
+ # Extract dependencies
65
+ metadata["dependencies"]["build"] = _extract_plan_array(
66
+ content, "pkg_build_deps"
67
+ )
68
+ metadata["dependencies"]["runtime"] = _extract_plan_array(content, "pkg_deps")
69
+
70
+ # Extract ports and bindings
71
+ metadata["ports"] = _extract_plan_exports(content, "pkg_exports")
72
+ metadata["binds"] = _extract_plan_exports(content, "pkg_binds_optional")
73
+
74
+ # Extract service config
75
+ metadata["service"]["run"] = _extract_plan_var(content, "pkg_svc_run")
76
+ metadata["service"]["user"] = _extract_plan_var(content, "pkg_svc_user")
77
+ metadata["service"]["group"] = _extract_plan_var(content, "pkg_svc_group")
78
+
79
+ # Extract callbacks
80
+ for callback in ["do_build", "do_install", "do_init", "do_setup_environment"]:
81
+ callback_content = _extract_plan_function(content, callback)
82
+ if callback_content:
83
+ metadata["callbacks"][callback] = callback_content
84
+
85
+ return json.dumps(metadata, indent=2)
86
+ except PermissionError:
87
+ return ERROR_PERMISSION_DENIED.format(path=plan_path)
88
+ except Exception as e:
89
+ return f"Error parsing Habitat plan: {e}"
90
+
91
+
92
+ def _extract_plan_var(content: str, var_name: str) -> str:
93
+ """
94
+ Extract a variable value from a Habitat plan.
95
+
96
+ This helper supports both quoted and unquoted assignments and allows
97
+ escaped quotes within quoted values. If the variable is not found,
98
+ an empty string is returned.
99
+
100
+ Args:
101
+ content: Full text of the Habitat plan.
102
+ var_name: Name of the variable to extract.
103
+
104
+ Returns:
105
+ The extracted variable value, or an empty string if not present.
106
+
107
+ """
108
+ # First, try to match a quoted value (single or double quotes), allowing
109
+ # escaped characters (e.g. \" or \') inside the value. We anchor at the
110
+ # start of the line to avoid partial matches elsewhere.
111
+ # Use a bounded, non-greedy quantifier to prevent ReDoS on malformed input.
112
+ quoted_pattern = (
113
+ rf'^{re.escape(var_name)}=(["\'])'
114
+ rf"(?P<value>(?:\\.|(?!\1).){{0,{MAX_PLAN_VALUE_LENGTH}}}?)\1"
115
+ )
116
+ match = re.search(quoted_pattern, content, re.MULTILINE | re.DOTALL)
117
+ if match:
118
+ return match.group("value").strip()
119
+
120
+ # Fallback: match an unquoted value up to the end of the line or a comment.
121
+ unquoted_pattern = rf"^{re.escape(var_name)}=([^\n#]+)"
122
+ match = re.search(unquoted_pattern, content, re.MULTILINE)
123
+ return match.group(1).strip() if match else ""
124
+
125
+
126
+ def _extract_plan_array(content: str, var_name: str) -> list[str]:
127
+ """Extract an array variable from a Habitat plan."""
128
+ # Find the start of the array declaration
129
+ pattern = rf"{var_name}=\("
130
+ match = re.search(pattern, content)
131
+ if not match:
132
+ return []
133
+
134
+ # Manually parse to handle nested parentheses correctly
135
+ start_pos = match.end()
136
+ paren_count = 1
137
+ end_pos = start_pos
138
+
139
+ # Find the matching closing parenthesis
140
+ while end_pos < len(content) and paren_count > 0:
141
+ if content[end_pos] == "(":
142
+ paren_count += 1
143
+ elif content[end_pos] == ")":
144
+ paren_count -= 1
145
+ end_pos += 1
146
+
147
+ if paren_count != 0:
148
+ # Unmatched parentheses
149
+ return []
150
+
151
+ # Extract the content between parentheses (excluding the final closing paren)
152
+ array_content = content[start_pos : end_pos - 1]
153
+
154
+ # Split by newlines and process each line
155
+ elements = []
156
+ for line in array_content.strip().split("\n"):
157
+ # Remove inline comments
158
+ line = line.split("#")[0].strip()
159
+ if not line:
160
+ continue
161
+
162
+ # Remove quotes and whitespace
163
+ line = line.strip("\"'").strip()
164
+ if line:
165
+ elements.append(line)
166
+
167
+ return elements
168
+
169
+
170
+ def _extract_plan_exports(content: str, var_name: str) -> list[dict[str, str]]:
171
+ """Extract port exports or bindings from a Habitat plan."""
172
+ # Find the start of the array declaration
173
+ pattern = rf"{var_name}=\("
174
+ match = re.search(pattern, content)
175
+ if not match:
176
+ return []
177
+
178
+ # Manually parse to handle nested parentheses correctly
179
+ start_pos = match.end()
180
+ paren_count = 1
181
+ end_pos = start_pos
182
+
183
+ # Find the matching closing parenthesis
184
+ while end_pos < len(content) and paren_count > 0:
185
+ if content[end_pos] == "(":
186
+ paren_count += 1
187
+ elif content[end_pos] == ")":
188
+ paren_count -= 1
189
+ end_pos += 1
190
+
191
+ if paren_count != 0:
192
+ # Unmatched parentheses
193
+ return []
194
+
195
+ # Extract the content between parentheses (excluding the final closing paren)
196
+ exports_content = content[start_pos : end_pos - 1]
197
+
198
+ exports = []
199
+ for line in exports_content.strip().split("\n"):
200
+ export_match = re.search(r"\[([^\]]+)\]=([^\s]+)", line)
201
+ if export_match:
202
+ exports.append(
203
+ {"name": export_match.group(1), "value": export_match.group(2)}
204
+ )
205
+ return exports
206
+
207
+
208
+ def _is_quote_blocked(
209
+ ch: str, in_single_quote: bool, in_double_quote: bool, in_backtick: bool
210
+ ) -> bool:
211
+ """Check if a quote character is blocked by other active quotes."""
212
+ if ch == "'":
213
+ return in_double_quote or in_backtick
214
+ if ch == '"':
215
+ return in_single_quote or in_backtick
216
+ if ch == "`":
217
+ return in_single_quote or in_double_quote
218
+ return False
219
+
220
+
221
+ def _toggle_quote(
222
+ ch: str, in_single_quote: bool, in_double_quote: bool, in_backtick: bool
223
+ ) -> tuple[bool, bool, bool]:
224
+ """Toggle the appropriate quote state based on character."""
225
+ if ch == "'":
226
+ return not in_single_quote, in_double_quote, in_backtick
227
+ if ch == '"':
228
+ return in_single_quote, not in_double_quote, in_backtick
229
+ if ch == "`":
230
+ return in_single_quote, in_double_quote, not in_backtick
231
+ return in_single_quote, in_double_quote, in_backtick
232
+
233
+
234
+ def _update_quote_state(
235
+ ch: str,
236
+ in_single_quote: bool,
237
+ in_double_quote: bool,
238
+ in_backtick: bool,
239
+ escape_next: bool,
240
+ ) -> tuple[bool, bool, bool, bool]:
241
+ """Update quote tracking state for shell script parsing."""
242
+ # Handle escape sequences
243
+ if escape_next:
244
+ return in_single_quote, in_double_quote, in_backtick, False
245
+
246
+ if ch == "\\":
247
+ return in_single_quote, in_double_quote, in_backtick, True
248
+
249
+ # Handle quote characters
250
+ if ch in ("'", '"', "`") and not _is_quote_blocked(
251
+ ch, in_single_quote, in_double_quote, in_backtick
252
+ ):
253
+ single, double, backtick = _toggle_quote(
254
+ ch, in_single_quote, in_double_quote, in_backtick
255
+ )
256
+ return single, double, backtick, False
257
+
258
+ return in_single_quote, in_double_quote, in_backtick, False
259
+
260
+
261
+ def _extract_plan_function(content: str, func_name: str) -> str:
262
+ """
263
+ Extract a shell function body from a Habitat plan.
264
+
265
+ Uses brace counting to handle nested braces in conditionals and loops.
266
+
267
+ Args:
268
+ content: Full text of the Habitat plan.
269
+ func_name: Name of the function to extract.
270
+
271
+ Returns:
272
+ The function body as a string, or an empty string if the function
273
+ is not found or is malformed.
274
+
275
+ """
276
+ # Find the start of the function definition: func_name() {
277
+ func_def_pattern = rf"{re.escape(func_name)}\s*\(\)\s*{{"
278
+ match = re.search(func_def_pattern, content)
279
+ if not match:
280
+ return ""
281
+
282
+ start_index = match.end()
283
+ brace_count = 1
284
+ i = start_index
285
+
286
+ in_single_quote = False
287
+ in_double_quote = False
288
+ in_backtick = False
289
+ escape_next = False
290
+
291
+ while i < len(content):
292
+ ch = content[i]
293
+
294
+ # Update quote/escape state
295
+ (
296
+ in_single_quote,
297
+ in_double_quote,
298
+ in_backtick,
299
+ escape_next,
300
+ ) = _update_quote_state(
301
+ ch, in_single_quote, in_double_quote, in_backtick, escape_next
302
+ )
303
+
304
+ # Count braces only when not inside quotes
305
+ if not (in_single_quote or in_double_quote or in_backtick):
306
+ if ch == "{":
307
+ brace_count += 1
308
+ elif ch == "}":
309
+ brace_count -= 1
310
+ if brace_count == 0:
311
+ body = content[start_index:i]
312
+ return body.strip("\n").strip()
313
+
314
+ i += 1
315
+
316
+ # Unbalanced braces or malformed definition
317
+ return ""