crackerjack 0.20.7__tar.gz → 0.20.11__tar.gz

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.
Files changed (87) hide show
  1. {crackerjack-0.20.7 → crackerjack-0.20.11}/PKG-INFO +1 -1
  2. crackerjack-0.20.11/crackerjack/.ruff_cache/0.11.13/1867267426380906393 +0 -0
  3. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/__init__.py +0 -2
  4. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/__main__.py +2 -11
  5. crackerjack-0.20.11/crackerjack/crackerjack.py +614 -0
  6. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/errors.py +0 -20
  7. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/interactive.py +38 -121
  8. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/py313.py +10 -50
  9. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/pyproject.toml +1 -1
  10. {crackerjack-0.20.7 → crackerjack-0.20.11}/pyproject.toml +1 -1
  11. {crackerjack-0.20.7 → crackerjack-0.20.11}/tests/conftest.py +2 -14
  12. {crackerjack-0.20.7 → crackerjack-0.20.11}/tests/test_crackerjack.py +160 -324
  13. {crackerjack-0.20.7 → crackerjack-0.20.11}/tests/test_crackerjack_runner.py +36 -56
  14. {crackerjack-0.20.7 → crackerjack-0.20.11}/tests/test_errors.py +15 -28
  15. {crackerjack-0.20.7 → crackerjack-0.20.11}/tests/test_interactive.py +4 -65
  16. {crackerjack-0.20.7 → crackerjack-0.20.11}/tests/test_interactive_run.py +15 -24
  17. {crackerjack-0.20.7 → crackerjack-0.20.11}/tests/test_main.py +0 -5
  18. {crackerjack-0.20.7 → crackerjack-0.20.11}/tests/test_py313_advanced.py +11 -39
  19. {crackerjack-0.20.7 → crackerjack-0.20.11}/tests/test_py313_features.py +1 -44
  20. {crackerjack-0.20.7 → crackerjack-0.20.11}/tests/test_pytest_features.py +0 -4
  21. {crackerjack-0.20.7 → crackerjack-0.20.11}/tests/test_structured_errors.py +83 -62
  22. crackerjack-0.20.7/crackerjack/.ruff_cache/0.11.13/1867267426380906393 +0 -0
  23. crackerjack-0.20.7/crackerjack/crackerjack.py +0 -1506
  24. {crackerjack-0.20.7 → crackerjack-0.20.11}/LICENSE +0 -0
  25. {crackerjack-0.20.7 → crackerjack-0.20.11}/README.md +0 -0
  26. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.gitignore +0 -0
  27. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.libcst.codemod.yaml +0 -0
  28. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.pdm.toml +0 -0
  29. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.pre-commit-config.yaml +0 -0
  30. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.pytest_cache/.gitignore +0 -0
  31. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.pytest_cache/CACHEDIR.TAG +0 -0
  32. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.pytest_cache/README.md +0 -0
  33. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.pytest_cache/v/cache/nodeids +0 -0
  34. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.pytest_cache/v/cache/stepwise +0 -0
  35. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/.gitignore +0 -0
  36. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.1.11/3256171999636029978 +0 -0
  37. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.1.14/602324811142551221 +0 -0
  38. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.1.4/10355199064880463147 +0 -0
  39. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.1.6/15140459877605758699 +0 -0
  40. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.1.7/1790508110482614856 +0 -0
  41. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.1.9/17041001205004563469 +0 -0
  42. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.11.11/18187162184424859798 +0 -0
  43. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.11.12/16869036553936192448 +0 -0
  44. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.11.12/1867267426380906393 +0 -0
  45. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.11.12/4240757255861806333 +0 -0
  46. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.11.12/4441409093023629623 +0 -0
  47. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.11.13/4240757255861806333 +0 -0
  48. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.11.2/4070660268492669020 +0 -0
  49. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.11.3/9818742842212983150 +0 -0
  50. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.11.4/9818742842212983150 +0 -0
  51. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.11.6/3557596832929915217 +0 -0
  52. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.11.7/10386934055395314831 +0 -0
  53. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.11.7/3557596832929915217 +0 -0
  54. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.11.8/530407680854991027 +0 -0
  55. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.2.0/10047773857155985907 +0 -0
  56. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.2.1/8522267973936635051 +0 -0
  57. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.2.2/18053836298936336950 +0 -0
  58. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.3.0/12548816621480535786 +0 -0
  59. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.3.3/11081883392474770722 +0 -0
  60. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.3.4/676973378459347183 +0 -0
  61. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.3.5/16311176246009842383 +0 -0
  62. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.5.7/1493622539551733492 +0 -0
  63. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.5.7/6231957614044513175 +0 -0
  64. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.5.7/9932762556785938009 +0 -0
  65. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.6.0/11982804814124138945 +0 -0
  66. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.6.0/12055761203849489982 +0 -0
  67. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.6.2/1206147804896221174 +0 -0
  68. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.6.4/1206147804896221174 +0 -0
  69. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.6.5/1206147804896221174 +0 -0
  70. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.6.7/3657366982708166874 +0 -0
  71. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.6.9/285614542852677309 +0 -0
  72. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.7.1/1024065805990144819 +0 -0
  73. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.7.1/285614542852677309 +0 -0
  74. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.7.3/16061516852537040135 +0 -0
  75. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.8.4/16354268377385700367 +0 -0
  76. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.9.10/12813592349865671909 +0 -0
  77. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.9.10/923908772239632759 +0 -0
  78. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.9.3/13948373885254993391 +0 -0
  79. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.9.9/12813592349865671909 +0 -0
  80. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/0.9.9/8843823720003377982 +0 -0
  81. {crackerjack-0.20.7 → crackerjack-0.20.11}/crackerjack/.ruff_cache/CACHEDIR.TAG +0 -0
  82. {crackerjack-0.20.7 → crackerjack-0.20.11}/tests/TESTING.md +0 -0
  83. {crackerjack-0.20.7 → crackerjack-0.20.11}/tests/__init__.py +0 -0
  84. {crackerjack-0.20.7 → crackerjack-0.20.11}/tests/data/comments_sample.txt +0 -0
  85. {crackerjack-0.20.7 → crackerjack-0.20.11}/tests/data/docstrings_sample.txt +0 -0
  86. {crackerjack-0.20.7 → crackerjack-0.20.11}/tests/data/expected_comments_sample.txt +0 -0
  87. {crackerjack-0.20.7 → crackerjack-0.20.11}/tests/data/init.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: crackerjack
3
- Version: 0.20.7
3
+ Version: 0.20.11
4
4
  Summary: Crackerjack: code quality toolkit
5
5
  Keywords: bandit,black,creosote,mypy,pyright,pytest,refurb,ruff
6
6
  Author-Email: lesleslie <les@wedgwoodwebworks.com>
@@ -1,5 +1,4 @@
1
1
  import typing as t
2
-
3
2
  from .crackerjack import Crackerjack, create_crackerjack_runner
4
3
  from .errors import (
5
4
  CleaningError,
@@ -22,7 +21,6 @@ try:
22
21
  __version__ = version("crackerjack")
23
22
  except (ImportError, ModuleNotFoundError):
24
23
  __version__ = "0.19.8"
25
-
26
24
  __all__: t.Sequence[str] = [
27
25
  "create_crackerjack_runner",
28
26
  "Crackerjack",
@@ -1,5 +1,4 @@
1
1
  from enum import Enum
2
-
3
2
  import typer
4
3
  from pydantic import BaseModel, field_validator
5
4
  from rich.console import Console
@@ -129,15 +128,10 @@ cli_options = {
129
128
  case_sensitive=False,
130
129
  ),
131
130
  "create_pr": typer.Option(
132
- False,
133
- "-r",
134
- "--pr",
135
- help="Create a pull request to the upstream repository.",
131
+ False, "-r", "--pr", help="Create a pull request to the upstream repository."
136
132
  ),
137
133
  "rich_ui": typer.Option(
138
- False,
139
- "--rich-ui",
140
- help="Use the interactive Rich UI for a better experience.",
134
+ False, "--rich-ui", help="Use the interactive Rich UI for a better experience."
141
135
  ),
142
136
  "ai_agent": typer.Option(
143
137
  False,
@@ -195,12 +189,10 @@ def main(
195
189
  create_pr=create_pr,
196
190
  rich_ui=rich_ui,
197
191
  )
198
-
199
192
  if ai_agent:
200
193
  import os
201
194
 
202
195
  os.environ["AI_AGENT"] = "1"
203
-
204
196
  if rich_ui:
205
197
  from crackerjack.interactive import launch_interactive_cli
206
198
 
@@ -210,7 +202,6 @@ def main(
210
202
  pkg_version = version("crackerjack")
211
203
  except (ImportError, ModuleNotFoundError):
212
204
  pkg_version = "0.19.8"
213
-
214
205
  launch_interactive_cli(pkg_version)
215
206
  else:
216
207
  runner = create_crackerjack_runner(console=console)
@@ -0,0 +1,614 @@
1
+ import ast
2
+ import re
3
+ import subprocess
4
+ import typing as t
5
+ from contextlib import suppress
6
+ from pathlib import Path
7
+ from subprocess import CompletedProcess
8
+ from subprocess import run as execute
9
+ from tomllib import loads
10
+ from pydantic import BaseModel
11
+ from rich.console import Console
12
+ from tomli_w import dumps
13
+ from crackerjack.errors import ErrorCode, ExecutionError
14
+
15
+ config_files = (".gitignore", ".pre-commit-config.yaml", ".libcst.codemod.yaml")
16
+ interactive_hooks = ("refurb", "bandit", "pyright")
17
+ default_python_version = "3.13"
18
+
19
+
20
+ @t.runtime_checkable
21
+ class CommandRunner(t.Protocol):
22
+ def execute_command(
23
+ self, cmd: list[str], **kwargs: t.Any
24
+ ) -> subprocess.CompletedProcess[str]: ...
25
+
26
+
27
+ @t.runtime_checkable
28
+ class OptionsProtocol(t.Protocol):
29
+ commit: bool
30
+ interactive: bool
31
+ doc: bool
32
+ no_config_updates: bool
33
+ verbose: bool
34
+ update_precommit: bool
35
+ clean: bool
36
+ test: bool
37
+ benchmark: bool
38
+ benchmark_regression: bool
39
+ benchmark_regression_threshold: float
40
+ test_workers: int = 0
41
+ test_timeout: int = 0
42
+ publish: t.Any | None
43
+ bump: t.Any | None
44
+ all: t.Any | None
45
+ ai_agent: bool = False
46
+ create_pr: bool = False
47
+ skip_hooks: bool = False
48
+
49
+
50
+ class CodeCleaner(BaseModel, arbitrary_types_allowed=True):
51
+ console: Console
52
+
53
+ def clean_files(self, pkg_dir: Path | None) -> None:
54
+ if pkg_dir is None:
55
+ return
56
+ for file_path in pkg_dir.rglob("*.py"):
57
+ if not str(file_path.parent).startswith("__"):
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()
72
+
73
+ def clean_file(self, file_path: Path) -> None:
74
+ try:
75
+ code = file_path.read_text()
76
+ code = self.remove_docstrings(code)
77
+ code = self.remove_line_comments(code)
78
+ code = self.remove_extra_whitespace(code)
79
+ code = self.reformat_code(code)
80
+ file_path.write_text(code)
81
+ print(f"Cleaned: {file_path}")
82
+ except Exception as e:
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)
98
+
99
+ def remove_line_comments(self, code: str) -> str:
100
+ lines = code.split("\n")
101
+ cleaned_lines = []
102
+ for line in lines:
103
+ if not line.strip():
104
+ cleaned_lines.append(line)
105
+ continue
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
125
+ else:
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)
132
+
133
+ def remove_extra_whitespace(self, code: str) -> str:
134
+ lines = code.split("\n")
135
+ cleaned_lines = []
136
+ for i, line in enumerate(lines):
137
+ line = line.rstrip()
138
+ if i > 0 and (not line) and (not cleaned_lines[-1]):
139
+ continue
140
+ cleaned_lines.append(line)
141
+ return "\n".join(cleaned_lines)
142
+
143
+ def reformat_code(self, code: str) -> str:
144
+ from crackerjack.errors import handle_error
145
+
146
+ try:
147
+ import tempfile
148
+
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)
154
+ try:
155
+ result = subprocess.run(
156
+ ["ruff", "format", str(temp_path)],
157
+ check=False,
158
+ capture_output=True,
159
+ text=True,
160
+ )
161
+ if result.returncode == 0:
162
+ formatted_code = temp_path.read_text()
163
+ else:
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,
175
+ )
176
+ formatted_code = code
177
+ except Exception as e:
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,
187
+ )
188
+ formatted_code = code
189
+ finally:
190
+ with suppress(FileNotFoundError):
191
+ temp_path.unlink()
192
+ return formatted_code
193
+ except Exception as e:
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,
203
+ )
204
+ return code
205
+
206
+
207
+ class ConfigManager(BaseModel, arbitrary_types_allowed=True):
208
+ our_path: Path
209
+ pkg_path: Path
210
+ pkg_name: str
211
+ console: Console
212
+ our_toml_path: Path | None = None
213
+ pkg_toml_path: Path | None = None
214
+ python_version: str = default_python_version
215
+ dry_run: bool = False
216
+
217
+ def swap_package_name(self, value: list[str] | str) -> list[str] | str:
218
+ if isinstance(value, list):
219
+ value.remove("crackerjack")
220
+ value.append(self.pkg_name)
221
+ else:
222
+ value = value.replace("crackerjack", self.pkg_name)
223
+ return value
224
+
225
+ def update_pyproject_configs(self) -> None:
226
+ self._setup_toml_paths()
227
+ if self._is_crackerjack_project():
228
+ self._handle_crackerjack_project()
229
+ return
230
+ our_toml_config = self._load_our_toml()
231
+ pkg_toml_config = self._load_pkg_toml()
232
+ self._ensure_required_sections(pkg_toml_config)
233
+ self._update_tool_settings(our_toml_config, pkg_toml_config)
234
+ self._update_python_version(our_toml_config, pkg_toml_config)
235
+ self._save_pkg_toml(pkg_toml_config)
236
+
237
+ def _setup_toml_paths(self) -> None:
238
+ toml_file = "pyproject.toml"
239
+ self.our_toml_path = self.our_path / toml_file
240
+ self.pkg_toml_path = self.pkg_path / toml_file
241
+
242
+ def _is_crackerjack_project(self) -> bool:
243
+ return self.pkg_path.stem == "crackerjack"
244
+
245
+ def _handle_crackerjack_project(self) -> None:
246
+ if self.our_toml_path and self.pkg_toml_path:
247
+ self.our_toml_path.write_text(self.pkg_toml_path.read_text())
248
+
249
+ def _load_our_toml(self) -> dict[str, t.Any]:
250
+ if self.our_toml_path:
251
+ return loads(self.our_toml_path.read_text())
252
+ return {}
253
+
254
+ def _load_pkg_toml(self) -> dict[str, t.Any]:
255
+ if self.pkg_toml_path:
256
+ return loads(self.pkg_toml_path.read_text())
257
+ return {}
258
+
259
+ def _ensure_required_sections(self, pkg_toml_config: dict[str, t.Any]) -> None:
260
+ pkg_toml_config.setdefault("tool", {})
261
+ pkg_toml_config.setdefault("project", {})
262
+
263
+ def _update_tool_settings(
264
+ self, our_toml_config: dict[str, t.Any], pkg_toml_config: dict[str, t.Any]
265
+ ) -> None:
266
+ for tool, settings in our_toml_config.get("tool", {}).items():
267
+ for setting, value in settings.items():
268
+ if isinstance(value, dict):
269
+ for k, v in {
270
+ x: self.swap_package_name(y)
271
+ for x, y in value.items()
272
+ if isinstance(y, str | list) and "crackerjack" in str(y)
273
+ }.items():
274
+ settings[setting][k] = v
275
+ elif isinstance(value, str | list) and "crackerjack" in str(value):
276
+ value = self.swap_package_name(value)
277
+ settings[setting] = value
278
+ if setting in (
279
+ "exclude-deps",
280
+ "exclude",
281
+ "excluded",
282
+ "skips",
283
+ "ignore",
284
+ ) and isinstance(value, list):
285
+ conf = pkg_toml_config["tool"].get(tool, {}).get(setting, [])
286
+ settings[setting] = list(set(conf + value))
287
+ pkg_toml_config["tool"][tool] = settings
288
+
289
+ def _update_python_version(
290
+ self, our_toml_config: dict[str, t.Any], pkg_toml_config: dict[str, t.Any]
291
+ ) -> None:
292
+ python_version_pattern = "\\s*W*(\\d\\.\\d*)"
293
+ requires_python = our_toml_config.get("project", {}).get("requires-python", "")
294
+ classifiers = []
295
+ for classifier in pkg_toml_config.get("project", {}).get("classifiers", []):
296
+ classifier = re.sub(
297
+ python_version_pattern, f" {self.python_version}", classifier
298
+ )
299
+ classifiers.append(classifier)
300
+ pkg_toml_config["project"]["classifiers"] = classifiers
301
+ if requires_python:
302
+ pkg_toml_config["project"]["requires-python"] = requires_python
303
+
304
+ def _save_pkg_toml(self, pkg_toml_config: dict[str, t.Any]) -> None:
305
+ if self.pkg_toml_path:
306
+ self.pkg_toml_path.write_text(dumps(pkg_toml_config))
307
+
308
+ def copy_configs(self) -> None:
309
+ for config in config_files:
310
+ config_path = self.our_path / config
311
+ pkg_config_path = self.pkg_path / config
312
+ pkg_config_path.touch()
313
+ if self.pkg_path.stem == "crackerjack":
314
+ config_path.write_text(pkg_config_path.read_text())
315
+ continue
316
+ if config != ".gitignore":
317
+ pkg_config_path.write_text(
318
+ config_path.read_text().replace("crackerjack", self.pkg_name)
319
+ )
320
+ self.execute_command(["git", "add", config])
321
+
322
+ def execute_command(
323
+ self, cmd: list[str], **kwargs: t.Any
324
+ ) -> subprocess.CompletedProcess[str]:
325
+ if self.dry_run:
326
+ self.console.print(f"[yellow]Would run: {' '.join(cmd)}[/yellow]")
327
+ return CompletedProcess(cmd, 0, "", "")
328
+ return execute(cmd, **kwargs)
329
+
330
+
331
+ class ProjectManager(BaseModel, arbitrary_types_allowed=True):
332
+ our_path: Path
333
+ pkg_path: Path
334
+ pkg_dir: Path | None = None
335
+ pkg_name: str = "crackerjack"
336
+ console: Console
337
+ code_cleaner: CodeCleaner
338
+ config_manager: ConfigManager
339
+ dry_run: bool = False
340
+
341
+ def run_interactive(self, hook: str) -> None:
342
+ success: bool = False
343
+ while not success:
344
+ fail = self.execute_command(
345
+ ["pre-commit", "run", hook.lower(), "--all-files"]
346
+ )
347
+ if fail.returncode > 0:
348
+ retry = input(f"\n\n{hook.title()} failed. Retry? (y/N): ")
349
+ self.console.print()
350
+ if retry.strip().lower() == "y":
351
+ continue
352
+ raise SystemExit(1)
353
+ success = True
354
+
355
+ def update_pkg_configs(self) -> None:
356
+ self.config_manager.copy_configs()
357
+ installed_pkgs = self.execute_command(
358
+ ["pdm", "list", "--freeze"], capture_output=True, text=True
359
+ ).stdout.splitlines()
360
+ if not len([pkg for pkg in installed_pkgs if "pre-commit" in pkg]):
361
+ self.console.print("Initializing project...")
362
+ self.execute_command(["pdm", "self", "add", "keyring"])
363
+ self.execute_command(["pdm", "config", "python.use_uv", "true"])
364
+ self.execute_command(["git", "init"])
365
+ self.execute_command(["git", "branch", "-m", "main"])
366
+ self.execute_command(["git", "add", "pyproject.toml"])
367
+ self.execute_command(["git", "add", "pdm.lock"])
368
+ self.execute_command(["pre-commit", "install"])
369
+ self.execute_command(["git", "config", "advice.addIgnoredFile", "false"])
370
+ self.config_manager.update_pyproject_configs()
371
+
372
+ def run_pre_commit(self) -> None:
373
+ self.console.print("\nRunning pre-commit hooks...\n")
374
+ check_all = self.execute_command(["pre-commit", "run", "--all-files"])
375
+ if check_all.returncode > 0:
376
+ check_all = self.execute_command(["pre-commit", "run", "--all-files"])
377
+ if check_all.returncode > 0:
378
+ self.console.print("\n\nPre-commit failed. Please fix errors.\n")
379
+ raise SystemExit(1)
380
+
381
+ def execute_command(
382
+ self, cmd: list[str], **kwargs: t.Any
383
+ ) -> subprocess.CompletedProcess[str]:
384
+ if self.dry_run:
385
+ self.console.print(f"[yellow]Would run: {' '.join(cmd)}[/yellow]")
386
+ return CompletedProcess(cmd, 0, "", "")
387
+ return execute(cmd, **kwargs)
388
+
389
+
390
+ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
391
+ our_path: Path = Path(__file__).parent
392
+ pkg_path: Path = Path(Path.cwd())
393
+ pkg_dir: Path | None = None
394
+ pkg_name: str = "crackerjack"
395
+ python_version: str = default_python_version
396
+ console: Console = Console(force_terminal=True)
397
+ dry_run: bool = False
398
+ code_cleaner: CodeCleaner | None = None
399
+ config_manager: ConfigManager | None = None
400
+ project_manager: ProjectManager | None = None
401
+
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
+ )
423
+
424
+ def _setup_package(self) -> None:
425
+ self.pkg_name = self.pkg_path.stem.lower().replace("-", "_")
426
+ self.pkg_dir = self.pkg_path / self.pkg_name
427
+ self.pkg_dir.mkdir(exist_ok=True)
428
+ self.console.print("\nCrackerjacking...\n")
429
+ self.config_manager.pkg_name = self.pkg_name
430
+ self.project_manager.pkg_name = self.pkg_name
431
+ self.project_manager.pkg_dir = self.pkg_dir
432
+
433
+ def _update_project(self, options: t.Any) -> None:
434
+ if not options.no_config_updates:
435
+ self.project_manager.update_pkg_configs()
436
+ result: CompletedProcess[str] = self.execute_command(
437
+ ["pdm", "install"], capture_output=True, text=True
438
+ )
439
+ if result.returncode == 0:
440
+ self.console.print("PDM installed: ✅\n")
441
+ else:
442
+ self.console.print(
443
+ "\n\n❌ PDM installation failed. Is PDM is installed? Run `pipx install pdm` and try again.\n\n"
444
+ )
445
+
446
+ def _update_precommit(self, options: t.Any) -> None:
447
+ if self.pkg_path.stem == "crackerjack" and options.update_precommit:
448
+ self.execute_command(["pre-commit", "autoupdate"])
449
+
450
+ def _run_interactive_hooks(self, options: t.Any) -> None:
451
+ if options.interactive:
452
+ for hook in interactive_hooks:
453
+ self.project_manager.run_interactive(hook)
454
+
455
+ def _clean_project(self, options: t.Any) -> None:
456
+ if options.clean:
457
+ if self.pkg_dir:
458
+ self.code_cleaner.clean_files(self.pkg_dir)
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)
464
+
465
+ def _prepare_pytest_command(self, options: OptionsProtocol) -> list[str]:
466
+ test = ["pytest"]
467
+ project_size = self._detect_project_size()
468
+ if options.test_timeout > 0:
469
+ test_timeout = options.test_timeout
470
+ else:
471
+ test_timeout = (
472
+ 300
473
+ if project_size == "large"
474
+ else 120
475
+ if project_size == "medium"
476
+ else 60
477
+ )
478
+ test.extend(
479
+ [
480
+ "--capture=fd",
481
+ "--tb=short",
482
+ "--no-header",
483
+ "--disable-warnings",
484
+ "--durations=0",
485
+ f"--timeout={test_timeout}",
486
+ ]
487
+ )
488
+ if options.benchmark or options.benchmark_regression:
489
+ if options.benchmark:
490
+ test.append("--benchmark")
491
+ if options.benchmark_regression:
492
+ test.extend(
493
+ [
494
+ "--benchmark-regression",
495
+ f"--benchmark-regression-threshold={options.benchmark_regression_threshold}",
496
+ ]
497
+ )
498
+ elif options.test_workers > 0:
499
+ if options.test_workers == 1:
500
+ test.append("-vs")
501
+ else:
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")
509
+ return test
510
+
511
+ def _detect_project_size(self) -> str:
512
+ if self.pkg_name in ("acb", "fastblocks"):
513
+ return "large"
514
+ try:
515
+ py_files = list(self.pkg_path.rglob("*.py"))
516
+ test_files = list(self.pkg_path.rglob("test_*.py"))
517
+ total_files = len(py_files)
518
+ num_test_files = len(test_files)
519
+ if total_files > 100 or num_test_files > 50:
520
+ return "large"
521
+ elif total_files > 50 or num_test_files > 20:
522
+ return "medium"
523
+ else:
524
+ return "small"
525
+ except Exception:
526
+ return "medium"
527
+
528
+ def _run_tests(self, options: t.Any) -> None:
529
+ if options.test:
530
+ self.console.print("\n\nRunning tests...\n")
531
+ test_command = self._prepare_pytest_command(options)
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")
541
+
542
+ def _bump_version(self, options: OptionsProtocol) -> None:
543
+ for option in (options.publish, options.bump):
544
+ if option:
545
+ self.execute_command(["pdm", "bump", option])
546
+ break
547
+
548
+ def _publish_project(self, options: OptionsProtocol) -> None:
549
+ if options.publish:
550
+ build = self.execute_command(
551
+ ["pdm", "build"], capture_output=True, text=True
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"])
559
+
560
+ def _commit_and_push(self, options: OptionsProtocol) -> None:
561
+ if options.commit:
562
+ commit_msg = input("\nCommit message: ")
563
+ self.execute_command(
564
+ ["git", "commit", "-m", commit_msg, "--no-verify", "--", "."]
565
+ )
566
+ self.execute_command(["git", "push", "origin", "main"])
567
+
568
+ def execute_command(
569
+ self, cmd: list[str], **kwargs: t.Any
570
+ ) -> subprocess.CompletedProcess[str]:
571
+ if self.dry_run:
572
+ self.console.print(f"[yellow]Would run: {' '.join(cmd)}[/yellow]")
573
+ return CompletedProcess(cmd, 0, "", "")
574
+ return execute(cmd, **kwargs)
575
+
576
+ def process(self, options: OptionsProtocol) -> None:
577
+ if options.all:
578
+ options.clean = True
579
+ options.test = True
580
+ options.publish = options.all
581
+ options.commit = True
582
+ self._setup_package()
583
+ self._update_project(options)
584
+ self._update_precommit(options)
585
+ self._run_interactive_hooks(options)
586
+ self._clean_project(options)
587
+ if not options.skip_hooks:
588
+ self.project_manager.run_pre_commit()
589
+ else:
590
+ self.console.print("Skipping pre-commit hooks")
591
+ self._run_tests(options)
592
+ self._bump_version(options)
593
+ self._publish_project(options)
594
+ self._commit_and_push(options)
595
+ self.console.print("\n🍺 Crackerjack complete!\n")
596
+
597
+
598
+ crackerjack_it = Crackerjack().process
599
+
600
+
601
+ def create_crackerjack_runner(
602
+ console: Console | None = None,
603
+ our_path: Path | None = None,
604
+ pkg_path: Path | None = None,
605
+ python_version: str = default_python_version,
606
+ dry_run: bool = False,
607
+ ) -> Crackerjack:
608
+ return Crackerjack(
609
+ console=console or Console(force_terminal=True),
610
+ our_path=our_path or Path(__file__).parent,
611
+ pkg_path=pkg_path or Path.cwd(),
612
+ python_version=python_version,
613
+ dry_run=dry_run,
614
+ )