crackerjack 0.20.20__py3-none-any.whl → 0.21.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.
@@ -26,7 +26,7 @@ repos:
26
26
 
27
27
  # Package management - once structure is valid
28
28
  - repo: https://github.com/pdm-project/pdm
29
- rev: 2.24.2
29
+ rev: 2.25.3
30
30
  hooks:
31
31
  - id: pdm-lock-check
32
32
  - id: pdm-sync
@@ -34,7 +34,7 @@ repos:
34
34
  - keyring
35
35
 
36
36
  - repo: https://github.com/astral-sh/uv-pre-commit
37
- rev: 0.7.12
37
+ rev: 0.7.15
38
38
  hooks:
39
39
  - id: uv-lock
40
40
  files: ^pyproject\.toml$
@@ -55,7 +55,7 @@ repos:
55
55
  - tomli
56
56
 
57
57
  - repo: https://github.com/astral-sh/ruff-pre-commit
58
- rev: v0.11.13
58
+ rev: v0.12.1
59
59
  hooks:
60
60
  - id: ruff-check
61
61
  - id: ruff-format
@@ -71,6 +71,12 @@ repos:
71
71
  hooks:
72
72
  - id: creosote
73
73
 
74
+ - repo: https://github.com/rohaquinlop/complexipy-pre-commit
75
+ rev: v3.0.0
76
+ hooks:
77
+ - id: complexipy
78
+ args: ["-d", "low"]
79
+
74
80
  - repo: https://github.com/dosisod/refurb
75
81
  rev: v2.1.0
76
82
  hooks:
@@ -95,7 +101,7 @@ repos:
95
101
  - libcst>=1.1.0
96
102
 
97
103
  - repo: https://github.com/PyCQA/bandit
98
- rev: '1.8.3'
104
+ rev: '1.8.5'
99
105
  hooks:
100
106
  - id: bandit
101
107
  args: ["-c", "pyproject.toml"]
crackerjack/__main__.py CHANGED
@@ -40,7 +40,6 @@ class Options(BaseModel):
40
40
  ai_agent: bool = False
41
41
  create_pr: bool = False
42
42
  skip_hooks: bool = False
43
- rich_ui: bool = False
44
43
 
45
44
  @classmethod
46
45
  @field_validator("publish", "bump", mode="before")
@@ -59,7 +58,10 @@ class Options(BaseModel):
59
58
  cli_options = {
60
59
  "commit": typer.Option(False, "-c", "--commit", help="Commit changes to Git."),
61
60
  "interactive": typer.Option(
62
- False, "-i", "--interactive", help="Run pre-commit hooks interactively."
61
+ False,
62
+ "-i",
63
+ "--interactive",
64
+ help="Use the interactive Rich UI for a better experience.",
63
65
  ),
64
66
  "doc": typer.Option(False, "-d", "--doc", help="Generate documentation."),
65
67
  "no_config_updates": typer.Option(
@@ -131,9 +133,6 @@ cli_options = {
131
133
  "create_pr": typer.Option(
132
134
  False, "-r", "--pr", help="Create a pull request to the upstream repository."
133
135
  ),
134
- "rich_ui": typer.Option(
135
- False, "--rich-ui", help="Use the interactive Rich UI for a better experience."
136
- ),
137
136
  "ai_agent": typer.Option(
138
137
  False,
139
138
  "--ai-agent",
@@ -165,7 +164,6 @@ def main(
165
164
  test_timeout: int = cli_options["test_timeout"],
166
165
  skip_hooks: bool = cli_options["skip_hooks"],
167
166
  create_pr: bool = cli_options["create_pr"],
168
- rich_ui: bool = cli_options["rich_ui"],
169
167
  ai_agent: bool = cli_options["ai_agent"],
170
168
  ) -> None:
171
169
  options = Options(
@@ -188,13 +186,12 @@ def main(
188
186
  all=all,
189
187
  ai_agent=ai_agent,
190
188
  create_pr=create_pr,
191
- rich_ui=rich_ui,
192
189
  )
193
190
  if ai_agent:
194
191
  import os
195
192
 
196
193
  os.environ["AI_AGENT"] = "1"
197
- if rich_ui:
194
+ if interactive:
198
195
  from crackerjack.interactive import launch_interactive_cli
199
196
 
200
197
  try:
@@ -13,7 +13,6 @@ from tomli_w import dumps
13
13
  from crackerjack.errors import ErrorCode, ExecutionError
14
14
 
15
15
  config_files = (".gitignore", ".pre-commit-config.yaml", ".libcst.codemod.yaml")
16
- interactive_hooks = ("refurb", "bandit", "pyright")
17
16
  default_python_version = "3.13"
18
17
 
19
18
 
@@ -85,36 +84,20 @@ class CodeCleaner(BaseModel, arbitrary_types_allowed=True):
85
84
  def remove_docstrings(self, code: str) -> str:
86
85
  lines = code.split("\n")
87
86
  cleaned_lines = []
88
- in_docstring = False
89
- docstring_delimiter = None
90
- waiting_for_docstring = False
87
+ docstring_state = {"in_docstring": False, "delimiter": None, "waiting": False}
91
88
  for line in lines:
92
89
  stripped = line.strip()
93
- if stripped.startswith(("def ", "class ", "async def ")):
94
- waiting_for_docstring = True
90
+ if self._is_function_or_class_definition(stripped):
91
+ docstring_state["waiting"] = True
95
92
  cleaned_lines.append(line)
96
93
  continue
97
- if waiting_for_docstring and stripped:
98
- if stripped.startswith(('"""', "'''", '"', "'")):
99
- if stripped.startswith(('"""', "'''")):
100
- docstring_delimiter = stripped[:3]
101
- else:
102
- docstring_delimiter = stripped[0]
103
- if stripped.endswith(docstring_delimiter) and len(stripped) > len(
104
- docstring_delimiter
105
- ):
106
- waiting_for_docstring = False
107
- continue
108
- else:
109
- in_docstring = True
110
- waiting_for_docstring = False
111
- continue
94
+ if docstring_state["waiting"] and stripped:
95
+ if self._handle_docstring_start(stripped, docstring_state):
96
+ continue
112
97
  else:
113
- waiting_for_docstring = False
114
- if in_docstring:
115
- if docstring_delimiter and stripped.endswith(docstring_delimiter):
116
- in_docstring = False
117
- docstring_delimiter = None
98
+ docstring_state["waiting"] = False
99
+ if docstring_state["in_docstring"]:
100
+ if self._handle_docstring_end(stripped, docstring_state):
118
101
  continue
119
102
  else:
120
103
  continue
@@ -122,6 +105,35 @@ class CodeCleaner(BaseModel, arbitrary_types_allowed=True):
122
105
 
123
106
  return "\n".join(cleaned_lines)
124
107
 
108
+ def _is_function_or_class_definition(self, stripped_line: str) -> bool:
109
+ return stripped_line.startswith(("def ", "class ", "async def "))
110
+
111
+ def _handle_docstring_start(self, stripped: str, state: dict[str, t.Any]) -> bool:
112
+ if not stripped.startswith(('"""', "'''", '"', "'")):
113
+ return False
114
+ if stripped.startswith(('"""', "'''")):
115
+ delimiter = stripped[:3]
116
+ else:
117
+ delimiter = stripped[0]
118
+ state["delimiter"] = delimiter
119
+ if self._is_single_line_docstring(stripped, delimiter):
120
+ state["waiting"] = False
121
+ return True
122
+ else:
123
+ state["in_docstring"] = True
124
+ state["waiting"] = False
125
+ return True
126
+
127
+ def _is_single_line_docstring(self, stripped: str, delimiter: str) -> bool:
128
+ return stripped.endswith(delimiter) and len(stripped) > len(delimiter)
129
+
130
+ def _handle_docstring_end(self, stripped: str, state: dict[str, t.Any]) -> bool:
131
+ if state["delimiter"] and stripped.endswith(state["delimiter"]):
132
+ state["in_docstring"] = False
133
+ state["delimiter"] = None
134
+ return True
135
+ return True
136
+
125
137
  def remove_line_comments(self, code: str) -> str:
126
138
  lines = code.split("\n")
127
139
  cleaned_lines = []
@@ -129,87 +141,153 @@ class CodeCleaner(BaseModel, arbitrary_types_allowed=True):
129
141
  if not line.strip():
130
142
  cleaned_lines.append(line)
131
143
  continue
132
- in_string = None
133
- result = []
134
- i = 0
135
- n = len(line)
136
- while i < n:
137
- char = line[i]
138
- if char in ("'", '"') and (i == 0 or line[i - 1] != "\\"):
139
- if in_string is None:
140
- in_string = char
141
- elif in_string == char:
142
- in_string = None
143
- result.append(char)
144
- i += 1
145
- elif char == "#" and in_string is None:
146
- comment = line[i:].strip()
147
- if re.match(
148
- r"^#\s*(?:type:\s*ignore(?:\[.*?\])?|noqa|nosec|pragma:\s*no\s*cover|pylint:\s*disable|mypy:\s*ignore)",
149
- comment,
150
- ):
151
- result.append(line[i:])
152
- break
153
- break
154
- else:
155
- result.append(char)
156
- i += 1
157
- cleaned_line = "".join(result).rstrip()
144
+ cleaned_line = self._process_line_for_comments(line)
158
145
  if cleaned_line or not line.strip():
159
146
  cleaned_lines.append(cleaned_line or line)
147
+
160
148
  return "\n".join(cleaned_lines)
161
149
 
150
+ def _process_line_for_comments(self, line: str) -> str:
151
+ result = []
152
+ string_state = {"in_string": None}
153
+ for i, char in enumerate(line):
154
+ if self._handle_string_character(char, i, line, string_state, result):
155
+ continue
156
+ elif self._handle_comment_character(char, i, line, string_state, result):
157
+ break
158
+ else:
159
+ result.append(char)
160
+
161
+ return "".join(result).rstrip()
162
+
163
+ def _handle_string_character(
164
+ self,
165
+ char: str,
166
+ index: int,
167
+ line: str,
168
+ string_state: dict[str, t.Any],
169
+ result: list[str],
170
+ ) -> bool:
171
+ """Handle string quote characters. Returns True if character was handled."""
172
+ if char not in ("'", '"'):
173
+ return False
174
+
175
+ if index > 0 and line[index - 1] == "\\":
176
+ return False
177
+
178
+ if string_state["in_string"] is None:
179
+ string_state["in_string"] = char
180
+ elif string_state["in_string"] == char:
181
+ string_state["in_string"] = None
182
+
183
+ result.append(char)
184
+ return True
185
+
186
+ def _handle_comment_character(
187
+ self,
188
+ char: str,
189
+ index: int,
190
+ line: str,
191
+ string_state: dict[str, t.Any],
192
+ result: list[str],
193
+ ) -> bool:
194
+ """Handle comment character. Returns True if comment was found."""
195
+ if char != "#" or string_state["in_string"] is not None:
196
+ return False
197
+
198
+ comment = line[index:].strip()
199
+ if self._is_special_comment_line(comment):
200
+ result.append(line[index:])
201
+
202
+ return True
203
+
204
+ def _is_special_comment_line(self, comment: str) -> bool:
205
+ special_comment_pattern = (
206
+ r"^#\s*(?:type:\s*ignore(?:\[.*?\])?|noqa|nosec|pragma:\s*no\s*cover"
207
+ r"|pylint:\s*disable|mypy:\s*ignore)"
208
+ )
209
+ return bool(re.match(special_comment_pattern, comment))
210
+
162
211
  def remove_extra_whitespace(self, code: str) -> str:
163
212
  lines = code.split("\n")
164
213
  cleaned_lines = []
165
- in_function = False
166
- function_indent = 0
214
+ function_tracker = {"in_function": False, "function_indent": 0}
167
215
  for i, line in enumerate(lines):
168
216
  line = line.rstrip()
169
217
  stripped_line = line.lstrip()
170
- if stripped_line.startswith(("def ", "async def ")):
171
- in_function = True
172
- function_indent = len(line) - len(stripped_line)
173
- elif (
174
- in_function
175
- and line
176
- and (len(line) - len(stripped_line) <= function_indent)
177
- and (not stripped_line.startswith(("@", "#")))
178
- ):
179
- in_function = False
180
- function_indent = 0
218
+ self._update_function_state(line, stripped_line, function_tracker)
181
219
  if not line:
182
- if i > 0 and cleaned_lines and (not cleaned_lines[-1]):
220
+ if self._should_skip_empty_line(
221
+ i, lines, cleaned_lines, function_tracker
222
+ ):
183
223
  continue
184
- if in_function:
185
- next_line_idx = i + 1
186
- if next_line_idx < len(lines):
187
- next_line = lines[next_line_idx].strip()
188
- if not (
189
- next_line.startswith(
190
- ("return", "class ", "def ", "async def ", "@")
191
- )
192
- or next_line in ("pass", "break", "continue", "raise")
193
- or (
194
- next_line.startswith("#")
195
- and any(
196
- pattern in next_line
197
- for pattern in (
198
- "type:",
199
- "noqa",
200
- "nosec",
201
- "pragma:",
202
- "pylint:",
203
- "mypy:",
204
- )
205
- )
206
- )
207
- ):
208
- continue
209
224
  cleaned_lines.append(line)
210
- while cleaned_lines and (not cleaned_lines[-1]):
211
- cleaned_lines.pop()
212
- return "\n".join(cleaned_lines)
225
+
226
+ return "\n".join(self._remove_trailing_empty_lines(cleaned_lines))
227
+
228
+ def _update_function_state(
229
+ self, line: str, stripped_line: str, function_tracker: dict[str, t.Any]
230
+ ) -> None:
231
+ """Update function tracking state based on current line."""
232
+ if stripped_line.startswith(("def ", "async def ")):
233
+ function_tracker["in_function"] = True
234
+ function_tracker["function_indent"] = len(line) - len(stripped_line)
235
+ elif self._is_function_end(line, stripped_line, function_tracker):
236
+ function_tracker["in_function"] = False
237
+ function_tracker["function_indent"] = 0
238
+
239
+ def _is_function_end(
240
+ self, line: str, stripped_line: str, function_tracker: dict[str, t.Any]
241
+ ) -> bool:
242
+ """Check if current line marks the end of a function."""
243
+ return (
244
+ function_tracker["in_function"]
245
+ and bool(line)
246
+ and (len(line) - len(stripped_line) <= function_tracker["function_indent"])
247
+ and (not stripped_line.startswith(("@", "#")))
248
+ )
249
+
250
+ def _should_skip_empty_line(
251
+ self,
252
+ line_idx: int,
253
+ lines: list[str],
254
+ cleaned_lines: list[str],
255
+ function_tracker: dict[str, t.Any],
256
+ ) -> bool:
257
+ """Determine if an empty line should be skipped."""
258
+ if line_idx > 0 and cleaned_lines and (not cleaned_lines[-1]):
259
+ return True
260
+
261
+ if function_tracker["in_function"]:
262
+ return self._should_skip_function_empty_line(line_idx, lines)
263
+
264
+ return False
265
+
266
+ def _should_skip_function_empty_line(self, line_idx: int, lines: list[str]) -> bool:
267
+ next_line_idx = line_idx + 1
268
+ if next_line_idx >= len(lines):
269
+ return False
270
+ next_line = lines[next_line_idx].strip()
271
+ return not self._is_significant_next_line(next_line)
272
+
273
+ def _is_significant_next_line(self, next_line: str) -> bool:
274
+ if next_line.startswith(("return", "class ", "def ", "async def ", "@")):
275
+ return True
276
+ if next_line in ("pass", "break", "continue", "raise"):
277
+ return True
278
+
279
+ return self._is_special_comment(next_line)
280
+
281
+ def _is_special_comment(self, line: str) -> bool:
282
+ if not line.startswith("#"):
283
+ return False
284
+ special_patterns = ("type:", "noqa", "nosec", "pragma:", "pylint:", "mypy:")
285
+ return any(pattern in line for pattern in special_patterns)
286
+
287
+ def _remove_trailing_empty_lines(self, lines: list[str]) -> list[str]:
288
+ while lines and (not lines[-1]):
289
+ lines.pop()
290
+ return lines
213
291
 
214
292
  def reformat_code(self, code: str) -> str:
215
293
  from crackerjack.errors import handle_error
@@ -335,27 +413,75 @@ class ConfigManager(BaseModel, arbitrary_types_allowed=True):
335
413
  self, our_toml_config: dict[str, t.Any], pkg_toml_config: dict[str, t.Any]
336
414
  ) -> None:
337
415
  for tool, settings in our_toml_config.get("tool", {}).items():
338
- for setting, value in settings.items():
339
- if isinstance(value, dict):
340
- for k, v in {
341
- x: self.swap_package_name(y)
342
- for x, y in value.items()
343
- if isinstance(y, str | list) and "crackerjack" in str(y)
344
- }.items():
345
- settings[setting][k] = v
346
- elif isinstance(value, str | list) and "crackerjack" in str(value):
347
- value = self.swap_package_name(value)
348
- settings[setting] = value
349
- if setting in (
350
- "exclude-deps",
351
- "exclude",
352
- "excluded",
353
- "skips",
354
- "ignore",
355
- ) and isinstance(value, list):
356
- conf = pkg_toml_config["tool"].get(tool, {}).get(setting, [])
357
- settings[setting] = list(set(conf + value))
358
- pkg_toml_config["tool"][tool] = settings
416
+ if tool not in pkg_toml_config["tool"]:
417
+ pkg_toml_config["tool"][tool] = {}
418
+
419
+ pkg_tool_config = pkg_toml_config["tool"][tool]
420
+
421
+ self._merge_tool_config(settings, pkg_tool_config, tool)
422
+
423
+ def _merge_tool_config(
424
+ self, our_config: dict[str, t.Any], pkg_config: dict[str, t.Any], tool: str
425
+ ) -> None:
426
+ """Recursively merge tool configuration, preserving existing project settings."""
427
+ for setting, value in our_config.items():
428
+ if isinstance(value, dict):
429
+ self._merge_nested_config(setting, value, pkg_config)
430
+ else:
431
+ self._merge_direct_config(setting, value, pkg_config)
432
+
433
+ def _merge_nested_config(
434
+ self, setting: str, value: dict[str, t.Any], pkg_config: dict[str, t.Any]
435
+ ) -> None:
436
+ """Handle nested configuration merging."""
437
+ if setting not in pkg_config:
438
+ pkg_config[setting] = {}
439
+ elif not isinstance(pkg_config[setting], dict):
440
+ pkg_config[setting] = {}
441
+
442
+ self._merge_tool_config(value, pkg_config[setting], "")
443
+
444
+ for k, v in value.items():
445
+ self._merge_nested_value(k, v, pkg_config[setting])
446
+
447
+ def _merge_nested_value(
448
+ self, key: str, value: t.Any, nested_config: dict[str, t.Any]
449
+ ) -> None:
450
+ """Merge individual nested values."""
451
+ if isinstance(value, str | list) and "crackerjack" in str(value):
452
+ nested_config[key] = self.swap_package_name(value)
453
+ elif self._is_mergeable_list(key, value):
454
+ existing = nested_config.get(key, [])
455
+ if isinstance(existing, list) and isinstance(value, list):
456
+ nested_config[key] = list(set(existing + value))
457
+ else:
458
+ nested_config[key] = value
459
+ elif key not in nested_config:
460
+ nested_config[key] = value
461
+
462
+ def _merge_direct_config(
463
+ self, setting: str, value: t.Any, pkg_config: dict[str, t.Any]
464
+ ) -> None:
465
+ """Handle direct configuration merging."""
466
+ if isinstance(value, str | list) and "crackerjack" in str(value):
467
+ pkg_config[setting] = self.swap_package_name(value)
468
+ elif self._is_mergeable_list(setting, value):
469
+ existing = pkg_config.get(setting, [])
470
+ if isinstance(existing, list) and isinstance(value, list):
471
+ pkg_config[setting] = list(set(existing + value))
472
+ else:
473
+ pkg_config[setting] = value
474
+ elif setting not in pkg_config:
475
+ pkg_config[setting] = value
476
+
477
+ def _is_mergeable_list(self, key: str, value: t.Any) -> bool:
478
+ return key in (
479
+ "exclude-deps",
480
+ "exclude",
481
+ "excluded",
482
+ "skips",
483
+ "ignore",
484
+ ) and isinstance(value, list)
359
485
 
360
486
  def _update_python_version(
361
487
  self, our_toml_config: dict[str, t.Any], pkg_toml_config: dict[str, t.Any]
@@ -520,11 +646,6 @@ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
520
646
  if self.pkg_path.stem == "crackerjack" and options.update_precommit:
521
647
  self.execute_command(["pre-commit", "autoupdate"])
522
648
 
523
- def _run_interactive_hooks(self, options: t.Any) -> None:
524
- if options.interactive:
525
- for hook in interactive_hooks:
526
- self.project_manager.run_interactive(hook)
527
-
528
649
  def _clean_project(self, options: t.Any) -> None:
529
650
  if options.clean:
530
651
  if self.pkg_dir:
@@ -655,7 +776,6 @@ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
655
776
  self._setup_package()
656
777
  self._update_project(options)
657
778
  self._update_precommit(options)
658
- self._run_interactive_hooks(options)
659
779
  self._clean_project(options)
660
780
  if not options.skip_hooks:
661
781
  self.project_manager.run_pre_commit()
@@ -260,11 +260,26 @@ class InteractiveCLI:
260
260
 
261
261
  def run_interactive(self) -> None:
262
262
  self.console.clear()
263
+ layout = self._setup_interactive_layout()
264
+ progress_tracker = self._create_progress_tracker()
265
+ with Live(layout, refresh_per_second=4, screen=True) as live:
266
+ try:
267
+ self._execute_workflow_loop(layout, progress_tracker, live)
268
+ self._display_final_summary(layout)
269
+ except KeyboardInterrupt:
270
+ self._handle_user_interruption(layout)
271
+ self.console.print("\nWorkflow Status:")
272
+ self.workflow.display_task_tree()
273
+
274
+ def _setup_interactive_layout(self) -> Layout:
263
275
  layout = self.setup_layout()
264
276
  layout["header"].update(
265
277
  Panel("Crackerjack Interactive Mode", style="bold cyan", box=ROUNDED)
266
278
  )
267
279
  layout["footer"].update(Panel("Press Ctrl+C to exit", style="dim", box=ROUNDED))
280
+ return layout
281
+
282
+ def _create_progress_tracker(self) -> dict[str, t.Any]:
268
283
  progress = Progress(
269
284
  SpinnerColumn(),
270
285
  TextColumn("[bold blue]{task.description}"),
@@ -274,72 +289,110 @@ class InteractiveCLI:
274
289
  )
275
290
  total_tasks = len(self.workflow.tasks)
276
291
  progress_task = progress.add_task("Running workflow", total=total_tasks)
277
- completed_tasks = 0
278
- with Live(layout, refresh_per_second=4, screen=True) as live:
279
- try:
280
- while not self.workflow.all_tasks_completed():
281
- layout["tasks"].update(self.show_task_table())
282
- next_task = self.workflow.get_next_task()
283
- if not next_task:
284
- break
285
- layout["details"].update(self.show_task_status(next_task))
286
- live.stop()
287
- should_run = Confirm.ask(
288
- f"Run task '{next_task.name}'?", default=True
289
- )
290
- live.start()
291
- if not should_run:
292
- next_task.skip()
293
- continue
294
- next_task.start()
295
- layout["details"].update(self.show_task_status(next_task))
296
- time.sleep(1)
297
- import random
298
-
299
- success = random.choice([True, True, True, False])
300
- if success:
301
- next_task.complete(True)
302
- completed_tasks += 1
303
- else:
304
- from .errors import ExecutionError
305
-
306
- error = ExecutionError(
307
- message=f"Task '{next_task.name}' failed",
308
- error_code=ErrorCode.COMMAND_EXECUTION_ERROR,
309
- details="This is a simulated failure for demonstration.",
310
- recovery=f"Retry the '{next_task.name}' task.",
311
- )
312
- next_task.fail(error)
313
- progress.update(progress_task, completed=completed_tasks)
314
- layout["details"].update(self.show_task_status(next_task))
315
- layout["tasks"].update(self.show_task_table())
316
- successful = sum(
317
- 1
318
- for task in self.workflow.tasks.values()
319
- if task.status == TaskStatus.SUCCESS
320
- )
321
- failed = sum(
322
- 1
323
- for task in self.workflow.tasks.values()
324
- if task.status == TaskStatus.FAILED
325
- )
326
- skipped = sum(
327
- 1
328
- for task in self.workflow.tasks.values()
329
- if task.status == TaskStatus.SKIPPED
330
- )
331
- summary = Panel(
332
- f"Workflow completed!\n\n[green]✅ Successful tasks: {successful}[/green]\n[red]❌ Failed tasks: {failed}[/red]\n[blue]⏩ Skipped tasks: {skipped}[/blue]",
333
- title="Summary",
334
- border_style="cyan",
335
- )
336
- layout["details"].update(summary)
337
- except KeyboardInterrupt:
338
- layout["footer"].update(
339
- Panel("Interrupted by user", style="yellow", box=ROUNDED)
340
- )
341
- self.console.print("\nWorkflow Status:")
342
- self.workflow.display_task_tree()
292
+
293
+ return {
294
+ "progress": progress,
295
+ "progress_task": progress_task,
296
+ "completed_tasks": 0,
297
+ }
298
+
299
+ def _execute_workflow_loop(
300
+ self, layout: Layout, progress_tracker: dict[str, t.Any], live: Live
301
+ ) -> None:
302
+ """Execute the main workflow loop."""
303
+ while not self.workflow.all_tasks_completed():
304
+ layout["tasks"].update(self.show_task_table())
305
+ next_task = self.workflow.get_next_task()
306
+
307
+ if not next_task:
308
+ break
309
+
310
+ if self._should_execute_task(layout, next_task, live):
311
+ self._execute_task(layout, next_task, progress_tracker)
312
+ else:
313
+ next_task.skip()
314
+
315
+ def _should_execute_task(self, layout: Layout, task: Task, live: Live) -> bool:
316
+ layout["details"].update(self.show_task_status(task))
317
+ live.stop()
318
+ should_run = Confirm.ask(f"Run task '{task.name}'?", default=True)
319
+ live.start()
320
+ return should_run
321
+
322
+ def _execute_task(
323
+ self, layout: Layout, task: Task, progress_tracker: dict[str, t.Any]
324
+ ) -> None:
325
+ """Execute a single task and update progress."""
326
+ task.start()
327
+ layout["details"].update(self.show_task_status(task))
328
+ time.sleep(1)
329
+
330
+ success = self._simulate_task_execution()
331
+
332
+ if success:
333
+ task.complete()
334
+ progress_tracker["completed_tasks"] += 1
335
+ else:
336
+ error = self._create_task_error(task.name)
337
+ task.fail(error)
338
+
339
+ progress_tracker["progress"].update(
340
+ progress_tracker["progress_task"],
341
+ completed=progress_tracker["completed_tasks"],
342
+ )
343
+ layout["details"].update(self.show_task_status(task))
344
+
345
+ def _simulate_task_execution(self) -> bool:
346
+ import random
347
+
348
+ return random.choice([True, True, True, False])
349
+
350
+ def _create_task_error(self, task_name: str) -> t.Any:
351
+ from .errors import ExecutionError
352
+
353
+ return ExecutionError(
354
+ message=f"Task '{task_name}' failed",
355
+ error_code=ErrorCode.COMMAND_EXECUTION_ERROR,
356
+ details="This is a simulated failure for demonstration.",
357
+ recovery=f"Retry the '{task_name}' task.",
358
+ )
359
+
360
+ def _display_final_summary(self, layout: Layout) -> None:
361
+ layout["tasks"].update(self.show_task_table())
362
+ task_counts = self._count_tasks_by_status()
363
+ summary = Panel(
364
+ f"Workflow completed!\n\n"
365
+ f"[green]✅ Successful tasks: {task_counts['successful']}[/green]\n"
366
+ f"[red]❌ Failed tasks: {task_counts['failed']}[/red]\n"
367
+ f"[blue]⏩ Skipped tasks: {task_counts['skipped']}[/blue]",
368
+ title="Summary",
369
+ border_style="cyan",
370
+ )
371
+ layout["details"].update(summary)
372
+
373
+ def _count_tasks_by_status(self) -> dict[str, int]:
374
+ return {
375
+ "successful": sum(
376
+ 1
377
+ for task in self.workflow.tasks.values()
378
+ if task.status == TaskStatus.SUCCESS
379
+ ),
380
+ "failed": sum(
381
+ 1
382
+ for task in self.workflow.tasks.values()
383
+ if task.status == TaskStatus.FAILED
384
+ ),
385
+ "skipped": sum(
386
+ 1
387
+ for task in self.workflow.tasks.values()
388
+ if task.status == TaskStatus.SKIPPED
389
+ ),
390
+ }
391
+
392
+ def _handle_user_interruption(self, layout: Layout) -> None:
393
+ layout["footer"].update(
394
+ Panel("Interrupted by user", style="yellow", box=ROUNDED)
395
+ )
343
396
 
344
397
  def ask_for_file(
345
398
  self, prompt: str, directory: Path, default: str | None = None
@@ -4,7 +4,7 @@ requires = [ "pdm-backend" ]
4
4
 
5
5
  [project]
6
6
  name = "crackerjack"
7
- version = "0.20.19"
7
+ version = "0.20.20"
8
8
  description = "Crackerjack: code quality toolkit"
9
9
  readme = "README.md"
10
10
  keywords = [
@@ -43,11 +43,11 @@ classifiers = [
43
43
  dependencies = [
44
44
  "autotyping>=24.9",
45
45
  "keyring>=25.6",
46
- "pdm>=2.25.2",
46
+ "pdm>=2.25.3",
47
47
  "pdm-bump>=0.9.12",
48
48
  "pre-commit>=4.2",
49
49
  "pydantic>=2.11.7",
50
- "pytest>=8.4",
50
+ "pytest>=8.4.1",
51
51
  "pytest-asyncio>=1",
52
52
  "pytest-benchmark>=5.1",
53
53
  "pytest-cov>=6.2.1",
@@ -58,7 +58,7 @@ dependencies = [
58
58
  "rich>=14",
59
59
  "tomli-w>=1.2",
60
60
  "typer>=0.16",
61
- "uv>=0.7.13",
61
+ "uv>=0.7.15",
62
62
  ]
63
63
  urls.documentation = "https://github.com/lesleslie/crackerjack"
64
64
  urls.homepage = "https://github.com/lesleslie/crackerjack"
@@ -89,6 +89,7 @@ lint.ignore = [
89
89
  "D105",
90
90
  "D106",
91
91
  "D107",
92
+ "E402",
92
93
  "F821",
93
94
  "UP040",
94
95
  ]
@@ -221,6 +222,8 @@ exclude-deps = [
221
222
  "google-crc32c",
222
223
  "pytest-timeout",
223
224
  "keyring",
225
+ "inflection",
226
+ "pydantic-settings",
224
227
  ]
225
228
 
226
229
  [tool.refurb]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: crackerjack
3
- Version: 0.20.20
3
+ Version: 0.21.0
4
4
  Summary: Crackerjack: code quality toolkit
5
5
  Keywords: bandit,black,creosote,mypy,pyright,pytest,refurb,ruff
6
6
  Author-Email: lesleslie <les@wedgwoodwebworks.com>
@@ -24,11 +24,11 @@ Project-URL: repository, https://github.com/lesleslie/crackerjack
24
24
  Requires-Python: >=3.13
25
25
  Requires-Dist: autotyping>=24.9
26
26
  Requires-Dist: keyring>=25.6
27
- Requires-Dist: pdm>=2.25.2
27
+ Requires-Dist: pdm>=2.25.3
28
28
  Requires-Dist: pdm-bump>=0.9.12
29
29
  Requires-Dist: pre-commit>=4.2
30
30
  Requires-Dist: pydantic>=2.11.7
31
- Requires-Dist: pytest>=8.4
31
+ Requires-Dist: pytest>=8.4.1
32
32
  Requires-Dist: pytest-asyncio>=1
33
33
  Requires-Dist: pytest-benchmark>=5.1
34
34
  Requires-Dist: pytest-cov>=6.2.1
@@ -39,7 +39,7 @@ Requires-Dist: pyyaml>=6.0.2
39
39
  Requires-Dist: rich>=14
40
40
  Requires-Dist: tomli-w>=1.2
41
41
  Requires-Dist: typer>=0.16
42
- Requires-Dist: uv>=0.7.13
42
+ Requires-Dist: uv>=0.7.15
43
43
  Description-Content-Type: text/markdown
44
44
 
45
45
  # Crackerjack: Elevate Your Python Development
@@ -1,11 +1,11 @@
1
- crackerjack-0.20.20.dist-info/METADATA,sha256=ZgwCSjG9EFBVIxNHdfz00rPm2J7CQTLkO1Tdui7w_P0,26413
2
- crackerjack-0.20.20.dist-info/WHEEL,sha256=tSfRZzRHthuv7vxpI4aehrdN9scLjk-dCJkPLzkHxGg,90
3
- crackerjack-0.20.20.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
4
- crackerjack-0.20.20.dist-info/licenses/LICENSE,sha256=fDt371P6_6sCu7RyqiZH_AhT1LdN3sN1zjBtqEhDYCk,1531
1
+ crackerjack-0.21.0.dist-info/METADATA,sha256=5sb-8guKpbw-RU1VKg-VttPLTQTefNrvlNhQgEW3kTc,26414
2
+ crackerjack-0.21.0.dist-info/WHEEL,sha256=tSfRZzRHthuv7vxpI4aehrdN9scLjk-dCJkPLzkHxGg,90
3
+ crackerjack-0.21.0.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
4
+ crackerjack-0.21.0.dist-info/licenses/LICENSE,sha256=fDt371P6_6sCu7RyqiZH_AhT1LdN3sN1zjBtqEhDYCk,1531
5
5
  crackerjack/.gitignore,sha256=FOLrDV-tuHpwof310aOi6cWOkJXeVI_gvWvg_paDzs4,199
6
6
  crackerjack/.libcst.codemod.yaml,sha256=a8DlErRAIPV1nE6QlyXPAzTOgkB24_spl2E9hphuf5s,772
7
7
  crackerjack/.pdm.toml,sha256=dZe44HRcuxxCFESGG8SZIjmc-cGzSoyK3Hs6t4NYA8w,23
8
- crackerjack/.pre-commit-config.yaml,sha256=RwXcH8uvF8-mvappcz_oUFB5GsRq-Z4fQ7OcCR1oLWU,2935
8
+ crackerjack/.pre-commit-config.yaml,sha256=glBcQ4nwC28t4Ier2E69bUg8pNQ9YyCtHmD-fV6bRm0,3076
9
9
  crackerjack/.pytest_cache/.gitignore,sha256=Ptcxtl0GFQwTji2tsL4Gl1UIiKa0frjEXsya26i46b0,37
10
10
  crackerjack/.pytest_cache/CACHEDIR.TAG,sha256=N9yI75oKvt2-gQU6bdj9-xOvthMEXqHrSlyBWnSjveQ,191
11
11
  crackerjack/.pytest_cache/README.md,sha256=c_1vzN2ALEGaay2YPWwxc7fal1WKxLWJ7ewt_kQ9ua0,302
@@ -32,6 +32,8 @@ crackerjack/.ruff_cache/0.11.6/3557596832929915217,sha256=yR2iXWDkSHVRw2eTiaCE8E
32
32
  crackerjack/.ruff_cache/0.11.7/10386934055395314831,sha256=lBNwN5zAgM4OzbkXIOzCczUtfooATrD10htj9ASlFkc,224
33
33
  crackerjack/.ruff_cache/0.11.7/3557596832929915217,sha256=fKlwUbsvT3YIKV6UR-aA_i64lLignWeVfVu-MMmVbU0,207
34
34
  crackerjack/.ruff_cache/0.11.8/530407680854991027,sha256=xAMAL3Vu_HR6M-h5ojCTaak0By5ii8u-14pXULLgLqw,224
35
+ crackerjack/.ruff_cache/0.12.0/5056746222905752453,sha256=MqrIT5qymJcgAOBZyn-TvYoGCFfDFCgN9IwSULq8n14,256
36
+ crackerjack/.ruff_cache/0.12.1/5056746222905752453,sha256=DxPQ70wEGAhDamfTzZgdH_n7fxBhsV_59f3jApsFXu0,256
35
37
  crackerjack/.ruff_cache/0.2.0/10047773857155985907,sha256=j9LNa_RQ4Plor7go1uTYgz17cEENKvZQ-dP6b9MX0ik,248
36
38
  crackerjack/.ruff_cache/0.2.1/8522267973936635051,sha256=u_aPBMibtAp_iYvLwR88GMAECMcIgHezxMyuapmU2P4,248
37
39
  crackerjack/.ruff_cache/0.2.2/18053836298936336950,sha256=Xb_ebP0pVuUfSqPEZKlhQ70so_vqkEfMYpuHQ06iR5U,248
@@ -60,10 +62,10 @@ crackerjack/.ruff_cache/0.9.9/12813592349865671909,sha256=tmr8_vhRD2OxsVuMfbJPdT
60
62
  crackerjack/.ruff_cache/0.9.9/8843823720003377982,sha256=e4ymkXfQsUg5e_mtO34xTsaTvs1uA3_fI216Qq9qCAM,136
61
63
  crackerjack/.ruff_cache/CACHEDIR.TAG,sha256=WVMVbX4MVkpCclExbq8m-IcOZIOuIZf5FrYw5Pk-Ma4,43
62
64
  crackerjack/__init__.py,sha256=8tBSPAru_YDuPpjz05cL7pNbZjYFoRT_agGd_FWa3gY,839
63
- crackerjack/__main__.py,sha256=A_qOog8kowRG3Z65pG11MK25t3n7eGdEldQNELurwJk,6734
64
- crackerjack/crackerjack.py,sha256=seIDpPlq6Kq_rzQh5ext-VCNhDIPfhIrfyaucGfCACQ,26882
65
+ crackerjack/__main__.py,sha256=AknITUlFjq3YUK9s2xeL62dM0GN82JBQyDkPzQ_hCUg,6561
66
+ crackerjack/crackerjack.py,sha256=pENFT19EqA4DBro9YtmP8avhfQbPljMkRF5MXn0eybo,30900
65
67
  crackerjack/errors.py,sha256=QEPtVuMtKtQHuawgr1ToMaN1KbUg5h9-4mS33YB5Znk,4062
66
- crackerjack/interactive.py,sha256=gP2Mb7jjBfR6RIlCUXh77_M-an5OXfTVXfUmnN-9ggA,15076
68
+ crackerjack/interactive.py,sha256=5KXKSvWKttLvHcI1L4VEDc3Rb-ZpHBOl_Qr7lhD-O4Q,16262
67
69
  crackerjack/py313.py,sha256=buYE7LO11Q64ffowEhTZRFQoAGj_8sg3DTlZuv8M9eo,5890
68
- crackerjack/pyproject.toml,sha256=9KWpTTwX6z2nDhZChQL6xIGuCnKGnCIikZlqvfDtmYs,4932
69
- crackerjack-0.20.20.dist-info/RECORD,,
70
+ crackerjack/pyproject.toml,sha256=kiN_CuXFS307ca2CvLt-nDvZbFgouTxb1uIsSEkdIDk,4989
71
+ crackerjack-0.21.0.dist-info/RECORD,,