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,211 @@
1
+ """Chef cookbook metadata parser."""
2
+
3
+ import re
4
+
5
+ from souschef.core.constants import (
6
+ ERROR_FILE_NOT_FOUND,
7
+ ERROR_IS_DIRECTORY,
8
+ ERROR_PERMISSION_DENIED,
9
+ METADATA_FILENAME,
10
+ )
11
+ from souschef.core.path_utils import _normalize_path, _safe_join
12
+
13
+
14
+ def read_cookbook_metadata(path: str) -> str:
15
+ """
16
+ Parse Chef cookbook metadata.rb file.
17
+
18
+ Args:
19
+ path: Path to the metadata.rb file.
20
+
21
+ Returns:
22
+ Formatted string with extracted metadata.
23
+
24
+ """
25
+ try:
26
+ file_path = _normalize_path(path)
27
+ content = file_path.read_text(encoding="utf-8")
28
+
29
+ metadata = _extract_metadata(content)
30
+
31
+ if not metadata:
32
+ return f"Warning: No metadata found in {path}"
33
+
34
+ return _format_metadata(metadata)
35
+
36
+ except ValueError as e:
37
+ return f"Error: {e}"
38
+ except FileNotFoundError:
39
+ return ERROR_FILE_NOT_FOUND.format(path=path)
40
+ except IsADirectoryError:
41
+ return ERROR_IS_DIRECTORY.format(path=path)
42
+ except PermissionError:
43
+ return ERROR_PERMISSION_DENIED.format(path=path)
44
+ except Exception as e:
45
+ return f"An error occurred: {e}"
46
+
47
+
48
+ def _scan_cookbook_directory(
49
+ cookbook_path, dir_name: str
50
+ ) -> tuple[str, list[str]] | None:
51
+ """
52
+ Scan a single cookbook directory for files.
53
+
54
+ Args:
55
+ cookbook_path: Path to the cookbook root.
56
+ dir_name: Name of the subdirectory to scan.
57
+
58
+ Returns:
59
+ Tuple of (dir_name, files) if directory exists and has files, None otherwise.
60
+
61
+ """
62
+ dir_path = _safe_join(cookbook_path, dir_name)
63
+ if not dir_path.exists() or not dir_path.is_dir():
64
+ return None
65
+
66
+ files = [f.name for f in dir_path.iterdir() if f.is_file()]
67
+ return (dir_name, files) if files else None
68
+
69
+
70
+ def _collect_cookbook_structure(cookbook_path) -> dict[str, list[str]]:
71
+ """
72
+ Collect all standard cookbook directories and their files.
73
+
74
+ Args:
75
+ cookbook_path: Path to the cookbook root.
76
+
77
+ Returns:
78
+ Dictionary mapping directory names to file lists.
79
+
80
+ """
81
+ structure = {}
82
+ common_dirs = [
83
+ "recipes",
84
+ "attributes",
85
+ "templates",
86
+ "files",
87
+ "resources",
88
+ "providers",
89
+ "libraries",
90
+ "definitions",
91
+ ]
92
+
93
+ for dir_name in common_dirs:
94
+ result = _scan_cookbook_directory(cookbook_path, dir_name)
95
+ if result:
96
+ structure[result[0]] = result[1]
97
+
98
+ # Check for metadata.rb
99
+ metadata_path = _safe_join(cookbook_path, METADATA_FILENAME)
100
+ if metadata_path.exists():
101
+ structure["metadata"] = [METADATA_FILENAME]
102
+
103
+ return structure
104
+
105
+
106
+ def list_cookbook_structure(path: str) -> str:
107
+ """
108
+ List the structure of a Chef cookbook directory.
109
+
110
+ Args:
111
+ path: Path to the cookbook root directory.
112
+
113
+ Returns:
114
+ Formatted string showing the cookbook structure.
115
+
116
+ """
117
+ try:
118
+ cookbook_path = _normalize_path(path)
119
+
120
+ if not cookbook_path.is_dir():
121
+ return f"Error: {path} is not a directory"
122
+
123
+ structure = _collect_cookbook_structure(cookbook_path)
124
+
125
+ if not structure:
126
+ return f"Warning: No standard cookbook structure found in {path}"
127
+
128
+ return _format_cookbook_structure(structure)
129
+
130
+ except PermissionError:
131
+ return ERROR_PERMISSION_DENIED.format(path=path)
132
+ except Exception as e:
133
+ return f"An error occurred: {e}"
134
+
135
+
136
+ def _extract_metadata(content: str) -> dict[str, str | list[str]]:
137
+ """
138
+ Extract metadata fields from cookbook content.
139
+
140
+ Args:
141
+ content: Raw content of metadata.rb file.
142
+
143
+ Returns:
144
+ Dictionary of extracted metadata fields.
145
+
146
+ """
147
+ metadata: dict[str, str | list[str]] = {}
148
+ patterns = {
149
+ "name": r"name\s+['\"]([^'\"]+)['\"]",
150
+ "maintainer": r"maintainer\s+['\"]([^'\"]+)['\"]",
151
+ "version": r"version\s+['\"]([^'\"]+)['\"]",
152
+ "description": r"description\s+['\"]([^'\"]+)['\"]",
153
+ "license": r"license\s+['\"]([^'\"]+)['\"]",
154
+ }
155
+
156
+ for key, pattern in patterns.items():
157
+ match = re.search(pattern, content)
158
+ if match:
159
+ metadata[key] = match.group(1)
160
+
161
+ depends = re.findall(r"depends\s+['\"]([^'\"]+)['\"]", content)
162
+ if depends:
163
+ metadata["depends"] = depends
164
+
165
+ supports = re.findall(r"supports\s+['\"]([^'\"]+)['\"]", content)
166
+ if supports:
167
+ metadata["supports"] = supports
168
+
169
+ return metadata
170
+
171
+
172
+ def _format_metadata(metadata: dict[str, str | list[str]]) -> str:
173
+ """
174
+ Format metadata dictionary as a readable string.
175
+
176
+ Args:
177
+ metadata: Dictionary of metadata fields.
178
+
179
+ Returns:
180
+ Formatted string representation.
181
+
182
+ """
183
+ result = []
184
+ for key, value in metadata.items():
185
+ if isinstance(value, list):
186
+ result.append(f"{key}: {', '.join(value)}")
187
+ else:
188
+ result.append(f"{key}: {value}")
189
+
190
+ return "\n".join(result)
191
+
192
+
193
+ def _format_cookbook_structure(structure: dict[str, list[str]]) -> str:
194
+ """
195
+ Format cookbook structure as a readable string.
196
+
197
+ Args:
198
+ structure: Dictionary mapping directory names to file lists.
199
+
200
+ Returns:
201
+ Formatted string representation.
202
+
203
+ """
204
+ result = []
205
+ for dir_name, files in structure.items():
206
+ result.append(f"{dir_name}/")
207
+ for file_name in sorted(files):
208
+ result.append(f" {file_name}")
209
+ result.append("")
210
+
211
+ return "\n".join(result).rstrip()
@@ -0,0 +1,200 @@
1
+ """Chef recipe 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
+ # Maximum length for resource body content in regex matching
15
+ # Prevents ReDoS attacks from extremely large resource blocks
16
+ MAX_RESOURCE_BODY_LENGTH = 15000
17
+
18
+ # Maximum length for conditional expressions and branches
19
+ MAX_CONDITION_LENGTH = 200
20
+
21
+ # Maximum length for case statement body content
22
+ MAX_CASE_BODY_LENGTH = 2000
23
+
24
+
25
+ def parse_recipe(path: str) -> str:
26
+ """
27
+ Parse a Chef recipe file and extract resources.
28
+
29
+ Args:
30
+ path: Path to the recipe (.rb) file.
31
+
32
+ Returns:
33
+ Formatted string with extracted Chef resources and their properties.
34
+
35
+ """
36
+ try:
37
+ file_path = _normalize_path(path)
38
+ content = file_path.read_text(encoding="utf-8")
39
+
40
+ resources = _extract_resources(content)
41
+
42
+ if not resources:
43
+ return f"Warning: No Chef resources found in {path}"
44
+
45
+ return _format_resources(resources)
46
+
47
+ except ValueError as e:
48
+ return f"Error: {e}"
49
+ except FileNotFoundError:
50
+ return ERROR_FILE_NOT_FOUND.format(path=path)
51
+ except IsADirectoryError:
52
+ return ERROR_IS_DIRECTORY.format(path=path)
53
+ except PermissionError:
54
+ return ERROR_PERMISSION_DENIED.format(path=path)
55
+ except Exception as e:
56
+ return f"An error occurred: {e}"
57
+
58
+
59
+ def _extract_resources(content: str) -> list[dict[str, str]]:
60
+ """
61
+ Extract Chef resources from recipe content.
62
+
63
+ Args:
64
+ content: Raw content of recipe file.
65
+
66
+ Returns:
67
+ List of dictionaries containing resource information.
68
+
69
+ """
70
+ resources = []
71
+ # Strip comments first
72
+ clean_content = _strip_ruby_comments(content)
73
+
74
+ # Match Chef resource declarations with various patterns:
75
+ # 1. Standard: package 'nginx' do ... end
76
+ # 2. With parentheses: package('nginx') do ... end
77
+ # 3. Multi-line strings: package 'nginx' do\n content <<-EOH\n ...\n EOH\nend
78
+ # Use a more robust pattern that avoids matching 'end' keywords within the body
79
+ # Pattern breakdown: match any char that doesn't start spelling 'end' at line start
80
+ # This prevents catastrophic backtracking on large files
81
+ pattern = (
82
+ r"(\w+)\s+(?:\()?['\"]([^'\"]+)['\"](?:\))?\s+do"
83
+ rf"((?:[^e]|e(?:[^n]|n(?:[^d]|\d))|(?!^)e(?:n(?:d))){{0,{MAX_RESOURCE_BODY_LENGTH}}}?)"
84
+ r"^end"
85
+ )
86
+
87
+ for match in re.finditer(pattern, clean_content, re.DOTALL | re.MULTILINE):
88
+ resource_type = match.group(1)
89
+ resource_name = match.group(2)
90
+ resource_body = match.group(3)
91
+
92
+ resource = {
93
+ "type": resource_type,
94
+ "name": resource_name,
95
+ }
96
+
97
+ # Extract action
98
+ action_match = re.search(r"action\s+:(\w+)", resource_body)
99
+ if action_match:
100
+ resource["action"] = action_match.group(1)
101
+
102
+ # Extract common properties
103
+ properties = {}
104
+ for prop_match in re.finditer(r"(\w+)\s+['\"]([^'\"]+)['\"]", resource_body):
105
+ prop_name = prop_match.group(1)
106
+ if prop_name not in ["action"]:
107
+ properties[prop_name] = prop_match.group(2)
108
+
109
+ if properties:
110
+ resource["properties"] = str(properties)
111
+
112
+ resources.append(resource)
113
+
114
+ return resources
115
+
116
+
117
+ def _extract_conditionals(content: str) -> list[dict[str, Any]]:
118
+ """
119
+ Extract Ruby conditionals from recipe code.
120
+
121
+ Args:
122
+ content: Ruby code content.
123
+
124
+ Returns:
125
+ List of dictionaries with conditional information.
126
+
127
+ """
128
+ conditionals = []
129
+
130
+ # Match case/when statements
131
+ # Use explicit non-'end' matching to avoid ReDoS
132
+ case_pattern = (
133
+ rf"case\s+([^\n]{{1,{MAX_CONDITION_LENGTH}}})\n"
134
+ rf"([^e]|e[^n]|en[^d]){{0,{MAX_CASE_BODY_LENGTH}}}^end"
135
+ )
136
+ for match in re.finditer(case_pattern, content, re.DOTALL | re.MULTILINE):
137
+ case_expr = match.group(1).strip()
138
+ case_body = match.group(2)
139
+ when_clauses = re.findall(
140
+ rf"when\s+['\"]?([^'\"\n]{{1,{MAX_CONDITION_LENGTH}}})['\"]?\s*\n",
141
+ case_body,
142
+ )
143
+ conditionals.append(
144
+ {
145
+ "type": "case",
146
+ "expression": case_expr,
147
+ "branches": when_clauses,
148
+ }
149
+ )
150
+
151
+ # Match if/elsif/else statements
152
+ if_pattern = r"if\s+([^\n]+)\n?"
153
+ for match in re.finditer(if_pattern, content):
154
+ condition = match.group(1).strip()
155
+ if condition and not condition.startswith(("elsif", "end")):
156
+ conditionals.append(
157
+ {
158
+ "type": "if",
159
+ "condition": condition,
160
+ }
161
+ )
162
+
163
+ # Match unless statements
164
+ unless_pattern = r"unless\s+([^\n]+)\n?"
165
+ for match in re.finditer(unless_pattern, content):
166
+ condition = match.group(1).strip()
167
+ conditionals.append(
168
+ {
169
+ "type": "unless",
170
+ "condition": condition,
171
+ }
172
+ )
173
+
174
+ return conditionals
175
+
176
+
177
+ def _format_resources(resources: list[dict[str, Any]]) -> str:
178
+ """
179
+ Format resources list as a readable string.
180
+
181
+ Args:
182
+ resources: List of resource dictionaries.
183
+
184
+ Returns:
185
+ Formatted string representation.
186
+
187
+ """
188
+ result = []
189
+ for i, resource in enumerate(resources, 1):
190
+ if i > 1:
191
+ 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']}")
199
+
200
+ return "\n".join(result)
@@ -0,0 +1,170 @@
1
+ """Chef custom resource 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
+ from souschef.parsers.template import _strip_ruby_comments
14
+
15
+
16
+ def parse_custom_resource(path: str) -> str:
17
+ """
18
+ Parse a Chef custom resource or LWRP file.
19
+
20
+ Args:
21
+ path: Path to the custom resource (.rb) file.
22
+
23
+ Returns:
24
+ JSON string with extracted properties, actions, and metadata.
25
+
26
+ """
27
+ try:
28
+ file_path = _normalize_path(path)
29
+ content = file_path.read_text(encoding="utf-8")
30
+
31
+ # Determine resource type
32
+ resource_type = "custom_resource" if "property" in content else "lwrp"
33
+
34
+ # Extract properties/attributes
35
+ properties = _extract_resource_properties(content)
36
+
37
+ # Extract actions
38
+ actions_info = _extract_resource_actions(content)
39
+
40
+ result = {
41
+ "resource_file": str(file_path),
42
+ "resource_name": file_path.stem,
43
+ "resource_type": resource_type,
44
+ "properties": properties,
45
+ "actions": actions_info["actions"],
46
+ "default_action": actions_info["default_action"],
47
+ }
48
+
49
+ return json.dumps(result, indent=2)
50
+
51
+ except FileNotFoundError:
52
+ return ERROR_FILE_NOT_FOUND.format(path=path)
53
+ except IsADirectoryError:
54
+ return ERROR_IS_DIRECTORY.format(path=path)
55
+ except PermissionError:
56
+ return ERROR_PERMISSION_DENIED.format(path=path)
57
+ except UnicodeDecodeError:
58
+ return f"Error: Unable to decode {path} as UTF-8 text"
59
+ except Exception as e:
60
+ return f"An error occurred: {e}"
61
+
62
+
63
+ def _extract_common_property_options(options: str, info: dict[str, Any]) -> None:
64
+ """
65
+ Extract common property options (default, required, name_property).
66
+
67
+ Args:
68
+ options: Options string from property/attribute definition.
69
+ info: Dictionary to update with extracted options.
70
+
71
+ """
72
+ # Extract name_property / name_attribute
73
+ if "name_property: true" in options or "name_attribute: true" in options:
74
+ info["name_property"] = True
75
+
76
+ # Extract default value
77
+ default_match = re.search(r"default:\s*([^,\n]+)", options)
78
+ if default_match:
79
+ info["default"] = default_match.group(1).strip()
80
+
81
+ # Extract required
82
+ if "required: true" in options:
83
+ info["required"] = True
84
+
85
+
86
+ def _extract_resource_properties(content: str) -> list[dict[str, Any]]:
87
+ """
88
+ Extract property definitions from custom resource.
89
+
90
+ Args:
91
+ content: Raw content of custom resource file.
92
+
93
+ Returns:
94
+ List of dictionaries containing property information.
95
+
96
+ """
97
+ properties = []
98
+ # Strip comments
99
+ clean_content = _strip_ruby_comments(content)
100
+
101
+ # Match modern property syntax: property :name, Type, options
102
+ # Updated to handle multi-line definitions and complex types like [true, false]
103
+ property_pattern = (
104
+ r"property\s+:(\w+),\s*([^,\n\[]+(?:\[[^\]]+\])?),?\s*([^\n]*?)(?:\n|$)"
105
+ )
106
+ for match in re.finditer(property_pattern, clean_content, re.MULTILINE):
107
+ prop_info: dict[str, Any] = {
108
+ "name": match.group(1),
109
+ "type": match.group(2).strip(),
110
+ }
111
+ _extract_common_property_options(match.group(3) or "", prop_info)
112
+ properties.append(prop_info)
113
+
114
+ # Match LWRP attribute syntax: attribute :name, kind_of: Type
115
+ attribute_pattern = r"attribute\s+:(\w+)(?:,\s*([^\n]+))?\n?"
116
+ for match in re.finditer(attribute_pattern, content, re.MULTILINE):
117
+ attr_options = match.group(2) or ""
118
+ attr_info: dict[str, Any] = {
119
+ "name": match.group(1),
120
+ "type": "Any", # Default type
121
+ }
122
+
123
+ # Extract type from kind_of
124
+ kind_of_match = re.search(r"kind_of:\s*(\w+)", attr_options)
125
+ if kind_of_match:
126
+ attr_info["type"] = kind_of_match.group(1)
127
+
128
+ _extract_common_property_options(attr_options, attr_info)
129
+ properties.append(attr_info)
130
+
131
+ return properties
132
+
133
+
134
+ def _extract_resource_actions(content: str) -> dict[str, Any]:
135
+ """
136
+ Extract action definitions from custom resource.
137
+
138
+ Args:
139
+ content: Raw content of custom resource file.
140
+
141
+ Returns:
142
+ Dictionary with actions list and default action.
143
+
144
+ """
145
+ result: dict[str, Any] = {
146
+ "actions": [],
147
+ "default_action": None,
148
+ }
149
+
150
+ # Extract modern action blocks: action :name do ... end
151
+ action_pattern = r"action\s+:(\w+)\s+do"
152
+ for match in re.finditer(action_pattern, content):
153
+ action_name = match.group(1)
154
+ if action_name not in result["actions"]:
155
+ result["actions"].append(action_name)
156
+
157
+ # Extract LWRP-style actions declaration: actions :create, :drop
158
+ actions_decl = re.search(r"actions\s+([^\n]+)\n?", content)
159
+ if actions_decl:
160
+ action_symbols = re.findall(r":(\w+)", actions_decl.group(1))
161
+ for action in action_symbols:
162
+ if action not in result["actions"]:
163
+ result["actions"].append(action)
164
+
165
+ # Extract default action
166
+ default_match = re.search(r"default_action\s+:(\w+)", content)
167
+ if default_match:
168
+ result["default_action"] = default_match.group(1)
169
+
170
+ return result