procler 0.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.
Files changed (83) hide show
  1. procler/__init__.py +3 -0
  2. procler/__main__.py +6 -0
  3. procler/api/__init__.py +5 -0
  4. procler/api/app.py +261 -0
  5. procler/api/deps.py +21 -0
  6. procler/api/routes/__init__.py +5 -0
  7. procler/api/routes/config.py +290 -0
  8. procler/api/routes/groups.py +62 -0
  9. procler/api/routes/logs.py +43 -0
  10. procler/api/routes/processes.py +185 -0
  11. procler/api/routes/recipes.py +69 -0
  12. procler/api/routes/snippets.py +134 -0
  13. procler/api/routes/ws.py +459 -0
  14. procler/cli.py +1478 -0
  15. procler/config/__init__.py +65 -0
  16. procler/config/changelog.py +148 -0
  17. procler/config/loader.py +256 -0
  18. procler/config/schema.py +315 -0
  19. procler/core/__init__.py +54 -0
  20. procler/core/context_base.py +117 -0
  21. procler/core/context_docker.py +384 -0
  22. procler/core/context_local.py +287 -0
  23. procler/core/daemon_detector.py +325 -0
  24. procler/core/events.py +74 -0
  25. procler/core/groups.py +419 -0
  26. procler/core/health.py +280 -0
  27. procler/core/log_tailer.py +262 -0
  28. procler/core/process_manager.py +1277 -0
  29. procler/core/recipes.py +330 -0
  30. procler/core/snippets.py +231 -0
  31. procler/core/variable_substitution.py +65 -0
  32. procler/db.py +96 -0
  33. procler/logging.py +41 -0
  34. procler/models.py +130 -0
  35. procler/py.typed +0 -0
  36. procler/settings.py +29 -0
  37. procler/static/assets/AboutView-BwZnsfpW.js +4 -0
  38. procler/static/assets/AboutView-UHbxWXcS.css +1 -0
  39. procler/static/assets/Code-HTS-H1S6.js +74 -0
  40. procler/static/assets/ConfigView-CGJcmp9G.css +1 -0
  41. procler/static/assets/ConfigView-aVtbRDf8.js +1 -0
  42. procler/static/assets/DashboardView-C5jw9Nsd.css +1 -0
  43. procler/static/assets/DashboardView-Dab7Cu9v.js +1 -0
  44. procler/static/assets/DataTable-z39TOAa4.js +746 -0
  45. procler/static/assets/DescriptionsItem-B2E8YbqJ.js +74 -0
  46. procler/static/assets/Divider-Dk-6aD2Y.js +42 -0
  47. procler/static/assets/Empty-MuygEHZM.js +24 -0
  48. procler/static/assets/Grid-CZ9QVKAT.js +1 -0
  49. procler/static/assets/GroupsView-BALG7i1X.js +1 -0
  50. procler/static/assets/GroupsView-gXAI1CVC.css +1 -0
  51. procler/static/assets/Input-e0xaxoWE.js +259 -0
  52. procler/static/assets/PhArrowsClockwise.vue-DqDg31az.js +1 -0
  53. procler/static/assets/PhCheckCircle.vue-Fwj9sh9m.js +1 -0
  54. procler/static/assets/PhEye.vue-JcPHciC2.js +1 -0
  55. procler/static/assets/PhPlay.vue-CZm7Gy3u.js +1 -0
  56. procler/static/assets/PhPlus.vue-yTWqKlSh.js +1 -0
  57. procler/static/assets/PhStop.vue-DxsqwIki.js +1 -0
  58. procler/static/assets/PhTrash.vue-DcqQbN1_.js +125 -0
  59. procler/static/assets/PhXCircle.vue-BXWmrabV.js +1 -0
  60. procler/static/assets/ProcessDetailView-DDbtIWq9.css +1 -0
  61. procler/static/assets/ProcessDetailView-DPtdNV-q.js +1 -0
  62. procler/static/assets/ProcessesView-B3a6Umur.js +1 -0
  63. procler/static/assets/ProcessesView-goLmghbJ.css +1 -0
  64. procler/static/assets/RecipesView-D2VxdneD.js +166 -0
  65. procler/static/assets/RecipesView-DXnFDCK4.css +1 -0
  66. procler/static/assets/Select-BBR17AHq.js +317 -0
  67. procler/static/assets/SnippetsView-B3a9q3AI.css +1 -0
  68. procler/static/assets/SnippetsView-DBCB2yGq.js +1 -0
  69. procler/static/assets/Spin-BXTjvFUk.js +90 -0
  70. procler/static/assets/Tag-Bh_qV63A.js +71 -0
  71. procler/static/assets/changelog-KkTT4H9-.js +1 -0
  72. procler/static/assets/groups-Zu-_v8ey.js +1 -0
  73. procler/static/assets/index-BsN-YMXq.css +1 -0
  74. procler/static/assets/index-BzW1XhyH.js +1282 -0
  75. procler/static/assets/procler-DOrSB1Vj.js +1 -0
  76. procler/static/assets/recipes-1w5SseGb.js +1 -0
  77. procler/static/index.html +17 -0
  78. procler/static/procler.png +0 -0
  79. procler-0.2.0.dist-info/METADATA +545 -0
  80. procler-0.2.0.dist-info/RECORD +83 -0
  81. procler-0.2.0.dist-info/WHEEL +4 -0
  82. procler-0.2.0.dist-info/entry_points.txt +2 -0
  83. procler-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,330 @@
1
+ """Recipe execution engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from typing import Any
8
+
9
+ from ..config import (
10
+ ChangelogAction,
11
+ OnErrorAction,
12
+ RecipeDef,
13
+ RecipeStepExec,
14
+ RecipeStepGroupStart,
15
+ RecipeStepGroupStop,
16
+ RecipeStepRestart,
17
+ RecipeStepStart,
18
+ RecipeStepStop,
19
+ RecipeStepWait,
20
+ append_changelog,
21
+ get_config,
22
+ )
23
+ from . import get_process_manager
24
+ from .events import EVENT_RECIPE_STEP, get_event_bus
25
+ from .groups import get_group_manager
26
+
27
+
28
+ class RecipeExecutor:
29
+ """Executes multi-step recipes."""
30
+
31
+ def __init__(self):
32
+ self._process_manager = get_process_manager()
33
+ self._group_manager = get_group_manager()
34
+
35
+ def list_recipes(self) -> dict[str, Any]:
36
+ """List all defined recipes."""
37
+ config = get_config()
38
+
39
+ recipes_data = []
40
+ for name, recipe in config.recipes.items():
41
+ recipes_data.append(
42
+ {
43
+ "name": name,
44
+ "description": recipe.description,
45
+ "steps_count": len(recipe.steps),
46
+ "on_error": recipe.on_error.value,
47
+ }
48
+ )
49
+
50
+ return {
51
+ "success": True,
52
+ "data": {
53
+ "recipes": recipes_data,
54
+ "count": len(recipes_data),
55
+ },
56
+ }
57
+
58
+ def get_recipe(self, name: str) -> dict[str, Any]:
59
+ """Get a specific recipe with full details."""
60
+ config = get_config()
61
+
62
+ if name not in config.recipes:
63
+ return {
64
+ "success": False,
65
+ "error": f"Recipe '{name}' not found",
66
+ "error_code": "recipe_not_found",
67
+ "suggestion": "Run 'procler recipe list' to see available recipes",
68
+ }
69
+
70
+ recipe = config.recipes[name]
71
+ return {
72
+ "success": True,
73
+ "data": {
74
+ "recipe": {
75
+ "name": name,
76
+ "description": recipe.description,
77
+ "steps": recipe.steps, # Raw steps for display
78
+ "on_error": recipe.on_error.value,
79
+ }
80
+ },
81
+ }
82
+
83
+ async def run_recipe(
84
+ self,
85
+ name: str,
86
+ dry_run: bool = False,
87
+ continue_on_error: bool | None = None,
88
+ ) -> dict[str, Any]:
89
+ """
90
+ Execute a recipe.
91
+
92
+ Args:
93
+ name: Recipe name
94
+ dry_run: If True, just show what would happen
95
+ continue_on_error: Override recipe's on_error setting
96
+ """
97
+ config = get_config()
98
+
99
+ if name not in config.recipes:
100
+ return {
101
+ "success": False,
102
+ "error": f"Recipe '{name}' not found",
103
+ "error_code": "recipe_not_found",
104
+ "suggestion": "Run 'procler recipe list' to see available recipes",
105
+ }
106
+
107
+ recipe = config.recipes[name]
108
+ steps = recipe.get_steps()
109
+
110
+ # Determine error handling behavior
111
+ if continue_on_error is not None:
112
+ should_continue = continue_on_error
113
+ else:
114
+ should_continue = recipe.on_error == OnErrorAction.CONTINUE
115
+
116
+ if dry_run:
117
+ return self._dry_run(name, recipe, steps)
118
+
119
+ # Execute for real
120
+ start_time = time.time()
121
+ results = []
122
+ all_success = True
123
+ stopped_at_step = None
124
+ total_steps = len(steps)
125
+
126
+ for i, step in enumerate(steps):
127
+ step_num = i + 1
128
+ action_desc = self._describe_step(step)
129
+
130
+ # Emit "running" event before execution
131
+ self._emit_step_event(name, step_num, total_steps, action_desc, "running")
132
+
133
+ step_result = await self._execute_step(step, step_num)
134
+ results.append(step_result)
135
+
136
+ # Determine status for event
137
+ if step_result["success"]:
138
+ status = "success"
139
+ elif step_result.get("ignore_error"):
140
+ status = "warning"
141
+ else:
142
+ status = "error"
143
+
144
+ # Emit completion event
145
+ self._emit_step_event(name, step_num, total_steps, action_desc, status, error=step_result.get("error"))
146
+
147
+ if not step_result["success"]:
148
+ all_success = False
149
+ if not should_continue and not step_result.get("ignore_error"):
150
+ stopped_at_step = step_num
151
+ # Emit skipped events for remaining steps
152
+ for j in range(i + 1, len(steps)):
153
+ skip_action = self._describe_step(steps[j])
154
+ self._emit_step_event(name, j + 1, total_steps, skip_action, "skipped")
155
+ break
156
+
157
+ duration_ms = int((time.time() - start_time) * 1000)
158
+
159
+ # Log execution to changelog
160
+ append_changelog(
161
+ action=ChangelogAction.EXECUTE,
162
+ entity_type="recipe",
163
+ entity_name=name,
164
+ details={
165
+ "duration_ms": duration_ms,
166
+ "success": all_success,
167
+ "steps_completed": len(results),
168
+ "stopped_at": stopped_at_step,
169
+ },
170
+ )
171
+
172
+ return {
173
+ "success": all_success,
174
+ "data": {
175
+ "recipe": name,
176
+ "duration_ms": duration_ms,
177
+ "steps_total": len(steps),
178
+ "steps_completed": len(results),
179
+ "stopped_at_step": stopped_at_step,
180
+ "results": results,
181
+ },
182
+ }
183
+
184
+ def _dry_run(self, name: str, recipe: RecipeDef, steps: list) -> dict[str, Any]:
185
+ """Show what a recipe would do without executing."""
186
+ planned_steps = []
187
+
188
+ for i, step in enumerate(steps):
189
+ planned_steps.append(
190
+ {
191
+ "step": i + 1,
192
+ "action": self._describe_step(step),
193
+ }
194
+ )
195
+
196
+ return {
197
+ "success": True,
198
+ "data": {
199
+ "recipe": name,
200
+ "description": recipe.description,
201
+ "dry_run": True,
202
+ "planned_steps": planned_steps,
203
+ },
204
+ }
205
+
206
+ def _emit_step_event(
207
+ self,
208
+ recipe: str,
209
+ step: int,
210
+ total: int,
211
+ action: str,
212
+ status: str,
213
+ error: str | None = None,
214
+ ) -> None:
215
+ """Emit a recipe step event for real-time updates."""
216
+ event_bus = get_event_bus()
217
+ event_bus.emit_sync(
218
+ EVENT_RECIPE_STEP,
219
+ {
220
+ "recipe": recipe,
221
+ "step": step,
222
+ "total": total,
223
+ "action": action,
224
+ "status": status, # running, success, error, warning, skipped
225
+ "error": error,
226
+ },
227
+ )
228
+
229
+ def _describe_step(self, step) -> str:
230
+ """Get a human-readable description of a step."""
231
+ if isinstance(step, RecipeStepStart):
232
+ return f"start process '{step.start}'"
233
+ elif isinstance(step, RecipeStepStop):
234
+ suffix = " (ignore_error)" if step.ignore_error else ""
235
+ return f"stop process '{step.stop}'{suffix}"
236
+ elif isinstance(step, RecipeStepRestart):
237
+ return f"restart process '{step.restart}'"
238
+ elif isinstance(step, RecipeStepGroupStart):
239
+ return f"start group '{step.group_start}'"
240
+ elif isinstance(step, RecipeStepGroupStop):
241
+ return f"stop group '{step.group_stop}'"
242
+ elif isinstance(step, RecipeStepWait):
243
+ return f"wait {step.wait}"
244
+ elif isinstance(step, RecipeStepExec):
245
+ ctx = f" (docker:{step.container})" if step.container else ""
246
+ suffix = " (ignore_error)" if step.ignore_error else ""
247
+ return f"exec '{step.exec}'{ctx}{suffix}"
248
+ else:
249
+ return f"unknown step: {step}"
250
+
251
+ async def _execute_step(self, step, step_num: int) -> dict[str, Any]:
252
+ """Execute a single recipe step."""
253
+ result = {
254
+ "step": step_num,
255
+ "action": self._describe_step(step),
256
+ "success": True,
257
+ }
258
+
259
+ try:
260
+ if isinstance(step, RecipeStepStart):
261
+ res = await self._process_manager.start(step.start)
262
+ result["success"] = res["success"]
263
+ result["details"] = res.get("data") or {"error": res.get("error")}
264
+
265
+ elif isinstance(step, RecipeStepStop):
266
+ res = await self._process_manager.stop(step.stop)
267
+ result["success"] = res["success"]
268
+ result["details"] = res.get("data") or {"error": res.get("error")}
269
+ result["ignore_error"] = step.ignore_error
270
+
271
+ elif isinstance(step, RecipeStepRestart):
272
+ res = await self._process_manager.restart(step.restart)
273
+ result["success"] = res["success"]
274
+ result["details"] = res.get("data") or {"error": res.get("error")}
275
+
276
+ elif isinstance(step, RecipeStepGroupStart):
277
+ res = await self._group_manager.start_group(step.group_start)
278
+ result["success"] = res["success"]
279
+ result["details"] = res.get("data") or {"error": res.get("error")}
280
+
281
+ elif isinstance(step, RecipeStepGroupStop):
282
+ res = await self._group_manager.stop_group(step.group_stop)
283
+ result["success"] = res["success"]
284
+ result["details"] = res.get("data") or {"error": res.get("error")}
285
+
286
+ elif isinstance(step, RecipeStepWait):
287
+ seconds = step.get_seconds()
288
+ await asyncio.sleep(seconds)
289
+ result["details"] = {"waited_seconds": seconds}
290
+
291
+ elif isinstance(step, RecipeStepExec):
292
+ timeout = step.get_timeout_seconds()
293
+ res = await self._process_manager.exec_command(
294
+ command=step.exec,
295
+ context_type=step.context.value,
296
+ container_name=step.container,
297
+ cwd=step.cwd,
298
+ timeout=timeout,
299
+ )
300
+ result["success"] = res["success"]
301
+ result["details"] = res.get("data") or {"error": res.get("error")}
302
+ result["ignore_error"] = step.ignore_error
303
+
304
+ else:
305
+ result["success"] = False
306
+ result["error"] = f"Unknown step type: {type(step)}"
307
+
308
+ except Exception as e:
309
+ result["success"] = False
310
+ result["error"] = str(e)
311
+
312
+ return result
313
+
314
+
315
+ # Singleton
316
+ _recipe_executor: RecipeExecutor | None = None
317
+
318
+
319
+ def get_recipe_executor() -> RecipeExecutor:
320
+ """Get the singleton RecipeExecutor instance."""
321
+ global _recipe_executor
322
+ if _recipe_executor is None:
323
+ _recipe_executor = RecipeExecutor()
324
+ return _recipe_executor
325
+
326
+
327
+ def reset_recipe_executor() -> None:
328
+ """Reset the singleton (for testing)."""
329
+ global _recipe_executor
330
+ _recipe_executor = None
@@ -0,0 +1,231 @@
1
+ """Snippet management for reusable commands."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from sqler.query import SQLerField as F
7
+
8
+ from ..db import init_database
9
+ from ..models import Snippet
10
+ from .context_docker import is_docker_available
11
+ from .context_local import get_local_context
12
+ from .variable_substitution import substitute_vars_from_config
13
+
14
+
15
+ class SnippetManager:
16
+ """Manager for command snippets."""
17
+
18
+ def __init__(self):
19
+ pass
20
+
21
+ def _get_snippet_by_name(self, name: str) -> Snippet | None:
22
+ """Get a snippet by name."""
23
+ init_database()
24
+ results = Snippet.query().filter(F("name") == name).all()
25
+ return results[0] if results else None
26
+
27
+ def _snippet_to_dict(self, snippet: Snippet) -> dict[str, Any]:
28
+ """Convert a Snippet to a dict for JSON output."""
29
+ return {
30
+ "id": snippet._id,
31
+ "name": snippet.name,
32
+ "command": snippet.command,
33
+ "description": snippet.description,
34
+ "context_type": snippet.context_type,
35
+ "container_name": snippet.container_name,
36
+ "tags": snippet.tags or [],
37
+ "created_at": snippet.created_at,
38
+ }
39
+
40
+ def list_snippets(self, tag: str | None = None) -> dict[str, Any]:
41
+ """
42
+ List all snippets, optionally filtered by tag.
43
+
44
+ Args:
45
+ tag: Optional tag to filter by
46
+
47
+ Returns a dict with snippet list (for JSON output).
48
+ """
49
+ init_database()
50
+
51
+ snippets = Snippet.query().all()
52
+
53
+ if tag:
54
+ # Filter by tag
55
+ snippets = [s for s in snippets if s.tags and tag in s.tags]
56
+
57
+ return {
58
+ "success": True,
59
+ "data": {
60
+ "snippets": [self._snippet_to_dict(s) for s in snippets],
61
+ "count": len(snippets),
62
+ },
63
+ }
64
+
65
+ def save_snippet(
66
+ self,
67
+ name: str,
68
+ command: str,
69
+ description: str | None = None,
70
+ context_type: str = "local",
71
+ container_name: str | None = None,
72
+ tags: list[str] | None = None,
73
+ ) -> dict[str, Any]:
74
+ """
75
+ Save a new snippet.
76
+
77
+ Args:
78
+ name: Unique snippet name
79
+ command: Command to execute
80
+ description: Optional description
81
+ context_type: Execution context ('local' or 'docker')
82
+ container_name: Docker container name (required if context=docker)
83
+ tags: Optional list of tags
84
+
85
+ Returns a dict with save result (for JSON output).
86
+ """
87
+ init_database()
88
+
89
+ # Validate docker context
90
+ if context_type == "docker" and not container_name:
91
+ return {
92
+ "success": False,
93
+ "error": "Container name required for docker context",
94
+ "error_code": "missing_container",
95
+ "suggestion": "Use --container <name> to specify the Docker container",
96
+ }
97
+
98
+ # Check if snippet already exists
99
+ existing = self._get_snippet_by_name(name)
100
+ if existing:
101
+ return {
102
+ "success": False,
103
+ "error": f"Snippet '{name}' already exists",
104
+ "error_code": "snippet_exists",
105
+ "suggestion": f"Use 'procler snippet remove {name}' first, or choose a different name",
106
+ }
107
+
108
+ snippet = Snippet(
109
+ name=name,
110
+ command=command,
111
+ description=description,
112
+ context_type=context_type,
113
+ container_name=container_name,
114
+ tags=tags,
115
+ created_at=datetime.now().isoformat(),
116
+ )
117
+ snippet.save()
118
+
119
+ return {
120
+ "success": True,
121
+ "data": {
122
+ "action": "created",
123
+ "snippet": self._snippet_to_dict(snippet),
124
+ },
125
+ }
126
+
127
+ def remove_snippet(self, name: str) -> dict[str, Any]:
128
+ """
129
+ Remove a snippet by name.
130
+
131
+ Returns a dict with removal result (for JSON output).
132
+ """
133
+ init_database()
134
+
135
+ snippet = self._get_snippet_by_name(name)
136
+ if not snippet:
137
+ return {
138
+ "success": False,
139
+ "error": f"Snippet '{name}' not found",
140
+ "error_code": "snippet_not_found",
141
+ "suggestion": "Run 'procler snippet list' to see available snippets",
142
+ }
143
+
144
+ snippet.delete()
145
+
146
+ return {
147
+ "success": True,
148
+ "data": {
149
+ "action": "removed",
150
+ "name": name,
151
+ },
152
+ }
153
+
154
+ async def run_snippet(self, name: str) -> dict[str, Any]:
155
+ """
156
+ Run a snippet by name.
157
+
158
+ Returns a dict with execution result (for JSON output).
159
+ """
160
+ init_database()
161
+
162
+ snippet = self._get_snippet_by_name(name)
163
+ if not snippet:
164
+ return {
165
+ "success": False,
166
+ "error": f"Snippet '{name}' not found",
167
+ "error_code": "snippet_not_found",
168
+ "suggestion": "Run 'procler snippet list' to see available snippets",
169
+ }
170
+
171
+ # Substitute config vars in command (e.g., ${SIM_CONTAINER})
172
+ resolved_command = substitute_vars_from_config(snippet.command)
173
+
174
+ # Validate docker context
175
+ if snippet.context_type == "docker":
176
+ if not snippet.container_name:
177
+ return {
178
+ "success": False,
179
+ "error": "Snippet has docker context but no container name",
180
+ "error_code": "missing_container",
181
+ }
182
+
183
+ if not is_docker_available():
184
+ return {
185
+ "success": False,
186
+ "error": "Docker is not available",
187
+ "error_code": "docker_unavailable",
188
+ "suggestion": "Ensure Docker is installed and running",
189
+ }
190
+
191
+ # Import here to avoid circular imports
192
+ from .context_docker import get_docker_context
193
+
194
+ context = get_docker_context()
195
+ try:
196
+ result = await context.exec_command(
197
+ command=resolved_command,
198
+ container_name=snippet.container_name,
199
+ )
200
+ except ValueError as e:
201
+ return {
202
+ "success": False,
203
+ "error": str(e),
204
+ "error_code": "container_not_found",
205
+ }
206
+ else:
207
+ # Local context
208
+ context = get_local_context()
209
+ result = await context.exec_command(command=resolved_command)
210
+
211
+ return {
212
+ "success": True,
213
+ "data": {
214
+ "snippet": name,
215
+ "stdout": result.stdout,
216
+ "stderr": result.stderr,
217
+ "exit_code": result.exit_code,
218
+ },
219
+ }
220
+
221
+
222
+ # Global singleton
223
+ _snippet_manager: SnippetManager | None = None
224
+
225
+
226
+ def get_snippet_manager() -> SnippetManager:
227
+ """Get the global SnippetManager instance."""
228
+ global _snippet_manager
229
+ if _snippet_manager is None:
230
+ _snippet_manager = SnippetManager()
231
+ return _snippet_manager
@@ -0,0 +1,65 @@
1
+ """Variable substitution for procler commands.
2
+
3
+ Replaces ${VAR} patterns in commands with values from config.vars.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import re
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Patterns that might indicate shell injection
14
+ SUSPICIOUS_PATTERNS = [";", "&&", "||", "$(", "`", "\n"]
15
+
16
+
17
+ def warn_suspicious_vars(vars: dict[str, str]) -> None:
18
+ """Log warnings for vars containing suspicious patterns."""
19
+ for name, value in vars.items():
20
+ for pattern in SUSPICIOUS_PATTERNS:
21
+ if pattern in value:
22
+ logger.warning(
23
+ f"Variable '{name}' contains suspicious pattern '{pattern}'. " f"Ensure this is intentional."
24
+ )
25
+ break
26
+
27
+
28
+ def substitute_vars(text: str, vars: dict[str, str]) -> str:
29
+ """
30
+ Replace ${VAR} patterns with values from vars dict.
31
+
32
+ If a variable is not found, the original ${VAR} pattern is kept.
33
+
34
+ Args:
35
+ text: String containing ${VAR} patterns
36
+ vars: Dict mapping variable names to values
37
+
38
+ Returns:
39
+ String with variables substituted
40
+ """
41
+ if not vars:
42
+ return text
43
+
44
+ def replacer(match: re.Match) -> str:
45
+ var_name = match.group(1)
46
+ if var_name in vars:
47
+ return vars[var_name]
48
+ # Keep original if not found
49
+ return match.group(0)
50
+
51
+ # Match ${VAR_NAME} pattern (alphanumeric and underscore)
52
+ return re.sub(r"\$\{(\w+)\}", replacer, text)
53
+
54
+
55
+ def substitute_vars_from_config(text: str) -> str:
56
+ """
57
+ Replace ${VAR} patterns with values from config.vars.
58
+
59
+ Convenience function that loads vars from the current config.
60
+ """
61
+ # Import here to avoid circular dependency
62
+ from procler.config.loader import get_config
63
+
64
+ config = get_config()
65
+ return substitute_vars(text, config.vars)