redo-cli 0.1.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.
modules/runner.py ADDED
@@ -0,0 +1,278 @@
1
+ import re
2
+ import subprocess
3
+
4
+ from rich import box
5
+ from rich.console import Console, Group
6
+ from rich.live import Live
7
+ from rich.panel import Panel
8
+ from rich.prompt import Confirm
9
+ from rich.spinner import Spinner
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+
13
+
14
+ console = Console(highlight=False)
15
+
16
+ DANGEROUS_PATTERNS = [
17
+ r"\bdel\s+/(?:s|f)\b.*\s+/(?:s|f)\b",
18
+ r"\brd\s+/s\b",
19
+ r"\brmdir\s+/s\b",
20
+ r"\bremove-item\b(?=.*-recurse\b)(?=.*-force\b)",
21
+ r"\bformat\b",
22
+ r"\bmkfs(?:\.[a-z0-9]+)?\b",
23
+ r"\bdd\s+.*\bof=",
24
+ r"\bsudo\b",
25
+ r"\bgit\s+reset\s+--hard\b",
26
+ ]
27
+ COMMAND_TIMEOUT_SECONDS = 1800
28
+ STATUS_PENDING = "Pending"
29
+ STATUS_RUNNING = "Running"
30
+ STATUS_DONE = "Done"
31
+ STATUS_FAILED = "Failed"
32
+ STATUS_SKIPPED = "Skipped"
33
+ SPINNER_NAME = "line"
34
+
35
+
36
+ def _result(code, status, message, data=None):
37
+ result = {
38
+ "code": code,
39
+ "status": status,
40
+ "message": message,
41
+ }
42
+ if data is not None:
43
+ result["data"] = data
44
+ return result
45
+
46
+
47
+ def is_dangerous_command(command):
48
+ normalized = " ".join(command.strip().lower().split())
49
+ return _is_dangerous_rm_command(normalized) or any(
50
+ re.search(pattern, normalized, flags=re.IGNORECASE) for pattern in DANGEROUS_PATTERNS
51
+ )
52
+
53
+
54
+ def _is_dangerous_rm_command(command):
55
+ segments = re.split(r"\s*(?:&&|\|\||[;|])\s*", command)
56
+ return any(_is_dangerous_rm_segment(segment) for segment in segments)
57
+
58
+
59
+ def _is_dangerous_rm_segment(command):
60
+ parts = command.strip().split()
61
+ if not parts or parts[0] != "rm":
62
+ return False
63
+
64
+ has_recursive = False
65
+ has_force = False
66
+ for part in parts[1:]:
67
+ if part in {"--recursive", "--dir"}:
68
+ has_recursive = True
69
+ elif part == "--force":
70
+ has_force = True
71
+ elif part.startswith("-") and not part.startswith("--"):
72
+ flags = part[1:]
73
+ has_recursive = has_recursive or "r" in flags
74
+ has_force = has_force or "f" in flags
75
+
76
+ return has_recursive and has_force
77
+
78
+
79
+ def _status_style(status):
80
+ if status == STATUS_DONE:
81
+ return "bold green"
82
+ if status == STATUS_RUNNING:
83
+ return "bold steel_blue"
84
+ if status == STATUS_FAILED:
85
+ return "bold red"
86
+ if status == STATUS_SKIPPED:
87
+ return "yellow"
88
+ return "dim"
89
+
90
+
91
+ def _workflow_table(commands, statuses):
92
+ table = Table(
93
+ box=box.ROUNDED,
94
+ border_style="grey46",
95
+ header_style="bold steel_blue",
96
+ expand=True,
97
+ )
98
+ table.add_column("#", justify="right", style="dim", no_wrap=True)
99
+ table.add_column("Command", overflow="fold")
100
+ table.add_column("Status", justify="right", no_wrap=True)
101
+
102
+ for index, command in enumerate(commands, start=1):
103
+ status = statuses[index - 1]
104
+ table.add_row(str(index), command, Text(status, style=_status_style(status)))
105
+
106
+ return table
107
+
108
+
109
+ def _workflow_view(commands, statuses):
110
+ loader = Spinner(
111
+ SPINNER_NAME,
112
+ text=Text("Your workflow is running, check the status.", style="bold steel_blue"),
113
+ style="steel_blue",
114
+ )
115
+ return Panel(
116
+ Group(loader, "", _workflow_table(commands, statuses)),
117
+ title="Workflow status",
118
+ border_style="grey46",
119
+ box=box.ROUNDED,
120
+ )
121
+
122
+
123
+ def _trim_output(output, max_lines=18):
124
+ output = _stringify_output(output)
125
+ lines = output.strip().splitlines()
126
+ if len(lines) <= max_lines:
127
+ return "\n".join(lines)
128
+ return "\n".join(["..."] + lines[-max_lines:])
129
+
130
+
131
+ def _stringify_output(output):
132
+ if output is None:
133
+ return ""
134
+ if isinstance(output, bytes):
135
+ return output.decode(errors="replace")
136
+ return str(output)
137
+
138
+
139
+ def _extract_git_upstream_hint(command, stderr):
140
+ if command.strip() != "git push":
141
+ return None
142
+ if "has no upstream branch" not in stderr:
143
+ return None
144
+
145
+ match = re.search(r"git push --set-upstream origin [^\s]+", stderr)
146
+ if not match:
147
+ return None
148
+
149
+ return f"Set the upstream once with: {match.group(0)}"
150
+
151
+
152
+ def _failure_message(command, returncode, stderr):
153
+ hint = _extract_git_upstream_hint(command, stderr)
154
+ if hint:
155
+ return f"command failed with exit code {returncode}. {hint}"
156
+ return f"command failed with exit code {returncode}"
157
+
158
+
159
+ def _show_failure_details(result):
160
+ data = result.get("data", {})
161
+ stderr = data.get("stderr", "").strip()
162
+ stdout = data.get("stdout", "").strip()
163
+ output = stderr or stdout or "No output captured."
164
+
165
+ body = Group(
166
+ Text(f"Command: {data.get('command', '-')}", style="bold"),
167
+ Text(f"Exit code: {data.get('returncode', '-')}", style="red"),
168
+ "",
169
+ Text(_trim_output(output)),
170
+ )
171
+ console.print(
172
+ Panel(
173
+ body,
174
+ title="Command failed",
175
+ border_style="red",
176
+ box=box.ROUNDED,
177
+ )
178
+ )
179
+
180
+
181
+ def run_command(command):
182
+ try:
183
+ completed = subprocess.run(
184
+ command,
185
+ shell=True,
186
+ capture_output=True,
187
+ text=True,
188
+ timeout=COMMAND_TIMEOUT_SECONDS,
189
+ )
190
+ except subprocess.TimeoutExpired as error:
191
+ data = {
192
+ "command": command,
193
+ "returncode": None,
194
+ "stdout": _stringify_output(error.stdout),
195
+ "stderr": _stringify_output(error.stderr),
196
+ "timeout": COMMAND_TIMEOUT_SECONDS,
197
+ }
198
+ return _result(1, "error", f"command timed out after {COMMAND_TIMEOUT_SECONDS} seconds", data)
199
+ except OSError as error:
200
+ data = {
201
+ "command": command,
202
+ "returncode": None,
203
+ "stdout": "",
204
+ "stderr": str(error),
205
+ }
206
+ return _result(1, "error", f"could not run command: {error}", data)
207
+
208
+ data = {
209
+ "command": command,
210
+ "returncode": completed.returncode,
211
+ "stdout": completed.stdout or "",
212
+ "stderr": completed.stderr or "",
213
+ }
214
+
215
+ if completed.returncode != 0:
216
+ return _result(
217
+ 1,
218
+ "error",
219
+ _failure_message(command, completed.returncode, data["stderr"]),
220
+ data,
221
+ )
222
+
223
+ return _result(0, "success", "command completed successfully", data)
224
+
225
+
226
+ def run_workflow_commands(commands, dry_run=False):
227
+ if dry_run:
228
+ return _result(
229
+ 0,
230
+ "success",
231
+ "dry run completed",
232
+ {"dry_run": True, "commands": commands},
233
+ )
234
+
235
+ dangerous_commands = [command for command in commands if is_dangerous_command(command)]
236
+ if dangerous_commands:
237
+ console.print("[bold yellow]Dangerous command detected:[/bold yellow]")
238
+ for command in dangerous_commands:
239
+ console.print(f" {command}")
240
+
241
+ if not Confirm.ask("Continue anyway?", default=False):
242
+ return _result(2, "warning", "workflow cancelled by user")
243
+
244
+ statuses = [STATUS_PENDING for _ in commands]
245
+ failed_result = None
246
+
247
+ with Live(
248
+ _workflow_view(commands, statuses),
249
+ console=console,
250
+ refresh_per_second=8,
251
+ ) as live:
252
+ for index, command in enumerate(commands):
253
+ statuses[index] = STATUS_RUNNING
254
+ live.update(_workflow_view(commands, statuses))
255
+
256
+ result = run_command(command)
257
+ if result["code"] != 0:
258
+ statuses[index] = STATUS_FAILED
259
+ for remaining_index in range(index + 1, len(statuses)):
260
+ statuses[remaining_index] = STATUS_SKIPPED
261
+ live.update(_workflow_view(commands, statuses))
262
+ failed_result = result
263
+ break
264
+
265
+ statuses[index] = STATUS_DONE
266
+ live.update(_workflow_view(commands, statuses))
267
+
268
+ if failed_result is not None:
269
+ console.print()
270
+ _show_failure_details(failed_result)
271
+ return failed_result
272
+
273
+ return _result(
274
+ 0,
275
+ "success",
276
+ "workflow completed successfully",
277
+ {"dry_run": False, "commands": commands},
278
+ )