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/storage.py ADDED
@@ -0,0 +1,515 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ from copy import deepcopy
5
+ from pathlib import Path
6
+
7
+
8
+ STATE_FILE_NAME = "redo_state.json"
9
+ RESERVED_WORKFLOW_NAMES = {
10
+ "autofix",
11
+ "clearhistory",
12
+ "copy",
13
+ "delete",
14
+ "doctor",
15
+ "export",
16
+ "guide",
17
+ "help",
18
+ "import",
19
+ "info",
20
+ "init",
21
+ "list",
22
+ "new",
23
+ "path",
24
+ "rename",
25
+ "run",
26
+ "search",
27
+ "show",
28
+ "stats",
29
+ }
30
+
31
+
32
+ def _user_data_dir():
33
+ override = os.environ.get("REDO_DATA_DIR")
34
+ if override:
35
+ return Path(override).expanduser()
36
+ appdata = os.environ.get("APPDATA")
37
+ if appdata:
38
+ return Path(appdata) / "Redo"
39
+ return Path.home() / ".redo"
40
+
41
+
42
+ DATA_DIR = _user_data_dir()
43
+ DATA_FILE = DATA_DIR / "workflows.json"
44
+
45
+
46
+ def _result(code, status, message, data=None):
47
+ result = {
48
+ "code": code,
49
+ "status": status,
50
+ "message": message,
51
+ }
52
+ if data is not None:
53
+ result["data"] = data
54
+ return result
55
+
56
+
57
+ def _write_text_file(path, content):
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ temp_path = None
60
+ try:
61
+ with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as file:
62
+ temp_path = Path(file.name)
63
+ file.write(content)
64
+ file.flush()
65
+ os.fsync(file.fileno())
66
+ temp_path.replace(path)
67
+ except OSError:
68
+ if temp_path is not None:
69
+ temp_path.unlink(missing_ok=True)
70
+ raise
71
+
72
+
73
+ def _write_json_file(path, data):
74
+ path.parent.mkdir(parents=True, exist_ok=True)
75
+ temp_path = None
76
+ try:
77
+ with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as file:
78
+ temp_path = Path(file.name)
79
+ json.dump(data, file, indent=2)
80
+ file.write("\n")
81
+ file.flush()
82
+ os.fsync(file.fileno())
83
+ temp_path.replace(path)
84
+ except OSError:
85
+ if temp_path is not None:
86
+ temp_path.unlink(missing_ok=True)
87
+ raise
88
+
89
+
90
+ def _require_loaded_workflows(result, allow_warnings=False):
91
+ if result["code"] == 1:
92
+ return None, result
93
+ if result["code"] == 2 and not allow_warnings:
94
+ return None, _result(1, "error", "workflow storage is invalid; run `redo autofix` first")
95
+ return result.get("data", {}), None
96
+
97
+
98
+ def _validate_workflow_name(name):
99
+ clean_name = str(name).strip()
100
+ if not clean_name:
101
+ return None, _result(2, "warning", "workflow name cannot be blank")
102
+ if clean_name.lower() in RESERVED_WORKFLOW_NAMES:
103
+ return None, _result(2, "warning", "workflow name is reserved by a Redo command")
104
+ return clean_name, None
105
+
106
+
107
+ def validate_workflow_name(name):
108
+ _, error = _validate_workflow_name(name)
109
+ if error:
110
+ return error
111
+ return _result(0, "success", "workflow name is valid")
112
+
113
+
114
+ def initialize_file():
115
+ try:
116
+ if DATA_FILE.exists() and DATA_FILE.read_text(encoding="utf-8").strip():
117
+ return _result(2, "warning", "workflow file already exists")
118
+ _write_text_file(DATA_FILE, "{}\n")
119
+ return _result(0, "success", "workflow file initialized")
120
+ except (OSError, UnicodeDecodeError) as error:
121
+ return _result(1, "error", f"could not initialize workflow file: {error}")
122
+
123
+
124
+ def _broken_backup_file():
125
+ candidate = DATA_FILE.with_name(f"{DATA_FILE.stem}.broken.json")
126
+ if not candidate.exists():
127
+ return candidate
128
+ index = 1
129
+ while True:
130
+ candidate = DATA_FILE.with_name(f"{DATA_FILE.stem}.broken.{index}.json")
131
+ if not candidate.exists():
132
+ return candidate
133
+ index += 1
134
+
135
+
136
+ def _state_file():
137
+ return DATA_FILE.parent / STATE_FILE_NAME
138
+
139
+
140
+ def _normalize_workflow(name, workflow):
141
+ if not isinstance(name, str) or not name.strip() or not isinstance(workflow, dict):
142
+ return None
143
+
144
+ commands = workflow.get("commands", [])
145
+ if isinstance(commands, str):
146
+ commands = [commands]
147
+ elif isinstance(commands, list):
148
+ commands = [str(command) for command in commands if str(command).strip()]
149
+ else:
150
+ commands = []
151
+
152
+ try:
153
+ runs = int(workflow.get("runs", 0))
154
+ except (TypeError, ValueError):
155
+ runs = 0
156
+
157
+ return {
158
+ "description": str(workflow.get("description", "")),
159
+ "commands": commands,
160
+ "runs": max(runs, 0),
161
+ }
162
+
163
+
164
+ def _normalize_workflows(workflows):
165
+ if not isinstance(workflows, dict):
166
+ return {}
167
+
168
+ normalized = {}
169
+ for name, workflow in workflows.items():
170
+ clean_name, name_error = _validate_workflow_name(name)
171
+ if name_error:
172
+ continue
173
+
174
+ fixed_workflow = _normalize_workflow(clean_name, workflow)
175
+ if fixed_workflow is not None:
176
+ normalized[clean_name] = fixed_workflow
177
+
178
+ return normalized
179
+
180
+
181
+ def autofix_storage():
182
+ fixes = []
183
+
184
+ try:
185
+ DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
186
+ except OSError as error:
187
+ return _result(1, "error", f"could not create Redo data directory: {error}")
188
+
189
+ try:
190
+ if not DATA_FILE.exists():
191
+ _write_text_file(DATA_FILE, "{}\n")
192
+ fixes.append("created workflow file")
193
+ return _result(0, "success", "autofix completed", {"fixes": fixes})
194
+
195
+ raw_text = DATA_FILE.read_text(encoding="utf-8")
196
+ if not raw_text.strip():
197
+ _write_text_file(DATA_FILE, "{}\n")
198
+ fixes.append("reset blank workflow file")
199
+ return _result(0, "success", "autofix completed", {"fixes": fixes})
200
+
201
+ try:
202
+ workflows = json.loads(raw_text)
203
+ except json.JSONDecodeError:
204
+ backup_file = _broken_backup_file()
205
+ _write_text_file(backup_file, raw_text)
206
+ _write_text_file(DATA_FILE, "{}\n")
207
+ fixes.append("backed up malformed workflow file")
208
+ fixes.append("reset workflow file")
209
+ return _result(0, "success", "autofix completed", {"fixes": fixes})
210
+
211
+ normalized = _normalize_workflows(workflows)
212
+ if normalized != workflows:
213
+ save_result = save_workflows(normalized)
214
+ if save_result["code"] != 0:
215
+ return save_result
216
+ fixes.append("normalized workflow entries")
217
+ except (OSError, UnicodeDecodeError) as error:
218
+ return _result(1, "error", f"could not autofix workflow storage: {error}")
219
+
220
+ if not fixes:
221
+ fixes.append("no fixes needed")
222
+
223
+ return _result(0, "success", "autofix completed", {"fixes": fixes})
224
+
225
+
226
+ def load_workflows():
227
+ try:
228
+ if not DATA_FILE.exists() or not DATA_FILE.read_text(encoding="utf-8").strip():
229
+ init_result = initialize_file()
230
+ if init_result["code"] == 1:
231
+ return {**init_result, "data": {}}
232
+
233
+ with DATA_FILE.open("r", encoding="utf-8") as file:
234
+ data = json.load(file)
235
+ except json.JSONDecodeError:
236
+ return _result(2, "warning", "workflow file is malformed", {})
237
+ except (OSError, UnicodeDecodeError) as error:
238
+ return _result(1, "error", f"could not load workflows: {error}", {})
239
+
240
+ if not isinstance(data, dict):
241
+ return _result(2, "warning", "workflow file must contain a JSON object", {})
242
+
243
+ return _result(0, "success", "workflows loaded successfully", data)
244
+
245
+
246
+ def save_workflows(workflows):
247
+ if not isinstance(workflows, dict):
248
+ return _result(1, "error", "workflows must be a dictionary")
249
+
250
+ try:
251
+ _write_json_file(DATA_FILE, workflows)
252
+ except OSError as error:
253
+ return _result(1, "error", f"could not save workflows: {error}")
254
+
255
+ return _result(0, "success", "workflows saved successfully")
256
+
257
+
258
+ def clear_workflows():
259
+ workflows, error = _require_loaded_workflows(load_workflows())
260
+ if error:
261
+ return error
262
+
263
+ save_result = save_workflows({})
264
+ if save_result["code"] != 0:
265
+ return save_result
266
+
267
+ return _result(0, "success", "workflow history cleared")
268
+
269
+
270
+ def should_offer_first_run_guide():
271
+ state_file = _state_file()
272
+ if not state_file.exists():
273
+ return True
274
+
275
+ try:
276
+ with state_file.open("r", encoding="utf-8") as file:
277
+ state = json.load(file)
278
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
279
+ return True
280
+
281
+ return not bool(state.get("first_run_guide_seen", False))
282
+
283
+
284
+ def mark_first_run_guide_seen():
285
+ state_file = _state_file()
286
+
287
+ try:
288
+ state = {}
289
+ if state_file.exists() and state_file.read_text(encoding="utf-8").strip():
290
+ with state_file.open("r", encoding="utf-8") as file:
291
+ state = json.load(file)
292
+
293
+ state["first_run_guide_seen"] = True
294
+ _write_json_file(state_file, state)
295
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError) as error:
296
+ return _result(1, "error", f"could not update first-run guide state: {error}")
297
+
298
+ return _result(0, "success", "first-run guide state updated")
299
+
300
+
301
+ def add_workflow(name, description, commands):
302
+ name, name_error = _validate_workflow_name(name)
303
+ if name_error:
304
+ return name_error
305
+
306
+ workflows, error = _require_loaded_workflows(load_workflows())
307
+ if error:
308
+ return error
309
+
310
+ if name in workflows:
311
+ return _result(2, "warning", "workflow already exists")
312
+
313
+ workflows[name] = {
314
+ "description": description,
315
+ "commands": commands,
316
+ "runs": 0,
317
+ }
318
+
319
+ save_result = save_workflows(workflows)
320
+ if save_result["code"] != 0:
321
+ return save_result
322
+
323
+ return _result(0, "success", "workflow saved successfully")
324
+
325
+
326
+ def get_workflow(name):
327
+ name = str(name).strip()
328
+ workflows, error = _require_loaded_workflows(load_workflows())
329
+ if error:
330
+ return error
331
+
332
+ workflow = workflows.get(name)
333
+ if workflow is None:
334
+ return _result(2, "warning", "workflow not found", None)
335
+
336
+ return _result(0, "success", "workflow found", workflow)
337
+
338
+
339
+ def delete_workflow(name):
340
+ name = str(name).strip()
341
+ workflows, error = _require_loaded_workflows(load_workflows())
342
+ if error:
343
+ return error
344
+
345
+ if name not in workflows:
346
+ return _result(2, "warning", "workflow not found")
347
+
348
+ del workflows[name]
349
+ save_result = save_workflows(workflows)
350
+ if save_result["code"] != 0:
351
+ return save_result
352
+
353
+ return _result(0, "success", "workflow deleted successfully")
354
+
355
+
356
+ def increment_runs(name):
357
+ name = str(name).strip()
358
+ workflows, error = _require_loaded_workflows(load_workflows())
359
+ if error:
360
+ return error
361
+
362
+ if name not in workflows:
363
+ return _result(2, "warning", "workflow not found")
364
+
365
+ try:
366
+ workflows[name]["runs"] = int(workflows[name].get("runs", 0)) + 1
367
+ except (TypeError, ValueError):
368
+ workflows[name]["runs"] = 1
369
+
370
+ save_result = save_workflows(workflows)
371
+ if save_result["code"] != 0:
372
+ return save_result
373
+
374
+ return _result(0, "success", "workflow run count updated")
375
+
376
+
377
+ def copy_workflow(source_name, target_name):
378
+ source_name = str(source_name).strip()
379
+ target_name, name_error = _validate_workflow_name(target_name)
380
+ if name_error:
381
+ return name_error
382
+
383
+ workflows, error = _require_loaded_workflows(load_workflows())
384
+ if error:
385
+ return error
386
+
387
+ if source_name not in workflows:
388
+ return _result(2, "warning", "source workflow not found")
389
+ if target_name in workflows:
390
+ return _result(2, "warning", "target workflow already exists")
391
+
392
+ workflows[target_name] = deepcopy(workflows[source_name])
393
+ workflows[target_name]["runs"] = 0
394
+
395
+ save_result = save_workflows(workflows)
396
+ if save_result["code"] != 0:
397
+ return save_result
398
+
399
+ return _result(0, "success", "workflow copied successfully")
400
+
401
+
402
+ def rename_workflow(old_name, new_name):
403
+ old_name = str(old_name).strip()
404
+ new_name, name_error = _validate_workflow_name(new_name)
405
+ if name_error:
406
+ return name_error
407
+
408
+ workflows, error = _require_loaded_workflows(load_workflows())
409
+ if error:
410
+ return error
411
+
412
+ if old_name not in workflows:
413
+ return _result(2, "warning", "workflow not found")
414
+ if new_name in workflows:
415
+ return _result(2, "warning", "target workflow already exists")
416
+
417
+ workflows[new_name] = workflows.pop(old_name)
418
+
419
+ save_result = save_workflows(workflows)
420
+ if save_result["code"] != 0:
421
+ return save_result
422
+
423
+ return _result(0, "success", "workflow renamed successfully")
424
+
425
+
426
+ def find_workflows(query):
427
+ workflows, error = _require_loaded_workflows(load_workflows())
428
+ if error:
429
+ return error
430
+
431
+ query = query.lower()
432
+ matches = {}
433
+
434
+ for name, workflow in workflows.items():
435
+ searchable_text = " ".join(
436
+ [
437
+ name,
438
+ workflow.get("description", ""),
439
+ " ".join(workflow.get("commands", [])),
440
+ ]
441
+ ).lower()
442
+ if query in searchable_text:
443
+ matches[name] = workflow
444
+
445
+ return _result(0, "success", "workflow search completed", matches)
446
+
447
+
448
+ def export_workflows(destination):
449
+ workflows, error = _require_loaded_workflows(load_workflows())
450
+ if error:
451
+ return error
452
+
453
+ destination = Path(destination)
454
+ try:
455
+ _write_json_file(destination, workflows)
456
+ except OSError as error:
457
+ return _result(1, "error", f"could not export workflows: {error}")
458
+
459
+ return _result(0, "success", f"workflows exported to {destination}")
460
+
461
+
462
+ def import_workflows(source, replace=False):
463
+ source = Path(source)
464
+ try:
465
+ with source.open("r", encoding="utf-8") as file:
466
+ imported = json.load(file)
467
+ except FileNotFoundError:
468
+ return _result(2, "warning", "import file not found")
469
+ except json.JSONDecodeError:
470
+ return _result(2, "warning", "import file is malformed")
471
+ except (OSError, UnicodeDecodeError) as error:
472
+ return _result(1, "error", f"could not import workflows: {error}")
473
+
474
+ if not isinstance(imported, dict):
475
+ return _result(2, "warning", "import file must contain a JSON object")
476
+
477
+ current_workflows, error = _require_loaded_workflows(load_workflows())
478
+ if error:
479
+ return error
480
+
481
+ workflows = {} if replace else current_workflows
482
+ imported_count = 0
483
+ skipped_count = 0
484
+
485
+ for name, workflow in imported.items():
486
+ clean_name, name_error = _validate_workflow_name(name)
487
+ if name_error:
488
+ skipped_count += 1
489
+ continue
490
+ if not isinstance(workflow, dict):
491
+ skipped_count += 1
492
+ continue
493
+ if not replace and clean_name in workflows:
494
+ skipped_count += 1
495
+ continue
496
+
497
+ normalized_workflow = _normalize_workflow(clean_name, workflow)
498
+ if normalized_workflow is None:
499
+ skipped_count += 1
500
+ continue
501
+
502
+ workflows[clean_name] = normalized_workflow
503
+ imported_count += 1
504
+
505
+ save_result = save_workflows(workflows)
506
+ if save_result["code"] != 0:
507
+ return save_result
508
+
509
+ message = f"imported {imported_count} workflow"
510
+ if imported_count != 1:
511
+ message += "s"
512
+ if skipped_count:
513
+ message += f", skipped {skipped_count}"
514
+
515
+ return _result(0, "success", message)