nextog-cli 1.0.0__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.
- nextog/__init__.py +4 -0
- nextog/cli.py +545 -0
- nextog/config/__init__.py +1 -0
- nextog/config/settings.py +132 -0
- nextog/core/__init__.py +1 -0
- nextog/core/engine.py +193 -0
- nextog/core/permissions.py +129 -0
- nextog/core/privacy.py +130 -0
- nextog/core/reporter.py +204 -0
- nextog/core/runner.py +236 -0
- nextog/data/__init__.py +1 -0
- nextog/data/local_db.py +367 -0
- nextog/data/models.py +72 -0
- nextog/data/sync.py +65 -0
- nextog/engines/__init__.py +1 -0
- nextog/engines/api/__init__.py +1 -0
- nextog/engines/api/graphql.py +54 -0
- nextog/engines/api/rest.py +346 -0
- nextog/engines/api/websocket.py +59 -0
- nextog/engines/embedded/__init__.py +1 -0
- nextog/engines/embedded/firmware.py +53 -0
- nextog/engines/embedded/hardware.py +330 -0
- nextog/engines/mobile/__init__.py +1 -0
- nextog/engines/mobile/android.py +333 -0
- nextog/engines/mobile/cross.py +48 -0
- nextog/engines/mobile/ios.py +46 -0
- nextog/engines/system/__init__.py +1 -0
- nextog/engines/system/load.py +121 -0
- nextog/engines/system/performance.py +128 -0
- nextog/engines/system/security.py +170 -0
- nextog/engines/web/__init__.py +1 -0
- nextog/engines/web/accessibility.py +191 -0
- nextog/engines/web/browser.py +387 -0
- nextog/engines/web/elements.py +285 -0
- nextog/engines/web/responsive.py +79 -0
- nextog/live/__init__.py +1 -0
- nextog/live/dashboard.py +30 -0
- nextog/live/panel.py +325 -0
- nextog/reports/__init__.py +1359 -0
- nextog/training/__init__.py +1 -0
- nextog/training/learner.py +269 -0
- nextog/training/patterns.py +102 -0
- nextog/utils/__init__.py +1 -0
- nextog/utils/helpers.py +91 -0
- nextog/utils/logger.py +37 -0
- nextog/utils/validators.py +98 -0
- nextog_cli-1.0.0.dist-info/METADATA +344 -0
- nextog_cli-1.0.0.dist-info/RECORD +51 -0
- nextog_cli-1.0.0.dist-info/WHEEL +5 -0
- nextog_cli-1.0.0.dist-info/entry_points.txt +2 -0
- nextog_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Training and auto-learning modules"""
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-Training Engine - Learns from user patterns automatically
|
|
3
|
+
All training happens locally - no external data transfer
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from typing import Dict, List, Any, Optional
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TrainingEngine:
|
|
18
|
+
"""Auto-trains from user interaction patterns to optimize testing"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, db, settings):
|
|
21
|
+
self.db = db
|
|
22
|
+
self.settings = settings
|
|
23
|
+
self.patterns: List[Dict] = []
|
|
24
|
+
self.test_history: List[Dict] = []
|
|
25
|
+
|
|
26
|
+
def get_status(self) -> Dict:
|
|
27
|
+
"""Get training status"""
|
|
28
|
+
pattern_count = self.db.count_patterns()
|
|
29
|
+
test_count = self.db.count_test_results()
|
|
30
|
+
last_trained = self.db.get_metadata("last_trained") or "Never"
|
|
31
|
+
accuracy = self.db.get_metadata("model_accuracy") or 0
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
"patterns": pattern_count,
|
|
35
|
+
"optimized": self.db.count_optimized_tests(),
|
|
36
|
+
"last_trained": last_trained,
|
|
37
|
+
"accuracy": accuracy,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def train(self, iterations: int = 100):
|
|
41
|
+
"""Run training cycle on user data"""
|
|
42
|
+
console.print("[bold]Loading user data (local only)...[/bold]")
|
|
43
|
+
|
|
44
|
+
# Load all local test history
|
|
45
|
+
self.test_history = self.db.get_all_test_results()
|
|
46
|
+
|
|
47
|
+
# Phase 1: Extract patterns
|
|
48
|
+
console.print(" → Extracting patterns...")
|
|
49
|
+
self._extract_patterns()
|
|
50
|
+
|
|
51
|
+
# Phase 2: Build frequency model
|
|
52
|
+
console.print(" → Building frequency model...")
|
|
53
|
+
self._build_frequency_model()
|
|
54
|
+
|
|
55
|
+
# Phase 3: Identify common failures
|
|
56
|
+
console.print(" → Identifying failure patterns...")
|
|
57
|
+
self._identify_failure_patterns()
|
|
58
|
+
|
|
59
|
+
# Phase 4: Optimize test order
|
|
60
|
+
console.print(" → Optimizing test order...")
|
|
61
|
+
self._optimize_test_order()
|
|
62
|
+
|
|
63
|
+
# Phase 5: Generate suggestions
|
|
64
|
+
console.print(" → Generating test suggestions...")
|
|
65
|
+
self._generate_suggestions()
|
|
66
|
+
|
|
67
|
+
# Save training results
|
|
68
|
+
self._save_training_results(iterations)
|
|
69
|
+
|
|
70
|
+
def _extract_patterns(self):
|
|
71
|
+
"""Extract patterns from test history"""
|
|
72
|
+
pattern_types = [
|
|
73
|
+
"element_click", "form_submit", "navigation",
|
|
74
|
+
"api_call", "error_occurred", "timeout",
|
|
75
|
+
"element_not_found", "assertion_failed",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
for test in self.test_history:
|
|
79
|
+
results = test.get("results", {})
|
|
80
|
+
|
|
81
|
+
for pattern_type in pattern_types:
|
|
82
|
+
occurrences = self._count_pattern(test, pattern_type)
|
|
83
|
+
|
|
84
|
+
if occurrences > 0:
|
|
85
|
+
pattern = {
|
|
86
|
+
"type": pattern_type,
|
|
87
|
+
"frequency": occurrences,
|
|
88
|
+
"context": test.get("engine", "unknown"),
|
|
89
|
+
"timestamp": test.get("timestamp", ""),
|
|
90
|
+
"related_elements": self._extract_elements(test),
|
|
91
|
+
}
|
|
92
|
+
self.patterns.append(pattern)
|
|
93
|
+
self.db.save_pattern(pattern)
|
|
94
|
+
|
|
95
|
+
def _build_frequency_model(self):
|
|
96
|
+
"""Build frequency model of user actions"""
|
|
97
|
+
action_counts = Counter()
|
|
98
|
+
|
|
99
|
+
for test in self.test_history:
|
|
100
|
+
engine = test.get("engine", "unknown")
|
|
101
|
+
action_counts[engine] += 1
|
|
102
|
+
|
|
103
|
+
# Count test types
|
|
104
|
+
categories = test.get("results", {}).get("categories", {})
|
|
105
|
+
for cat_name, cat_data in categories.items():
|
|
106
|
+
action_counts[f"{engine}:{cat_name}"] += cat_data.get("total", 0)
|
|
107
|
+
|
|
108
|
+
# Save frequency model
|
|
109
|
+
model = {
|
|
110
|
+
"action_frequencies": dict(action_counts),
|
|
111
|
+
"total_tests": len(self.test_history),
|
|
112
|
+
"most_used_engine": action_counts.most_common(1)[0][0] if action_counts else "none",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
self.db.save_metadata("frequency_model", model)
|
|
116
|
+
|
|
117
|
+
def _identify_failure_patterns(self):
|
|
118
|
+
"""Identify common failure patterns"""
|
|
119
|
+
failure_patterns = []
|
|
120
|
+
|
|
121
|
+
for test in self.test_history:
|
|
122
|
+
results = test.get("results", {})
|
|
123
|
+
details = results.get("details", [])
|
|
124
|
+
|
|
125
|
+
for detail in details:
|
|
126
|
+
if isinstance(detail, dict) and detail.get("status") == "failed":
|
|
127
|
+
pattern = {
|
|
128
|
+
"engine": test.get("engine", "unknown"),
|
|
129
|
+
"test_name": detail.get("name", "unknown"),
|
|
130
|
+
"error": detail.get("error", ""),
|
|
131
|
+
"element": detail.get("element", ""),
|
|
132
|
+
"timestamp": test.get("timestamp", ""),
|
|
133
|
+
}
|
|
134
|
+
failure_patterns.append(pattern)
|
|
135
|
+
|
|
136
|
+
# Group similar failures
|
|
137
|
+
error_groups = {}
|
|
138
|
+
for fp in failure_patterns:
|
|
139
|
+
key = fp["error"][:50] # Group by error prefix
|
|
140
|
+
if key not in error_groups:
|
|
141
|
+
error_groups[key] = []
|
|
142
|
+
error_groups[key].append(fp)
|
|
143
|
+
|
|
144
|
+
# Save failure patterns
|
|
145
|
+
self.db.save_metadata("failure_patterns", {
|
|
146
|
+
"groups": {k: len(v) for k, v in error_groups.items()},
|
|
147
|
+
"top_failures": sorted(error_groups.items(), key=lambda x: len(x[1]), reverse=True)[:10],
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
def _optimize_test_order(self):
|
|
151
|
+
"""Optimize test execution order based on failure history"""
|
|
152
|
+
# Tests that fail more often should run earlier (fail fast)
|
|
153
|
+
failure_count = Counter()
|
|
154
|
+
|
|
155
|
+
for test in self.test_history:
|
|
156
|
+
results = test.get("results", {})
|
|
157
|
+
details = results.get("details", [])
|
|
158
|
+
|
|
159
|
+
for detail in details:
|
|
160
|
+
if isinstance(detail, dict) and detail.get("status") == "failed":
|
|
161
|
+
failure_count[detail.get("name", "unknown")] += 1
|
|
162
|
+
|
|
163
|
+
# Save optimized order
|
|
164
|
+
optimized_order = [name for name, _ in failure_count.most_common()]
|
|
165
|
+
self.db.save_metadata("optimized_test_order", optimized_order)
|
|
166
|
+
|
|
167
|
+
def _generate_suggestions(self):
|
|
168
|
+
"""Generate test suggestions based on patterns"""
|
|
169
|
+
suggestions = []
|
|
170
|
+
|
|
171
|
+
# If many form failures, suggest more form tests
|
|
172
|
+
form_failures = sum(1 for p in self.patterns if "form" in p.get("type", ""))
|
|
173
|
+
if form_failures > 5:
|
|
174
|
+
suggestions.append({
|
|
175
|
+
"type": "add_tests",
|
|
176
|
+
"area": "forms",
|
|
177
|
+
"reason": f"High form failure rate ({form_failures} patterns)",
|
|
178
|
+
"priority": "high",
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
# If many timeouts, suggest performance tests
|
|
182
|
+
timeout_patterns = sum(1 for p in self.patterns if p.get("type") == "timeout")
|
|
183
|
+
if timeout_patterns > 3:
|
|
184
|
+
suggestions.append({
|
|
185
|
+
"type": "add_tests",
|
|
186
|
+
"area": "performance",
|
|
187
|
+
"reason": f"Frequent timeouts ({timeout_patterns} occurrences)",
|
|
188
|
+
"priority": "high",
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
# If many element_not_found, suggest element coverage review
|
|
192
|
+
not_found = sum(1 for p in self.patterns if p.get("type") == "element_not_found")
|
|
193
|
+
if not_found > 5:
|
|
194
|
+
suggestions.append({
|
|
195
|
+
"type": "review",
|
|
196
|
+
"area": "elements",
|
|
197
|
+
"reason": f"Elements frequently not found ({not_found} times)",
|
|
198
|
+
"priority": "medium",
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
self.db.save_metadata("suggestions", suggestions)
|
|
202
|
+
|
|
203
|
+
def _count_pattern(self, test: Dict, pattern_type: str) -> int:
|
|
204
|
+
"""Count occurrences of a pattern in a test result"""
|
|
205
|
+
results = test.get("results", {})
|
|
206
|
+
details = results.get("details", [])
|
|
207
|
+
count = 0
|
|
208
|
+
|
|
209
|
+
for detail in details:
|
|
210
|
+
if isinstance(detail, dict):
|
|
211
|
+
if pattern_type in str(detail).lower():
|
|
212
|
+
count += 1
|
|
213
|
+
|
|
214
|
+
return count
|
|
215
|
+
|
|
216
|
+
def _extract_elements(self, test: Dict) -> List[str]:
|
|
217
|
+
"""Extract element names from test results"""
|
|
218
|
+
elements = []
|
|
219
|
+
details = test.get("results", {}).get("details", [])
|
|
220
|
+
|
|
221
|
+
for detail in details:
|
|
222
|
+
if isinstance(detail, dict) and "element" in detail:
|
|
223
|
+
elements.append(detail["element"])
|
|
224
|
+
|
|
225
|
+
return elements
|
|
226
|
+
|
|
227
|
+
def _save_training_results(self, iterations: int):
|
|
228
|
+
"""Save training results"""
|
|
229
|
+
accuracy = self._calculate_accuracy()
|
|
230
|
+
|
|
231
|
+
self.db.save_metadata("last_trained", datetime.now().isoformat())
|
|
232
|
+
self.db.save_metadata("model_accuracy", accuracy)
|
|
233
|
+
self.db.save_metadata("training_iterations", iterations)
|
|
234
|
+
|
|
235
|
+
def _calculate_accuracy(self) -> float:
|
|
236
|
+
"""Calculate model accuracy from pattern matching"""
|
|
237
|
+
if not self.test_history:
|
|
238
|
+
return 0.0
|
|
239
|
+
|
|
240
|
+
correct_predictions = 0
|
|
241
|
+
total_predictions = 0
|
|
242
|
+
|
|
243
|
+
for i, test in enumerate(self.test_history[1:], 1):
|
|
244
|
+
# Simple prediction: based on previous test patterns
|
|
245
|
+
prev_test = self.test_history[i - 1]
|
|
246
|
+
prev_engine = prev_test.get("engine", "")
|
|
247
|
+
curr_engine = test.get("engine", "")
|
|
248
|
+
|
|
249
|
+
total_predictions += 1
|
|
250
|
+
if prev_engine == curr_engine:
|
|
251
|
+
correct_predictions += 1
|
|
252
|
+
|
|
253
|
+
if total_predictions == 0:
|
|
254
|
+
return 0.0
|
|
255
|
+
|
|
256
|
+
return round((correct_predictions / total_predictions) * 100, 2)
|
|
257
|
+
|
|
258
|
+
def optimize_tests(self):
|
|
259
|
+
"""Apply learned optimizations to test suite"""
|
|
260
|
+
console.print("[bold]Applying optimizations...[/bold]")
|
|
261
|
+
|
|
262
|
+
optimized_order = self.db.get_metadata("optimized_test_order") or []
|
|
263
|
+
suggestions = self.db.get_metadata("suggestions") or []
|
|
264
|
+
|
|
265
|
+
console.print(f" → Reordered {len(optimized_order)} tests for fail-fast execution")
|
|
266
|
+
console.print(f" → Generated {len(suggestions)} test suggestions")
|
|
267
|
+
|
|
268
|
+
for suggestion in suggestions:
|
|
269
|
+
console.print(f" → [{suggestion['priority']}] {suggestion['reason']}")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Pattern Recognition for Test Optimization"""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List, Any
|
|
4
|
+
from collections import Counter, defaultdict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PatternRecognizer:
|
|
8
|
+
"""Recognizes patterns in test execution for optimization"""
|
|
9
|
+
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.patterns: List[Dict] = []
|
|
12
|
+
self.sequence_patterns: List[Dict] = []
|
|
13
|
+
self.failure_correlations: Dict[str, List[str]] = defaultdict(list)
|
|
14
|
+
|
|
15
|
+
def analyze_sequences(self, test_results: List[Dict]) -> List[Dict]:
|
|
16
|
+
"""Analyze sequences of test executions to find patterns"""
|
|
17
|
+
sequences = []
|
|
18
|
+
|
|
19
|
+
for i in range(len(test_results) - 2):
|
|
20
|
+
seq = [
|
|
21
|
+
test_results[i].get("engine", ""),
|
|
22
|
+
test_results[i+1].get("engine", ""),
|
|
23
|
+
test_results[i+2].get("engine", ""),
|
|
24
|
+
]
|
|
25
|
+
sequences.append(" → ".join(seq))
|
|
26
|
+
|
|
27
|
+
# Find most common sequences
|
|
28
|
+
seq_counts = Counter(sequences)
|
|
29
|
+
|
|
30
|
+
return [
|
|
31
|
+
{"pattern": pattern, "count": count, "confidence": count / len(sequences) * 100}
|
|
32
|
+
for pattern, count in seq_counts.most_common(10)
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def find_failure_correlations(self, test_results: List[Dict]) -> Dict[str, List[str]]:
|
|
36
|
+
"""Find correlations between test failures"""
|
|
37
|
+
failures_by_engine = defaultdict(list)
|
|
38
|
+
|
|
39
|
+
for test in test_results:
|
|
40
|
+
engine = test.get("engine", "unknown")
|
|
41
|
+
results = test.get("results", {})
|
|
42
|
+
|
|
43
|
+
if results.get("failed", 0) > 0:
|
|
44
|
+
failures_by_engine[engine].append(test.get("timestamp", ""))
|
|
45
|
+
|
|
46
|
+
# Find correlations
|
|
47
|
+
correlations = {}
|
|
48
|
+
engines = list(failures_by_engine.keys())
|
|
49
|
+
|
|
50
|
+
for i, eng1 in enumerate(engines):
|
|
51
|
+
for eng2 in engines[i+1:]:
|
|
52
|
+
# Check if failures happen at similar times
|
|
53
|
+
times1 = set(failures_by_engine[eng1])
|
|
54
|
+
times2 = set(failures_by_engine[eng2])
|
|
55
|
+
|
|
56
|
+
overlap = times1 & times2
|
|
57
|
+
if len(overlap) > 0:
|
|
58
|
+
correlations[f"{eng1}↔{eng2}"] = list(overlap)
|
|
59
|
+
|
|
60
|
+
return correlations
|
|
61
|
+
|
|
62
|
+
def predict_failures(self, test_results: List[Dict], next_test: Dict) -> float:
|
|
63
|
+
"""Predict likelihood of failure for next test based on patterns"""
|
|
64
|
+
if not test_results:
|
|
65
|
+
return 0.0
|
|
66
|
+
|
|
67
|
+
# Simple prediction based on recent failure rate
|
|
68
|
+
recent = test_results[-10:] # Last 10 tests
|
|
69
|
+
failures = sum(1 for t in recent if t.get("results", {}).get("failed", 0) > 0)
|
|
70
|
+
|
|
71
|
+
# Same engine failure rate
|
|
72
|
+
same_engine = [t for t in recent if t.get("engine") == next_test.get("engine")]
|
|
73
|
+
if same_engine:
|
|
74
|
+
engine_failures = sum(1 for t in same_engine if t.get("results", {}).get("failed", 0) > 0)
|
|
75
|
+
return round(engine_failures / len(same_engine) * 100, 2)
|
|
76
|
+
|
|
77
|
+
return round(failures / len(recent) * 100, 2)
|
|
78
|
+
|
|
79
|
+
def suggest_test_coverage(self, test_results: List[Dict]) -> List[Dict]:
|
|
80
|
+
"""Suggest areas that need more test coverage"""
|
|
81
|
+
coverage_by_area = defaultdict(lambda: {"tested": 0, "total": 0})
|
|
82
|
+
|
|
83
|
+
for test in test_results:
|
|
84
|
+
engine = test.get("engine", "unknown")
|
|
85
|
+
results = test.get("results", {})
|
|
86
|
+
|
|
87
|
+
coverage_by_area[engine]["tested"] += results.get("passed", 0)
|
|
88
|
+
coverage_by_area[engine]["total"] += results.get("total", 0)
|
|
89
|
+
|
|
90
|
+
suggestions = []
|
|
91
|
+
for area, data in coverage_by_area.items():
|
|
92
|
+
coverage = (data["tested"] / data["total"] * 100) if data["total"] > 0 else 0
|
|
93
|
+
|
|
94
|
+
if coverage < 60:
|
|
95
|
+
suggestions.append({
|
|
96
|
+
"area": area,
|
|
97
|
+
"current_coverage": round(coverage, 2),
|
|
98
|
+
"recommended": "Add more test cases",
|
|
99
|
+
"priority": "high" if coverage < 40 else "medium",
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
return suggestions
|
nextog/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utility modules"""
|
nextog/utils/helpers.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Helper utilities for nextOG CLI"""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import hashlib
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_url(url: str) -> bool:
|
|
11
|
+
"""Validate URL format"""
|
|
12
|
+
pattern = re.compile(
|
|
13
|
+
r'^https?://'
|
|
14
|
+
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|'
|
|
15
|
+
r'localhost|'
|
|
16
|
+
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
|
|
17
|
+
r'(?::\d+)?'
|
|
18
|
+
r'(?:/?|[/?]\S+)$', re.IGNORECASE
|
|
19
|
+
)
|
|
20
|
+
return bool(pattern.match(url))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_email(email: str) -> bool:
|
|
24
|
+
"""Validate email format"""
|
|
25
|
+
pattern = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
|
26
|
+
return bool(pattern.match(email))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def format_timestamp(dt: datetime = None) -> str:
|
|
30
|
+
"""Format timestamp for display"""
|
|
31
|
+
if dt is None:
|
|
32
|
+
dt = datetime.now()
|
|
33
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def generate_id(prefix: str = "test") -> str:
|
|
37
|
+
"""Generate unique test ID"""
|
|
38
|
+
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
39
|
+
hash_suffix = hashlib.md5(str(datetime.now().microsecond).encode()).hexdigest()[:6]
|
|
40
|
+
return f"{prefix}_{timestamp}_{hash_suffix}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def format_bytes(size: int) -> str:
|
|
44
|
+
"""Format bytes to human readable"""
|
|
45
|
+
for unit in ["B", "KB", "MB", "GB"]:
|
|
46
|
+
if size < 1024:
|
|
47
|
+
return f"{size:.1f} {unit}"
|
|
48
|
+
size /= 1024
|
|
49
|
+
return f"{size:.1f} TB"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def truncate(text: str, max_length: int = 100) -> str:
|
|
53
|
+
"""Truncate text with ellipsis"""
|
|
54
|
+
if len(text) <= max_length:
|
|
55
|
+
return text
|
|
56
|
+
return text[:max_length - 3] + "..."
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ensure_dir(path: Path) -> Path:
|
|
60
|
+
"""Ensure directory exists"""
|
|
61
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
return path
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def safe_filename(name: str) -> str:
|
|
66
|
+
"""Convert string to safe filename"""
|
|
67
|
+
return re.sub(r'[^\w\-_.]', '_', name)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def merge_dicts(base: Dict, override: Dict) -> Dict:
|
|
71
|
+
"""Deep merge two dictionaries"""
|
|
72
|
+
result = dict(base)
|
|
73
|
+
for key, value in override.items():
|
|
74
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
75
|
+
result[key] = merge_dicts(result[key], value)
|
|
76
|
+
else:
|
|
77
|
+
result[key] = value
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_duration(duration_str: str) -> float:
|
|
82
|
+
"""Parse duration string to seconds"""
|
|
83
|
+
units = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
84
|
+
|
|
85
|
+
match = re.match(r'^(\d+(?:\.\d+)?)\s*([smhd]?)$', duration_str.lower())
|
|
86
|
+
if match:
|
|
87
|
+
value = float(match.group(1))
|
|
88
|
+
unit = match.group(2) or "s"
|
|
89
|
+
return value * units.get(unit, 1)
|
|
90
|
+
|
|
91
|
+
return 0.0
|
nextog/utils/logger.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Logging module for nextOG CLI
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from rich.logging import RichHandler
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_logger(name: str = "nextog", verbose: bool = False) -> logging.Logger:
|
|
11
|
+
"""Get configured logger"""
|
|
12
|
+
logger = logging.getLogger(name)
|
|
13
|
+
|
|
14
|
+
if not logger.handlers:
|
|
15
|
+
# Console handler with rich formatting
|
|
16
|
+
console_handler = RichHandler(
|
|
17
|
+
rich_tracebacks=True,
|
|
18
|
+
markup=True,
|
|
19
|
+
show_path=verbose,
|
|
20
|
+
)
|
|
21
|
+
console_handler.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
22
|
+
|
|
23
|
+
# File handler
|
|
24
|
+
log_dir = Path.home() / ".nextog" / "logs"
|
|
25
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
file_handler = logging.FileHandler(log_dir / "nextog.log")
|
|
28
|
+
file_handler.setLevel(logging.DEBUG)
|
|
29
|
+
file_handler.setFormatter(
|
|
30
|
+
logging.Formatter("%(asctime)s | %(name)s | %(levelname)s | %(message)s")
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
logger.addHandler(console_handler)
|
|
34
|
+
logger.addHandler(file_handler)
|
|
35
|
+
logger.setLevel(logging.DEBUG)
|
|
36
|
+
|
|
37
|
+
return logger
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Input validators for nextOG CLI"""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ValidationError(Exception):
|
|
9
|
+
"""Custom validation error"""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_project_name(name: str) -> str:
|
|
14
|
+
"""Validate project name"""
|
|
15
|
+
if not name:
|
|
16
|
+
raise ValidationError("Project name cannot be empty")
|
|
17
|
+
if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', name):
|
|
18
|
+
raise ValidationError("Project name must start with letter and contain only alphanumeric, -, _")
|
|
19
|
+
if len(name) > 64:
|
|
20
|
+
raise ValidationError("Project name must be 64 characters or less")
|
|
21
|
+
return name
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_url(url: str) -> str:
|
|
25
|
+
"""Validate URL"""
|
|
26
|
+
if not url:
|
|
27
|
+
raise ValidationError("URL cannot be empty")
|
|
28
|
+
if not re.match(r'^https?://', url):
|
|
29
|
+
raise ValidationError("URL must start with http:// or https://")
|
|
30
|
+
return url
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def validate_port(port: int) -> int:
|
|
34
|
+
"""Validate port number"""
|
|
35
|
+
if not isinstance(port, int):
|
|
36
|
+
raise ValidationError("Port must be an integer")
|
|
37
|
+
if port < 1 or port > 65535:
|
|
38
|
+
raise ValidationError("Port must be between 1 and 65535")
|
|
39
|
+
return port
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_coverage_target(target: int) -> int:
|
|
43
|
+
"""Validate coverage target"""
|
|
44
|
+
if target < 0 or target > 100:
|
|
45
|
+
raise ValidationError("Coverage target must be between 0 and 100")
|
|
46
|
+
return target
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate_role(role: str) -> str:
|
|
50
|
+
"""Validate user role"""
|
|
51
|
+
valid_roles = ["admin", "tester", "viewer", "ci"]
|
|
52
|
+
if role not in valid_roles:
|
|
53
|
+
raise ValidationError(f"Role must be one of: {valid_roles}")
|
|
54
|
+
return role
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_file_exists(path: str) -> Path:
|
|
58
|
+
"""Validate file exists"""
|
|
59
|
+
p = Path(path)
|
|
60
|
+
if not p.exists():
|
|
61
|
+
raise ValidationError(f"File not found: {path}")
|
|
62
|
+
if not p.is_file():
|
|
63
|
+
raise ValidationError(f"Not a file: {path}")
|
|
64
|
+
return p
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_dir_exists(path: str) -> Path:
|
|
68
|
+
"""Validate directory exists"""
|
|
69
|
+
p = Path(path)
|
|
70
|
+
if not p.exists():
|
|
71
|
+
raise ValidationError(f"Directory not found: {path}")
|
|
72
|
+
if not p.is_dir():
|
|
73
|
+
raise ValidationError(f"Not a directory: {path}")
|
|
74
|
+
return p
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_browser(browser: str) -> str:
|
|
78
|
+
"""Validate browser name"""
|
|
79
|
+
valid_browsers = ["chromium", "firefox", "webkit"]
|
|
80
|
+
if browser not in valid_browsers:
|
|
81
|
+
raise ValidationError(f"Browser must be one of: {valid_browsers}")
|
|
82
|
+
return browser
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def validate_platform(platform: str) -> str:
|
|
86
|
+
"""Validate platform name"""
|
|
87
|
+
valid_platforms = ["android", "ios"]
|
|
88
|
+
if platform not in valid_platforms:
|
|
89
|
+
raise ValidationError(f"Platform must be one of: {valid_platforms}")
|
|
90
|
+
return platform
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def validate_protocol(protocol: str) -> str:
|
|
94
|
+
"""Validate protocol"""
|
|
95
|
+
valid_protocols = ["mqtt", "coap", "http", "serial"]
|
|
96
|
+
if protocol not in valid_protocols:
|
|
97
|
+
raise ValidationError(f"Protocol must be one of: {valid_protocols}")
|
|
98
|
+
return protocol
|