crackerjack 0.20.3__py3-none-any.whl → 0.20.10__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.
@@ -1,23 +1,16 @@
1
- import io
2
- import os
3
- import platform
4
- import queue
1
+ import ast
5
2
  import re
6
3
  import subprocess
7
- import threading
8
- import time
9
- import tokenize
10
4
  import typing as t
11
5
  from contextlib import suppress
12
- from dataclasses import dataclass, field
13
6
  from pathlib import Path
14
7
  from subprocess import CompletedProcess
15
8
  from subprocess import run as execute
16
- from token import STRING
17
9
  from tomllib import loads
18
-
10
+ from pydantic import BaseModel
19
11
  from rich.console import Console
20
12
  from tomli_w import dumps
13
+ from crackerjack.errors import ErrorCode, ExecutionError
21
14
 
22
15
  config_files = (".gitignore", ".pre-commit-config.yaml", ".libcst.codemod.yaml")
23
16
  interactive_hooks = ("refurb", "bandit", "pyright")
@@ -54,8 +47,7 @@ class OptionsProtocol(t.Protocol):
54
47
  skip_hooks: bool = False
55
48
 
56
49
 
57
- @dataclass
58
- class CodeCleaner:
50
+ class CodeCleaner(BaseModel, arbitrary_types_allowed=True):
59
51
  console: Console
60
52
 
61
53
  def clean_files(self, pkg_dir: Path | None) -> None:
@@ -64,188 +56,79 @@ class CodeCleaner:
64
56
  for file_path in pkg_dir.rglob("*.py"):
65
57
  if not str(file_path.parent).startswith("__"):
66
58
  self.clean_file(file_path)
59
+ with suppress(PermissionError, OSError):
60
+ pycache_dir = pkg_dir / "__pycache__"
61
+ if pycache_dir.exists():
62
+ for cache_file in pycache_dir.iterdir():
63
+ with suppress(PermissionError, OSError):
64
+ cache_file.unlink()
65
+ pycache_dir.rmdir()
66
+ parent_pycache = pkg_dir.parent / "__pycache__"
67
+ if parent_pycache.exists():
68
+ for cache_file in parent_pycache.iterdir():
69
+ with suppress(PermissionError, OSError):
70
+ cache_file.unlink()
71
+ parent_pycache.rmdir()
67
72
 
68
73
  def clean_file(self, file_path: Path) -> None:
69
- from .errors import CleaningError, ErrorCode, FileError, handle_error
70
-
71
74
  try:
72
- if file_path.resolve() == Path(__file__).resolve():
73
- self.console.print(f"Skipping cleaning of {file_path} (self file).")
74
- return
75
- except Exception as e:
76
- error = FileError(
77
- message="Error comparing file paths",
78
- error_code=ErrorCode.FILE_READ_ERROR,
79
- details=f"Failed to compare {file_path} with the current file: {e}",
80
- recovery="This is likely a file system permission issue. Check file permissions.",
81
- exit_code=0, # Non-fatal error
82
- )
83
- handle_error(error, self.console, verbose=True, exit_on_error=False)
84
- return
85
-
86
- try:
87
- # Check if file exists and is readable
88
- if not file_path.exists():
89
- error = FileError(
90
- message="File not found",
91
- error_code=ErrorCode.FILE_NOT_FOUND,
92
- details=f"The file {file_path} does not exist.",
93
- recovery="Check the file path and ensure the file exists.",
94
- exit_code=0, # Non-fatal error
95
- )
96
- handle_error(error, self.console, verbose=True, exit_on_error=False)
97
- return
98
-
99
- try:
100
- code = file_path.read_text()
101
- except Exception as e:
102
- error = FileError(
103
- message="Error reading file",
104
- error_code=ErrorCode.FILE_READ_ERROR,
105
- details=f"Failed to read {file_path}: {e}",
106
- recovery="Check file permissions and ensure the file is not locked by another process.",
107
- exit_code=0, # Non-fatal error
108
- )
109
- handle_error(error, self.console, verbose=True, exit_on_error=False)
110
- return
111
-
112
- # Process the file content
75
+ code = file_path.read_text()
113
76
  code = self.remove_docstrings(code)
114
77
  code = self.remove_line_comments(code)
115
78
  code = self.remove_extra_whitespace(code)
116
79
  code = self.reformat_code(code)
117
-
118
- try:
119
- file_path.write_text(code) # type: ignore
120
- self.console.print(f"Cleaned: {file_path}")
121
- except Exception as e:
122
- error = FileError(
123
- message="Error writing file",
124
- error_code=ErrorCode.FILE_WRITE_ERROR,
125
- details=f"Failed to write to {file_path}: {e}",
126
- recovery="Check file permissions and ensure the file is not locked by another process.",
127
- exit_code=0, # Non-fatal error
128
- )
129
- handle_error(error, self.console, verbose=True, exit_on_error=False)
130
-
80
+ file_path.write_text(code)
81
+ print(f"Cleaned: {file_path}")
131
82
  except Exception as e:
132
- error = CleaningError(
133
- message="Error cleaning file",
134
- error_code=ErrorCode.CODE_CLEANING_ERROR,
135
- details=f"Failed to clean {file_path}: {e}",
136
- recovery="This could be due to syntax errors in the file. Try manually checking the file for syntax errors.",
137
- exit_code=0, # Non-fatal error
138
- )
139
- handle_error(error, self.console, verbose=True, exit_on_error=False)
83
+ print(f"Error cleaning {file_path}: {e}")
84
+
85
+ def remove_docstrings(self, code: str) -> str:
86
+ tree = ast.parse(code)
87
+ for node in ast.walk(tree):
88
+ if isinstance(
89
+ node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.Module
90
+ ):
91
+ if ast.get_docstring(node):
92
+ node.body = (
93
+ node.body[1:]
94
+ if isinstance(node.body[0], ast.Expr)
95
+ else node.body
96
+ )
97
+ return ast.unparse(tree)
140
98
 
141
99
  def remove_line_comments(self, code: str) -> str:
142
- new_lines = []
143
- for line in code.splitlines():
144
- if "#" not in line or line.endswith("# skip"):
145
- new_lines.append(line)
100
+ lines = code.split("\n")
101
+ cleaned_lines = []
102
+ for line in lines:
103
+ if not line.strip():
104
+ cleaned_lines.append(line)
146
105
  continue
147
- if re.search(r"#\s", line):
148
- idx = line.find("#")
149
- code_part = line[:idx].rstrip()
150
- comment_part = line[idx:]
151
- if (
152
- " type: ignore" in comment_part
153
- or " noqa" in comment_part
154
- or " nosec" in comment_part
155
- or " codespell:ignore" in comment_part
156
- ):
157
- new_lines.append(line)
106
+ in_string = None
107
+ result = []
108
+ i = 0
109
+ n = len(line)
110
+ while i < n:
111
+ char = line[i]
112
+ if char in ("'", '"') and (i == 0 or line[i - 1] != "\\"):
113
+ if in_string is None:
114
+ in_string = char
115
+ elif in_string == char:
116
+ in_string = None
117
+ result.append(char)
118
+ i += 1
119
+ elif char == "#" and in_string is None:
120
+ comment = line[i:].strip()
121
+ if re.match("^#\\s*(?:type:\\s*ignore|noqa)\\b", comment):
122
+ result.append(line[i:])
123
+ break
124
+ break
158
125
  else:
159
- if code_part:
160
- new_lines.append(code_part)
161
- else:
162
- new_lines.append(line)
163
- return "\n".join(new_lines)
164
-
165
- def _is_triple_quoted(self, token_string: str) -> bool:
166
- triple_quote_patterns = [
167
- ('"""', '"""'),
168
- ("'''", "'''"),
169
- ('r"""', '"""'),
170
- ("r'''", "'''"),
171
- ]
172
- return any(
173
- token_string.startswith(start) and token_string.endswith(end)
174
- for start, end in triple_quote_patterns
175
- )
176
-
177
- def _is_module_docstring(
178
- self, tokens: list[tokenize.TokenInfo], i: int, indent_level: int
179
- ) -> bool:
180
- if i <= 0 or indent_level != 0:
181
- return False
182
- preceding_tokens = tokens[:i]
183
- return not preceding_tokens
184
-
185
- def _is_function_or_class_docstring(
186
- self,
187
- tokens: list[tokenize.TokenInfo],
188
- i: int,
189
- last_token_type: t.Any,
190
- last_token_string: str,
191
- ) -> bool:
192
- if last_token_type != tokenize.OP or last_token_string != ":": # nosec B105
193
- return False
194
- for prev_idx in range(i - 1, max(0, i - 20), -1):
195
- prev_token = tokens[prev_idx]
196
- if prev_token[1] in ("def", "class") and prev_token[0] == tokenize.NAME:
197
- return True
198
- elif prev_token[0] == tokenize.DEDENT:
199
- break
200
- return False
201
-
202
- def _is_variable_docstring(
203
- self, tokens: list[tokenize.TokenInfo], i: int, indent_level: int
204
- ) -> bool:
205
- if indent_level <= 0:
206
- return False
207
- for prev_idx in range(i - 1, max(0, i - 10), -1):
208
- if tokens[prev_idx][0]:
209
- return True
210
- return False
211
-
212
- def remove_docstrings(self, source: str) -> str:
213
- try:
214
- io_obj = io.StringIO(source)
215
- tokens = list(tokenize.generate_tokens(io_obj.readline))
216
- result_tokens = []
217
- indent_level = 0
218
- last_non_ws_token_type = None
219
- last_non_ws_token_string = "" # nosec B105
220
- for i, token in enumerate(tokens):
221
- token_type, token_string, _, _, _ = token
222
- if token_type == tokenize.INDENT:
223
- indent_level += 1
224
- elif token_type == tokenize.DEDENT:
225
- indent_level -= 1
226
- if token_type == STRING and self._is_triple_quoted(token_string):
227
- is_docstring = (
228
- self._is_module_docstring(tokens, i, indent_level)
229
- or self._is_function_or_class_docstring(
230
- tokens, i, last_non_ws_token_type, last_non_ws_token_string
231
- )
232
- or self._is_variable_docstring(tokens, i, indent_level)
233
- )
234
- if is_docstring:
235
- continue
236
- if token_type not in (
237
- tokenize.NL,
238
- tokenize.NEWLINE,
239
- tokenize.INDENT,
240
- tokenize.DEDENT,
241
- ):
242
- last_non_ws_token_type = token_type
243
- last_non_ws_token_string = token_string
244
- result_tokens.append(token)
245
- return tokenize.untokenize(result_tokens)
246
- except Exception as e:
247
- self.console.print(f"Error removing docstrings: {e}")
248
- return source
126
+ result.append(char)
127
+ i += 1
128
+ cleaned_line = "".join(result).rstrip()
129
+ if cleaned_line or not line.strip():
130
+ cleaned_lines.append(cleaned_line or line)
131
+ return "\n".join(cleaned_lines)
249
132
 
250
133
  def remove_extra_whitespace(self, code: str) -> str:
251
134
  lines = code.split("\n")
@@ -257,31 +140,17 @@ class CodeCleaner:
257
140
  cleaned_lines.append(line)
258
141
  return "\n".join(cleaned_lines)
259
142
 
260
- def reformat_code(self, code: str) -> str | None:
261
- from .errors import CleaningError, ErrorCode, handle_error
143
+ def reformat_code(self, code: str) -> str:
144
+ from crackerjack.errors import handle_error
262
145
 
263
146
  try:
264
147
  import tempfile
265
148
 
266
- # Create a temporary file for formatting
267
- try:
268
- with tempfile.NamedTemporaryFile(
269
- suffix=".py", mode="w+", delete=False
270
- ) as temp:
271
- temp_path = Path(temp.name)
272
- temp_path.write_text(code)
273
- except Exception as e:
274
- error = CleaningError(
275
- message="Failed to create temporary file for formatting",
276
- error_code=ErrorCode.FORMATTING_ERROR,
277
- details=f"Error: {e}",
278
- recovery="Check disk space and permissions for the temp directory.",
279
- exit_code=0, # Non-fatal
280
- )
281
- handle_error(error, self.console, verbose=True, exit_on_error=False)
282
- return code
283
-
284
- # Run Ruff to format the code
149
+ with tempfile.NamedTemporaryFile(
150
+ suffix=".py", mode="w+", delete=False
151
+ ) as temp:
152
+ temp_path = Path(temp.name)
153
+ temp_path.write_text(code)
285
154
  try:
286
155
  result = subprocess.run(
287
156
  ["ruff", "format", str(temp_path)],
@@ -289,63 +158,53 @@ class CodeCleaner:
289
158
  capture_output=True,
290
159
  text=True,
291
160
  )
292
-
293
161
  if result.returncode == 0:
294
- try:
295
- formatted_code = temp_path.read_text()
296
- except Exception as e:
297
- error = CleaningError(
298
- message="Failed to read formatted code",
299
- error_code=ErrorCode.FORMATTING_ERROR,
300
- details=f"Error reading temporary file after formatting: {e}",
301
- recovery="This might be a permissions issue. Check if Ruff is installed properly.",
302
- exit_code=0, # Non-fatal
303
- )
304
- handle_error(
305
- error, self.console, verbose=True, exit_on_error=False
306
- )
307
- formatted_code = code
162
+ formatted_code = temp_path.read_text()
308
163
  else:
309
- error = CleaningError(
310
- message="Ruff formatting failed",
311
- error_code=ErrorCode.FORMATTING_ERROR,
312
- details=f"Ruff output: {result.stderr}",
313
- recovery="The file might contain syntax errors. Check the file manually.",
314
- exit_code=0, # Non-fatal
164
+ self.console.print(
165
+ f"[yellow]Ruff formatting failed: {result.stderr}[/yellow]"
166
+ )
167
+ handle_error(
168
+ ExecutionError(
169
+ message="Code formatting failed",
170
+ error_code=ErrorCode.FORMATTING_ERROR,
171
+ details=result.stderr,
172
+ recovery="Check Ruff configuration and formatting rules",
173
+ ),
174
+ console=self.console,
315
175
  )
316
- handle_error(error, self.console, exit_on_error=False)
317
176
  formatted_code = code
318
177
  except Exception as e:
319
- error = CleaningError(
320
- message="Error running Ruff formatter",
321
- error_code=ErrorCode.FORMATTING_ERROR,
322
- details=f"Error: {e}",
323
- recovery="Ensure Ruff is installed correctly. Run 'pip install ruff' to install it.",
324
- exit_code=0, # Non-fatal
178
+ self.console.print(f"[red]Error running Ruff: {e}[/red]")
179
+ handle_error(
180
+ ExecutionError(
181
+ message="Error running Ruff",
182
+ error_code=ErrorCode.FORMATTING_ERROR,
183
+ details=str(e),
184
+ recovery="Verify Ruff is installed and configured correctly",
185
+ ),
186
+ console=self.console,
325
187
  )
326
- handle_error(error, self.console, verbose=True, exit_on_error=False)
327
188
  formatted_code = code
328
189
  finally:
329
- # Clean up temporary file
330
190
  with suppress(FileNotFoundError):
331
191
  temp_path.unlink()
332
-
333
192
  return formatted_code
334
-
335
193
  except Exception as e:
336
- error = CleaningError(
337
- message="Unexpected error during code formatting",
338
- error_code=ErrorCode.FORMATTING_ERROR,
339
- details=f"Error: {e}",
340
- recovery="This is an unexpected error. Please report this issue.",
341
- exit_code=0, # Non-fatal
194
+ self.console.print(f"[red]Error during reformatting: {e}[/red]")
195
+ handle_error(
196
+ ExecutionError(
197
+ message="Error during reformatting",
198
+ error_code=ErrorCode.FORMATTING_ERROR,
199
+ details=str(e),
200
+ recovery="Check file permissions and disk space",
201
+ ),
202
+ console=self.console,
342
203
  )
343
- handle_error(error, self.console, verbose=True, exit_on_error=False)
344
204
  return code
345
205
 
346
206
 
347
- @dataclass
348
- class ConfigManager:
207
+ class ConfigManager(BaseModel, arbitrary_types_allowed=True):
349
208
  our_path: Path
350
209
  pkg_path: Path
351
210
  pkg_name: str
@@ -469,64 +328,29 @@ class ConfigManager:
469
328
  return execute(cmd, **kwargs)
470
329
 
471
330
 
472
- @dataclass
473
- class ProjectManager:
331
+ class ProjectManager(BaseModel, arbitrary_types_allowed=True):
474
332
  our_path: Path
475
333
  pkg_path: Path
334
+ pkg_dir: Path | None = None
335
+ pkg_name: str = "crackerjack"
476
336
  console: Console
477
337
  code_cleaner: CodeCleaner
478
338
  config_manager: ConfigManager
479
- pkg_dir: Path | None = None
480
- pkg_name: str = "crackerjack"
481
339
  dry_run: bool = False
482
340
 
483
341
  def run_interactive(self, hook: str) -> None:
484
- from .errors import ErrorCode, ExecutionError, handle_error
485
-
486
342
  success: bool = False
487
- attempts = 0
488
- max_attempts = 3
489
-
490
- while not success and attempts < max_attempts:
491
- attempts += 1
492
- result = self.execute_command(
343
+ while not success:
344
+ fail = self.execute_command(
493
345
  ["pre-commit", "run", hook.lower(), "--all-files"]
494
346
  )
495
-
496
- if result.returncode > 0:
497
- self.console.print(
498
- f"\n\n[yellow]Hook '{hook}' failed (attempt {attempts}/{max_attempts})[/yellow]"
499
- )
500
-
501
- # Give more detailed information about the failure
502
- if result.stderr:
503
- self.console.print(f"[red]Error details:[/red]\n{result.stderr}")
504
-
505
- retry = input(f"Retry running {hook.title()}? (y/N): ")
347
+ if fail.returncode > 0:
348
+ retry = input(f"\n\n{hook.title()} failed. Retry? (y/N): ")
506
349
  self.console.print()
507
-
508
- if retry.strip().lower() != "y":
509
- error = ExecutionError(
510
- message=f"Interactive hook '{hook}' failed",
511
- error_code=ErrorCode.PRE_COMMIT_ERROR,
512
- details=f"Hook execution output:\n{result.stderr or result.stdout}",
513
- recovery=f"Try running the hook manually: pre-commit run {hook.lower()} --all-files",
514
- exit_code=1,
515
- )
516
- handle_error(error=error, console=self.console)
517
- else:
518
- self.console.print(f"[green]✅ Hook '{hook}' succeeded![/green]")
519
- success = True
520
-
521
- if not success:
522
- error = ExecutionError(
523
- message=f"Interactive hook '{hook}' failed after {max_attempts} attempts",
524
- error_code=ErrorCode.PRE_COMMIT_ERROR,
525
- details="The hook continued to fail after multiple attempts.",
526
- recovery=f"Fix the issues manually and run: pre-commit run {hook.lower()} --all-files",
527
- exit_code=1,
528
- )
529
- handle_error(error=error, console=self.console)
350
+ if retry.strip().lower() == "y":
351
+ continue
352
+ raise SystemExit(1)
353
+ success = True
530
354
 
531
355
  def update_pkg_configs(self) -> None:
532
356
  self.config_manager.copy_configs()
@@ -546,25 +370,13 @@ class ProjectManager:
546
370
  self.config_manager.update_pyproject_configs()
547
371
 
548
372
  def run_pre_commit(self) -> None:
549
- from .errors import ErrorCode, ExecutionError, handle_error
550
-
551
373
  self.console.print("\nRunning pre-commit hooks...\n")
552
374
  check_all = self.execute_command(["pre-commit", "run", "--all-files"])
553
-
554
375
  if check_all.returncode > 0:
555
- # First retry
556
- self.console.print("\nSome pre-commit hooks failed. Retrying once...\n")
557
376
  check_all = self.execute_command(["pre-commit", "run", "--all-files"])
558
-
559
377
  if check_all.returncode > 0:
560
- error = ExecutionError(
561
- message="Pre-commit hooks failed",
562
- error_code=ErrorCode.PRE_COMMIT_ERROR,
563
- details="Pre-commit hooks failed even after a retry. Check the output above for specific hook failures.",
564
- recovery="Review the error messages above. Manually fix the issues or run specific hooks interactively with 'pre-commit run <hook-id>'.",
565
- exit_code=1,
566
- )
567
- handle_error(error=error, console=self.console, verbose=True)
378
+ self.console.print("\n\nPre-commit failed. Please fix errors.\n")
379
+ raise SystemExit(1)
568
380
 
569
381
  def execute_command(
570
382
  self, cmd: list[str], **kwargs: t.Any
@@ -575,42 +387,39 @@ class ProjectManager:
575
387
  return execute(cmd, **kwargs)
576
388
 
577
389
 
578
- @dataclass
579
- class Crackerjack:
580
- our_path: Path = field(default_factory=lambda: Path(__file__).parent)
581
- pkg_path: Path = field(default_factory=lambda: Path(Path.cwd()))
390
+ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
391
+ our_path: Path = Path(__file__).parent
392
+ pkg_path: Path = Path(Path.cwd())
582
393
  pkg_dir: Path | None = None
583
394
  pkg_name: str = "crackerjack"
584
395
  python_version: str = default_python_version
585
- console: Console = field(default_factory=lambda: Console(force_terminal=True))
396
+ console: Console = Console(force_terminal=True)
586
397
  dry_run: bool = False
587
398
  code_cleaner: CodeCleaner | None = None
588
399
  config_manager: ConfigManager | None = None
589
400
  project_manager: ProjectManager | None = None
590
401
 
591
- def __post_init__(self) -> None:
592
- if self.code_cleaner is None:
593
- self.code_cleaner = CodeCleaner(console=self.console)
594
- if self.config_manager is None:
595
- self.config_manager = ConfigManager(
596
- our_path=self.our_path,
597
- pkg_path=self.pkg_path,
598
- pkg_name=self.pkg_name,
599
- console=self.console,
600
- python_version=self.python_version,
601
- dry_run=self.dry_run,
602
- )
603
- if self.project_manager is None:
604
- self.project_manager = ProjectManager(
605
- our_path=self.our_path,
606
- pkg_path=self.pkg_path,
607
- pkg_dir=self.pkg_dir,
608
- pkg_name=self.pkg_name,
609
- console=self.console,
610
- code_cleaner=self.code_cleaner,
611
- config_manager=self.config_manager,
612
- dry_run=self.dry_run,
613
- )
402
+ def __init__(self, **data: t.Any) -> None:
403
+ super().__init__(**data)
404
+ self.code_cleaner = CodeCleaner(console=self.console)
405
+ self.config_manager = ConfigManager(
406
+ our_path=self.our_path,
407
+ pkg_path=self.pkg_path,
408
+ pkg_name=self.pkg_name,
409
+ console=self.console,
410
+ python_version=self.python_version,
411
+ dry_run=self.dry_run,
412
+ )
413
+ self.project_manager = ProjectManager(
414
+ our_path=self.our_path,
415
+ pkg_path=self.pkg_path,
416
+ pkg_dir=self.pkg_dir,
417
+ pkg_name=self.pkg_name,
418
+ console=self.console,
419
+ code_cleaner=self.code_cleaner,
420
+ config_manager=self.config_manager,
421
+ dry_run=self.dry_run,
422
+ )
614
423
 
615
424
  def _setup_package(self) -> None:
616
425
  self.pkg_name = self.pkg_path.stem.lower().replace("-", "_")
@@ -621,9 +430,7 @@ class Crackerjack:
621
430
  self.project_manager.pkg_name = self.pkg_name
622
431
  self.project_manager.pkg_dir = self.pkg_dir
623
432
 
624
- def _update_project(self, options: OptionsProtocol) -> None:
625
- from .errors import ErrorCode, ExecutionError, handle_error
626
-
433
+ def _update_project(self, options: t.Any) -> None:
627
434
  if not options.no_config_updates:
628
435
  self.project_manager.update_pkg_configs()
629
436
  result: CompletedProcess[str] = self.execute_command(
@@ -632,69 +439,35 @@ class Crackerjack:
632
439
  if result.returncode == 0:
633
440
  self.console.print("PDM installed: ✅\n")
634
441
  else:
635
- error = ExecutionError(
636
- message="PDM installation failed",
637
- error_code=ErrorCode.PDM_INSTALL_ERROR,
638
- details=f"Command output:\n{result.stderr}",
639
- recovery="Ensure PDM is installed. Run `pipx install pdm` and try again. Check for network issues or package conflicts.",
640
- exit_code=1,
641
- )
642
-
643
- # Don't exit immediately - this isn't always fatal
644
- handle_error(
645
- error=error,
646
- console=self.console,
647
- verbose=options.verbose,
648
- ai_agent=options.ai_agent,
649
- exit_on_error=False,
442
+ self.console.print(
443
+ "\n\n❌ PDM installation failed. Is PDM is installed? Run `pipx install pdm` and try again.\n\n"
650
444
  )
651
445
 
652
- def _update_precommit(self, options: OptionsProtocol) -> None:
446
+ def _update_precommit(self, options: t.Any) -> None:
653
447
  if self.pkg_path.stem == "crackerjack" and options.update_precommit:
654
448
  self.execute_command(["pre-commit", "autoupdate"])
655
449
 
656
- def _run_interactive_hooks(self, options: OptionsProtocol) -> None:
450
+ def _run_interactive_hooks(self, options: t.Any) -> None:
657
451
  if options.interactive:
658
452
  for hook in interactive_hooks:
659
453
  self.project_manager.run_interactive(hook)
660
454
 
661
- def _clean_project(self, options: OptionsProtocol) -> None:
455
+ def _clean_project(self, options: t.Any) -> None:
662
456
  if options.clean:
663
457
  if self.pkg_dir:
664
458
  self.code_cleaner.clean_files(self.pkg_dir)
665
- # Skip cleaning test files as they may contain test data in docstrings and comments
666
- # that are necessary for the tests to function properly
459
+ if self.pkg_path.stem == "crackerjack":
460
+ tests_dir = self.pkg_path / "tests"
461
+ if tests_dir.exists() and tests_dir.is_dir():
462
+ self.console.print("\nCleaning tests directory...\n")
463
+ self.code_cleaner.clean_files(tests_dir)
667
464
 
668
465
  def _prepare_pytest_command(self, options: OptionsProtocol) -> list[str]:
669
- """Prepare pytest command with appropriate options.
670
-
671
- Configures pytest command with:
672
- - Standard options for formatting and output control
673
- - Benchmark options when benchmark mode is enabled
674
- - Benchmark regression options when regression testing is enabled
675
- - Parallel execution via xdist for non-benchmark tests
676
-
677
- Benchmark and parallel execution (xdist) are incompatible, so the command
678
- automatically disables parallelism when benchmarks are enabled.
679
-
680
- Args:
681
- options: Command options with benchmark and test settings
682
-
683
- Returns:
684
- List of command-line arguments for pytest
685
- """
686
466
  test = ["pytest"]
687
- if options.verbose:
688
- test.append("-v")
689
-
690
- # Detect project size to adjust timeouts and parallelization
691
467
  project_size = self._detect_project_size()
692
-
693
- # User can override the timeout, otherwise use project size to determine
694
468
  if options.test_timeout > 0:
695
469
  test_timeout = options.test_timeout
696
470
  else:
697
- # Use a longer timeout for larger projects
698
471
  test_timeout = (
699
472
  300
700
473
  if project_size == "large"
@@ -702,27 +475,19 @@ class Crackerjack:
702
475
  if project_size == "medium"
703
476
  else 60
704
477
  )
705
-
706
478
  test.extend(
707
479
  [
708
- "--capture=fd", # Capture stdout/stderr at file descriptor level
709
- "--tb=short", # Shorter traceback format
710
- "--no-header", # Reduce output noise
711
- "--disable-warnings", # Disable warning capture
712
- "--durations=0", # Show slowest tests to identify potential hanging tests
713
- f"--timeout={test_timeout}", # Dynamic timeout based on project size or user override
480
+ "--capture=fd",
481
+ "--tb=short",
482
+ "--no-header",
483
+ "--disable-warnings",
484
+ "--durations=0",
485
+ f"--timeout={test_timeout}",
714
486
  ]
715
487
  )
716
-
717
- # Benchmarks and parallel testing are incompatible
718
- # Handle them mutually exclusively
719
488
  if options.benchmark or options.benchmark_regression:
720
- # When running benchmarks, avoid parallel execution
721
- # and apply specific benchmark options
722
489
  if options.benchmark:
723
490
  test.append("--benchmark")
724
-
725
- # Add benchmark regression testing options if enabled
726
491
  if options.benchmark_regression:
727
492
  test.extend(
728
493
  [
@@ -730,51 +495,27 @@ class Crackerjack:
730
495
  f"--benchmark-regression-threshold={options.benchmark_regression_threshold}",
731
496
  ]
732
497
  )
733
- else:
734
- # Use user-specified number of workers if provided
735
- if options.test_workers > 0:
736
- # User explicitly set number of workers
737
- if options.test_workers == 1:
738
- # Single worker means no parallelism, just use normal pytest mode
739
- test.append("-vs")
740
- else:
741
- # Use specified number of workers
742
- test.extend(["-xvs", "-n", str(options.test_workers)])
498
+ elif options.test_workers > 0:
499
+ if options.test_workers == 1:
500
+ test.append("-vs")
743
501
  else:
744
- # Auto-detect based on project size
745
- if project_size == "large":
746
- # For large projects, use a fixed number of workers to avoid overwhelming the system
747
- test.extend(
748
- ["-xvs", "-n", "2"]
749
- ) # Only 2 parallel processes for large projects
750
- elif project_size == "medium":
751
- test.extend(
752
- ["-xvs", "-n", "auto"]
753
- ) # Auto-detect number of processes but limit it
754
- else:
755
- test.append("-xvs") # Default behavior for small projects
756
-
502
+ test.extend(["-xvs", "-n", str(options.test_workers)])
503
+ elif project_size == "large":
504
+ test.extend(["-xvs", "-n", "2"])
505
+ elif project_size == "medium":
506
+ test.extend(["-xvs", "-n", "auto"])
507
+ else:
508
+ test.append("-xvs")
757
509
  return test
758
510
 
759
511
  def _detect_project_size(self) -> str:
760
- """Detect the approximate size of the project to adjust test parameters.
761
-
762
- Returns:
763
- "small", "medium", or "large" based on codebase size
764
- """
765
- # Check for known large projects by name
766
512
  if self.pkg_name in ("acb", "fastblocks"):
767
513
  return "large"
768
-
769
- # Count Python files to estimate project size
770
514
  try:
771
515
  py_files = list(self.pkg_path.rglob("*.py"))
772
516
  test_files = list(self.pkg_path.rglob("test_*.py"))
773
-
774
517
  total_files = len(py_files)
775
518
  num_test_files = len(test_files)
776
-
777
- # Rough heuristics for project size
778
519
  if total_files > 100 or num_test_files > 50:
779
520
  return "large"
780
521
  elif total_files > 50 or num_test_files > 20:
@@ -782,234 +523,21 @@ class Crackerjack:
782
523
  else:
783
524
  return "small"
784
525
  except Exception:
785
- # Default to medium in case of error
786
526
  return "medium"
787
527
 
788
- def _setup_test_environment(self) -> None:
789
- os.environ["PYTHONASYNCIO_DEBUG"] = "0" # Disable asyncio debug mode
790
- os.environ["RUNNING_UNDER_CRACKERJACK"] = "1" # Signal to conftest.py
791
- if "PYTEST_ASYNCIO_MODE" not in os.environ:
792
- os.environ["PYTEST_ASYNCIO_MODE"] = "strict"
793
-
794
- def _run_pytest_process(
795
- self, test_command: list[str]
796
- ) -> subprocess.CompletedProcess[str]:
797
- import queue
798
-
799
- from .errors import ErrorCode, ExecutionError, handle_error
800
-
801
- try:
802
- # Detect project size to determine appropriate timeout
803
- project_size = self._detect_project_size()
804
- # Longer timeouts for larger projects
805
- global_timeout = (
806
- 1200
807
- if project_size == "large"
808
- else 600
809
- if project_size == "medium"
810
- else 300
811
- )
812
-
813
- # Show timeout information
814
- self.console.print(f"[blue]Project size detected as: {project_size}[/blue]")
815
- self.console.print(
816
- f"[blue]Using global timeout of {global_timeout} seconds[/blue]"
817
- )
818
-
819
- # Use non-blocking IO to avoid deadlocks
820
- process = subprocess.Popen(
821
- test_command,
822
- stdout=subprocess.PIPE,
823
- stderr=subprocess.PIPE,
824
- text=True,
825
- bufsize=1,
826
- universal_newlines=True,
827
- )
828
-
829
- stdout_data = []
830
- stderr_data = []
831
-
832
- # Output collection queues
833
- stdout_queue = queue.Queue()
834
- stderr_queue = queue.Queue()
835
-
836
- # Use separate threads to read from stdout and stderr to prevent deadlocks
837
- def read_output(
838
- pipe: t.TextIO,
839
- output_queue: "queue.Queue[str]",
840
- data_collector: list[str],
841
- ) -> None:
842
- try:
843
- for line in iter(pipe.readline, ""):
844
- output_queue.put(line)
845
- data_collector.append(line)
846
- except (OSError, ValueError):
847
- # Pipe has been closed
848
- pass
849
- finally:
850
- pipe.close()
851
-
852
- # Start output reader threads
853
- stdout_thread = threading.Thread(
854
- target=read_output,
855
- args=(process.stdout, stdout_queue, stdout_data),
856
- daemon=True,
857
- )
858
- stderr_thread = threading.Thread(
859
- target=read_output,
860
- args=(process.stderr, stderr_queue, stderr_data),
861
- daemon=True,
862
- )
863
-
864
- stdout_thread.start()
865
- stderr_thread.start()
866
-
867
- # Start time for timeout tracking
868
- start_time = time.time()
869
-
870
- # Process is running, monitor and display output until completion or timeout
871
- while process.poll() is None:
872
- # Check for timeout
873
- elapsed = time.time() - start_time
874
- if elapsed > global_timeout:
875
- error = ExecutionError(
876
- message=f"Test execution timed out after {global_timeout // 60} minutes.",
877
- error_code=ErrorCode.COMMAND_TIMEOUT,
878
- details=f"Command: {' '.join(test_command)}\nTimeout: {global_timeout} seconds",
879
- recovery="Check for infinite loops or deadlocks in your tests. Consider increasing the timeout or optimizing your tests.",
880
- )
881
-
882
- self.console.print(
883
- f"[red]Test execution timed out after {global_timeout // 60} minutes. Terminating...[/red]"
884
- )
885
- process.terminate()
886
- try:
887
- process.wait(timeout=5)
888
- except subprocess.TimeoutExpired:
889
- process.kill()
890
- stderr_data.append(
891
- "Process had to be forcefully terminated after timeout."
892
- )
893
- break
894
-
895
- # Print any available output
896
- self._process_output_queue(stdout_queue, stderr_queue)
897
-
898
- # Small sleep to avoid CPU spinning but still be responsive
899
- time.sleep(0.05)
900
-
901
- # Periodically output a heartbeat for very long-running tests
902
- if elapsed > 60 and elapsed % 60 < 0.1: # Roughly every minute
903
- self.console.print(
904
- f"[blue]Tests still running, elapsed time: {int(elapsed)} seconds...[/blue]"
905
- )
906
-
907
- # Process has exited, get remaining output
908
- time.sleep(0.1) # Allow threads to flush final output
909
- self._process_output_queue(stdout_queue, stderr_queue)
910
-
911
- # Ensure threads are done
912
- if stdout_thread.is_alive():
913
- stdout_thread.join(1.0)
914
- if stderr_thread.is_alive():
915
- stderr_thread.join(1.0)
916
-
917
- returncode = process.returncode or 0
918
- stdout = "".join(stdout_data)
919
- stderr = "".join(stderr_data)
920
-
921
- return subprocess.CompletedProcess(
922
- args=test_command, returncode=returncode, stdout=stdout, stderr=stderr
923
- )
924
-
925
- except Exception as e:
926
- error = ExecutionError(
927
- message=f"Error running tests: {e}",
928
- error_code=ErrorCode.TEST_EXECUTION_ERROR,
929
- details=f"Command: {' '.join(test_command)}\nError: {e}",
930
- recovery="Check if pytest is installed and that your test files are properly formatted.",
931
- exit_code=1,
932
- )
933
-
934
- # Don't exit here, let the caller handle it
935
- handle_error(
936
- error=error, console=self.console, verbose=True, exit_on_error=False
937
- )
938
-
939
- return subprocess.CompletedProcess(test_command, 1, "", str(e))
940
-
941
- def _process_output_queue(
942
- self, stdout_queue: "queue.Queue[str]", stderr_queue: "queue.Queue[str]"
943
- ) -> None:
944
- """Process and display output from the queues without blocking."""
945
- # Process stdout
946
- while not stdout_queue.empty():
947
- try:
948
- line = stdout_queue.get_nowait()
949
- if line:
950
- self.console.print(line, end="")
951
- except queue.Empty:
952
- break
953
-
954
- # Process stderr
955
- while not stderr_queue.empty():
956
- try:
957
- line = stderr_queue.get_nowait()
958
- if line:
959
- self.console.print(f"[red]{line}[/red]", end="")
960
- except queue.Empty:
961
- break
962
-
963
- def _report_test_results(
964
- self, result: subprocess.CompletedProcess[str], ai_agent: str
965
- ) -> None:
966
- from .errors import ErrorCode, TestError, handle_error
967
-
968
- if result.returncode > 0:
969
- error_details = None
970
- if result.stderr:
971
- self.console.print(result.stderr)
972
- error_details = result.stderr
973
-
974
- if ai_agent:
975
- self.console.print(
976
- '[json]{"status": "failed", "action": "tests", "returncode": '
977
- + str(result.returncode)
978
- + "}[/json]"
979
- )
980
- else:
981
- # Use the structured error handler
982
- error = TestError(
983
- message="Tests failed. Please fix the errors.",
984
- error_code=ErrorCode.TEST_FAILURE,
985
- details=error_details,
986
- recovery="Review the test output above for specific failures. Fix the issues in your code and run tests again.",
987
- exit_code=1,
988
- )
989
- handle_error(
990
- error=error,
991
- console=self.console,
992
- ai_agent=(ai_agent != ""),
993
- )
994
-
995
- if ai_agent:
996
- self.console.print('[json]{"status": "success", "action": "tests"}[/json]')
997
- else:
998
- self.console.print("\n\n✅ Tests passed successfully!\n")
999
-
1000
- def _run_tests(self, options: OptionsProtocol) -> None:
528
+ def _run_tests(self, options: t.Any) -> None:
1001
529
  if options.test:
1002
- ai_agent = os.environ.get("AI_AGENT", "")
1003
- if ai_agent:
1004
- self.console.print(
1005
- '[json]{"status": "running", "action": "tests"}[/json]'
1006
- )
1007
- else:
1008
- self.console.print("\n\nRunning tests...\n")
530
+ self.console.print("\n\nRunning tests...\n")
1009
531
  test_command = self._prepare_pytest_command(options)
1010
- self._setup_test_environment()
1011
- result = self._run_pytest_process(test_command)
1012
- self._report_test_results(result, ai_agent)
532
+ result = self.execute_command(test_command, capture_output=True, text=True)
533
+ if result.stdout:
534
+ self.console.print(result.stdout)
535
+ if result.returncode > 0:
536
+ if result.stderr:
537
+ self.console.print(result.stderr)
538
+ self.console.print("\n\n❌ Tests failed. Please fix errors.\n")
539
+ return
540
+ self.console.print("\n\n✅ Tests passed successfully!\n")
1013
541
 
1014
542
  def _bump_version(self, options: OptionsProtocol) -> None:
1015
543
  for option in (options.publish, options.bump):
@@ -1018,79 +546,16 @@ class Crackerjack:
1018
546
  break
1019
547
 
1020
548
  def _publish_project(self, options: OptionsProtocol) -> None:
1021
- from .errors import ErrorCode, PublishError, handle_error
1022
-
1023
549
  if options.publish:
1024
- if platform.system() == "Darwin":
1025
- # First check if keyring is already installed in PDM
1026
- check_keyring = self.execute_command(
1027
- ["pdm", "self", "list"], capture_output=True, text=True
1028
- )
1029
- keyring_installed = "keyring" in check_keyring.stdout
1030
-
1031
- if not keyring_installed:
1032
- # Only attempt to install keyring if it's not already installed
1033
- self.console.print("Installing keyring for PDM...")
1034
- authorize = self.execute_command(
1035
- ["pdm", "self", "add", "keyring"],
1036
- capture_output=True,
1037
- text=True,
1038
- )
1039
- if authorize.returncode > 0:
1040
- error = PublishError(
1041
- message="Authentication setup failed",
1042
- error_code=ErrorCode.AUTHENTICATION_ERROR,
1043
- details=f"Failed to add keyring support to PDM.\nCommand output:\n{authorize.stderr}",
1044
- recovery="Please manually add your keyring credentials to PDM. Run `pdm self add keyring` and try again.",
1045
- exit_code=1,
1046
- )
1047
- handle_error(
1048
- error=error,
1049
- console=self.console,
1050
- verbose=options.verbose,
1051
- ai_agent=options.ai_agent,
1052
- )
1053
-
1054
550
  build = self.execute_command(
1055
551
  ["pdm", "build"], capture_output=True, text=True
1056
552
  )
1057
553
  self.console.print(build.stdout)
1058
-
1059
554
  if build.returncode > 0:
1060
- error = PublishError(
1061
- message="Package build failed",
1062
- error_code=ErrorCode.BUILD_ERROR,
1063
- details=f"Command output:\n{build.stderr}",
1064
- recovery="Review the error message above for details. Common issues include missing dependencies, invalid project structure, or incorrect metadata in pyproject.toml.",
1065
- exit_code=1,
1066
- )
1067
- handle_error(
1068
- error=error,
1069
- console=self.console,
1070
- verbose=options.verbose,
1071
- ai_agent=options.ai_agent,
1072
- )
1073
-
1074
- publish_result = self.execute_command(
1075
- ["pdm", "publish", "--no-build"], capture_output=True, text=True
1076
- )
1077
-
1078
- if publish_result.returncode > 0:
1079
- error = PublishError(
1080
- message="Package publication failed",
1081
- error_code=ErrorCode.PUBLISH_ERROR,
1082
- details=f"Command output:\n{publish_result.stderr}",
1083
- recovery="Ensure you have the correct PyPI credentials configured. Check your internet connection and that the package name is available on PyPI.",
1084
- exit_code=1,
1085
- )
1086
- handle_error(
1087
- error=error,
1088
- console=self.console,
1089
- verbose=options.verbose,
1090
- ai_agent=options.ai_agent,
1091
- )
1092
- else:
1093
- self.console.print("[green]✅ Package published successfully![/green]")
555
+ self.console.print(build.stderr)
556
+ self.console.print("\n\nBuild failed. Please fix errors.\n")
557
+ raise SystemExit(1)
558
+ self.execute_command(["pdm", "publish", "--no-build"])
1094
559
 
1095
560
  def _commit_and_push(self, options: OptionsProtocol) -> None:
1096
561
  if options.commit:
@@ -1100,131 +565,6 @@ class Crackerjack:
1100
565
  )
1101
566
  self.execute_command(["git", "push", "origin", "main"])
1102
567
 
1103
- def _create_pull_request(self, options: OptionsProtocol) -> None:
1104
- if options.create_pr:
1105
- self.console.print("\nCreating pull request...")
1106
- current_branch = self.execute_command(
1107
- ["git", "branch", "--show-current"], capture_output=True, text=True
1108
- ).stdout.strip()
1109
- remote_url = self.execute_command(
1110
- ["git", "remote", "get-url", "origin"], capture_output=True, text=True
1111
- ).stdout.strip()
1112
- is_github = "github.com" in remote_url
1113
- is_gitlab = "gitlab.com" in remote_url
1114
- if is_github:
1115
- gh_installed = (
1116
- self.execute_command(
1117
- ["which", "gh"], capture_output=True, text=True
1118
- ).returncode
1119
- == 0
1120
- )
1121
- if not gh_installed:
1122
- self.console.print(
1123
- "\n[red]GitHub CLI (gh) is not installed. Please install it first:[/red]\n"
1124
- " brew install gh # for macOS\n"
1125
- " or visit https://cli.github.com/ for other installation methods"
1126
- )
1127
- return
1128
- auth_status = self.execute_command(
1129
- ["gh", "auth", "status"], capture_output=True, text=True
1130
- ).returncode
1131
- if auth_status != 0:
1132
- self.console.print(
1133
- "\n[red]You need to authenticate with GitHub first. Run:[/red]\n"
1134
- " gh auth login"
1135
- )
1136
- return
1137
- pr_title = input("\nEnter a title for your pull request: ")
1138
- self.console.print(
1139
- "Enter a description for your pull request (press Ctrl+D when done):"
1140
- )
1141
- pr_description = ""
1142
- with suppress(EOFError):
1143
- pr_description = "".join(iter(input, ""))
1144
- self.console.print("Creating pull request to GitHub repository...")
1145
- result = self.execute_command(
1146
- [
1147
- "gh",
1148
- "pr",
1149
- "create",
1150
- "--title",
1151
- pr_title,
1152
- "--body",
1153
- pr_description,
1154
- ],
1155
- capture_output=True,
1156
- text=True,
1157
- )
1158
- if result.returncode == 0:
1159
- self.console.print(
1160
- f"\n[green]Pull request created successfully![/green]\n{result.stdout}"
1161
- )
1162
- else:
1163
- self.console.print(
1164
- f"\n[red]Failed to create pull request:[/red]\n{result.stderr}"
1165
- )
1166
- elif is_gitlab:
1167
- glab_installed = (
1168
- self.execute_command(
1169
- ["which", "glab"], capture_output=True, text=True
1170
- ).returncode
1171
- == 0
1172
- )
1173
- if not glab_installed:
1174
- self.console.print(
1175
- "\n[red]GitLab CLI (glab) is not installed. Please install it first:[/red]\n"
1176
- " brew install glab # for macOS\n"
1177
- " or visit https://gitlab.com/gitlab-org/cli for other installation methods"
1178
- )
1179
- return
1180
- auth_status = self.execute_command(
1181
- ["glab", "auth", "status"], capture_output=True, text=True
1182
- ).returncode
1183
- if auth_status != 0:
1184
- self.console.print(
1185
- "\n[red]You need to authenticate with GitLab first. Run:[/red]\n"
1186
- " glab auth login"
1187
- )
1188
- return
1189
- mr_title = input("\nEnter a title for your merge request: ")
1190
- self.console.print(
1191
- "Enter a description for your merge request (press Ctrl+D when done):"
1192
- )
1193
- mr_description = ""
1194
- with suppress(EOFError):
1195
- mr_description = "".join(iter(input, ""))
1196
- self.console.print("Creating merge request to GitLab repository...")
1197
- result = self.execute_command(
1198
- [
1199
- "glab",
1200
- "mr",
1201
- "create",
1202
- "--title",
1203
- mr_title,
1204
- "--description",
1205
- mr_description,
1206
- "--source-branch",
1207
- current_branch,
1208
- "--target-branch",
1209
- "main",
1210
- ],
1211
- capture_output=True,
1212
- text=True,
1213
- )
1214
- if result.returncode == 0:
1215
- self.console.print(
1216
- f"\n[green]Merge request created successfully![/green]\n{result.stdout}"
1217
- )
1218
- else:
1219
- self.console.print(
1220
- f"\n[red]Failed to create merge request:[/red]\n{result.stderr}"
1221
- )
1222
- else:
1223
- self.console.print(
1224
- f"\n[red]Unsupported git hosting service: {remote_url}[/red]\n"
1225
- "This command currently supports GitHub and GitLab."
1226
- )
1227
-
1228
568
  def execute_command(
1229
569
  self, cmd: list[str], **kwargs: t.Any
1230
570
  ) -> subprocess.CompletedProcess[str]:
@@ -1234,71 +574,28 @@ class Crackerjack:
1234
574
  return execute(cmd, **kwargs)
1235
575
 
1236
576
  def process(self, options: OptionsProtocol) -> None:
1237
- actions_performed = []
1238
577
  if options.all:
1239
578
  options.clean = True
1240
579
  options.test = True
1241
580
  options.publish = options.all
1242
581
  options.commit = True
1243
-
1244
582
  self._setup_package()
1245
- actions_performed.append("setup_package")
1246
-
1247
583
  self._update_project(options)
1248
- actions_performed.append("update_project")
1249
-
1250
584
  self._update_precommit(options)
1251
- if options.update_precommit:
1252
- actions_performed.append("update_precommit")
1253
-
1254
585
  self._run_interactive_hooks(options)
1255
- if options.interactive:
1256
- actions_performed.append("run_interactive_hooks")
1257
-
1258
586
  self._clean_project(options)
1259
- if options.clean:
1260
- actions_performed.append("clean_project")
1261
-
1262
587
  if not options.skip_hooks:
1263
588
  self.project_manager.run_pre_commit()
1264
- actions_performed.append("run_pre_commit")
1265
589
  else:
1266
- self.console.print(
1267
- "\n[yellow]Skipping pre-commit hooks as requested[/yellow]\n"
1268
- )
1269
- actions_performed.append("skip_pre_commit")
1270
-
590
+ self.console.print("Skipping pre-commit hooks")
1271
591
  self._run_tests(options)
1272
- if options.test:
1273
- actions_performed.append("run_tests")
1274
-
1275
592
  self._bump_version(options)
1276
- if options.bump or options.publish:
1277
- actions_performed.append("bump_version")
1278
-
1279
593
  self._publish_project(options)
1280
- if options.publish:
1281
- actions_performed.append("publish_project")
1282
-
1283
594
  self._commit_and_push(options)
1284
- if options.commit:
1285
- actions_performed.append("commit_and_push")
595
+ self.console.print("\n🍺 Crackerjack complete!\n")
1286
596
 
1287
- self._create_pull_request(options)
1288
- if options.create_pr:
1289
- actions_performed.append("create_pull_request")
1290
597
 
1291
- if getattr(options, "ai_agent", False):
1292
- import json
1293
-
1294
- result = {
1295
- "status": "complete",
1296
- "package": self.pkg_name,
1297
- "actions": actions_performed,
1298
- }
1299
- self.console.print(f"[json]{json.dumps(result)}[/json]")
1300
- else:
1301
- self.console.print("\n🍺 Crackerjack complete!\n")
598
+ crackerjack_it = Crackerjack().process
1302
599
 
1303
600
 
1304
601
  def create_crackerjack_runner(