crackerjack 0.20.7__py3-none-any.whl → 0.20.11__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):
@@ -1017,269 +545,17 @@ class Crackerjack:
1017
545
  self.execute_command(["pdm", "bump", option])
1018
546
  break
1019
547
 
1020
- def _ensure_keyring_installed(self, options: OptionsProtocol) -> None:
1021
- """Ensure keyring is installed for PDM on macOS."""
1022
- from .errors import ErrorCode, PublishError, handle_error
1023
-
1024
- if platform.system() != "Darwin":
1025
- return
1026
-
1027
- # Check if keyring is already installed in PDM
1028
- check_keyring = self.execute_command(
1029
- ["pdm", "self", "list"], capture_output=True, text=True
1030
- )
1031
- keyring_installed = "keyring" in check_keyring.stdout
1032
-
1033
- if not keyring_installed:
1034
- # Only attempt to install keyring if it's not already installed
1035
- self.console.print("Installing keyring for PDM...")
1036
- authorize = self.execute_command(
1037
- ["pdm", "self", "add", "keyring"],
1038
- capture_output=True,
1039
- text=True,
1040
- )
1041
- if authorize.returncode > 0:
1042
- error = PublishError(
1043
- message="Authentication setup failed",
1044
- error_code=ErrorCode.AUTHENTICATION_ERROR,
1045
- details=f"Failed to add keyring support to PDM.\nCommand output:\n{authorize.stderr}",
1046
- recovery="Please manually add your keyring credentials to PDM. Run `pdm self add keyring` and try again.",
1047
- exit_code=1,
1048
- )
1049
- handle_error(
1050
- error=error,
1051
- console=self.console,
1052
- verbose=options.verbose,
1053
- ai_agent=options.ai_agent,
1054
- )
1055
-
1056
- def _build_package(self, options: OptionsProtocol) -> None:
1057
- """Build the package using PDM."""
1058
- from .errors import ErrorCode, PublishError, handle_error
1059
-
1060
- build = self.execute_command(["pdm", "build"], capture_output=True, text=True)
1061
- self.console.print(build.stdout)
1062
-
1063
- if build.returncode > 0:
1064
- error = PublishError(
1065
- message="Package build failed",
1066
- error_code=ErrorCode.BUILD_ERROR,
1067
- details=f"Command output:\n{build.stderr}",
1068
- recovery="Review the error message above for details. Common issues include missing dependencies, invalid project structure, or incorrect metadata in pyproject.toml.",
1069
- exit_code=1,
1070
- )
1071
- handle_error(
1072
- error=error,
1073
- console=self.console,
1074
- verbose=options.verbose,
1075
- ai_agent=options.ai_agent,
1076
- )
1077
-
1078
- def _prepare_publish_command(self) -> list[str]:
1079
- """Prepare the PDM publish command with appropriate flags."""
1080
- # Prepare the publish command
1081
- publish_cmd = ["pdm", "publish", "--no-build"]
1082
-
1083
- # Check if we're running in a CI environment
1084
- is_ci = any(env in os.environ for env in ("CI", "GITHUB_ACTIONS", "GITLAB_CI"))
1085
-
1086
- # If in CI environment, check if OIDC is likely to be the issue
1087
- if is_ci:
1088
- self.console.print("[yellow]Detected CI environment[/yellow]")
1089
-
1090
- # Check for required OIDC-related environment variables
1091
- if "GITHUB_ACTIONS" not in os.environ and "GITLAB_CI" not in os.environ:
1092
- self.console.print(
1093
- "[yellow]Non-GitHub/GitLab CI environment detected[/yellow]"
1094
- )
1095
- # Don't add --no-oidc flag as it may not be supported by all PDM versions
1096
- elif "ACTIONS_ID_TOKEN_REQUEST_URL" not in os.environ:
1097
- self.console.print("[yellow]OIDC token request URL not found[/yellow]")
1098
- # Don't add --no-oidc flag as it may not be supported by all PDM versions
1099
-
1100
- # Check for API token in environment regardless of OIDC status
1101
- self._check_for_token_env_vars()
1102
-
1103
- return publish_cmd
1104
-
1105
- def _check_for_token_env_vars(self) -> None:
1106
- """Check for token environment variables."""
1107
- for token_var in ("PYPI_TOKEN", "PYPI_API_TOKEN", "TWINE_PASSWORD"):
1108
- if token_var in os.environ:
1109
- self.console.print(
1110
- f"[yellow]Found {token_var} environment variable, will use for authentication[/yellow]"
1111
- )
1112
- # If we have a token, PDM will use it automatically
1113
- break
1114
- else:
1115
- self.console.print(
1116
- "[yellow]No PyPI token found in environment variables[/yellow]"
1117
- )
1118
-
1119
- def _check_keyring_credentials(self) -> None:
1120
- """Check for PyPI credentials in keyring."""
1121
- # First try to verify if keyring has PyPI credentials, wrapped in double try/except
1122
- # to handle both ImportError and keyring backend errors
1123
- try:
1124
- try:
1125
- import keyring
1126
-
1127
- username = keyring.get_password("pdm-publish", "username")
1128
- token = (
1129
- keyring.get_password("pdm-publish", username) if username else None
1130
- )
1131
-
1132
- if username and token:
1133
- self.console.print(
1134
- f"[green]Found PyPI credentials in keyring for user: {username}[/green]"
1135
- )
1136
- else:
1137
- self.console.print(
1138
- "[yellow]Warning: Could not find PyPI credentials in keyring.[/yellow]"
1139
- )
1140
- self.console.print(
1141
- "[yellow]You might be prompted for username and password.[/yellow]"
1142
- )
1143
- except ImportError:
1144
- self.console.print(
1145
- "[yellow]Warning: Could not import keyring module to check credentials.[/yellow]"
1146
- )
1147
- except Exception as e:
1148
- # Catch any keyring-related exceptions, including NoKeyringError
1149
- self.console.print(f"[yellow]Warning: Keyring error: {e}[/yellow]")
1150
- self.console.print(
1151
- "[yellow]Will use PDM's built-in credential handling.[/yellow]"
1152
- )
1153
-
1154
- def _execute_publish_command(
1155
- self, publish_cmd: list[str], options: OptionsProtocol
1156
- ) -> None:
1157
- """Execute the publish command and handle OIDC errors."""
1158
- from .errors import (
1159
- ErrorCode,
1160
- ExecutionError,
1161
- PublishError,
1162
- check_command_result,
1163
- handle_error,
1164
- )
1165
-
1166
- # Log the exact command we're running
1167
- self.console.print(f"[yellow]Running command: {' '.join(publish_cmd)}[/yellow]")
1168
-
1169
- # Execute the publish command with detailed output
1170
- publish_result = self.execute_command(
1171
- publish_cmd, capture_output=True, text=True
1172
- )
1173
-
1174
- # Print the command's stdout and stderr for debugging
1175
- self.console.print(
1176
- f"[yellow]Debug: publish stdout:[/yellow]\n{publish_result.stdout}"
1177
- )
1178
- self.console.print(
1179
- f"[yellow]Debug: publish stderr:[/yellow]\n{publish_result.stderr}"
1180
- )
1181
- self.console.print(
1182
- f"[yellow]Debug: publish return code: {publish_result.returncode}[/yellow]"
1183
- )
1184
-
1185
- # If first attempt failed with OIDC error, try an alternative approach
1186
- if publish_result.returncode > 0 and "OIDC" in publish_result.stderr:
1187
- self.console.print(
1188
- "[yellow]Detected OIDC error, OIDC may not be supported on this platform[/yellow]"
1189
- )
1190
-
1191
- # Provide a more helpful error message
1192
- error = PublishError(
1193
- message="Package publication failed - OIDC authentication issue",
1194
- error_code=ErrorCode.AUTHENTICATION_ERROR,
1195
- details=f"OIDC authentication is not supported on this platform or configuration.\n\nCommand output:\n{publish_result.stderr}",
1196
- recovery="Consider manually setting PyPI credentials using the keyring or environment variables. Run 'pdm config pypi.username YOUR_USERNAME' and 'pdm config pypi.password YOUR_PASSWORD' to configure credentials.",
1197
- exit_code=1,
1198
- )
1199
- handle_error(
1200
- error=error,
1201
- console=self.console,
1202
- verbose=options.verbose,
1203
- ai_agent=options.ai_agent,
1204
- )
1205
- return
1206
-
1207
- # Check the command result and raise an error if it failed
1208
- try:
1209
- cmd_str = " ".join(publish_cmd)
1210
- check_command_result(
1211
- publish_result,
1212
- cmd_str,
1213
- "Package publication failed",
1214
- ErrorCode.PUBLISH_ERROR,
1215
- "Ensure you have the correct PyPI credentials configured. Check your internet connection and that the package name is available on PyPI.",
1216
- )
1217
- except ExecutionError as e:
1218
- # Convert to PublishError for consistent error handling
1219
- error = PublishError(
1220
- message=e.message,
1221
- error_code=ErrorCode.PUBLISH_ERROR,
1222
- details=e.details,
1223
- recovery=e.recovery,
1224
- exit_code=1,
1225
- )
1226
- handle_error(
1227
- error=error,
1228
- console=self.console,
1229
- verbose=options.verbose,
1230
- ai_agent=options.ai_agent,
1231
- )
1232
-
1233
548
  def _publish_project(self, options: OptionsProtocol) -> None:
1234
- """Publish the package to PyPI."""
1235
- from .errors import ErrorCode, PublishError, handle_error
1236
-
1237
- if not options.publish:
1238
- return
1239
-
1240
- try:
1241
- # Step 1: Ensure keyring is installed (on macOS)
1242
- self._ensure_keyring_installed(options)
1243
-
1244
- # Step 2: Build the package
1245
- self._build_package(options)
1246
-
1247
- # Print debug info before executing publish command
1248
- self.console.print(
1249
- "[yellow]Debug: About to execute PDM publish command[/yellow]"
1250
- )
1251
-
1252
- # Step 3: Setup environment variables for authentication
1253
- os.environ["PDM_USE_KEYRING"] = "1"
1254
-
1255
- # Step 4: Prepare publish command with appropriate flags
1256
- publish_cmd = self._prepare_publish_command()
1257
-
1258
- # Step 5: Check keyring credentials
1259
- self._check_keyring_credentials()
1260
-
1261
- # Step 6: Execute publish command and handle OIDC errors
1262
- self._execute_publish_command(publish_cmd, options)
1263
-
1264
- # Success message
1265
- self.console.print("[green]✅ Package published successfully![/green]")
1266
-
1267
- except Exception as e:
1268
- # Catch any other unexpected errors
1269
- self.console.print(f"[red]Debug: Unexpected error: {e}[/red]")
1270
- error = PublishError(
1271
- message="Package publication failed",
1272
- error_code=ErrorCode.PUBLISH_ERROR,
1273
- details=f"Unexpected error: {e}",
1274
- recovery="This is an unexpected error. Please report this issue.",
1275
- exit_code=1,
1276
- )
1277
- handle_error(
1278
- error=error,
1279
- console=self.console,
1280
- verbose=options.verbose,
1281
- ai_agent=options.ai_agent,
549
+ if options.publish:
550
+ build = self.execute_command(
551
+ ["pdm", "build"], capture_output=True, text=True
1282
552
  )
553
+ self.console.print(build.stdout)
554
+ if build.returncode > 0:
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"])
1283
559
 
1284
560
  def _commit_and_push(self, options: OptionsProtocol) -> None:
1285
561
  if options.commit:
@@ -1289,131 +565,6 @@ class Crackerjack:
1289
565
  )
1290
566
  self.execute_command(["git", "push", "origin", "main"])
1291
567
 
1292
- def _create_pull_request(self, options: OptionsProtocol) -> None:
1293
- if options.create_pr:
1294
- self.console.print("\nCreating pull request...")
1295
- current_branch = self.execute_command(
1296
- ["git", "branch", "--show-current"], capture_output=True, text=True
1297
- ).stdout.strip()
1298
- remote_url = self.execute_command(
1299
- ["git", "remote", "get-url", "origin"], capture_output=True, text=True
1300
- ).stdout.strip()
1301
- is_github = "github.com" in remote_url
1302
- is_gitlab = "gitlab.com" in remote_url
1303
- if is_github:
1304
- gh_installed = (
1305
- self.execute_command(
1306
- ["which", "gh"], capture_output=True, text=True
1307
- ).returncode
1308
- == 0
1309
- )
1310
- if not gh_installed:
1311
- self.console.print(
1312
- "\n[red]GitHub CLI (gh) is not installed. Please install it first:[/red]\n"
1313
- " brew install gh # for macOS\n"
1314
- " or visit https://cli.github.com/ for other installation methods"
1315
- )
1316
- return
1317
- auth_status = self.execute_command(
1318
- ["gh", "auth", "status"], capture_output=True, text=True
1319
- ).returncode
1320
- if auth_status != 0:
1321
- self.console.print(
1322
- "\n[red]You need to authenticate with GitHub first. Run:[/red]\n"
1323
- " gh auth login"
1324
- )
1325
- return
1326
- pr_title = input("\nEnter a title for your pull request: ")
1327
- self.console.print(
1328
- "Enter a description for your pull request (press Ctrl+D when done):"
1329
- )
1330
- pr_description = ""
1331
- with suppress(EOFError):
1332
- pr_description = "".join(iter(input, ""))
1333
- self.console.print("Creating pull request to GitHub repository...")
1334
- result = self.execute_command(
1335
- [
1336
- "gh",
1337
- "pr",
1338
- "create",
1339
- "--title",
1340
- pr_title,
1341
- "--body",
1342
- pr_description,
1343
- ],
1344
- capture_output=True,
1345
- text=True,
1346
- )
1347
- if result.returncode == 0:
1348
- self.console.print(
1349
- f"\n[green]Pull request created successfully![/green]\n{result.stdout}"
1350
- )
1351
- else:
1352
- self.console.print(
1353
- f"\n[red]Failed to create pull request:[/red]\n{result.stderr}"
1354
- )
1355
- elif is_gitlab:
1356
- glab_installed = (
1357
- self.execute_command(
1358
- ["which", "glab"], capture_output=True, text=True
1359
- ).returncode
1360
- == 0
1361
- )
1362
- if not glab_installed:
1363
- self.console.print(
1364
- "\n[red]GitLab CLI (glab) is not installed. Please install it first:[/red]\n"
1365
- " brew install glab # for macOS\n"
1366
- " or visit https://gitlab.com/gitlab-org/cli for other installation methods"
1367
- )
1368
- return
1369
- auth_status = self.execute_command(
1370
- ["glab", "auth", "status"], capture_output=True, text=True
1371
- ).returncode
1372
- if auth_status != 0:
1373
- self.console.print(
1374
- "\n[red]You need to authenticate with GitLab first. Run:[/red]\n"
1375
- " glab auth login"
1376
- )
1377
- return
1378
- mr_title = input("\nEnter a title for your merge request: ")
1379
- self.console.print(
1380
- "Enter a description for your merge request (press Ctrl+D when done):"
1381
- )
1382
- mr_description = ""
1383
- with suppress(EOFError):
1384
- mr_description = "".join(iter(input, ""))
1385
- self.console.print("Creating merge request to GitLab repository...")
1386
- result = self.execute_command(
1387
- [
1388
- "glab",
1389
- "mr",
1390
- "create",
1391
- "--title",
1392
- mr_title,
1393
- "--description",
1394
- mr_description,
1395
- "--source-branch",
1396
- current_branch,
1397
- "--target-branch",
1398
- "main",
1399
- ],
1400
- capture_output=True,
1401
- text=True,
1402
- )
1403
- if result.returncode == 0:
1404
- self.console.print(
1405
- f"\n[green]Merge request created successfully![/green]\n{result.stdout}"
1406
- )
1407
- else:
1408
- self.console.print(
1409
- f"\n[red]Failed to create merge request:[/red]\n{result.stderr}"
1410
- )
1411
- else:
1412
- self.console.print(
1413
- f"\n[red]Unsupported git hosting service: {remote_url}[/red]\n"
1414
- "This command currently supports GitHub and GitLab."
1415
- )
1416
-
1417
568
  def execute_command(
1418
569
  self, cmd: list[str], **kwargs: t.Any
1419
570
  ) -> subprocess.CompletedProcess[str]:
@@ -1423,71 +574,28 @@ class Crackerjack:
1423
574
  return execute(cmd, **kwargs)
1424
575
 
1425
576
  def process(self, options: OptionsProtocol) -> None:
1426
- actions_performed = []
1427
577
  if options.all:
1428
578
  options.clean = True
1429
579
  options.test = True
1430
580
  options.publish = options.all
1431
581
  options.commit = True
1432
-
1433
582
  self._setup_package()
1434
- actions_performed.append("setup_package")
1435
-
1436
583
  self._update_project(options)
1437
- actions_performed.append("update_project")
1438
-
1439
584
  self._update_precommit(options)
1440
- if options.update_precommit:
1441
- actions_performed.append("update_precommit")
1442
-
1443
585
  self._run_interactive_hooks(options)
1444
- if options.interactive:
1445
- actions_performed.append("run_interactive_hooks")
1446
-
1447
586
  self._clean_project(options)
1448
- if options.clean:
1449
- actions_performed.append("clean_project")
1450
-
1451
587
  if not options.skip_hooks:
1452
588
  self.project_manager.run_pre_commit()
1453
- actions_performed.append("run_pre_commit")
1454
589
  else:
1455
- self.console.print(
1456
- "\n[yellow]Skipping pre-commit hooks as requested[/yellow]\n"
1457
- )
1458
- actions_performed.append("skip_pre_commit")
1459
-
590
+ self.console.print("Skipping pre-commit hooks")
1460
591
  self._run_tests(options)
1461
- if options.test:
1462
- actions_performed.append("run_tests")
1463
-
1464
592
  self._bump_version(options)
1465
- if options.bump or options.publish:
1466
- actions_performed.append("bump_version")
1467
-
1468
593
  self._publish_project(options)
1469
- if options.publish:
1470
- actions_performed.append("publish_project")
1471
-
1472
594
  self._commit_and_push(options)
1473
- if options.commit:
1474
- actions_performed.append("commit_and_push")
595
+ self.console.print("\n🍺 Crackerjack complete!\n")
1475
596
 
1476
- self._create_pull_request(options)
1477
- if options.create_pr:
1478
- actions_performed.append("create_pull_request")
1479
597
 
1480
- if getattr(options, "ai_agent", False):
1481
- import json
1482
-
1483
- result = {
1484
- "status": "complete",
1485
- "package": self.pkg_name,
1486
- "actions": actions_performed,
1487
- }
1488
- self.console.print(f"[json]{json.dumps(result)}[/json]")
1489
- else:
1490
- self.console.print("\n🍺 Crackerjack complete!\n")
598
+ crackerjack_it = Crackerjack().process
1491
599
 
1492
600
 
1493
601
  def create_crackerjack_runner(