crackerjack 0.19.7__py3-none-any.whl → 0.20.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.
crackerjack/errors.py ADDED
@@ -0,0 +1,176 @@
1
+ """Error handling module for Crackerjack.
2
+
3
+ This module defines a structured error system with error codes, detailed error messages,
4
+ and recovery suggestions. It provides a consistent way to handle errors throughout
5
+ the application.
6
+ """
7
+
8
+ import sys
9
+ import typing as t
10
+ from enum import Enum
11
+ from pathlib import Path
12
+
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+
16
+
17
+ class ErrorCode(Enum):
18
+ CONFIG_FILE_NOT_FOUND = 1001
19
+ CONFIG_PARSE_ERROR = 1002
20
+ INVALID_CONFIG = 1003
21
+ MISSING_CONFIG_FIELD = 1004
22
+
23
+ COMMAND_EXECUTION_ERROR = 2001
24
+ COMMAND_TIMEOUT = 2002
25
+ EXTERNAL_TOOL_ERROR = 2003
26
+ PDM_INSTALL_ERROR = 2004
27
+ PRE_COMMIT_ERROR = 2005
28
+
29
+ TEST_EXECUTION_ERROR = 3001
30
+ TEST_FAILURE = 3002
31
+ BENCHMARK_REGRESSION = 3003
32
+
33
+ BUILD_ERROR = 4001
34
+ PUBLISH_ERROR = 4002
35
+ VERSION_BUMP_ERROR = 4003
36
+ AUTHENTICATION_ERROR = 4004
37
+
38
+ GIT_COMMAND_ERROR = 5001
39
+ PULL_REQUEST_ERROR = 5002
40
+ COMMIT_ERROR = 5003
41
+
42
+ FILE_NOT_FOUND = 6001
43
+ PERMISSION_ERROR = 6002
44
+ FILE_READ_ERROR = 6003
45
+ FILE_WRITE_ERROR = 6004
46
+
47
+ CODE_CLEANING_ERROR = 7001
48
+ FORMATTING_ERROR = 7002
49
+
50
+ UNKNOWN_ERROR = 9001
51
+ NOT_IMPLEMENTED = 9002
52
+ UNEXPECTED_ERROR = 9999
53
+
54
+
55
+ class CrackerjackError(Exception):
56
+ def __init__(
57
+ self,
58
+ message: str,
59
+ error_code: ErrorCode,
60
+ details: str | None = None,
61
+ recovery: str | None = None,
62
+ exit_code: int = 1,
63
+ ) -> None:
64
+ self.message = message
65
+ self.error_code = error_code
66
+ self.details = details
67
+ self.recovery = recovery
68
+ self.exit_code = exit_code
69
+ super().__init__(message)
70
+
71
+
72
+ class ConfigError(CrackerjackError):
73
+ pass
74
+
75
+
76
+ class ExecutionError(CrackerjackError):
77
+ pass
78
+
79
+
80
+ class TestError(CrackerjackError):
81
+ pass
82
+
83
+
84
+ class PublishError(CrackerjackError):
85
+ pass
86
+
87
+
88
+ class GitError(CrackerjackError):
89
+ pass
90
+
91
+
92
+ class FileError(CrackerjackError):
93
+ pass
94
+
95
+
96
+ class CleaningError(CrackerjackError):
97
+ pass
98
+
99
+
100
+ def handle_error(
101
+ error: CrackerjackError,
102
+ console: Console,
103
+ verbose: bool = False,
104
+ ai_agent: bool = False,
105
+ exit_on_error: bool = True,
106
+ ) -> None:
107
+ if ai_agent:
108
+ import json
109
+
110
+ error_data = {
111
+ "status": "error",
112
+ "error_code": error.error_code.name,
113
+ "code": error.error_code.value,
114
+ "message": error.message,
115
+ }
116
+ if verbose and error.details:
117
+ error_data["details"] = error.details
118
+ if error.recovery:
119
+ error_data["recovery"] = error.recovery
120
+ formatted_json = json.dumps(error_data)
121
+ console.print(f"[json]{formatted_json}[/json]")
122
+ else:
123
+ title = f"❌ Error {error.error_code.value}: {error.error_code.name}"
124
+ content = [error.message]
125
+
126
+ if verbose and error.details:
127
+ content.extend(("\n[bold]Details:[/bold]", str(error.details)))
128
+
129
+ if error.recovery:
130
+ content.extend(
131
+ ("\n[bold green]Recovery suggestion:[/bold green]", str(error.recovery))
132
+ )
133
+
134
+ console.print(
135
+ Panel(
136
+ "\n".join(content),
137
+ title=title,
138
+ border_style="red",
139
+ title_align="left",
140
+ expand=False,
141
+ )
142
+ )
143
+
144
+ if exit_on_error:
145
+ sys.exit(error.exit_code)
146
+
147
+
148
+ def check_file_exists(path: Path, error_message: str) -> None:
149
+ if not path.exists():
150
+ raise FileError(
151
+ message=error_message,
152
+ error_code=ErrorCode.FILE_NOT_FOUND,
153
+ details=f"The file at {path} does not exist.",
154
+ recovery="Check the file path and ensure the file exists.",
155
+ )
156
+
157
+
158
+ def check_command_result(
159
+ result: t.Any,
160
+ command: str,
161
+ error_message: str,
162
+ error_code: ErrorCode = ErrorCode.COMMAND_EXECUTION_ERROR,
163
+ recovery: str | None = None,
164
+ ) -> None:
165
+ if getattr(result, "returncode", 0) != 0:
166
+ stderr = getattr(result, "stderr", "")
167
+ details = f"Command '{command}' failed with return code {result.returncode}."
168
+ if stderr:
169
+ details += f"\nStandard error output:\n{stderr}"
170
+
171
+ raise ExecutionError(
172
+ message=error_message,
173
+ error_code=error_code,
174
+ details=details,
175
+ recovery=recovery,
176
+ )
@@ -0,0 +1,487 @@
1
+ """Interactive CLI module using Rich for enhanced user experience.
2
+
3
+ This module provides an enhanced interactive CLI experience using Rich library components.
4
+ It includes progress tracking, interactive prompts, and detailed error reporting.
5
+ """
6
+
7
+ import time
8
+ import typing as t
9
+ from enum import Enum, auto
10
+ from pathlib import Path
11
+
12
+ from rich.box import ROUNDED
13
+ from rich.console import Console
14
+ from rich.layout import Layout
15
+ from rich.live import Live
16
+ from rich.panel import Panel
17
+ from rich.progress import (
18
+ BarColumn,
19
+ Progress,
20
+ SpinnerColumn,
21
+ TextColumn,
22
+ TimeElapsedColumn,
23
+ )
24
+ from rich.prompt import Confirm, Prompt
25
+ from rich.table import Table
26
+ from rich.text import Text
27
+ from rich.tree import Tree
28
+
29
+ from .errors import CrackerjackError, ErrorCode, handle_error
30
+
31
+
32
+ class TaskStatus(Enum):
33
+ PENDING = auto()
34
+ RUNNING = auto()
35
+ SUCCESS = auto()
36
+ FAILED = auto()
37
+ SKIPPED = auto()
38
+
39
+
40
+ class Task:
41
+ def __init__(
42
+ self,
43
+ name: str,
44
+ description: str,
45
+ dependencies: list["Task"] | None = None,
46
+ ) -> None:
47
+ self.name = name
48
+ self.description = description
49
+ self.dependencies = dependencies or []
50
+ self.status = TaskStatus.PENDING
51
+ self.start_time: float | None = None
52
+ self.end_time: float | None = None
53
+ self.error: CrackerjackError | None = None
54
+
55
+ @property
56
+ def duration(self) -> float | None:
57
+ if self.start_time is None:
58
+ return None
59
+ end = self.end_time or time.time()
60
+ return end - self.start_time
61
+
62
+ def start(self) -> None:
63
+ self.status = TaskStatus.RUNNING
64
+ self.start_time = time.time()
65
+
66
+ def complete(self, success: bool = True) -> None:
67
+ self.end_time = time.time()
68
+ self.status = TaskStatus.SUCCESS if success else TaskStatus.FAILED
69
+
70
+ def skip(self) -> None:
71
+ self.status = TaskStatus.SKIPPED
72
+
73
+ def fail(self, error: CrackerjackError) -> None:
74
+ self.end_time = time.time()
75
+ self.status = TaskStatus.FAILED
76
+ self.error = error
77
+
78
+ def can_run(self) -> bool:
79
+ return all(
80
+ dep.status in (TaskStatus.SUCCESS, TaskStatus.SKIPPED)
81
+ for dep in self.dependencies
82
+ )
83
+
84
+ def __str__(self) -> str:
85
+ return f"{self.name} ({self.status.name})"
86
+
87
+
88
+ class WorkflowManager:
89
+ def __init__(self, console: Console) -> None:
90
+ self.console = console
91
+ self.tasks: dict[str, Task] = {}
92
+ self.current_task: Task | None = None
93
+
94
+ def add_task(
95
+ self,
96
+ name: str,
97
+ description: str,
98
+ dependencies: list[str] | None = None,
99
+ ) -> Task:
100
+ dep_tasks = []
101
+ if dependencies:
102
+ for dep_name in dependencies:
103
+ if dep_name not in self.tasks:
104
+ raise ValueError(f"Dependency task '{dep_name}' not found")
105
+ dep_tasks.append(self.tasks[dep_name])
106
+
107
+ task = Task(name, description, dep_tasks)
108
+ self.tasks[name] = task
109
+ return task
110
+
111
+ def get_next_task(self) -> Task | None:
112
+ for task in self.tasks.values():
113
+ if (
114
+ task.status == TaskStatus.PENDING
115
+ and task.can_run()
116
+ and task != self.current_task
117
+ ):
118
+ return task
119
+ return None
120
+
121
+ def all_tasks_completed(self) -> bool:
122
+ return all(
123
+ task.status in (TaskStatus.SUCCESS, TaskStatus.FAILED, TaskStatus.SKIPPED)
124
+ for task in self.tasks.values()
125
+ )
126
+
127
+ def run_task(self, task: Task, func: t.Callable[[], t.Any]) -> bool:
128
+ self.current_task = task
129
+ task.start()
130
+
131
+ try:
132
+ func()
133
+ task.complete()
134
+ return True
135
+ except CrackerjackError as e:
136
+ task.fail(e)
137
+ return False
138
+ except Exception as e:
139
+ from .errors import ExecutionError
140
+
141
+ error = ExecutionError(
142
+ message=f"Unexpected error in task '{task.name}'",
143
+ error_code=ErrorCode.UNEXPECTED_ERROR,
144
+ details=str(e),
145
+ recovery=f"This is an unexpected error in task '{task.name}'. Please report this issue.",
146
+ )
147
+ task.fail(error)
148
+ return False
149
+ finally:
150
+ self.current_task = None
151
+
152
+ def display_task_tree(self) -> None:
153
+ tree = Tree("Workflow")
154
+
155
+ for task in self.tasks.values():
156
+ if not task.dependencies:
157
+ self._add_task_to_tree(task, tree)
158
+
159
+ self.console.print(tree)
160
+
161
+ def _add_task_to_tree(self, task: Task, parent: Tree) -> None:
162
+ if task.status == TaskStatus.SUCCESS:
163
+ status = "[green]✅[/green]"
164
+ elif task.status == TaskStatus.FAILED:
165
+ status = "[red]❌[/red]"
166
+ elif task.status == TaskStatus.RUNNING:
167
+ status = "[yellow]⏳[/yellow]"
168
+ elif task.status == TaskStatus.SKIPPED:
169
+ status = "[blue]⏩[/blue]"
170
+ else:
171
+ status = "[grey]⏸️[/grey]"
172
+
173
+ branch = parent.add(f"{status} {task.name} - {task.description}")
174
+
175
+ for dependent in self.tasks.values():
176
+ if task in dependent.dependencies:
177
+ self._add_task_to_tree(dependent, branch)
178
+
179
+
180
+ class InteractiveCLI:
181
+ def __init__(self, console: Console | None = None) -> None:
182
+ self.console = console or Console()
183
+ self.workflow = WorkflowManager(self.console)
184
+
185
+ def show_banner(self, version: str) -> None:
186
+ title = Text("Crackerjack", style="bold cyan")
187
+ version_text = Text(f"v{version}", style="dim cyan")
188
+ subtitle = Text("Your Python project management toolkit", style="italic")
189
+
190
+ panel = Panel(
191
+ f"{title} {version_text}\n{subtitle}",
192
+ box=ROUNDED,
193
+ border_style="cyan",
194
+ expand=False,
195
+ )
196
+
197
+ self.console.print(panel)
198
+ self.console.print()
199
+
200
+ def create_standard_workflow(self) -> None:
201
+ self.workflow.add_task("setup", "Initialize project structure")
202
+ self.workflow.add_task(
203
+ "config", "Update configuration files", dependencies=["setup"]
204
+ )
205
+ self.workflow.add_task(
206
+ "clean", "Clean code (remove docstrings, comments)", dependencies=["config"]
207
+ )
208
+ self.workflow.add_task("hooks", "Run pre-commit hooks", dependencies=["clean"])
209
+ self.workflow.add_task("test", "Run tests", dependencies=["hooks"])
210
+ self.workflow.add_task("version", "Bump version", dependencies=["test"])
211
+ self.workflow.add_task("publish", "Publish package", dependencies=["version"])
212
+ self.workflow.add_task("commit", "Commit changes", dependencies=["publish"])
213
+
214
+ def setup_layout(self) -> Layout:
215
+ layout = Layout()
216
+
217
+ layout.split(
218
+ Layout(name="header", size=3),
219
+ Layout(name="main"),
220
+ Layout(name="footer", size=3),
221
+ )
222
+
223
+ layout["main"].split_row(
224
+ Layout(name="tasks", ratio=1),
225
+ Layout(name="details", ratio=2),
226
+ )
227
+
228
+ return layout
229
+
230
+ def show_task_status(self, task: Task) -> Panel:
231
+ if task.status == TaskStatus.RUNNING:
232
+ status = "[yellow]⏳ Running[/yellow]"
233
+ style = "yellow"
234
+ elif task.status == TaskStatus.SUCCESS:
235
+ status = "[green]✅ Success[/green]"
236
+ style = "green"
237
+ elif task.status == TaskStatus.FAILED:
238
+ status = "[red]❌ Failed[/red]"
239
+ style = "red"
240
+ elif task.status == TaskStatus.SKIPPED:
241
+ status = "[blue]⏩ Skipped[/blue]"
242
+ style = "blue"
243
+ else:
244
+ status = "[grey]⏸️ Pending[/grey]"
245
+ style = "dim"
246
+
247
+ duration = task.duration
248
+ duration_text = f"Duration: {duration:.2f}s" if duration else ""
249
+
250
+ content = f"{task.name}: {task.description}\nStatus: {status}\n{duration_text}"
251
+
252
+ if task.error:
253
+ content += f"\n[red]Error: {task.error.message}[/red]"
254
+ if task.error.details:
255
+ content += f"\n[dim red]Details: {task.error.details}[/dim red]"
256
+ if task.error.recovery:
257
+ content += f"\n[yellow]Recovery: {task.error.recovery}[/yellow]"
258
+
259
+ return Panel(
260
+ content,
261
+ title=task.name,
262
+ border_style=style,
263
+ expand=False,
264
+ )
265
+
266
+ def show_task_table(self) -> Table:
267
+ table = Table(
268
+ title="Workflow Tasks",
269
+ box=ROUNDED,
270
+ show_header=True,
271
+ header_style="bold cyan",
272
+ )
273
+
274
+ table.add_column("Task", style="cyan")
275
+ table.add_column("Status")
276
+ table.add_column("Duration")
277
+ table.add_column("Dependencies")
278
+
279
+ for task in self.workflow.tasks.values():
280
+ if task.status == TaskStatus.RUNNING:
281
+ status = "[yellow]⏳ Running[/yellow]"
282
+ elif task.status == TaskStatus.SUCCESS:
283
+ status = "[green]✅ Success[/green]"
284
+ elif task.status == TaskStatus.FAILED:
285
+ status = "[red]❌ Failed[/red]"
286
+ elif task.status == TaskStatus.SKIPPED:
287
+ status = "[blue]⏩ Skipped[/blue]"
288
+ else:
289
+ status = "[grey]⏸️ Pending[/grey]"
290
+
291
+ duration = task.duration
292
+ duration_text = f"{duration:.2f}s" if duration else "-"
293
+
294
+ deps = ", ".join(dep.name for dep in task.dependencies) or "-"
295
+
296
+ table.add_row(task.name, status, duration_text, deps)
297
+
298
+ return table
299
+
300
+ def run_interactive(self) -> None:
301
+ self.console.clear()
302
+ layout = self.setup_layout()
303
+
304
+ layout["header"].update(
305
+ Panel(
306
+ "Crackerjack Interactive Mode",
307
+ style="bold cyan",
308
+ box=ROUNDED,
309
+ )
310
+ )
311
+
312
+ layout["footer"].update(
313
+ Panel(
314
+ "Press Ctrl+C to exit",
315
+ style="dim",
316
+ box=ROUNDED,
317
+ )
318
+ )
319
+
320
+ progress = Progress(
321
+ SpinnerColumn(),
322
+ TextColumn("[bold blue]{task.description}"),
323
+ BarColumn(),
324
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
325
+ TimeElapsedColumn(),
326
+ )
327
+
328
+ total_tasks = len(self.workflow.tasks)
329
+ progress_task = progress.add_task("Running workflow", total=total_tasks)
330
+ completed_tasks = 0
331
+
332
+ with Live(layout, refresh_per_second=4, screen=True) as live:
333
+ try:
334
+ while not self.workflow.all_tasks_completed():
335
+ layout["tasks"].update(self.show_task_table())
336
+
337
+ next_task = self.workflow.get_next_task()
338
+ if not next_task:
339
+ break
340
+
341
+ layout["details"].update(self.show_task_status(next_task))
342
+
343
+ live.stop()
344
+ should_run = Confirm.ask(
345
+ f"Run task '{next_task.name}'?",
346
+ default=True,
347
+ )
348
+ live.start()
349
+
350
+ if not should_run:
351
+ next_task.skip()
352
+ continue
353
+
354
+ next_task.start()
355
+ layout["details"].update(self.show_task_status(next_task))
356
+ time.sleep(1)
357
+
358
+ import random
359
+
360
+ success = random.choice([True, True, True, False])
361
+
362
+ if success:
363
+ next_task.complete(True)
364
+ completed_tasks += 1
365
+ else:
366
+ from .errors import ExecutionError
367
+
368
+ error = ExecutionError(
369
+ message=f"Task '{next_task.name}' failed",
370
+ error_code=ErrorCode.COMMAND_EXECUTION_ERROR,
371
+ details="This is a simulated failure for demonstration.",
372
+ recovery=f"Retry the '{next_task.name}' task.",
373
+ )
374
+ next_task.fail(error)
375
+
376
+ progress.update(progress_task, completed=completed_tasks)
377
+
378
+ layout["details"].update(self.show_task_status(next_task))
379
+
380
+ layout["tasks"].update(self.show_task_table())
381
+
382
+ successful = sum(
383
+ 1
384
+ for task in self.workflow.tasks.values()
385
+ if task.status == TaskStatus.SUCCESS
386
+ )
387
+ failed = sum(
388
+ 1
389
+ for task in self.workflow.tasks.values()
390
+ if task.status == TaskStatus.FAILED
391
+ )
392
+ skipped = sum(
393
+ 1
394
+ for task in self.workflow.tasks.values()
395
+ if task.status == TaskStatus.SKIPPED
396
+ )
397
+
398
+ summary = Panel(
399
+ f"Workflow completed!\n\n"
400
+ f"[green]✅ Successful tasks: {successful}[/green]\n"
401
+ f"[red]❌ Failed tasks: {failed}[/red]\n"
402
+ f"[blue]⏩ Skipped tasks: {skipped}[/blue]",
403
+ title="Summary",
404
+ border_style="cyan",
405
+ )
406
+ layout["details"].update(summary)
407
+
408
+ except KeyboardInterrupt:
409
+ layout["footer"].update(
410
+ Panel(
411
+ "Interrupted by user",
412
+ style="yellow",
413
+ box=ROUNDED,
414
+ )
415
+ )
416
+
417
+ self.console.print("\nWorkflow Status:")
418
+ self.workflow.display_task_tree()
419
+
420
+ def ask_for_file(
421
+ self, prompt: str, directory: Path, default: str | None = None
422
+ ) -> Path:
423
+ self.console.print(f"\n[bold]{prompt}[/bold]")
424
+
425
+ files = list(directory.iterdir())
426
+ files.sort()
427
+
428
+ table = Table(title=f"Files in {directory}", box=ROUNDED)
429
+ table.add_column("#", style="cyan")
430
+ table.add_column("Filename", style="green")
431
+ table.add_column("Size", style="blue")
432
+ table.add_column("Modified", style="yellow")
433
+
434
+ for i, file in enumerate(files, 1):
435
+ if file.is_file():
436
+ size = f"{file.stat().st_size / 1024:.1f} KB"
437
+ mtime = time.strftime(
438
+ "%Y-%m-%d %H:%M:%S",
439
+ time.localtime(file.stat().st_mtime),
440
+ )
441
+ table.add_row(str(i), file.name, size, mtime)
442
+
443
+ self.console.print(table)
444
+
445
+ selection = Prompt.ask(
446
+ "Enter file number or name",
447
+ default=default or "",
448
+ )
449
+
450
+ if selection.isdigit() and 1 <= int(selection) <= len(files):
451
+ return files[int(selection) - 1]
452
+ else:
453
+ for file in files:
454
+ if file.name == selection:
455
+ return file
456
+
457
+ return directory / selection
458
+
459
+ def confirm_dangerous_action(self, action: str, details: str) -> bool:
460
+ panel = Panel(
461
+ f"[bold red]WARNING: {action}[/bold red]\n\n{details}\n\n"
462
+ "This action cannot be undone. Please type the action name to confirm.",
463
+ title="Confirmation Required",
464
+ border_style="red",
465
+ )
466
+
467
+ self.console.print(panel)
468
+
469
+ confirmation = Prompt.ask("Type the action name to confirm")
470
+
471
+ return confirmation.lower() == action.lower()
472
+
473
+ def show_error(self, error: CrackerjackError, verbose: bool = False) -> None:
474
+ handle_error(error, self.console, verbose, exit_on_error=False)
475
+
476
+
477
+ def launch_interactive_cli(version: str) -> None:
478
+ console = Console()
479
+ cli = InteractiveCLI(console)
480
+
481
+ cli.show_banner(version)
482
+ cli.create_standard_workflow()
483
+ cli.run_interactive()
484
+
485
+
486
+ if __name__ == "__main__":
487
+ launch_interactive_cli("0.19.8")