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.
- {mcp_souschef-2.0.1.dist-info → mcp_souschef-2.2.0.dist-info}/METADATA +453 -77
- mcp_souschef-2.2.0.dist-info/RECORD +31 -0
- souschef/__init__.py +17 -0
- souschef/assessment.py +1498 -0
- souschef/cli.py +90 -0
- souschef/converters/__init__.py +23 -0
- souschef/converters/habitat.py +674 -0
- souschef/converters/playbook.py +1736 -0
- souschef/converters/resource.py +325 -0
- souschef/core/__init__.py +80 -0
- souschef/core/constants.py +145 -0
- souschef/core/errors.py +275 -0
- souschef/core/path_utils.py +58 -0
- souschef/core/ruby_utils.py +39 -0
- souschef/core/validation.py +555 -0
- souschef/deployment.py +1906 -0
- souschef/filesystem/__init__.py +5 -0
- souschef/filesystem/operations.py +67 -0
- souschef/parsers/__init__.py +36 -0
- souschef/parsers/attributes.py +257 -0
- souschef/parsers/habitat.py +317 -0
- souschef/parsers/inspec.py +809 -0
- souschef/parsers/metadata.py +211 -0
- souschef/parsers/recipe.py +200 -0
- souschef/parsers/resource.py +170 -0
- souschef/parsers/template.py +342 -0
- souschef/profiling.py +568 -0
- souschef/server.py +1854 -7481
- mcp_souschef-2.0.1.dist-info/RECORD +0 -8
- {mcp_souschef-2.0.1.dist-info → mcp_souschef-2.2.0.dist-info}/WHEEL +0 -0
- {mcp_souschef-2.0.1.dist-info → mcp_souschef-2.2.0.dist-info}/entry_points.txt +0 -0
- {mcp_souschef-2.0.1.dist-info → mcp_souschef-2.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|