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.
- crackerjack/.ruff_cache/0.11.13/1867267426380906393 +0 -0
- crackerjack/__init__.py +0 -2
- crackerjack/__main__.py +2 -11
- crackerjack/crackerjack.py +191 -1083
- crackerjack/errors.py +0 -20
- crackerjack/interactive.py +38 -121
- crackerjack/py313.py +10 -50
- crackerjack/pyproject.toml +1 -1
- {crackerjack-0.20.7.dist-info → crackerjack-0.20.11.dist-info}/METADATA +1 -1
- {crackerjack-0.20.7.dist-info → crackerjack-0.20.11.dist-info}/RECORD +13 -13
- {crackerjack-0.20.7.dist-info → crackerjack-0.20.11.dist-info}/WHEEL +0 -0
- {crackerjack-0.20.7.dist-info → crackerjack-0.20.11.dist-info}/entry_points.txt +0 -0
- {crackerjack-0.20.7.dist-info → crackerjack-0.20.11.dist-info}/licenses/LICENSE +0 -0
crackerjack/crackerjack.py
CHANGED
@@ -1,23 +1,16 @@
|
|
1
|
-
import
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
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
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
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
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
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
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
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
|
261
|
-
from .errors import
|
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
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
)
|
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
|
-
|
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
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
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
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
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
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
488
|
-
|
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
|
-
|
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
|
-
|
509
|
-
|
510
|
-
|
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
|
-
|
561
|
-
|
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
|
-
|
579
|
-
|
580
|
-
|
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 =
|
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
|
592
|
-
|
593
|
-
|
594
|
-
|
595
|
-
self.
|
596
|
-
|
597
|
-
|
598
|
-
|
599
|
-
|
600
|
-
|
601
|
-
|
602
|
-
|
603
|
-
|
604
|
-
self.
|
605
|
-
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
|
610
|
-
|
611
|
-
|
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:
|
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
|
-
|
636
|
-
|
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:
|
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:
|
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:
|
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
|
-
|
666
|
-
|
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",
|
709
|
-
"--tb=short",
|
710
|
-
"--no-header",
|
711
|
-
"--disable-warnings",
|
712
|
-
"--durations=0",
|
713
|
-
f"--timeout={test_timeout}",
|
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
|
-
|
734
|
-
|
735
|
-
|
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
|
-
|
745
|
-
|
746
|
-
|
747
|
-
|
748
|
-
|
749
|
-
|
750
|
-
|
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
|
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
|
-
|
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.
|
1011
|
-
result
|
1012
|
-
|
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
|
-
|
1235
|
-
|
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
|
-
|
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
|
-
|
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(
|