crackerjack 0.30.3__py3-none-any.whl → 0.31.7__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.

Potentially problematic release.


This version of crackerjack might be problematic. Click here for more details.

Files changed (156) hide show
  1. crackerjack/CLAUDE.md +1005 -0
  2. crackerjack/RULES.md +380 -0
  3. crackerjack/__init__.py +42 -13
  4. crackerjack/__main__.py +227 -299
  5. crackerjack/agents/__init__.py +41 -0
  6. crackerjack/agents/architect_agent.py +281 -0
  7. crackerjack/agents/base.py +170 -0
  8. crackerjack/agents/coordinator.py +512 -0
  9. crackerjack/agents/documentation_agent.py +498 -0
  10. crackerjack/agents/dry_agent.py +388 -0
  11. crackerjack/agents/formatting_agent.py +245 -0
  12. crackerjack/agents/import_optimization_agent.py +281 -0
  13. crackerjack/agents/performance_agent.py +669 -0
  14. crackerjack/agents/proactive_agent.py +104 -0
  15. crackerjack/agents/refactoring_agent.py +788 -0
  16. crackerjack/agents/security_agent.py +529 -0
  17. crackerjack/agents/test_creation_agent.py +657 -0
  18. crackerjack/agents/test_specialist_agent.py +486 -0
  19. crackerjack/agents/tracker.py +212 -0
  20. crackerjack/api.py +560 -0
  21. crackerjack/cli/__init__.py +24 -0
  22. crackerjack/cli/facade.py +104 -0
  23. crackerjack/cli/handlers.py +267 -0
  24. crackerjack/cli/interactive.py +471 -0
  25. crackerjack/cli/options.py +409 -0
  26. crackerjack/cli/utils.py +18 -0
  27. crackerjack/code_cleaner.py +618 -928
  28. crackerjack/config/__init__.py +19 -0
  29. crackerjack/config/hooks.py +218 -0
  30. crackerjack/core/__init__.py +0 -0
  31. crackerjack/core/async_workflow_orchestrator.py +406 -0
  32. crackerjack/core/autofix_coordinator.py +200 -0
  33. crackerjack/core/container.py +104 -0
  34. crackerjack/core/enhanced_container.py +542 -0
  35. crackerjack/core/performance.py +243 -0
  36. crackerjack/core/phase_coordinator.py +585 -0
  37. crackerjack/core/proactive_workflow.py +316 -0
  38. crackerjack/core/session_coordinator.py +289 -0
  39. crackerjack/core/workflow_orchestrator.py +826 -0
  40. crackerjack/dynamic_config.py +94 -103
  41. crackerjack/errors.py +263 -41
  42. crackerjack/executors/__init__.py +11 -0
  43. crackerjack/executors/async_hook_executor.py +431 -0
  44. crackerjack/executors/cached_hook_executor.py +242 -0
  45. crackerjack/executors/hook_executor.py +345 -0
  46. crackerjack/executors/individual_hook_executor.py +669 -0
  47. crackerjack/intelligence/__init__.py +44 -0
  48. crackerjack/intelligence/adaptive_learning.py +751 -0
  49. crackerjack/intelligence/agent_orchestrator.py +551 -0
  50. crackerjack/intelligence/agent_registry.py +414 -0
  51. crackerjack/intelligence/agent_selector.py +502 -0
  52. crackerjack/intelligence/integration.py +290 -0
  53. crackerjack/interactive.py +576 -315
  54. crackerjack/managers/__init__.py +11 -0
  55. crackerjack/managers/async_hook_manager.py +135 -0
  56. crackerjack/managers/hook_manager.py +137 -0
  57. crackerjack/managers/publish_manager.py +433 -0
  58. crackerjack/managers/test_command_builder.py +151 -0
  59. crackerjack/managers/test_executor.py +443 -0
  60. crackerjack/managers/test_manager.py +258 -0
  61. crackerjack/managers/test_manager_backup.py +1124 -0
  62. crackerjack/managers/test_progress.py +114 -0
  63. crackerjack/mcp/__init__.py +0 -0
  64. crackerjack/mcp/cache.py +336 -0
  65. crackerjack/mcp/client_runner.py +104 -0
  66. crackerjack/mcp/context.py +621 -0
  67. crackerjack/mcp/dashboard.py +636 -0
  68. crackerjack/mcp/enhanced_progress_monitor.py +479 -0
  69. crackerjack/mcp/file_monitor.py +336 -0
  70. crackerjack/mcp/progress_components.py +569 -0
  71. crackerjack/mcp/progress_monitor.py +949 -0
  72. crackerjack/mcp/rate_limiter.py +332 -0
  73. crackerjack/mcp/server.py +22 -0
  74. crackerjack/mcp/server_core.py +244 -0
  75. crackerjack/mcp/service_watchdog.py +501 -0
  76. crackerjack/mcp/state.py +395 -0
  77. crackerjack/mcp/task_manager.py +257 -0
  78. crackerjack/mcp/tools/__init__.py +17 -0
  79. crackerjack/mcp/tools/core_tools.py +249 -0
  80. crackerjack/mcp/tools/error_analyzer.py +308 -0
  81. crackerjack/mcp/tools/execution_tools.py +372 -0
  82. crackerjack/mcp/tools/execution_tools_backup.py +1097 -0
  83. crackerjack/mcp/tools/intelligence_tool_registry.py +80 -0
  84. crackerjack/mcp/tools/intelligence_tools.py +314 -0
  85. crackerjack/mcp/tools/monitoring_tools.py +502 -0
  86. crackerjack/mcp/tools/proactive_tools.py +384 -0
  87. crackerjack/mcp/tools/progress_tools.py +217 -0
  88. crackerjack/mcp/tools/utility_tools.py +341 -0
  89. crackerjack/mcp/tools/workflow_executor.py +565 -0
  90. crackerjack/mcp/websocket/__init__.py +14 -0
  91. crackerjack/mcp/websocket/app.py +39 -0
  92. crackerjack/mcp/websocket/endpoints.py +559 -0
  93. crackerjack/mcp/websocket/jobs.py +253 -0
  94. crackerjack/mcp/websocket/server.py +116 -0
  95. crackerjack/mcp/websocket/websocket_handler.py +78 -0
  96. crackerjack/mcp/websocket_server.py +10 -0
  97. crackerjack/models/__init__.py +31 -0
  98. crackerjack/models/config.py +93 -0
  99. crackerjack/models/config_adapter.py +230 -0
  100. crackerjack/models/protocols.py +118 -0
  101. crackerjack/models/task.py +154 -0
  102. crackerjack/monitoring/ai_agent_watchdog.py +450 -0
  103. crackerjack/monitoring/regression_prevention.py +638 -0
  104. crackerjack/orchestration/__init__.py +0 -0
  105. crackerjack/orchestration/advanced_orchestrator.py +970 -0
  106. crackerjack/orchestration/coverage_improvement.py +223 -0
  107. crackerjack/orchestration/execution_strategies.py +341 -0
  108. crackerjack/orchestration/test_progress_streamer.py +636 -0
  109. crackerjack/plugins/__init__.py +15 -0
  110. crackerjack/plugins/base.py +200 -0
  111. crackerjack/plugins/hooks.py +246 -0
  112. crackerjack/plugins/loader.py +335 -0
  113. crackerjack/plugins/managers.py +259 -0
  114. crackerjack/py313.py +8 -3
  115. crackerjack/services/__init__.py +22 -0
  116. crackerjack/services/cache.py +314 -0
  117. crackerjack/services/config.py +358 -0
  118. crackerjack/services/config_integrity.py +99 -0
  119. crackerjack/services/contextual_ai_assistant.py +516 -0
  120. crackerjack/services/coverage_ratchet.py +356 -0
  121. crackerjack/services/debug.py +736 -0
  122. crackerjack/services/dependency_monitor.py +617 -0
  123. crackerjack/services/enhanced_filesystem.py +439 -0
  124. crackerjack/services/file_hasher.py +151 -0
  125. crackerjack/services/filesystem.py +421 -0
  126. crackerjack/services/git.py +176 -0
  127. crackerjack/services/health_metrics.py +611 -0
  128. crackerjack/services/initialization.py +873 -0
  129. crackerjack/services/log_manager.py +286 -0
  130. crackerjack/services/logging.py +174 -0
  131. crackerjack/services/metrics.py +578 -0
  132. crackerjack/services/pattern_cache.py +362 -0
  133. crackerjack/services/pattern_detector.py +515 -0
  134. crackerjack/services/performance_benchmarks.py +653 -0
  135. crackerjack/services/security.py +163 -0
  136. crackerjack/services/server_manager.py +234 -0
  137. crackerjack/services/smart_scheduling.py +144 -0
  138. crackerjack/services/tool_version_service.py +61 -0
  139. crackerjack/services/unified_config.py +437 -0
  140. crackerjack/services/version_checker.py +248 -0
  141. crackerjack/slash_commands/__init__.py +14 -0
  142. crackerjack/slash_commands/init.md +122 -0
  143. crackerjack/slash_commands/run.md +163 -0
  144. crackerjack/slash_commands/status.md +127 -0
  145. crackerjack-0.31.7.dist-info/METADATA +742 -0
  146. crackerjack-0.31.7.dist-info/RECORD +149 -0
  147. crackerjack-0.31.7.dist-info/entry_points.txt +2 -0
  148. crackerjack/.gitignore +0 -34
  149. crackerjack/.libcst.codemod.yaml +0 -18
  150. crackerjack/.pdm.toml +0 -1
  151. crackerjack/crackerjack.py +0 -3805
  152. crackerjack/pyproject.toml +0 -286
  153. crackerjack-0.30.3.dist-info/METADATA +0 -1290
  154. crackerjack-0.30.3.dist-info/RECORD +0 -16
  155. {crackerjack-0.30.3.dist-info → crackerjack-0.31.7.dist-info}/WHEEL +0 -0
  156. {crackerjack-0.30.3.dist-info → crackerjack-0.31.7.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,439 @@
1
+ import asyncio
2
+ import hashlib
3
+ import time
4
+ from collections.abc import Iterator
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import aiofiles
9
+
10
+ from crackerjack.errors import FileError
11
+ from crackerjack.models.protocols import FileSystemInterface
12
+ from crackerjack.services.logging import LoggingContext, get_logger
13
+
14
+
15
+ class FileCache:
16
+ def __init__(self, max_size: int = 1000, default_ttl: float = 300.0) -> None:
17
+ self.max_size = max_size
18
+ self.default_ttl = default_ttl
19
+ self._cache: dict[str, dict[str, Any]] = {}
20
+ self._access_times: dict[str, float] = {}
21
+ self.logger = get_logger("crackerjack.filesystem.cache")
22
+
23
+ def get(self, key: str) -> str | None:
24
+ if key not in self._cache:
25
+ return None
26
+
27
+ cache_entry = self._cache[key]
28
+ now = time.time()
29
+
30
+ if now - cache_entry["timestamp"] > cache_entry["ttl"]:
31
+ self._evict(key)
32
+ return None
33
+
34
+ self._access_times[key] = now
35
+ self.logger.debug("Cache hit", key=key)
36
+ return cache_entry["content"]
37
+
38
+ def put(self, key: str, content: str, ttl: float | None = None) -> None:
39
+ if len(self._cache) >= self.max_size:
40
+ self._evict_lru()
41
+
42
+ now = time.time()
43
+ self._cache[key] = {
44
+ "content": content,
45
+ "timestamp": now,
46
+ "ttl": ttl or self.default_ttl,
47
+ "size": len(content),
48
+ }
49
+ self._access_times[key] = now
50
+ self.logger.debug("Cache put", key=key, size=len(content))
51
+
52
+ def _evict(self, key: str) -> None:
53
+ self._cache.pop(key, None)
54
+ self._access_times.pop(key, None)
55
+
56
+ def _evict_lru(self) -> None:
57
+ if not self._access_times:
58
+ return
59
+
60
+ lru_key = min(self._access_times, key=lambda k: self._access_times.get(k, 0.0))
61
+ self._evict(lru_key)
62
+ self.logger.debug("Cache LRU eviction", key=lru_key)
63
+
64
+ def clear(self) -> None:
65
+ self._cache.clear()
66
+ self._access_times.clear()
67
+ self.logger.debug("Cache cleared")
68
+
69
+ def get_stats(self) -> dict[str, Any]:
70
+ total_size = sum(entry["size"] for entry in self._cache.values())
71
+ return {
72
+ "entries": len(self._cache),
73
+ "max_size": self.max_size,
74
+ "total_content_size": total_size,
75
+ "memory_usage_mb": total_size / (1024 * 1024),
76
+ }
77
+
78
+
79
+ class BatchFileOperations:
80
+ def __init__(self, batch_size: int = 10) -> None:
81
+ self.batch_size = batch_size
82
+ self.read_queue: list[tuple[Path, asyncio.Future[str]]] = []
83
+ self.write_queue: list[tuple[Path, str, asyncio.Future[None]]] = []
84
+ self.logger = get_logger("crackerjack.filesystem.batch")
85
+
86
+ async def queue_read(self, path: Path) -> str:
87
+ future: asyncio.Future[str] = asyncio.Future()
88
+ self.read_queue.append((path, future))
89
+
90
+ if len(self.read_queue) >= self.batch_size:
91
+ await self._flush_reads()
92
+
93
+ return await future
94
+
95
+ async def queue_write(self, path: Path, content: str) -> None:
96
+ future: asyncio.Future[None] = asyncio.Future()
97
+ self.write_queue.append((path, content, future))
98
+
99
+ if len(self.write_queue) >= self.batch_size:
100
+ await self._flush_writes()
101
+
102
+ await future
103
+
104
+ async def flush_all(self) -> None:
105
+ await asyncio.gather(
106
+ self._flush_reads(),
107
+ self._flush_writes(),
108
+ return_exceptions=True,
109
+ )
110
+
111
+ async def _flush_reads(self) -> None:
112
+ if not self.read_queue:
113
+ return
114
+
115
+ with LoggingContext("batch_file_reads", count=len(self.read_queue)):
116
+ batch = self.read_queue.copy()
117
+ self.read_queue.clear()
118
+
119
+ from itertools import starmap
120
+
121
+ tasks = list(starmap(self._read_single_async, batch))
122
+
123
+ await asyncio.gather(*tasks, return_exceptions=True)
124
+
125
+ async def _flush_writes(self) -> None:
126
+ if not self.write_queue:
127
+ return
128
+
129
+ with LoggingContext("batch_file_writes", count=len(self.write_queue)):
130
+ batch = self.write_queue.copy()
131
+ self.write_queue.clear()
132
+
133
+ from itertools import starmap
134
+
135
+ tasks = list(starmap(self._write_single_async, batch))
136
+
137
+ await asyncio.gather(*tasks, return_exceptions=True)
138
+
139
+ async def _read_single_async(self, path: Path, future: asyncio.Future[str]) -> None:
140
+ try:
141
+ async with aiofiles.open(path, encoding="utf-8") as f:
142
+ content = await f.read()
143
+ future.set_result(content)
144
+ except Exception as e:
145
+ future.set_exception(e)
146
+
147
+ async def _write_single_async(
148
+ self,
149
+ path: Path,
150
+ content: str,
151
+ future: asyncio.Future[None],
152
+ ) -> None:
153
+ try:
154
+ path.parent.mkdir(parents=True, exist_ok=True)
155
+ async with aiofiles.open(path, "w", encoding="utf-8") as f:
156
+ await f.write(content)
157
+ future.set_result(None)
158
+ except Exception as e:
159
+ future.set_exception(e)
160
+
161
+
162
+ class EnhancedFileSystemService(FileSystemInterface):
163
+ def __init__(
164
+ self,
165
+ cache_size: int = 1000,
166
+ cache_ttl: float = 300.0,
167
+ batch_size: int = 10,
168
+ enable_async: bool = True,
169
+ ) -> None:
170
+ self.cache = FileCache(cache_size, cache_ttl)
171
+ self.batch_ops = BatchFileOperations(batch_size) if enable_async else None
172
+ self.enable_async = enable_async
173
+ self.logger = get_logger("crackerjack.filesystem.enhanced")
174
+
175
+ self._file_timestamps: dict[str, float] = {}
176
+
177
+ def read_file(self, path: str | Path) -> str:
178
+ path_obj = Path(path) if isinstance(path, str) else path
179
+
180
+ with LoggingContext("read_file", path=str(path_obj)):
181
+ cache_key = self._get_cache_key(path_obj)
182
+ cached_content = self._get_from_cache(cache_key, path_obj)
183
+
184
+ if cached_content is not None:
185
+ return cached_content
186
+
187
+ content = self._read_file_direct(path_obj)
188
+
189
+ self.cache.put(cache_key, content)
190
+ self._file_timestamps[str(path_obj)] = path_obj.stat().st_mtime
191
+
192
+ return content
193
+
194
+ def write_file(self, path: str | Path, content: str) -> None:
195
+ if not isinstance(content, str): # type: ignore[arg-type]
196
+ raise TypeError("Content must be a string")
197
+
198
+ path_obj = Path(path) if isinstance(path, str) else path
199
+
200
+ with LoggingContext("write_file", path=str(path_obj), size=len(content)):
201
+ self._write_file_direct(path_obj, content)
202
+
203
+ cache_key = self._get_cache_key(path_obj)
204
+ self.cache._evict(cache_key)
205
+ self._file_timestamps[str(path_obj)] = time.time()
206
+
207
+ async def read_file_async(self, path: Path) -> str:
208
+ if not self.enable_async or not self.batch_ops:
209
+ return self.read_file(path)
210
+
211
+ cache_key = self._get_cache_key(path)
212
+ cached_content = self._get_from_cache(cache_key, path)
213
+
214
+ if cached_content is not None:
215
+ return cached_content
216
+
217
+ content = await self.batch_ops.queue_read(path)
218
+
219
+ self.cache.put(cache_key, content)
220
+ self._file_timestamps[str(path)] = path.stat().st_mtime
221
+
222
+ return content
223
+
224
+ async def write_file_async(self, path: Path, content: str) -> None:
225
+ if not self.enable_async or not self.batch_ops:
226
+ self.write_file(path, content)
227
+ return
228
+
229
+ await self.batch_ops.queue_write(path, content)
230
+
231
+ cache_key = self._get_cache_key(path)
232
+ self.cache._evict(cache_key)
233
+ self._file_timestamps[str(path)] = time.time()
234
+
235
+ async def read_multiple_files(self, paths: list[Path]) -> dict[Path, str]:
236
+ results = {}
237
+
238
+ if not self.enable_async or not self.batch_ops:
239
+ for path in paths:
240
+ try:
241
+ results[path] = self.read_file(path)
242
+ except Exception as e:
243
+ self.logger.exception(
244
+ "Failed to read file",
245
+ path=str(path),
246
+ error=str(e),
247
+ )
248
+ results[path] = ""
249
+ return results
250
+
251
+ with LoggingContext("read_multiple_files", count=len(paths)):
252
+ tasks = [self.read_file_async(path) for path in paths]
253
+
254
+ results_list = await asyncio.gather(*tasks, return_exceptions=True)
255
+
256
+ for path, result in zip(paths, results_list, strict=False):
257
+ if isinstance(result, Exception):
258
+ self.logger.error(
259
+ "Failed to read file",
260
+ path=str(path),
261
+ error=str(result),
262
+ )
263
+ results[path] = ""
264
+ else:
265
+ results[path] = result
266
+
267
+ return results
268
+
269
+ async def write_multiple_files(self, file_data: dict[Path, str]) -> None:
270
+ if not self.enable_async or not self.batch_ops:
271
+ for path, content in file_data.items():
272
+ try:
273
+ self.write_file(path, content)
274
+ except Exception as e:
275
+ self.logger.exception(
276
+ "Failed to write file",
277
+ path=str(path),
278
+ error=str(e),
279
+ )
280
+ return
281
+
282
+ with LoggingContext("write_multiple_files", count=len(file_data)):
283
+ from itertools import starmap
284
+
285
+ tasks = list(starmap(self.write_file_async, file_data.items()))
286
+
287
+ await asyncio.gather(*tasks, return_exceptions=True)
288
+
289
+ def _get_cache_key(self, path: Path) -> str:
290
+ path_str = str(path.resolve())
291
+ return hashlib.md5(path_str.encode(), usedforsecurity=False).hexdigest()
292
+
293
+ def _get_from_cache(self, cache_key: str, path: Path) -> str | None:
294
+ if not path.exists():
295
+ return None
296
+
297
+ path_str = str(path)
298
+ if path_str in self._file_timestamps:
299
+ current_mtime = path.stat().st_mtime
300
+ cached_mtime = self._file_timestamps[path_str]
301
+
302
+ if current_mtime > cached_mtime:
303
+ self.cache._evict(cache_key)
304
+ del self._file_timestamps[path_str]
305
+ return None
306
+
307
+ return self.cache.get(cache_key)
308
+
309
+ def _read_file_direct(self, path: Path) -> str:
310
+ try:
311
+ if not path.exists():
312
+ raise FileError(
313
+ message=f"File does not exist: {path}",
314
+ details=f"Attempted to read file at {path.absolute()}",
315
+ recovery="Check file path and ensure file exists",
316
+ )
317
+ return path.read_text(encoding="utf-8")
318
+ except PermissionError as e:
319
+ raise FileError(
320
+ message=f"Permission denied reading file: {path}",
321
+ details=str(e),
322
+ recovery="Check file permissions and user access rights",
323
+ ) from e
324
+ except UnicodeDecodeError as e:
325
+ raise FileError(
326
+ message=f"Unable to decode file as UTF-8: {path}",
327
+ details=str(e),
328
+ recovery="Ensure file is text-based and UTF-8 encoded",
329
+ ) from e
330
+ except OSError as e:
331
+ raise FileError(
332
+ message=f"System error reading file: {path}",
333
+ details=str(e),
334
+ recovery="Check disk space and file system integrity",
335
+ ) from e
336
+
337
+ def _write_file_direct(self, path: Path, content: str) -> None:
338
+ try:
339
+ try:
340
+ path.parent.mkdir(parents=True, exist_ok=True)
341
+ except OSError as e:
342
+ raise FileError(
343
+ message=f"Cannot create parent directory: {path.parent}",
344
+ details=str(e),
345
+ recovery="Check directory permissions and disk space",
346
+ ) from e
347
+
348
+ path.write_text(content, encoding="utf-8")
349
+
350
+ except PermissionError as e:
351
+ raise FileError(
352
+ message=f"Permission denied writing file: {path}",
353
+ details=str(e),
354
+ recovery="Check file and directory permissions",
355
+ ) from e
356
+ except OSError as e:
357
+ raise FileError(
358
+ message=f"System error writing file: {path}",
359
+ details=str(e),
360
+ recovery="Check disk space and file system integrity",
361
+ ) from e
362
+
363
+ def file_exists(self, path: str | Path) -> bool:
364
+ return (Path(path) if isinstance(path, str) else path).exists()
365
+
366
+ def create_directory(self, path: str | Path) -> None:
367
+ path_obj = Path(path) if isinstance(path, str) else path
368
+ try:
369
+ path_obj.mkdir(parents=True, exist_ok=True)
370
+ self.logger.debug("Directory created", path=str(path_obj))
371
+ except OSError as e:
372
+ raise FileError(
373
+ message=f"Cannot create directory: {path_obj}",
374
+ details=str(e),
375
+ recovery="Check parent directory permissions and disk space",
376
+ ) from e
377
+
378
+ def delete_file(self, path: str | Path) -> None:
379
+ path_obj = Path(path) if isinstance(path, str) else path
380
+
381
+ try:
382
+ if path_obj.exists():
383
+ path_obj.unlink()
384
+
385
+ cache_key = self._get_cache_key(path_obj)
386
+ self.cache._evict(cache_key)
387
+ self._file_timestamps.pop(str(path_obj), None)
388
+
389
+ self.logger.debug("File deleted", path=str(path_obj))
390
+ except OSError as e:
391
+ raise FileError(
392
+ message=f"Cannot delete file: {path_obj}",
393
+ details=str(e),
394
+ recovery="Check file permissions",
395
+ ) from e
396
+
397
+ def list_files(self, path: str | Path, pattern: str = "*") -> Iterator[Path]:
398
+ path_obj = Path(path) if isinstance(path, str) else path
399
+
400
+ if not path_obj.is_dir():
401
+ raise FileError(
402
+ message=f"Path is not a directory: {path_obj}",
403
+ details=f"Cannot list files in {path_obj}",
404
+ recovery="Ensure path points to a valid directory",
405
+ )
406
+
407
+ try:
408
+ yield from path_obj.glob(pattern)
409
+ except OSError as e:
410
+ raise FileError(
411
+ message=f"Cannot list files in directory: {path_obj}",
412
+ details=str(e),
413
+ recovery="Check directory permissions",
414
+ ) from e
415
+
416
+ async def flush_operations(self) -> None:
417
+ if self.batch_ops:
418
+ await self.batch_ops.flush_all()
419
+
420
+ def get_cache_stats(self) -> dict[str, Any]:
421
+ return self.cache.get_stats()
422
+
423
+ def clear_cache(self) -> None:
424
+ self.cache.clear()
425
+ self._file_timestamps.clear()
426
+
427
+ def exists(self, path: str | Path) -> bool:
428
+ return (Path(path) if isinstance(path, str) else path).exists()
429
+
430
+ def mkdir(self, path: str | Path, parents: bool = False) -> None:
431
+ path_obj = Path(path) if isinstance(path, str) else path
432
+ try:
433
+ path_obj.mkdir(parents=parents, exist_ok=True)
434
+ except OSError as e:
435
+ raise FileError(
436
+ message=f"Cannot create directory: {path_obj}",
437
+ details=str(e),
438
+ recovery="Check parent directory permissions",
439
+ ) from e
@@ -0,0 +1,151 @@
1
+ import asyncio
2
+ import hashlib
3
+ from concurrent.futures import ThreadPoolExecutor
4
+ from pathlib import Path
5
+
6
+ from .cache import CrackerjackCache
7
+
8
+
9
+ class FileHasher:
10
+ def __init__(self, cache: CrackerjackCache | None = None) -> None:
11
+ self.cache = cache or CrackerjackCache()
12
+ self._executor = ThreadPoolExecutor(max_workers=4)
13
+
14
+ def get_file_hash(self, file_path: Path, algorithm: str = "md5") -> str:
15
+ if not file_path.exists():
16
+ return ""
17
+
18
+ cached_hash = self.cache.get_file_hash(file_path)
19
+ if cached_hash:
20
+ return cached_hash
21
+
22
+ file_hash = self._compute_file_hash(file_path, algorithm)
23
+ self.cache.set_file_hash(file_path, file_hash)
24
+ return file_hash
25
+
26
+ def get_directory_hash(
27
+ self,
28
+ directory: Path,
29
+ patterns: list[str] | None = None,
30
+ ) -> str:
31
+ if patterns is None:
32
+ patterns = ["*.py"]
33
+
34
+ file_hashes: list[str] = []
35
+ for pattern in patterns:
36
+ for file_path in directory.rglob(pattern):
37
+ if file_path.is_file():
38
+ file_hash = self.get_file_hash(file_path)
39
+ file_hashes.append(
40
+ f"{file_path.relative_to(directory)}: {file_hash}",
41
+ )
42
+
43
+ file_hashes.sort()
44
+ combined_content = "\n".join(file_hashes)
45
+ return hashlib.md5(combined_content.encode(), usedforsecurity=False).hexdigest()
46
+
47
+ def get_files_hash_list(self, files: list[Path]) -> list[str]:
48
+ return [
49
+ self.get_file_hash(file_path) for file_path in files if file_path.exists()
50
+ ]
51
+
52
+ async def get_files_hash_list_async(self, files: list[Path]) -> list[str]:
53
+ loop = asyncio.get_running_loop()
54
+ tasks = [
55
+ loop.run_in_executor(self._executor, self.get_file_hash, file_path)
56
+ for file_path in files
57
+ if file_path.exists()
58
+ ]
59
+ return await asyncio.gather(*tasks)
60
+
61
+ def has_files_changed(self, files: list[Path], cached_hashes: list[str]) -> bool:
62
+ if len(files) != len(cached_hashes):
63
+ return True
64
+
65
+ current_hashes = self.get_files_hash_list(files)
66
+ return current_hashes != cached_hashes
67
+
68
+ def _compute_file_hash(self, file_path: Path, algorithm: str = "md5") -> str:
69
+ hash_func = hashlib.new(algorithm)
70
+
71
+ try:
72
+ with file_path.open("rb") as f:
73
+ while chunk := f.read(8192):
74
+ hash_func.update(chunk)
75
+ return hash_func.hexdigest()
76
+ except OSError:
77
+ return ""
78
+
79
+ def get_project_files_hash(self, project_path: Path) -> dict[str, str]:
80
+ patterns = ["*.py", "*.toml", "*.cfg", "*.ini", "*.yaml", "*.yml"]
81
+ file_hashes = {}
82
+
83
+ for pattern in patterns:
84
+ for file_path in project_path.rglob(pattern):
85
+ if file_path.is_file() and not self._should_ignore_file(file_path):
86
+ relative_path = str(file_path.relative_to(project_path))
87
+ file_hashes[relative_path] = self.get_file_hash(file_path)
88
+
89
+ return file_hashes
90
+
91
+ def _should_ignore_file(self, file_path: Path) -> bool:
92
+ ignore_patterns = [
93
+ ".git",
94
+ ".venv",
95
+ "__pycache__",
96
+ ".pytest_cache",
97
+ ".coverage",
98
+ ".crackerjack_cache",
99
+ "node_modules",
100
+ ".tox",
101
+ "dist",
102
+ "build",
103
+ "*.egg-info",
104
+ ]
105
+
106
+ path_str = str(file_path)
107
+ return any(pattern in path_str for pattern in ignore_patterns)
108
+
109
+ def invalidate_cache(self, file_path: Path | None = None) -> None:
110
+ if file_path:
111
+ stat = file_path.stat() if file_path.exists() else None
112
+ if stat:
113
+ cache_key = f"file_hash:{file_path}:{stat.st_mtime}:{stat.st_size}"
114
+ self.cache.file_hash_cache.invalidate(cache_key)
115
+ else:
116
+ self.cache.file_hash_cache.clear()
117
+
118
+
119
+ class SmartFileWatcher:
120
+ def __init__(self, file_hasher: FileHasher) -> None:
121
+ self.file_hasher = file_hasher
122
+ self._watched_files: dict[Path, tuple[float, int]] = {}
123
+
124
+ def register_files(self, files: list[Path]) -> None:
125
+ for file_path in files:
126
+ if file_path.exists():
127
+ stat = file_path.stat()
128
+ self._watched_files[file_path] = (stat.st_mtime, stat.st_size)
129
+
130
+ def check_changes(self) -> list[Path]:
131
+ changed_files: list[Path] = []
132
+
133
+ for file_path, (old_mtime, old_size) in self._watched_files.items():
134
+ if not file_path.exists():
135
+ changed_files.append(file_path)
136
+ continue
137
+
138
+ stat = file_path.stat()
139
+ if stat.st_mtime != old_mtime or stat.st_size != old_size:
140
+ changed_files.append(file_path)
141
+ self._watched_files[file_path] = (stat.st_mtime, stat.st_size)
142
+
143
+ return changed_files
144
+
145
+ def invalidate_changed_files(self) -> int:
146
+ changed_files = self.check_changes()
147
+
148
+ for file_path in changed_files:
149
+ self.file_hasher.invalidate_cache(file_path)
150
+
151
+ return len(changed_files)