crackerjack 0.30.3__py3-none-any.whl → 0.31.4__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.
- crackerjack/CLAUDE.md +1005 -0
- crackerjack/RULES.md +380 -0
- crackerjack/__init__.py +42 -13
- crackerjack/__main__.py +225 -299
- crackerjack/agents/__init__.py +41 -0
- crackerjack/agents/architect_agent.py +281 -0
- crackerjack/agents/base.py +169 -0
- crackerjack/agents/coordinator.py +512 -0
- crackerjack/agents/documentation_agent.py +498 -0
- crackerjack/agents/dry_agent.py +388 -0
- crackerjack/agents/formatting_agent.py +245 -0
- crackerjack/agents/import_optimization_agent.py +281 -0
- crackerjack/agents/performance_agent.py +669 -0
- crackerjack/agents/proactive_agent.py +104 -0
- crackerjack/agents/refactoring_agent.py +788 -0
- crackerjack/agents/security_agent.py +529 -0
- crackerjack/agents/test_creation_agent.py +652 -0
- crackerjack/agents/test_specialist_agent.py +486 -0
- crackerjack/agents/tracker.py +212 -0
- crackerjack/api.py +560 -0
- crackerjack/cli/__init__.py +24 -0
- crackerjack/cli/facade.py +104 -0
- crackerjack/cli/handlers.py +267 -0
- crackerjack/cli/interactive.py +471 -0
- crackerjack/cli/options.py +401 -0
- crackerjack/cli/utils.py +18 -0
- crackerjack/code_cleaner.py +618 -928
- crackerjack/config/__init__.py +19 -0
- crackerjack/config/hooks.py +218 -0
- crackerjack/core/__init__.py +0 -0
- crackerjack/core/async_workflow_orchestrator.py +406 -0
- crackerjack/core/autofix_coordinator.py +200 -0
- crackerjack/core/container.py +104 -0
- crackerjack/core/enhanced_container.py +542 -0
- crackerjack/core/performance.py +243 -0
- crackerjack/core/phase_coordinator.py +561 -0
- crackerjack/core/proactive_workflow.py +316 -0
- crackerjack/core/session_coordinator.py +289 -0
- crackerjack/core/workflow_orchestrator.py +640 -0
- crackerjack/dynamic_config.py +94 -103
- crackerjack/errors.py +263 -41
- crackerjack/executors/__init__.py +11 -0
- crackerjack/executors/async_hook_executor.py +431 -0
- crackerjack/executors/cached_hook_executor.py +242 -0
- crackerjack/executors/hook_executor.py +345 -0
- crackerjack/executors/individual_hook_executor.py +669 -0
- crackerjack/intelligence/__init__.py +44 -0
- crackerjack/intelligence/adaptive_learning.py +751 -0
- crackerjack/intelligence/agent_orchestrator.py +551 -0
- crackerjack/intelligence/agent_registry.py +414 -0
- crackerjack/intelligence/agent_selector.py +502 -0
- crackerjack/intelligence/integration.py +290 -0
- crackerjack/interactive.py +576 -315
- crackerjack/managers/__init__.py +11 -0
- crackerjack/managers/async_hook_manager.py +135 -0
- crackerjack/managers/hook_manager.py +137 -0
- crackerjack/managers/publish_manager.py +411 -0
- crackerjack/managers/test_command_builder.py +151 -0
- crackerjack/managers/test_executor.py +435 -0
- crackerjack/managers/test_manager.py +258 -0
- crackerjack/managers/test_manager_backup.py +1124 -0
- crackerjack/managers/test_progress.py +144 -0
- crackerjack/mcp/__init__.py +0 -0
- crackerjack/mcp/cache.py +336 -0
- crackerjack/mcp/client_runner.py +104 -0
- crackerjack/mcp/context.py +615 -0
- crackerjack/mcp/dashboard.py +636 -0
- crackerjack/mcp/enhanced_progress_monitor.py +479 -0
- crackerjack/mcp/file_monitor.py +336 -0
- crackerjack/mcp/progress_components.py +569 -0
- crackerjack/mcp/progress_monitor.py +949 -0
- crackerjack/mcp/rate_limiter.py +332 -0
- crackerjack/mcp/server.py +22 -0
- crackerjack/mcp/server_core.py +244 -0
- crackerjack/mcp/service_watchdog.py +501 -0
- crackerjack/mcp/state.py +395 -0
- crackerjack/mcp/task_manager.py +257 -0
- crackerjack/mcp/tools/__init__.py +17 -0
- crackerjack/mcp/tools/core_tools.py +249 -0
- crackerjack/mcp/tools/error_analyzer.py +308 -0
- crackerjack/mcp/tools/execution_tools.py +370 -0
- crackerjack/mcp/tools/execution_tools_backup.py +1097 -0
- crackerjack/mcp/tools/intelligence_tool_registry.py +80 -0
- crackerjack/mcp/tools/intelligence_tools.py +314 -0
- crackerjack/mcp/tools/monitoring_tools.py +502 -0
- crackerjack/mcp/tools/proactive_tools.py +384 -0
- crackerjack/mcp/tools/progress_tools.py +141 -0
- crackerjack/mcp/tools/utility_tools.py +341 -0
- crackerjack/mcp/tools/workflow_executor.py +360 -0
- crackerjack/mcp/websocket/__init__.py +14 -0
- crackerjack/mcp/websocket/app.py +39 -0
- crackerjack/mcp/websocket/endpoints.py +559 -0
- crackerjack/mcp/websocket/jobs.py +253 -0
- crackerjack/mcp/websocket/server.py +116 -0
- crackerjack/mcp/websocket/websocket_handler.py +78 -0
- crackerjack/mcp/websocket_server.py +10 -0
- crackerjack/models/__init__.py +31 -0
- crackerjack/models/config.py +93 -0
- crackerjack/models/config_adapter.py +230 -0
- crackerjack/models/protocols.py +118 -0
- crackerjack/models/task.py +154 -0
- crackerjack/monitoring/ai_agent_watchdog.py +450 -0
- crackerjack/monitoring/regression_prevention.py +638 -0
- crackerjack/orchestration/__init__.py +0 -0
- crackerjack/orchestration/advanced_orchestrator.py +970 -0
- crackerjack/orchestration/execution_strategies.py +341 -0
- crackerjack/orchestration/test_progress_streamer.py +636 -0
- crackerjack/plugins/__init__.py +15 -0
- crackerjack/plugins/base.py +200 -0
- crackerjack/plugins/hooks.py +246 -0
- crackerjack/plugins/loader.py +335 -0
- crackerjack/plugins/managers.py +259 -0
- crackerjack/py313.py +8 -3
- crackerjack/services/__init__.py +22 -0
- crackerjack/services/cache.py +314 -0
- crackerjack/services/config.py +347 -0
- crackerjack/services/config_integrity.py +99 -0
- crackerjack/services/contextual_ai_assistant.py +516 -0
- crackerjack/services/coverage_ratchet.py +347 -0
- crackerjack/services/debug.py +736 -0
- crackerjack/services/dependency_monitor.py +617 -0
- crackerjack/services/enhanced_filesystem.py +439 -0
- crackerjack/services/file_hasher.py +151 -0
- crackerjack/services/filesystem.py +395 -0
- crackerjack/services/git.py +165 -0
- crackerjack/services/health_metrics.py +611 -0
- crackerjack/services/initialization.py +847 -0
- crackerjack/services/log_manager.py +286 -0
- crackerjack/services/logging.py +174 -0
- crackerjack/services/metrics.py +578 -0
- crackerjack/services/pattern_cache.py +362 -0
- crackerjack/services/pattern_detector.py +515 -0
- crackerjack/services/performance_benchmarks.py +653 -0
- crackerjack/services/security.py +163 -0
- crackerjack/services/server_manager.py +234 -0
- crackerjack/services/smart_scheduling.py +144 -0
- crackerjack/services/tool_version_service.py +61 -0
- crackerjack/services/unified_config.py +437 -0
- crackerjack/services/version_checker.py +248 -0
- crackerjack/slash_commands/__init__.py +14 -0
- crackerjack/slash_commands/init.md +122 -0
- crackerjack/slash_commands/run.md +163 -0
- crackerjack/slash_commands/status.md +127 -0
- crackerjack-0.31.4.dist-info/METADATA +742 -0
- crackerjack-0.31.4.dist-info/RECORD +148 -0
- crackerjack-0.31.4.dist-info/entry_points.txt +2 -0
- crackerjack/.gitignore +0 -34
- crackerjack/.libcst.codemod.yaml +0 -18
- crackerjack/.pdm.toml +0 -1
- crackerjack/crackerjack.py +0 -3805
- crackerjack/pyproject.toml +0 -286
- crackerjack-0.30.3.dist-info/METADATA +0 -1290
- crackerjack-0.30.3.dist-info/RECORD +0 -16
- {crackerjack-0.30.3.dist-info → crackerjack-0.31.4.dist-info}/WHEEL +0 -0
- {crackerjack-0.30.3.dist-info → crackerjack-0.31.4.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)
|