mcp-souschef 3.2.0__py3-none-any.whl → 3.5.2__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-3.2.0.dist-info → mcp_souschef-3.5.2.dist-info}/METADATA +159 -30
- {mcp_souschef-3.2.0.dist-info → mcp_souschef-3.5.2.dist-info}/RECORD +19 -14
- {mcp_souschef-3.2.0.dist-info → mcp_souschef-3.5.2.dist-info}/WHEEL +1 -1
- souschef/assessment.py +81 -25
- souschef/cli.py +265 -6
- souschef/converters/playbook.py +413 -156
- souschef/converters/template.py +122 -5
- souschef/core/ai_schemas.py +81 -0
- souschef/core/http_client.py +394 -0
- souschef/core/logging.py +344 -0
- souschef/core/metrics.py +73 -6
- souschef/core/url_validation.py +230 -0
- souschef/server.py +130 -0
- souschef/ui/app.py +20 -6
- souschef/ui/pages/ai_settings.py +151 -30
- souschef/ui/pages/chef_server_settings.py +300 -0
- souschef/ui/pages/cookbook_analysis.py +66 -10
- {mcp_souschef-3.2.0.dist-info → mcp_souschef-3.5.2.dist-info}/entry_points.txt +0 -0
- {mcp_souschef-3.2.0.dist-info → mcp_souschef-3.5.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Chef Server Settings Page for SousChef UI.
|
|
3
|
+
|
|
4
|
+
Configure and validate Chef Server connectivity for dynamic inventory and node queries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
import streamlit as st
|
|
10
|
+
|
|
11
|
+
from souschef.core.url_validation import validate_user_provided_url
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import requests
|
|
15
|
+
from requests.exceptions import (
|
|
16
|
+
ConnectionError, # noqa: A004
|
|
17
|
+
Timeout,
|
|
18
|
+
)
|
|
19
|
+
except ImportError:
|
|
20
|
+
requests = None # type: ignore[assignment]
|
|
21
|
+
ConnectionError = Exception # type: ignore[assignment,misc] # noqa: A001
|
|
22
|
+
Timeout = Exception # type: ignore[assignment,misc]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _handle_chef_server_response(
|
|
26
|
+
response: "requests.Response", server_url: str
|
|
27
|
+
) -> tuple[bool, str]:
|
|
28
|
+
"""
|
|
29
|
+
Handle Chef Server search response.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
response: HTTP response from Chef Server
|
|
33
|
+
server_url: The Chef Server URL that was queried
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Tuple of (success: bool, message: str)
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
if response.status_code == 200:
|
|
40
|
+
return True, f"✅ Successfully connected to Chef Server at {server_url}"
|
|
41
|
+
if response.status_code == 401:
|
|
42
|
+
return (
|
|
43
|
+
False,
|
|
44
|
+
"❌ Authentication failed - check your Chef Server credentials",
|
|
45
|
+
)
|
|
46
|
+
if response.status_code == 404:
|
|
47
|
+
return False, "❌ Chef Server search endpoint not found"
|
|
48
|
+
return (
|
|
49
|
+
False,
|
|
50
|
+
f"❌ Connection failed with status code {response.status_code}",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate_chef_server_connection(
|
|
55
|
+
server_url: str, node_name: str
|
|
56
|
+
) -> tuple[bool, str]:
|
|
57
|
+
"""
|
|
58
|
+
Validate Chef Server connection by testing the search endpoint.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
server_url: Base URL of the Chef Server
|
|
62
|
+
node_name: Chef node name for authentication
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Tuple of (success: bool, message: str)
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
if not requests:
|
|
69
|
+
return False, "requests library not installed"
|
|
70
|
+
|
|
71
|
+
if not server_url:
|
|
72
|
+
return False, "Server URL is required"
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
server_url = validate_user_provided_url(server_url)
|
|
76
|
+
except ValueError as exc:
|
|
77
|
+
return False, f"Invalid server URL: {exc}"
|
|
78
|
+
|
|
79
|
+
if not node_name:
|
|
80
|
+
return False, "Node name is required for authentication"
|
|
81
|
+
|
|
82
|
+
# Test the search endpoint
|
|
83
|
+
try:
|
|
84
|
+
search_url = f"{server_url.rstrip('/')}/search/node"
|
|
85
|
+
response = requests.get(
|
|
86
|
+
search_url,
|
|
87
|
+
params={"q": "*:*"},
|
|
88
|
+
timeout=5,
|
|
89
|
+
headers={"Accept": "application/json"},
|
|
90
|
+
)
|
|
91
|
+
return _handle_chef_server_response(response, server_url)
|
|
92
|
+
|
|
93
|
+
except Timeout:
|
|
94
|
+
return False, f"❌ Connection timeout - could not reach {server_url}"
|
|
95
|
+
except ConnectionError:
|
|
96
|
+
return False, f"❌ Connection error - Chef Server not reachable at {server_url}"
|
|
97
|
+
except Exception as e:
|
|
98
|
+
return False, f"❌ Unexpected error: {e}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _render_chef_server_configuration() -> tuple[str, str]:
|
|
102
|
+
"""
|
|
103
|
+
Render Chef Server configuration UI and return config values.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Tuple of (server_url, node_name)
|
|
107
|
+
|
|
108
|
+
"""
|
|
109
|
+
st.subheader("Chef Server Configuration")
|
|
110
|
+
|
|
111
|
+
st.markdown("""
|
|
112
|
+
Configure your Chef Server connection for dynamic inventory generation
|
|
113
|
+
and node queries. This allows SousChef to retrieve live node data from
|
|
114
|
+
your Chef infrastructure.
|
|
115
|
+
""")
|
|
116
|
+
|
|
117
|
+
col1, col2 = st.columns(2)
|
|
118
|
+
|
|
119
|
+
with col1:
|
|
120
|
+
server_url = st.text_input(
|
|
121
|
+
"Chef Server URL",
|
|
122
|
+
help="Full URL of your Chef Server (e.g., https://chef.example.com)",
|
|
123
|
+
key="chef_server_url_input",
|
|
124
|
+
placeholder="https://chef.example.com",
|
|
125
|
+
value=os.environ.get("CHEF_SERVER_URL", ""),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
with col2:
|
|
129
|
+
node_name = st.text_input(
|
|
130
|
+
"Chef Node Name",
|
|
131
|
+
help="Node name for authentication with Chef Server",
|
|
132
|
+
key="chef_node_name_input",
|
|
133
|
+
placeholder="my-node",
|
|
134
|
+
value=os.environ.get("CHEF_NODE_NAME", ""),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return server_url, node_name
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _render_test_connection_button(server_url: str, node_name: str) -> None:
|
|
141
|
+
"""
|
|
142
|
+
Render the test connection button and display results.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
server_url: Chef Server URL to test
|
|
146
|
+
node_name: Chef node name for authentication
|
|
147
|
+
|
|
148
|
+
"""
|
|
149
|
+
st.markdown("---")
|
|
150
|
+
st.subheader("Test Connection")
|
|
151
|
+
|
|
152
|
+
col1, col2 = st.columns([1, 3])
|
|
153
|
+
|
|
154
|
+
with col1:
|
|
155
|
+
test_button = st.button(
|
|
156
|
+
"Test Chef Server Connection",
|
|
157
|
+
type="primary",
|
|
158
|
+
help="Verify connectivity to Chef Server",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if test_button:
|
|
162
|
+
with col2, st.spinner("Testing Chef Server connection..."):
|
|
163
|
+
success, message = _validate_chef_server_connection(server_url, node_name)
|
|
164
|
+
|
|
165
|
+
if success:
|
|
166
|
+
st.success(message)
|
|
167
|
+
else:
|
|
168
|
+
st.error(message)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _render_usage_examples() -> None:
|
|
172
|
+
"""Render usage examples for Chef Server integration."""
|
|
173
|
+
st.markdown("---")
|
|
174
|
+
st.subheader("Usage Examples")
|
|
175
|
+
|
|
176
|
+
with st.expander("Dynamic Inventory from Chef Searches"):
|
|
177
|
+
st.markdown("""
|
|
178
|
+
Once configured, SousChef can query your Chef Server to generate dynamic
|
|
179
|
+
Ansible inventories based on Chef node searches:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
# Example Chef search query
|
|
183
|
+
search_query = "role:webserver AND chef_environment:production"
|
|
184
|
+
|
|
185
|
+
# SousChef will convert this to an Ansible dynamic inventory
|
|
186
|
+
# that queries your Chef Server in real-time
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Benefits:
|
|
190
|
+
- Real-time node discovery
|
|
191
|
+
- No manual inventory maintenance
|
|
192
|
+
- Leverage existing Chef infrastructure
|
|
193
|
+
- Seamless migration path
|
|
194
|
+
""")
|
|
195
|
+
|
|
196
|
+
with st.expander("Environment Variables"):
|
|
197
|
+
st.markdown("""
|
|
198
|
+
You can also configure Chef Server settings via environment variables:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
export CHEF_SERVER_URL="https://chef.example.com"
|
|
202
|
+
export CHEF_NODE_NAME="my-node"
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
These will be automatically detected by SousChef.
|
|
206
|
+
""")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _render_save_settings_section(server_url: str, node_name: str) -> None:
|
|
210
|
+
"""
|
|
211
|
+
Render the save settings section.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
server_url: Chef Server URL to save
|
|
215
|
+
node_name: Chef node name to save
|
|
216
|
+
|
|
217
|
+
"""
|
|
218
|
+
st.markdown("---")
|
|
219
|
+
st.subheader("Save Settings")
|
|
220
|
+
|
|
221
|
+
col1, col2 = st.columns([1, 3])
|
|
222
|
+
|
|
223
|
+
with col1:
|
|
224
|
+
save_button = st.button(
|
|
225
|
+
"Save Configuration",
|
|
226
|
+
type="primary",
|
|
227
|
+
help="Save Chef Server settings to session",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if save_button:
|
|
231
|
+
with col2:
|
|
232
|
+
# Save to session state
|
|
233
|
+
st.session_state.chef_server_url = server_url
|
|
234
|
+
st.session_state.chef_node_name = node_name
|
|
235
|
+
|
|
236
|
+
st.success("""
|
|
237
|
+
✅ Chef Server configuration saved to session!
|
|
238
|
+
|
|
239
|
+
**Note:** For persistent configuration across sessions,
|
|
240
|
+
set environment variables:
|
|
241
|
+
- `CHEF_SERVER_URL`
|
|
242
|
+
- `CHEF_NODE_NAME`
|
|
243
|
+
""")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _render_current_configuration() -> None:
|
|
247
|
+
"""Display current Chef Server configuration from environment or session."""
|
|
248
|
+
current_url = os.environ.get("CHEF_SERVER_URL") or st.session_state.get(
|
|
249
|
+
"chef_server_url", "Not configured"
|
|
250
|
+
)
|
|
251
|
+
current_node = os.environ.get("CHEF_NODE_NAME") or st.session_state.get(
|
|
252
|
+
"chef_node_name", "Not configured"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
st.info(f"""
|
|
256
|
+
**Current Configuration:**
|
|
257
|
+
- Server URL: `{current_url}`
|
|
258
|
+
- Node Name: `{current_node}`
|
|
259
|
+
""")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def show_chef_server_settings_page() -> None:
|
|
263
|
+
"""Display Chef Server settings and configuration page."""
|
|
264
|
+
st.title("🔧 Chef Server Settings")
|
|
265
|
+
|
|
266
|
+
st.markdown("""
|
|
267
|
+
Configure your Chef Server connection to enable dynamic inventory generation
|
|
268
|
+
and live node queries. This allows SousChef to integrate with your existing
|
|
269
|
+
Chef infrastructure during the migration process.
|
|
270
|
+
""")
|
|
271
|
+
|
|
272
|
+
# Display current configuration
|
|
273
|
+
_render_current_configuration()
|
|
274
|
+
|
|
275
|
+
st.markdown("---")
|
|
276
|
+
|
|
277
|
+
# Configuration inputs
|
|
278
|
+
server_url, node_name = _render_chef_server_configuration()
|
|
279
|
+
|
|
280
|
+
# Test connection
|
|
281
|
+
_render_test_connection_button(server_url, node_name)
|
|
282
|
+
|
|
283
|
+
# Save settings
|
|
284
|
+
_render_save_settings_section(server_url, node_name)
|
|
285
|
+
|
|
286
|
+
# Usage examples
|
|
287
|
+
_render_usage_examples()
|
|
288
|
+
|
|
289
|
+
# Additional information
|
|
290
|
+
st.markdown("---")
|
|
291
|
+
st.markdown("""
|
|
292
|
+
### Security Note
|
|
293
|
+
|
|
294
|
+
Chef Server authentication typically requires:
|
|
295
|
+
- Client key file for API authentication
|
|
296
|
+
- Proper permissions on the Chef Server
|
|
297
|
+
|
|
298
|
+
For production use, ensure your Chef Server credentials are properly secured
|
|
299
|
+
and not committed to version control.
|
|
300
|
+
""")
|
|
@@ -522,6 +522,9 @@ def _extract_tar_securely(
|
|
|
522
522
|
# Use 'data' filter to prevent extraction of special files and symlinks
|
|
523
523
|
open_kwargs["filter"] = "data"
|
|
524
524
|
|
|
525
|
+
# Resource consumption controls (S5042): Pre-scan validates all members for
|
|
526
|
+
# size limits (MAX_ARCHIVE_SIZE, MAX_FILE_SIZE), file count (MAX_FILES),
|
|
527
|
+
# depth (MAX_DEPTH), and blocks malicious files before extraction.
|
|
525
528
|
with tarfile.open(**open_kwargs) as tar_ref:
|
|
526
529
|
members = tar_ref.getmembers()
|
|
527
530
|
# Pre-validate all members before allowing extraction
|
|
@@ -665,6 +668,11 @@ def create_results_archive(results: list, cookbook_path: str) -> bytes:
|
|
|
665
668
|
# Add individual cookbook reports
|
|
666
669
|
for result in results:
|
|
667
670
|
if result["status"] == ANALYSIS_STATUS_ANALYSED:
|
|
671
|
+
manual_hours = result["estimated_hours"]
|
|
672
|
+
souschef_hours = result.get(
|
|
673
|
+
"estimated_hours_with_souschef", manual_hours * 0.5
|
|
674
|
+
)
|
|
675
|
+
time_saved = manual_hours - souschef_hours
|
|
668
676
|
report_content = f"""# Cookbook Analysis Report: {result["name"]}
|
|
669
677
|
|
|
670
678
|
## Metadata
|
|
@@ -672,7 +680,14 @@ def create_results_archive(results: list, cookbook_path: str) -> bytes:
|
|
|
672
680
|
- **Maintainer**: {result["maintainer"]}
|
|
673
681
|
- **Dependencies**: {result["dependencies"]}
|
|
674
682
|
- **Complexity**: {result["complexity"]}
|
|
675
|
-
|
|
683
|
+
|
|
684
|
+
## Effort Estimates
|
|
685
|
+
### Manual Migration (Without SousChef):
|
|
686
|
+
- **Estimated Hours**: {manual_hours:.1f}
|
|
687
|
+
|
|
688
|
+
### AI-Assisted (With SousChef):
|
|
689
|
+
- **Estimated Hours**: {souschef_hours:.1f}
|
|
690
|
+
- **Time Saved**: {time_saved:.1f} hours (50% faster)
|
|
676
691
|
|
|
677
692
|
## Recommendations
|
|
678
693
|
{result["recommendations"]}
|
|
@@ -686,16 +701,27 @@ def create_results_archive(results: list, cookbook_path: str) -> bytes:
|
|
|
686
701
|
successful = len(
|
|
687
702
|
[r for r in results if r["status"] == ANALYSIS_STATUS_ANALYSED]
|
|
688
703
|
)
|
|
689
|
-
|
|
704
|
+
total_hours_manual = sum(r.get("estimated_hours", 0) for r in results)
|
|
705
|
+
total_hours_souschef = sum(
|
|
706
|
+
r.get("estimated_hours_with_souschef", r.get("estimated_hours", 0) * 0.5)
|
|
707
|
+
for r in results
|
|
708
|
+
)
|
|
709
|
+
time_saved_total = total_hours_manual - total_hours_souschef
|
|
690
710
|
|
|
691
711
|
summary_content = f"""# SousChef Cookbook Analysis Summary
|
|
692
712
|
|
|
693
713
|
## Overview
|
|
694
714
|
- **Cookbooks Analysed**: {len(results)}
|
|
695
|
-
|
|
696
715
|
- **Successfully Analysed**: {successful}
|
|
697
716
|
|
|
698
|
-
|
|
717
|
+
## Effort Estimates
|
|
718
|
+
### Manual Migration (Without AI):
|
|
719
|
+
- **Total Estimated Hours**: {total_hours_manual:.1f}
|
|
720
|
+
|
|
721
|
+
### AI-Assisted (With SousChef):
|
|
722
|
+
- **Total Estimated Hours**: {total_hours_souschef:.1f}
|
|
723
|
+
- **Time Saved**: {time_saved_total:.1f} hours (50% faster)
|
|
724
|
+
|
|
699
725
|
- **Source**: {cookbook_path} # deepcode ignore PT: used for display only
|
|
700
726
|
|
|
701
727
|
## Results Summary
|
|
@@ -704,10 +730,15 @@ def create_results_archive(results: list, cookbook_path: str) -> bytes:
|
|
|
704
730
|
status_icon = (
|
|
705
731
|
"PASS" if result["status"] == ANALYSIS_STATUS_ANALYSED else "FAIL"
|
|
706
732
|
)
|
|
733
|
+
manual_hours = result.get("estimated_hours", 0)
|
|
734
|
+
souschef_hours = result.get(
|
|
735
|
+
"estimated_hours_with_souschef", manual_hours * 0.5
|
|
736
|
+
)
|
|
707
737
|
summary_content += f"- {status_icon} {result['name']}: {result['status']}"
|
|
708
738
|
if result["status"] == ANALYSIS_STATUS_ANALYSED:
|
|
709
739
|
summary_content += (
|
|
710
|
-
f" ({
|
|
740
|
+
f" (Manual: {manual_hours:.1f}h, "
|
|
741
|
+
f"With SousChef: {souschef_hours:.1f}h, "
|
|
711
742
|
f"{result['complexity']} complexity)"
|
|
712
743
|
)
|
|
713
744
|
summary_content += "\n"
|
|
@@ -1415,6 +1446,9 @@ def _build_assessment_result(
|
|
|
1415
1446
|
"dependencies": int(cookbook_assessment.get("dependencies", 0) or 0),
|
|
1416
1447
|
"complexity": cookbook_assessment.get("migration_priority", "Unknown").title(),
|
|
1417
1448
|
"estimated_hours": effort_metrics.estimated_hours,
|
|
1449
|
+
"estimated_hours_with_souschef": effort_metrics.estimated_hours_with_souschef,
|
|
1450
|
+
"time_saved_hours": effort_metrics.time_saved * 8,
|
|
1451
|
+
"efficiency_gain_percent": effort_metrics.efficiency_gain_percent,
|
|
1418
1452
|
"recommendations": recommendations,
|
|
1419
1453
|
"status": ANALYSIS_STATUS_ANALYSED,
|
|
1420
1454
|
}
|
|
@@ -3404,8 +3438,18 @@ def _display_analysis_summary(results, total_cookbooks):
|
|
|
3404
3438
|
st.metric("Successfully Analysed", f"{successful}/{total_cookbooks}")
|
|
3405
3439
|
|
|
3406
3440
|
with col2:
|
|
3407
|
-
|
|
3408
|
-
|
|
3441
|
+
total_hours_manual = sum(r.get("estimated_hours", 0) for r in results)
|
|
3442
|
+
total_hours_souschef = sum(
|
|
3443
|
+
r.get("estimated_hours_with_souschef", r.get("estimated_hours", 0) * 0.5)
|
|
3444
|
+
for r in results
|
|
3445
|
+
)
|
|
3446
|
+
time_saved = total_hours_manual - total_hours_souschef
|
|
3447
|
+
st.metric(
|
|
3448
|
+
"Manual Effort (hrs)",
|
|
3449
|
+
f"{total_hours_manual:.1f}",
|
|
3450
|
+
delta=f"With AI: {total_hours_souschef:.1f}h (save {time_saved:.1f}h)",
|
|
3451
|
+
delta_color="inverse",
|
|
3452
|
+
)
|
|
3409
3453
|
|
|
3410
3454
|
with col3:
|
|
3411
3455
|
complexities = [r.get("complexity", "Unknown") for r in results]
|
|
@@ -3491,7 +3535,7 @@ def _display_single_cookbook_details(result):
|
|
|
3491
3535
|
st.metric("Dependencies", result.get("dependencies", 0))
|
|
3492
3536
|
|
|
3493
3537
|
# Complexity and effort
|
|
3494
|
-
col1, col2 = st.columns(
|
|
3538
|
+
col1, col2, col3 = st.columns(3)
|
|
3495
3539
|
with col1:
|
|
3496
3540
|
complexity = result.get("complexity", "Unknown")
|
|
3497
3541
|
if complexity == "High":
|
|
@@ -3501,8 +3545,20 @@ def _display_single_cookbook_details(result):
|
|
|
3501
3545
|
else:
|
|
3502
3546
|
st.metric("Complexity", complexity, delta="Low")
|
|
3503
3547
|
with col2:
|
|
3504
|
-
|
|
3505
|
-
st.metric("
|
|
3548
|
+
hours_manual = result.get("estimated_hours", 0)
|
|
3549
|
+
st.metric("Manual Effort (hrs)", f"{hours_manual:.1f}")
|
|
3550
|
+
with col3:
|
|
3551
|
+
hours_souschef = result.get(
|
|
3552
|
+
"estimated_hours_with_souschef", hours_manual * 0.5
|
|
3553
|
+
)
|
|
3554
|
+
time_saved = hours_manual - hours_souschef
|
|
3555
|
+
savings_pct = int((time_saved / hours_manual) * 100)
|
|
3556
|
+
st.metric(
|
|
3557
|
+
"With SousChef (hrs)",
|
|
3558
|
+
f"{hours_souschef:.1f}",
|
|
3559
|
+
delta=f"Save {time_saved:.1f}h ({savings_pct}%)",
|
|
3560
|
+
delta_color="inverse",
|
|
3561
|
+
)
|
|
3506
3562
|
|
|
3507
3563
|
# Path
|
|
3508
3564
|
st.write(f"**Cookbook Path:** {result['path']}")
|
|
File without changes
|
|
File without changes
|