crackerjack 0.31.10__py3-none-any.whl → 0.31.12__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 +288 -705
- crackerjack/__main__.py +22 -8
- crackerjack/agents/__init__.py +0 -3
- crackerjack/agents/architect_agent.py +0 -43
- crackerjack/agents/base.py +1 -9
- crackerjack/agents/coordinator.py +2 -148
- crackerjack/agents/documentation_agent.py +109 -81
- crackerjack/agents/dry_agent.py +122 -97
- crackerjack/agents/formatting_agent.py +3 -16
- crackerjack/agents/import_optimization_agent.py +1174 -130
- crackerjack/agents/performance_agent.py +956 -188
- crackerjack/agents/performance_helpers.py +229 -0
- crackerjack/agents/proactive_agent.py +1 -48
- crackerjack/agents/refactoring_agent.py +516 -246
- crackerjack/agents/refactoring_helpers.py +282 -0
- crackerjack/agents/security_agent.py +393 -90
- crackerjack/agents/test_creation_agent.py +1776 -120
- crackerjack/agents/test_specialist_agent.py +59 -15
- crackerjack/agents/tracker.py +0 -102
- crackerjack/api.py +145 -37
- crackerjack/cli/handlers.py +48 -30
- crackerjack/cli/interactive.py +11 -11
- crackerjack/cli/options.py +66 -4
- crackerjack/code_cleaner.py +808 -148
- crackerjack/config/global_lock_config.py +110 -0
- crackerjack/config/hooks.py +43 -64
- crackerjack/core/async_workflow_orchestrator.py +247 -97
- crackerjack/core/autofix_coordinator.py +192 -109
- crackerjack/core/enhanced_container.py +46 -63
- crackerjack/core/file_lifecycle.py +549 -0
- crackerjack/core/performance.py +9 -8
- crackerjack/core/performance_monitor.py +395 -0
- crackerjack/core/phase_coordinator.py +281 -94
- crackerjack/core/proactive_workflow.py +9 -58
- crackerjack/core/resource_manager.py +501 -0
- crackerjack/core/service_watchdog.py +490 -0
- crackerjack/core/session_coordinator.py +4 -8
- crackerjack/core/timeout_manager.py +504 -0
- crackerjack/core/websocket_lifecycle.py +475 -0
- crackerjack/core/workflow_orchestrator.py +343 -209
- crackerjack/dynamic_config.py +47 -6
- crackerjack/errors.py +3 -4
- crackerjack/executors/async_hook_executor.py +63 -13
- crackerjack/executors/cached_hook_executor.py +14 -14
- crackerjack/executors/hook_executor.py +100 -37
- crackerjack/executors/hook_lock_manager.py +856 -0
- crackerjack/executors/individual_hook_executor.py +120 -86
- crackerjack/intelligence/__init__.py +0 -7
- crackerjack/intelligence/adaptive_learning.py +13 -86
- crackerjack/intelligence/agent_orchestrator.py +15 -78
- crackerjack/intelligence/agent_registry.py +12 -59
- crackerjack/intelligence/agent_selector.py +31 -92
- crackerjack/intelligence/integration.py +1 -41
- crackerjack/interactive.py +9 -9
- crackerjack/managers/async_hook_manager.py +25 -8
- crackerjack/managers/hook_manager.py +9 -9
- crackerjack/managers/publish_manager.py +57 -59
- crackerjack/managers/test_command_builder.py +6 -36
- crackerjack/managers/test_executor.py +9 -61
- crackerjack/managers/test_manager.py +17 -63
- crackerjack/managers/test_manager_backup.py +77 -127
- crackerjack/managers/test_progress.py +4 -23
- crackerjack/mcp/cache.py +5 -12
- crackerjack/mcp/client_runner.py +10 -10
- crackerjack/mcp/context.py +64 -6
- crackerjack/mcp/dashboard.py +14 -11
- crackerjack/mcp/enhanced_progress_monitor.py +55 -55
- crackerjack/mcp/file_monitor.py +72 -42
- crackerjack/mcp/progress_components.py +103 -84
- crackerjack/mcp/progress_monitor.py +122 -49
- crackerjack/mcp/rate_limiter.py +12 -12
- crackerjack/mcp/server_core.py +16 -22
- crackerjack/mcp/service_watchdog.py +26 -26
- crackerjack/mcp/state.py +15 -0
- crackerjack/mcp/tools/core_tools.py +95 -39
- crackerjack/mcp/tools/error_analyzer.py +6 -32
- crackerjack/mcp/tools/execution_tools.py +1 -56
- crackerjack/mcp/tools/execution_tools_backup.py +35 -131
- crackerjack/mcp/tools/intelligence_tool_registry.py +0 -36
- crackerjack/mcp/tools/intelligence_tools.py +2 -55
- crackerjack/mcp/tools/monitoring_tools.py +308 -145
- crackerjack/mcp/tools/proactive_tools.py +12 -42
- crackerjack/mcp/tools/progress_tools.py +23 -15
- crackerjack/mcp/tools/utility_tools.py +3 -40
- crackerjack/mcp/tools/workflow_executor.py +40 -60
- crackerjack/mcp/websocket/app.py +0 -3
- crackerjack/mcp/websocket/endpoints.py +206 -268
- crackerjack/mcp/websocket/jobs.py +213 -66
- crackerjack/mcp/websocket/server.py +84 -6
- crackerjack/mcp/websocket/websocket_handler.py +137 -29
- crackerjack/models/config_adapter.py +3 -16
- crackerjack/models/protocols.py +162 -3
- crackerjack/models/resource_protocols.py +454 -0
- crackerjack/models/task.py +3 -3
- crackerjack/monitoring/__init__.py +0 -0
- crackerjack/monitoring/ai_agent_watchdog.py +25 -71
- crackerjack/monitoring/regression_prevention.py +28 -87
- crackerjack/orchestration/advanced_orchestrator.py +44 -78
- crackerjack/orchestration/coverage_improvement.py +10 -60
- crackerjack/orchestration/execution_strategies.py +16 -16
- crackerjack/orchestration/test_progress_streamer.py +61 -53
- crackerjack/plugins/base.py +1 -1
- crackerjack/plugins/managers.py +22 -20
- crackerjack/py313.py +65 -21
- crackerjack/services/backup_service.py +467 -0
- crackerjack/services/bounded_status_operations.py +627 -0
- crackerjack/services/cache.py +7 -9
- crackerjack/services/config.py +35 -52
- crackerjack/services/config_integrity.py +5 -16
- crackerjack/services/config_merge.py +542 -0
- crackerjack/services/contextual_ai_assistant.py +17 -19
- crackerjack/services/coverage_ratchet.py +44 -73
- crackerjack/services/debug.py +25 -39
- crackerjack/services/dependency_monitor.py +52 -50
- crackerjack/services/enhanced_filesystem.py +14 -11
- crackerjack/services/file_hasher.py +1 -1
- crackerjack/services/filesystem.py +1 -12
- crackerjack/services/git.py +71 -47
- crackerjack/services/health_metrics.py +31 -27
- crackerjack/services/initialization.py +276 -428
- crackerjack/services/input_validator.py +760 -0
- crackerjack/services/log_manager.py +16 -16
- crackerjack/services/logging.py +7 -6
- crackerjack/services/metrics.py +43 -43
- crackerjack/services/pattern_cache.py +2 -31
- crackerjack/services/pattern_detector.py +26 -63
- crackerjack/services/performance_benchmarks.py +20 -45
- crackerjack/services/regex_patterns.py +2887 -0
- crackerjack/services/regex_utils.py +537 -0
- crackerjack/services/secure_path_utils.py +683 -0
- crackerjack/services/secure_status_formatter.py +534 -0
- crackerjack/services/secure_subprocess.py +605 -0
- crackerjack/services/security.py +47 -10
- crackerjack/services/security_logger.py +492 -0
- crackerjack/services/server_manager.py +109 -50
- crackerjack/services/smart_scheduling.py +8 -25
- crackerjack/services/status_authentication.py +603 -0
- crackerjack/services/status_security_manager.py +442 -0
- crackerjack/services/thread_safe_status_collector.py +546 -0
- crackerjack/services/tool_version_service.py +1 -23
- crackerjack/services/unified_config.py +36 -58
- crackerjack/services/validation_rate_limiter.py +269 -0
- crackerjack/services/version_checker.py +9 -40
- crackerjack/services/websocket_resource_limiter.py +572 -0
- crackerjack/slash_commands/__init__.py +52 -2
- crackerjack/tools/__init__.py +0 -0
- crackerjack/tools/validate_input_validator_patterns.py +262 -0
- crackerjack/tools/validate_regex_patterns.py +198 -0
- {crackerjack-0.31.10.dist-info → crackerjack-0.31.12.dist-info}/METADATA +197 -12
- crackerjack-0.31.12.dist-info/RECORD +178 -0
- crackerjack/cli/facade.py +0 -104
- crackerjack-0.31.10.dist-info/RECORD +0 -149
- {crackerjack-0.31.10.dist-info → crackerjack-0.31.12.dist-info}/WHEEL +0 -0
- {crackerjack-0.31.10.dist-info → crackerjack-0.31.12.dist-info}/entry_points.txt +0 -0
- {crackerjack-0.31.10.dist-info → crackerjack-0.31.12.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import json
|
|
2
1
|
import os
|
|
3
2
|
import typing as t
|
|
4
3
|
from contextlib import suppress
|
|
5
4
|
from pathlib import Path
|
|
6
5
|
from typing import Any
|
|
7
6
|
|
|
8
|
-
import yaml
|
|
9
7
|
from pydantic import BaseModel, Field, field_validator
|
|
10
8
|
from rich.console import Console
|
|
11
9
|
|
|
@@ -27,7 +25,7 @@ class CrackerjackConfig(BaseModel):
|
|
|
27
25
|
|
|
28
26
|
test_timeout: int = 300
|
|
29
27
|
test_workers: int = Field(default_factory=lambda: os.cpu_count() or 1)
|
|
30
|
-
min_coverage: float = 10.
|
|
28
|
+
min_coverage: float = 10.0
|
|
31
29
|
|
|
32
30
|
log_level: str = "INFO"
|
|
33
31
|
log_json: bool = False
|
|
@@ -38,11 +36,7 @@ class CrackerjackConfig(BaseModel):
|
|
|
38
36
|
skip_hooks: bool = False
|
|
39
37
|
experimental_hooks: bool = False
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
benchmark_mode: bool = False
|
|
43
|
-
|
|
44
|
-
publish_enabled: bool = False
|
|
45
|
-
keyring_provider: str = "subprocess"
|
|
39
|
+
# Removed unused configuration fields: performance_tracking, benchmark_mode, publish_enabled, keyring_provider
|
|
46
40
|
|
|
47
41
|
batch_file_operations: bool = True
|
|
48
42
|
file_operation_batch_size: int = 10
|
|
@@ -95,7 +89,7 @@ class ConfigSource:
|
|
|
95
89
|
self.priority = priority
|
|
96
90
|
self.logger = get_logger("crackerjack.config.source")
|
|
97
91
|
|
|
98
|
-
def load(self) -> dict[str, Any]:
|
|
92
|
+
def load(self) -> dict[str, t.Any]:
|
|
99
93
|
raise NotImplementedError
|
|
100
94
|
|
|
101
95
|
def is_available(self) -> bool:
|
|
@@ -108,8 +102,8 @@ class EnvironmentConfigSource(ConfigSource):
|
|
|
108
102
|
def __init__(self, priority: int = 100) -> None:
|
|
109
103
|
super().__init__(priority)
|
|
110
104
|
|
|
111
|
-
def load(self) -> dict[str, Any]:
|
|
112
|
-
config: dict[str, Any] = {}
|
|
105
|
+
def load(self) -> dict[str, t.Any]:
|
|
106
|
+
config: dict[str, t.Any] = {}
|
|
113
107
|
|
|
114
108
|
for key, value in os.environ.items():
|
|
115
109
|
if key.startswith(self.ENV_PREFIX):
|
|
@@ -117,71 +111,58 @@ class EnvironmentConfigSource(ConfigSource):
|
|
|
117
111
|
|
|
118
112
|
config[config_key] = self._convert_value(value)
|
|
119
113
|
|
|
120
|
-
self.logger.debug("Loaded environment config", keys=list(config.keys()))
|
|
121
114
|
return config
|
|
122
115
|
|
|
123
|
-
def _convert_value(self, value: str) -> Any:
|
|
116
|
+
def _convert_value(self, value: str) -> t.Any:
|
|
124
117
|
if value.lower() in ("true", "1", "yes", "on"):
|
|
125
118
|
return True
|
|
126
119
|
if value.lower() in ("false", "0", "no", "off"):
|
|
127
120
|
return False
|
|
128
121
|
|
|
122
|
+
# Handle negative numbers with spaces (e.g., "- 10")
|
|
123
|
+
cleaned_value = value.replace(" ", "")
|
|
124
|
+
|
|
129
125
|
with suppress(ValueError):
|
|
130
|
-
return int(
|
|
126
|
+
return int(cleaned_value)
|
|
131
127
|
|
|
132
128
|
with suppress(ValueError):
|
|
133
|
-
return float(
|
|
129
|
+
return float(cleaned_value)
|
|
134
130
|
|
|
135
131
|
return value
|
|
136
132
|
|
|
137
133
|
|
|
138
134
|
class FileConfigSource(ConfigSource):
|
|
139
|
-
def __init__(self,
|
|
135
|
+
def __init__(self, config_path: Path, priority: int = 50) -> None:
|
|
140
136
|
super().__init__(priority)
|
|
141
|
-
self.
|
|
137
|
+
self.config_path = config_path
|
|
142
138
|
|
|
143
139
|
def is_available(self) -> bool:
|
|
144
|
-
return self.
|
|
140
|
+
return self.config_path.exists() and self.config_path.is_file()
|
|
145
141
|
|
|
146
|
-
def load(self) -> dict[str, Any]:
|
|
142
|
+
def load(self) -> dict[str, t.Any]:
|
|
147
143
|
if not self.is_available():
|
|
148
144
|
return {}
|
|
149
145
|
|
|
150
146
|
try:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
else:
|
|
168
|
-
self.logger.warning(
|
|
169
|
-
"Unknown config file format",
|
|
170
|
-
path=str(self.file_path),
|
|
171
|
-
)
|
|
172
|
-
return {}
|
|
173
|
-
|
|
174
|
-
self.logger.debug(
|
|
175
|
-
"Loaded file config",
|
|
176
|
-
path=str(self.file_path),
|
|
177
|
-
keys=list(config.keys()),
|
|
178
|
-
)
|
|
179
|
-
return config
|
|
180
|
-
|
|
147
|
+
with self.config_path.open("r") as f:
|
|
148
|
+
if self.config_path.suffix in (".yaml", ".yml"):
|
|
149
|
+
import yaml
|
|
150
|
+
|
|
151
|
+
result = yaml.safe_load(f)
|
|
152
|
+
return result if isinstance(result, dict) else {}
|
|
153
|
+
elif self.config_path.suffix == ".json":
|
|
154
|
+
import json
|
|
155
|
+
|
|
156
|
+
result = json.load(f)
|
|
157
|
+
return result if isinstance(result, dict) else {}
|
|
158
|
+
else:
|
|
159
|
+
self.logger.warning(
|
|
160
|
+
f"Unsupported config file format: {self.config_path.suffix}"
|
|
161
|
+
)
|
|
162
|
+
return {}
|
|
181
163
|
except Exception as e:
|
|
182
164
|
self.logger.exception(
|
|
183
|
-
"Failed to load config file",
|
|
184
|
-
path=str(self.file_path),
|
|
165
|
+
f"Failed to load config from file: {self.config_path}",
|
|
185
166
|
error=str(e),
|
|
186
167
|
)
|
|
187
168
|
return {}
|
|
@@ -195,7 +176,7 @@ class PyprojectConfigSource(ConfigSource):
|
|
|
195
176
|
def is_available(self) -> bool:
|
|
196
177
|
return self.pyproject_path.exists() and self.pyproject_path.is_file()
|
|
197
178
|
|
|
198
|
-
def load(self) -> dict[str, Any]:
|
|
179
|
+
def load(self) -> dict[str, t.Any]:
|
|
199
180
|
if not self.is_available():
|
|
200
181
|
return {}
|
|
201
182
|
|
|
@@ -234,8 +215,8 @@ class OptionsConfigSource(ConfigSource):
|
|
|
234
215
|
super().__init__(priority)
|
|
235
216
|
self.options = options
|
|
236
217
|
|
|
237
|
-
def load(self) -> dict[str, Any]:
|
|
238
|
-
config: dict[str, Any] = {}
|
|
218
|
+
def load(self) -> dict[str, t.Any]:
|
|
219
|
+
config: dict[str, t.Any] = {}
|
|
239
220
|
|
|
240
221
|
option_mappings = {
|
|
241
222
|
"testing": "test_mode",
|
|
@@ -280,9 +261,6 @@ class UnifiedConfigurationService:
|
|
|
280
261
|
),
|
|
281
262
|
)
|
|
282
263
|
|
|
283
|
-
# .crackerjack.* config files are no longer supported
|
|
284
|
-
# Configuration should be done through pyproject.toml
|
|
285
|
-
|
|
286
264
|
self.sources.append(EnvironmentConfigSource())
|
|
287
265
|
|
|
288
266
|
if options:
|
|
@@ -294,7 +272,7 @@ class UnifiedConfigurationService:
|
|
|
294
272
|
pkg_path = self.pkg_path
|
|
295
273
|
|
|
296
274
|
class DefaultConfigSource(ConfigSource):
|
|
297
|
-
def load(self) -> dict[str, Any]:
|
|
275
|
+
def load(self) -> dict[str, t.Any]:
|
|
298
276
|
return {
|
|
299
277
|
"package_path": pkg_path,
|
|
300
278
|
"cache_enabled": True,
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rate limiting for input validation to prevent abuse and DoS attacks.
|
|
3
|
+
|
|
4
|
+
This module provides rate limiting functionality to:
|
|
5
|
+
- Prevent excessive validation failures
|
|
6
|
+
- Protect against DoS attacks via malformed input
|
|
7
|
+
- Track validation patterns for security monitoring
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
import typing as t
|
|
12
|
+
from collections import defaultdict, deque
|
|
13
|
+
from threading import Lock
|
|
14
|
+
|
|
15
|
+
from .security_logger import SecurityEventLevel, get_security_logger
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ValidationRateLimit:
|
|
19
|
+
"""Rate limiting configuration for different validation types."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
max_failures: int = 10,
|
|
24
|
+
window_seconds: int = 60,
|
|
25
|
+
block_duration: int = 300, # 5 minutes
|
|
26
|
+
):
|
|
27
|
+
self.max_failures = max_failures
|
|
28
|
+
self.window_seconds = window_seconds
|
|
29
|
+
self.block_duration = block_duration
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ValidationRateLimiter:
|
|
33
|
+
"""
|
|
34
|
+
Rate limiter for validation failures to prevent abuse.
|
|
35
|
+
|
|
36
|
+
Tracks validation failures by client/source and blocks excessive failures.
|
|
37
|
+
Uses sliding window approach for accurate rate limiting.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self):
|
|
41
|
+
self._failure_windows: dict[str, deque[float]] = defaultdict(deque)
|
|
42
|
+
self._blocked_until: dict[str, float] = {}
|
|
43
|
+
self._lock = Lock()
|
|
44
|
+
self._logger = get_security_logger()
|
|
45
|
+
|
|
46
|
+
# Default rate limits by validation type
|
|
47
|
+
self._limits = {
|
|
48
|
+
"default": ValidationRateLimit(),
|
|
49
|
+
"command_injection": ValidationRateLimit(
|
|
50
|
+
max_failures=3, block_duration=600
|
|
51
|
+
),
|
|
52
|
+
"path_traversal": ValidationRateLimit(max_failures=3, block_duration=600),
|
|
53
|
+
"sql_injection": ValidationRateLimit(max_failures=2, block_duration=900),
|
|
54
|
+
"code_injection": ValidationRateLimit(max_failures=2, block_duration=900),
|
|
55
|
+
"json_payload": ValidationRateLimit(max_failures=20),
|
|
56
|
+
"job_id": ValidationRateLimit(max_failures=15),
|
|
57
|
+
"project_name": ValidationRateLimit(max_failures=15),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def is_blocked(self, client_id: str) -> bool:
|
|
61
|
+
"""Check if a client is currently blocked."""
|
|
62
|
+
with self._lock:
|
|
63
|
+
if client_id in self._blocked_until:
|
|
64
|
+
if time.time() < self._blocked_until[client_id]:
|
|
65
|
+
return True
|
|
66
|
+
else:
|
|
67
|
+
# Block expired, remove it
|
|
68
|
+
del self._blocked_until[client_id]
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
def record_failure(
|
|
72
|
+
self,
|
|
73
|
+
client_id: str,
|
|
74
|
+
validation_type: str,
|
|
75
|
+
severity: SecurityEventLevel = SecurityEventLevel.MEDIUM,
|
|
76
|
+
) -> bool:
|
|
77
|
+
"""
|
|
78
|
+
Record a validation failure and check if client should be blocked.
|
|
79
|
+
|
|
80
|
+
Returns True if the client should be blocked, False otherwise.
|
|
81
|
+
"""
|
|
82
|
+
with self._lock:
|
|
83
|
+
current_time = time.time()
|
|
84
|
+
|
|
85
|
+
# Get rate limit for this validation type
|
|
86
|
+
limit = self._limits.get(validation_type, self._limits["default"])
|
|
87
|
+
|
|
88
|
+
# Initialize failure window if needed
|
|
89
|
+
if client_id not in self._failure_windows:
|
|
90
|
+
self._failure_windows[client_id] = deque()
|
|
91
|
+
|
|
92
|
+
# Clean old failures outside the window
|
|
93
|
+
window = self._failure_windows[client_id]
|
|
94
|
+
while window and current_time - window[0] > limit.window_seconds:
|
|
95
|
+
window.popleft()
|
|
96
|
+
|
|
97
|
+
# Record this failure
|
|
98
|
+
window.append(current_time)
|
|
99
|
+
|
|
100
|
+
# Check if limit exceeded
|
|
101
|
+
if len(window) >= limit.max_failures:
|
|
102
|
+
# Block this client
|
|
103
|
+
self._blocked_until[client_id] = current_time + limit.block_duration
|
|
104
|
+
|
|
105
|
+
# Log the rate limit exceeded event
|
|
106
|
+
self._logger.log_rate_limit_exceeded(
|
|
107
|
+
limit_type=validation_type,
|
|
108
|
+
current_count=len(window),
|
|
109
|
+
max_allowed=limit.max_failures,
|
|
110
|
+
client_id=client_id,
|
|
111
|
+
block_duration=limit.block_duration,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Clear the failure window since client is now blocked
|
|
115
|
+
self._failure_windows[client_id].clear()
|
|
116
|
+
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
def get_remaining_attempts(self, client_id: str, validation_type: str) -> int:
|
|
122
|
+
"""Get remaining validation attempts before rate limit."""
|
|
123
|
+
with self._lock:
|
|
124
|
+
if self.is_blocked(client_id):
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
limit = self._limits.get(validation_type, self._limits["default"])
|
|
128
|
+
current_failures = len(self._failure_windows.get(client_id, []))
|
|
129
|
+
return max(0, limit.max_failures - current_failures)
|
|
130
|
+
|
|
131
|
+
def get_block_time_remaining(self, client_id: str) -> int:
|
|
132
|
+
"""Get seconds remaining until client is unblocked."""
|
|
133
|
+
with self._lock:
|
|
134
|
+
if client_id in self._blocked_until:
|
|
135
|
+
remaining = max(0, int(self._blocked_until[client_id] - time.time()))
|
|
136
|
+
return remaining
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
def get_client_stats(self, client_id: str) -> dict[str, t.Any]:
|
|
140
|
+
"""Get rate limiting statistics for a client."""
|
|
141
|
+
with self._lock:
|
|
142
|
+
current_time = time.time()
|
|
143
|
+
|
|
144
|
+
stats = {
|
|
145
|
+
"client_id": client_id,
|
|
146
|
+
"is_blocked": self.is_blocked(client_id),
|
|
147
|
+
"block_time_remaining": self.get_block_time_remaining(client_id),
|
|
148
|
+
"recent_failures": 0,
|
|
149
|
+
"total_failures": 0,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if client_id in self._failure_windows:
|
|
153
|
+
window = self._failure_windows[client_id]
|
|
154
|
+
stats["total_failures"] = len(window)
|
|
155
|
+
|
|
156
|
+
# Count recent failures (last 5 minutes)
|
|
157
|
+
recent_count = sum(
|
|
158
|
+
1 for failure_time in window if current_time - failure_time <= 300
|
|
159
|
+
)
|
|
160
|
+
stats["recent_failures"] = recent_count
|
|
161
|
+
|
|
162
|
+
return stats
|
|
163
|
+
|
|
164
|
+
def cleanup_expired_data(self) -> int:
|
|
165
|
+
"""Clean up expired data and return number of items removed."""
|
|
166
|
+
with self._lock:
|
|
167
|
+
current_time = time.time()
|
|
168
|
+
removed_count = 0
|
|
169
|
+
|
|
170
|
+
# Clean expired blocks
|
|
171
|
+
expired_blocks = [
|
|
172
|
+
client_id
|
|
173
|
+
for client_id, block_until in self._blocked_until.items()
|
|
174
|
+
if current_time >= block_until
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
for client_id in expired_blocks:
|
|
178
|
+
del self._blocked_until[client_id]
|
|
179
|
+
removed_count += 1
|
|
180
|
+
|
|
181
|
+
# Clean old failure windows (keep only last 24 hours)
|
|
182
|
+
for client_id, window in list(self._failure_windows.items()):
|
|
183
|
+
# Remove failures older than 24 hours
|
|
184
|
+
while window and current_time - window[0] > 86400: # 24 hours
|
|
185
|
+
window.popleft()
|
|
186
|
+
removed_count += 1
|
|
187
|
+
|
|
188
|
+
# Remove empty windows
|
|
189
|
+
if not window:
|
|
190
|
+
del self._failure_windows[client_id]
|
|
191
|
+
|
|
192
|
+
return removed_count
|
|
193
|
+
|
|
194
|
+
def update_rate_limits(
|
|
195
|
+
self,
|
|
196
|
+
validation_type: str,
|
|
197
|
+
max_failures: int,
|
|
198
|
+
window_seconds: int,
|
|
199
|
+
block_duration: int,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Update rate limits for a specific validation type."""
|
|
202
|
+
with self._lock:
|
|
203
|
+
self._limits[validation_type] = ValidationRateLimit(
|
|
204
|
+
max_failures=max_failures,
|
|
205
|
+
window_seconds=window_seconds,
|
|
206
|
+
block_duration=block_duration,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def get_all_stats(self) -> dict[str, t.Any]:
|
|
210
|
+
"""Get comprehensive rate limiting statistics."""
|
|
211
|
+
with self._lock:
|
|
212
|
+
current_time = time.time()
|
|
213
|
+
|
|
214
|
+
stats: dict[str, t.Any] = {
|
|
215
|
+
"total_clients_tracked": len(self._failure_windows),
|
|
216
|
+
"currently_blocked": len(self._blocked_until),
|
|
217
|
+
"rate_limits": {
|
|
218
|
+
vtype: {
|
|
219
|
+
"max_failures": limit.max_failures,
|
|
220
|
+
"window_seconds": limit.window_seconds,
|
|
221
|
+
"block_duration": limit.block_duration,
|
|
222
|
+
}
|
|
223
|
+
for vtype, limit in self._limits.items()
|
|
224
|
+
},
|
|
225
|
+
"blocked_clients": [],
|
|
226
|
+
"active_clients": [],
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
# Get blocked client info
|
|
230
|
+
for client_id, block_until in self._blocked_until.items():
|
|
231
|
+
remaining = max(0, int(block_until - current_time))
|
|
232
|
+
stats["blocked_clients"].append(
|
|
233
|
+
{
|
|
234
|
+
"client_id": client_id,
|
|
235
|
+
"blocked_until": block_until,
|
|
236
|
+
"time_remaining": remaining,
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Get active client info (with recent failures)
|
|
241
|
+
for client_id, window in self._failure_windows.items():
|
|
242
|
+
if client_id not in self._blocked_until and window:
|
|
243
|
+
recent_failures = sum(
|
|
244
|
+
1
|
|
245
|
+
for failure_time in window
|
|
246
|
+
if current_time - failure_time <= 300 # Last 5 minutes
|
|
247
|
+
)
|
|
248
|
+
if recent_failures > 0:
|
|
249
|
+
stats["active_clients"].append(
|
|
250
|
+
{
|
|
251
|
+
"client_id": client_id,
|
|
252
|
+
"recent_failures": recent_failures,
|
|
253
|
+
"total_failures": len(window),
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
return stats
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# Global rate limiter instance
|
|
261
|
+
_rate_limiter: ValidationRateLimiter | None = None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def get_validation_rate_limiter() -> ValidationRateLimiter:
|
|
265
|
+
"""Get the global validation rate limiter instance."""
|
|
266
|
+
global _rate_limiter
|
|
267
|
+
if _rate_limiter is None:
|
|
268
|
+
_rate_limiter = ValidationRateLimiter()
|
|
269
|
+
return _rate_limiter
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
"""Core version checking and comparison functionality.
|
|
2
|
-
|
|
3
|
-
This module handles tool version detection, comparison, and update notifications.
|
|
4
|
-
Split from tool_version_service.py to follow single responsibility principle.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
1
|
import subprocess
|
|
8
2
|
import typing as t
|
|
9
3
|
from dataclasses import dataclass
|
|
@@ -14,8 +8,6 @@ from rich.console import Console
|
|
|
14
8
|
|
|
15
9
|
@dataclass
|
|
16
10
|
class VersionInfo:
|
|
17
|
-
"""Information about a tool's version and update status."""
|
|
18
|
-
|
|
19
11
|
tool_name: str
|
|
20
12
|
current_version: str
|
|
21
13
|
latest_version: str | None = None
|
|
@@ -24,8 +16,6 @@ class VersionInfo:
|
|
|
24
16
|
|
|
25
17
|
|
|
26
18
|
class VersionChecker:
|
|
27
|
-
"""Service for checking tool versions and updates."""
|
|
28
|
-
|
|
29
19
|
def __init__(self, console: Console) -> None:
|
|
30
20
|
self.console = console
|
|
31
21
|
self.tools_to_check = {
|
|
@@ -36,7 +26,6 @@ class VersionChecker:
|
|
|
36
26
|
}
|
|
37
27
|
|
|
38
28
|
async def check_tool_updates(self) -> dict[str, VersionInfo]:
|
|
39
|
-
"""Check updates for all registered tools."""
|
|
40
29
|
results = {}
|
|
41
30
|
for tool_name, version_getter in self.tools_to_check.items():
|
|
42
31
|
results[tool_name] = await self._check_single_tool(
|
|
@@ -47,7 +36,6 @@ class VersionChecker:
|
|
|
47
36
|
async def _check_single_tool(
|
|
48
37
|
self, tool_name: str, version_getter: t.Callable[[], str | None]
|
|
49
38
|
) -> VersionInfo:
|
|
50
|
-
"""Check updates for a single tool."""
|
|
51
39
|
try:
|
|
52
40
|
current_version = version_getter()
|
|
53
41
|
if current_version:
|
|
@@ -63,7 +51,6 @@ class VersionChecker:
|
|
|
63
51
|
def _create_installed_version_info(
|
|
64
52
|
self, tool_name: str, current_version: str, latest_version: str | None
|
|
65
53
|
) -> VersionInfo:
|
|
66
|
-
"""Create version info for installed tool."""
|
|
67
54
|
update_available = (
|
|
68
55
|
latest_version is not None
|
|
69
56
|
and self._version_compare(current_version, latest_version) < 0
|
|
@@ -72,7 +59,7 @@ class VersionChecker:
|
|
|
72
59
|
if update_available:
|
|
73
60
|
self.console.print(
|
|
74
61
|
f"[yellow]🔄 {tool_name} update available: "
|
|
75
|
-
f"{current_version} → {latest_version}[/yellow]"
|
|
62
|
+
f"{current_version} → {latest_version}[/ yellow]"
|
|
76
63
|
)
|
|
77
64
|
|
|
78
65
|
return VersionInfo(
|
|
@@ -83,8 +70,7 @@ class VersionChecker:
|
|
|
83
70
|
)
|
|
84
71
|
|
|
85
72
|
def _create_missing_tool_info(self, tool_name: str) -> VersionInfo:
|
|
86
|
-
"
|
|
87
|
-
self.console.print(f"[red]⚠️ {tool_name} not installed[/red]")
|
|
73
|
+
self.console.print(f"[red]⚠️ {tool_name} not installed[/ red]")
|
|
88
74
|
return VersionInfo(
|
|
89
75
|
tool_name=tool_name,
|
|
90
76
|
current_version="not installed",
|
|
@@ -94,8 +80,7 @@ class VersionChecker:
|
|
|
94
80
|
def _create_error_version_info(
|
|
95
81
|
self, tool_name: str, error: Exception
|
|
96
82
|
) -> VersionInfo:
|
|
97
|
-
"
|
|
98
|
-
self.console.print(f"[red]❌ Error checking {tool_name}: {error}[/red]")
|
|
83
|
+
self.console.print(f"[red]❌ Error checking {tool_name}: {error}[/ red]")
|
|
99
84
|
return VersionInfo(
|
|
100
85
|
tool_name=tool_name,
|
|
101
86
|
current_version="unknown",
|
|
@@ -103,23 +88,18 @@ class VersionChecker:
|
|
|
103
88
|
)
|
|
104
89
|
|
|
105
90
|
def _get_ruff_version(self) -> str | None:
|
|
106
|
-
"""Get currently installed Ruff version."""
|
|
107
91
|
return self._get_tool_version("ruff")
|
|
108
92
|
|
|
109
93
|
def _get_pyright_version(self) -> str | None:
|
|
110
|
-
"""Get currently installed Pyright version."""
|
|
111
94
|
return self._get_tool_version("pyright")
|
|
112
95
|
|
|
113
96
|
def _get_precommit_version(self) -> str | None:
|
|
114
|
-
"""Get currently installed pre-commit version."""
|
|
115
97
|
return self._get_tool_version("pre-commit")
|
|
116
98
|
|
|
117
99
|
def _get_uv_version(self) -> str | None:
|
|
118
|
-
"""Get currently installed UV version."""
|
|
119
100
|
return self._get_tool_version("uv")
|
|
120
101
|
|
|
121
102
|
def _get_tool_version(self, tool_name: str) -> str | None:
|
|
122
|
-
"""Generic method to get tool version via subprocess."""
|
|
123
103
|
try:
|
|
124
104
|
result = subprocess.run(
|
|
125
105
|
[tool_name, "--version"],
|
|
@@ -136,13 +116,12 @@ class VersionChecker:
|
|
|
136
116
|
return None
|
|
137
117
|
|
|
138
118
|
async def _fetch_latest_version(self, tool_name: str) -> str | None:
|
|
139
|
-
"""Fetch latest version from PyPI."""
|
|
140
119
|
try:
|
|
141
120
|
pypi_urls = {
|
|
142
|
-
"ruff": "https
|
|
143
|
-
"pyright": "https
|
|
144
|
-
"pre-commit": "https
|
|
145
|
-
"uv": "https
|
|
121
|
+
"ruff": "https: / / pypi.org / pypi / ruff / json",
|
|
122
|
+
"pyright": "https: / / pypi.org / pypi / pyright / json",
|
|
123
|
+
"pre-commit": "https: / / pypi.org / pypi / pre-commit / json",
|
|
124
|
+
"uv": "https: / / pypi.org / pypi / uv / json",
|
|
146
125
|
}
|
|
147
126
|
|
|
148
127
|
url = pypi_urls.get(tool_name)
|
|
@@ -160,24 +139,20 @@ class VersionChecker:
|
|
|
160
139
|
return None
|
|
161
140
|
|
|
162
141
|
def _version_compare(self, current: str, latest: str) -> int:
|
|
163
|
-
"""Compare two version strings. Returns -1 if current < latest, 0 if equal, 1 if current > latest."""
|
|
164
142
|
try:
|
|
165
143
|
current_parts, current_len = self._parse_version_parts(current)
|
|
166
144
|
latest_parts, latest_len = self._parse_version_parts(latest)
|
|
167
145
|
|
|
168
|
-
# Normalize lengths
|
|
169
146
|
normalized_current, normalized_latest = self._normalize_version_parts(
|
|
170
147
|
current_parts, latest_parts
|
|
171
148
|
)
|
|
172
149
|
|
|
173
|
-
# Compare numeric values
|
|
174
150
|
numeric_result = self._compare_numeric_parts(
|
|
175
151
|
normalized_current, normalized_latest
|
|
176
152
|
)
|
|
177
153
|
if numeric_result != 0:
|
|
178
154
|
return numeric_result
|
|
179
155
|
|
|
180
|
-
# Handle length differences when numeric values are equal
|
|
181
156
|
return self._handle_length_differences(
|
|
182
157
|
current_len, latest_len, normalized_current, normalized_latest
|
|
183
158
|
)
|
|
@@ -186,14 +161,12 @@ class VersionChecker:
|
|
|
186
161
|
return 0
|
|
187
162
|
|
|
188
163
|
def _parse_version_parts(self, version: str) -> tuple[list[int], int]:
|
|
189
|
-
"""Parse version string into integer parts and return original length."""
|
|
190
164
|
parts = [int(x) for x in version.split(".")]
|
|
191
165
|
return parts, len(parts)
|
|
192
166
|
|
|
193
167
|
def _normalize_version_parts(
|
|
194
168
|
self, current_parts: list[int], latest_parts: list[int]
|
|
195
169
|
) -> tuple[list[int], list[int]]:
|
|
196
|
-
"""Extend version parts to same length with zeros."""
|
|
197
170
|
max_len = max(len(current_parts), len(latest_parts))
|
|
198
171
|
current_normalized = current_parts + [0] * (max_len - len(current_parts))
|
|
199
172
|
latest_normalized = latest_parts + [0] * (max_len - len(latest_parts))
|
|
@@ -202,7 +175,6 @@ class VersionChecker:
|
|
|
202
175
|
def _compare_numeric_parts(
|
|
203
176
|
self, current_parts: list[int], latest_parts: list[int]
|
|
204
177
|
) -> int:
|
|
205
|
-
"""Compare version parts numerically."""
|
|
206
178
|
for current_part, latest_part in zip(current_parts, latest_parts):
|
|
207
179
|
if current_part < latest_part:
|
|
208
180
|
return -1
|
|
@@ -217,7 +189,6 @@ class VersionChecker:
|
|
|
217
189
|
current_parts: list[int],
|
|
218
190
|
latest_parts: list[int],
|
|
219
191
|
) -> int:
|
|
220
|
-
"""Handle version comparison when lengths differ but numeric values are equal."""
|
|
221
192
|
if current_len == latest_len:
|
|
222
193
|
return 0
|
|
223
194
|
|
|
@@ -230,19 +201,17 @@ class VersionChecker:
|
|
|
230
201
|
def _compare_when_current_shorter(
|
|
231
202
|
self, current_len: int, latest_len: int, latest_parts: list[int]
|
|
232
203
|
) -> int:
|
|
233
|
-
"""Compare when current version has fewer parts than latest."""
|
|
234
204
|
extra_parts = latest_parts[current_len:]
|
|
235
205
|
if any(part != 0 for part in extra_parts):
|
|
236
206
|
return -1
|
|
237
|
-
|
|
207
|
+
|
|
238
208
|
return -1 if current_len > 1 else 0
|
|
239
209
|
|
|
240
210
|
def _compare_when_latest_shorter(
|
|
241
211
|
self, latest_len: int, current_len: int, current_parts: list[int]
|
|
242
212
|
) -> int:
|
|
243
|
-
"""Compare when latest version has fewer parts than current."""
|
|
244
213
|
extra_parts = current_parts[latest_len:]
|
|
245
214
|
if any(part != 0 for part in extra_parts):
|
|
246
215
|
return 1
|
|
247
|
-
|
|
216
|
+
|
|
248
217
|
return 1 if latest_len > 1 else 0
|