overload-cli 0.1.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.
Files changed (40) hide show
  1. overload/__init__.py +3 -0
  2. overload/__main__.py +5 -0
  3. overload/cli.py +393 -0
  4. overload/collection/__init__.py +1 -0
  5. overload/collection/environment.py +23 -0
  6. overload/collection/models.py +88 -0
  7. overload/collection/parser.py +220 -0
  8. overload/collection/variables.py +84 -0
  9. overload/config_file.py +73 -0
  10. overload/engine/__init__.py +1 -0
  11. overload/engine/assertions.py +151 -0
  12. overload/engine/auth.py +87 -0
  13. overload/engine/events.py +50 -0
  14. overload/engine/http_client.py +274 -0
  15. overload/engine/load_patterns.py +730 -0
  16. overload/engine/models.py +254 -0
  17. overload/engine/rate_limiter.py +124 -0
  18. overload/engine/runner.py +86 -0
  19. overload/report/__init__.py +1 -0
  20. overload/report/exporters.py +77 -0
  21. overload/report/generator.py +71 -0
  22. overload/report/templates/report.html +369 -0
  23. overload/utils/__init__.py +1 -0
  24. overload/utils/naming.py +26 -0
  25. overload/web/__init__.py +1 -0
  26. overload/web/app.py +38 -0
  27. overload/web/routes/__init__.py +1 -0
  28. overload/web/routes/api.py +461 -0
  29. overload/web/routes/ws.py +77 -0
  30. overload/web/static/css/app.css +242 -0
  31. overload/web/static/js/app.js +241 -0
  32. overload/web/static/js/charts.js +385 -0
  33. overload/web/static/js/collection.js +344 -0
  34. overload/web/static/js/runner.js +625 -0
  35. overload/web/templates/index.html +23 -0
  36. overload_cli-0.1.0.dist-info/METADATA +267 -0
  37. overload_cli-0.1.0.dist-info/RECORD +40 -0
  38. overload_cli-0.1.0.dist-info/WHEEL +4 -0
  39. overload_cli-0.1.0.dist-info/entry_points.txt +2 -0
  40. overload_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,461 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from fastapi import APIRouter, File, UploadFile
11
+ from fastapi.responses import FileResponse, JSONResponse
12
+
13
+ from overload.collection.environment import parse_environment
14
+ from overload.collection.models import ParsedCollection
15
+ from overload.collection.parser import parse_collection
16
+ from overload.collection.variables import VariableContext
17
+ from overload.config_file import extract_config, extract_test_type, extract_thresholds, load_config, save_config
18
+ from overload.engine.assertions import evaluate, print_verdict, write_junit_xml
19
+ from overload.engine.http_client import HttpClient
20
+ from overload.engine.load_patterns import get_pattern
21
+ from overload.engine.models import PatternConfig, RunProgress, Stats, TestType, Threshold
22
+ from overload.engine.rate_limiter import run_rate_limit_test
23
+ from overload.engine.runner import run_sequential
24
+ from overload.report.exporters import export_csv, export_json
25
+ from overload.report.generator import generate_report
26
+ from overload.utils.naming import generate_run_id
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ router = APIRouter()
31
+
32
+ _state: dict[str, Any] = {
33
+ "collection": None,
34
+ "environment": None,
35
+ "variables": None,
36
+ "runs": {},
37
+ "current_task": None,
38
+ "cancel_event": None,
39
+ "working_dir": os.getcwd(),
40
+ }
41
+
42
+
43
+ def _detect_postman_files(directory: str) -> dict[str, list[dict]]:
44
+ collections: list[dict] = []
45
+ environments: list[dict] = []
46
+
47
+ dir_path = Path(directory)
48
+ if not dir_path.is_dir():
49
+ return {"collections": collections, "environments": environments}
50
+
51
+ for f in sorted(dir_path.glob("*.json")):
52
+ try:
53
+ with open(f, encoding="utf-8") as fh:
54
+ data = json.load(fh)
55
+
56
+ if isinstance(data, dict):
57
+ info = data.get("info", {})
58
+ schema = info.get("schema", "")
59
+ if "postman" in schema.lower() or (info.get("name") and "item" in data):
60
+ collections.append({
61
+ "name": info.get("name", f.stem),
62
+ "filename": f.name,
63
+ "path": str(f),
64
+ "request_count": _count_requests(data.get("item", [])),
65
+ })
66
+ elif "values" in data and isinstance(data.get("values"), list):
67
+ environments.append({
68
+ "name": data.get("name", f.stem),
69
+ "filename": f.name,
70
+ "path": str(f),
71
+ "variable_count": len(data.get("values", [])),
72
+ })
73
+ except (json.JSONDecodeError, OSError):
74
+ continue
75
+
76
+ return {"collections": collections, "environments": environments}
77
+
78
+
79
+ def _count_requests(items: list) -> int:
80
+ count = 0
81
+ for item in items:
82
+ if "request" in item:
83
+ count += 1
84
+ if "item" in item:
85
+ count += _count_requests(item["item"])
86
+ return count
87
+
88
+
89
+ @router.get("/detect")
90
+ async def detect_files() -> JSONResponse:
91
+ working_dir = _state.get("working_dir", os.getcwd())
92
+ detected = _detect_postman_files(working_dir)
93
+ return JSONResponse({
94
+ "status": "ok",
95
+ "working_dir": working_dir,
96
+ **detected,
97
+ })
98
+
99
+
100
+ @router.post("/collection/load-local")
101
+ async def load_local_collection(body: dict) -> JSONResponse:
102
+ filepath = body.get("path", "")
103
+ if not filepath or not os.path.isfile(filepath):
104
+ return JSONResponse({"status": "error", "message": "File not found"}, status_code=400)
105
+ try:
106
+ collection = parse_collection(filepath)
107
+ _state["collection"] = collection
108
+
109
+ env_vars = _state["environment"] or {}
110
+ _state["variables"] = VariableContext(
111
+ collection_vars=collection.variables,
112
+ environment_vars=env_vars,
113
+ )
114
+
115
+ logger.info("Collection loaded from disk: %s (%d requests)", collection.name, len(collection.requests))
116
+ return JSONResponse({"status": "ok", "collection": collection.to_dict()})
117
+ except Exception as exc:
118
+ logger.exception("Error parsing collection")
119
+ return JSONResponse({"status": "error", "message": str(exc)}, status_code=400)
120
+
121
+
122
+ @router.post("/environment/load-local")
123
+ async def load_local_environment(body: dict) -> JSONResponse:
124
+ filepath = body.get("path", "")
125
+ if not filepath or not os.path.isfile(filepath):
126
+ return JSONResponse({"status": "error", "message": "File not found"}, status_code=400)
127
+ try:
128
+ env_vars = parse_environment(filepath)
129
+ _state["environment"] = env_vars
130
+
131
+ if _state["collection"]:
132
+ _state["variables"] = VariableContext(
133
+ collection_vars=_state["collection"].variables,
134
+ environment_vars=env_vars,
135
+ )
136
+
137
+ logger.info("Environment loaded from disk: %d variables", len(env_vars))
138
+ return JSONResponse({"status": "ok", "variables": env_vars})
139
+ except Exception as exc:
140
+ logger.exception("Error parsing environment")
141
+ return JSONResponse({"status": "error", "message": str(exc)}, status_code=400)
142
+
143
+
144
+ @router.post("/collection/upload")
145
+ async def upload_collection(file: UploadFile = File(...)) -> JSONResponse:
146
+ try:
147
+ content = await file.read()
148
+ data = json.loads(content)
149
+ collection = parse_collection(data)
150
+ _state["collection"] = collection
151
+
152
+ env_vars = _state["environment"] or {}
153
+ _state["variables"] = VariableContext(
154
+ collection_vars=collection.variables,
155
+ environment_vars=env_vars,
156
+ )
157
+
158
+ logger.info("Collection loaded: %s (%d requests)", collection.name, len(collection.requests))
159
+ return JSONResponse({"status": "ok", "collection": collection.to_dict()})
160
+ except json.JSONDecodeError:
161
+ return JSONResponse({"status": "error", "message": "Invalid JSON file"}, status_code=400)
162
+ except Exception as exc:
163
+ logger.exception("Error parsing collection")
164
+ return JSONResponse({"status": "error", "message": str(exc)}, status_code=400)
165
+
166
+
167
+ @router.post("/environment/upload")
168
+ async def upload_environment(file: UploadFile = File(...)) -> JSONResponse:
169
+ try:
170
+ content = await file.read()
171
+ data = json.loads(content)
172
+ env_vars = parse_environment(data)
173
+ _state["environment"] = env_vars
174
+
175
+ if _state["collection"]:
176
+ _state["variables"] = VariableContext(
177
+ collection_vars=_state["collection"].variables,
178
+ environment_vars=env_vars,
179
+ )
180
+
181
+ logger.info("Environment loaded: %d variables", len(env_vars))
182
+ return JSONResponse({"status": "ok", "variables": env_vars})
183
+ except Exception as exc:
184
+ logger.exception("Error parsing environment")
185
+ return JSONResponse({"status": "error", "message": str(exc)}, status_code=400)
186
+
187
+
188
+ @router.post("/variables/update")
189
+ async def update_variables(body: dict) -> JSONResponse:
190
+ overrides = body.get("variables", {})
191
+ if _state["variables"]:
192
+ for key, value in overrides.items():
193
+ _state["variables"].set_variable(key, value)
194
+ else:
195
+ collection = _state.get("collection")
196
+ _state["variables"] = VariableContext(
197
+ collection_vars=collection.variables if collection else [],
198
+ runtime_vars=overrides,
199
+ )
200
+ return JSONResponse({"status": "ok"})
201
+
202
+
203
+ @router.post("/test/start")
204
+ async def start_test(body: dict) -> JSONResponse:
205
+ collection: ParsedCollection | None = _state.get("collection")
206
+ if not collection or not collection.requests:
207
+ return JSONResponse(
208
+ {"status": "error", "message": "No collection loaded"},
209
+ status_code=400,
210
+ )
211
+
212
+ if _state.get("current_task") and not _state["current_task"].done():
213
+ return JSONResponse(
214
+ {"status": "error", "message": "A test is already running"},
215
+ status_code=409,
216
+ )
217
+
218
+ test_type = body.get("test_type", "burst")
219
+ config_dict = body.get("config", {})
220
+ selected_indices = body.get("selected_requests")
221
+
222
+ thresholds: list[Threshold] = []
223
+ for entry in body.get("thresholds", []):
224
+ if isinstance(entry, dict) and "metric" in entry:
225
+ thresholds.append(Threshold(
226
+ metric=entry["metric"],
227
+ operator=entry.get("operator", "<"),
228
+ value=float(entry.get("value", 0)),
229
+ ))
230
+
231
+ run_id = generate_run_id()
232
+ cancel_event = asyncio.Event()
233
+ _state["cancel_event"] = cancel_event
234
+
235
+ config = PatternConfig(**{k: v for k, v in config_dict.items() if hasattr(PatternConfig, k)})
236
+
237
+ requests = collection.requests
238
+ if selected_indices is not None:
239
+ requests = [collection.requests[i] for i in selected_indices if i < len(collection.requests)]
240
+
241
+ variables = _state.get("variables") or VariableContext(collection_vars=collection.variables)
242
+ output_dir = os.path.join(_state.get("working_dir", os.getcwd()), "reports")
243
+
244
+ from overload.web.routes.ws import broadcast_progress
245
+
246
+ async def on_progress(progress: RunProgress) -> None:
247
+ await broadcast_progress(run_id, progress)
248
+
249
+ async def _run_test() -> None:
250
+ stats = Stats()
251
+ ramp_rows: list[dict] = []
252
+
253
+ try:
254
+ async with HttpClient(
255
+ timeout=config.timeout_seconds,
256
+ verify_ssl=config.verify_ssl,
257
+ follow_redirects=config.follow_redirects,
258
+ max_connections=config.concurrency * 2,
259
+ save_responses=config.save_responses,
260
+ ) as client:
261
+ await client.prepare_collection_auth(collection.auth, variables)
262
+ if test_type == TestType.SEQUENTIAL:
263
+ results = await run_sequential(
264
+ client, requests, variables, config,
265
+ run_id, cancel_event, on_progress,
266
+ )
267
+ stats.add_all(results)
268
+ elif test_type == TestType.RATE_LIMIT:
269
+ results, ramp_rows = await run_rate_limit_test(
270
+ client, requests, variables, config,
271
+ run_id, cancel_event, on_progress,
272
+ )
273
+ stats.add_all(results)
274
+ else:
275
+ pattern = get_pattern(test_type)
276
+ results = await pattern.execute(
277
+ client, requests, variables, config,
278
+ run_id, cancel_event, on_progress,
279
+ )
280
+ stats.add_all(results)
281
+
282
+ computed = stats.compute()
283
+
284
+ verdict_data = None
285
+ if thresholds and computed:
286
+ verdict = evaluate(computed, thresholds)
287
+ verdict_data = {
288
+ "passed": verdict.passed,
289
+ "results": [
290
+ {
291
+ "metric": r.metric,
292
+ "operator": r.operator,
293
+ "expected": r.expected,
294
+ "actual": round(r.actual, 2),
295
+ "passed": r.passed,
296
+ }
297
+ for r in verdict.results
298
+ ],
299
+ }
300
+
301
+ report_config = {
302
+ "test_type": test_type,
303
+ "concurrency": config.concurrency,
304
+ "total_requests_configured": config.total_requests,
305
+ }
306
+ report_path = generate_report(
307
+ stats, test_type, report_config,
308
+ run_id=run_id, ramp_rows=ramp_rows,
309
+ output_dir=output_dir,
310
+ verdict=verdict_data,
311
+ )
312
+
313
+ _state["runs"][run_id] = {
314
+ "run_id": run_id,
315
+ "test_type": test_type,
316
+ "stats": computed,
317
+ "ramp_rows": ramp_rows,
318
+ "report_path": report_path,
319
+ "status": "complete",
320
+ "verdict": verdict_data,
321
+ }
322
+
323
+ await broadcast_progress(run_id, RunProgress(
324
+ run_id=run_id,
325
+ total_requests=stats.total,
326
+ completed_requests=stats.total,
327
+ current_rps=0,
328
+ phase="complete",
329
+ elapsed_seconds=computed["duration_seconds"] if computed else 0,
330
+ ))
331
+ logger.info("Test complete: %s (%d requests)", run_id, stats.total)
332
+
333
+ except asyncio.CancelledError:
334
+ logger.info("Test cancelled: %s", run_id)
335
+ computed = stats.compute() if stats.total > 0 else None
336
+ _state["runs"][run_id] = {
337
+ "run_id": run_id,
338
+ "test_type": test_type,
339
+ "stats": computed,
340
+ "status": "cancelled",
341
+ }
342
+ await broadcast_progress(run_id, RunProgress(
343
+ run_id=run_id,
344
+ total_requests=stats.total,
345
+ completed_requests=stats.total,
346
+ current_rps=0,
347
+ phase="complete (stopped)",
348
+ elapsed_seconds=computed["duration_seconds"] if computed else 0,
349
+ ))
350
+ except Exception:
351
+ logger.exception("Test failed: %s", run_id)
352
+ _state["runs"][run_id] = {"run_id": run_id, "status": "error"}
353
+
354
+ task = asyncio.create_task(_run_test())
355
+ _state["current_task"] = task
356
+
357
+ return JSONResponse({"status": "ok", "run_id": run_id})
358
+
359
+
360
+ @router.post("/test/stop")
361
+ async def stop_test() -> JSONResponse:
362
+ cancel_event = _state.get("cancel_event")
363
+ if cancel_event:
364
+ cancel_event.set()
365
+ logger.info("Stop signal sent")
366
+ task = _state.get("current_task")
367
+ if task and not task.done():
368
+ task.cancel()
369
+ logger.info("Task cancelled")
370
+ return JSONResponse({"status": "ok"})
371
+
372
+
373
+ @router.get("/runs")
374
+ async def list_runs() -> JSONResponse:
375
+ runs = []
376
+ for run_id, run_data in _state["runs"].items():
377
+ summary = {
378
+ "run_id": run_id,
379
+ "test_type": run_data.get("test_type", ""),
380
+ "status": run_data.get("status", ""),
381
+ }
382
+ stats = run_data.get("stats")
383
+ if stats:
384
+ summary["total"] = stats.get("total", 0)
385
+ summary["ok"] = stats.get("ok", 0)
386
+ summary["errors"] = stats.get("errors", 0)
387
+ summary["avg_rps"] = stats.get("avg_rps", 0)
388
+ summary["duration"] = stats.get("duration_seconds", 0)
389
+ verdict = run_data.get("verdict")
390
+ if verdict is not None:
391
+ summary["verdict"] = verdict["passed"]
392
+ runs.append(summary)
393
+ return JSONResponse({"runs": runs})
394
+
395
+
396
+ @router.get("/runs/{run_id}/data")
397
+ async def get_run_data(run_id: str) -> JSONResponse:
398
+ run_data = _state["runs"].get(run_id)
399
+ if not run_data:
400
+ return JSONResponse({"status": "error", "message": "Run not found"}, status_code=404)
401
+ return JSONResponse(run_data)
402
+
403
+
404
+ @router.get("/runs/{run_id}/report")
405
+ async def get_run_report(run_id: str) -> FileResponse:
406
+ run_data = _state["runs"].get(run_id)
407
+ if not run_data or not run_data.get("report_path"):
408
+ return JSONResponse({"status": "error", "message": "Report not found"}, status_code=404)
409
+ return FileResponse(run_data["report_path"], media_type="text/html")
410
+
411
+
412
+ @router.get("/runs/{run_id}/export/csv")
413
+ async def export_run_csv(run_id: str) -> JSONResponse:
414
+ run_data = _state["runs"].get(run_id)
415
+ if not run_data:
416
+ return JSONResponse({"status": "error", "message": "Run not found"}, status_code=404)
417
+ return JSONResponse({"status": "error", "message": "CSV export requires stored results"}, status_code=501)
418
+
419
+
420
+ @router.get("/runs/{run_id}/export/json")
421
+ async def export_run_json(run_id: str) -> JSONResponse:
422
+ run_data = _state["runs"].get(run_id)
423
+ if not run_data:
424
+ return JSONResponse({"status": "error", "message": "Run not found"}, status_code=404)
425
+ return JSONResponse(run_data)
426
+
427
+
428
+ @router.get("/test/status")
429
+ async def test_status() -> JSONResponse:
430
+ task = _state.get("current_task")
431
+ if task and not task.done():
432
+ return JSONResponse({"status": "running"})
433
+ return JSONResponse({"status": "idle"})
434
+
435
+
436
+ @router.post("/config/save")
437
+ async def save_config_endpoint(body: dict) -> JSONResponse:
438
+ working_dir = _state.get("working_dir", os.getcwd())
439
+ test_type = body.get("test_type", "burst")
440
+ config_dict = body.get("config", {})
441
+ threshold_list: list[Threshold] = []
442
+ for entry in body.get("thresholds", []):
443
+ if isinstance(entry, dict) and "metric" in entry:
444
+ threshold_list.append(Threshold(
445
+ metric=entry["metric"],
446
+ operator=entry.get("operator", "<"),
447
+ value=float(entry.get("value", 0)),
448
+ ))
449
+ path = os.path.join(working_dir, "overload.config.yaml")
450
+ save_config(path, test_type, config_dict, threshold_list if threshold_list else None)
451
+ return JSONResponse({"status": "ok", "path": path})
452
+
453
+
454
+ @router.get("/config/load")
455
+ async def load_config_endpoint() -> JSONResponse:
456
+ working_dir = _state.get("working_dir", os.getcwd())
457
+ path = os.path.join(working_dir, "overload.config.yaml")
458
+ if not os.path.isfile(path):
459
+ return JSONResponse({"status": "error", "message": "No overload.config.yaml found"}, status_code=404)
460
+ raw = load_config(path)
461
+ return JSONResponse({"status": "ok", "config": raw})
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from dataclasses import asdict
7
+
8
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
9
+
10
+ from overload.engine.models import RunProgress
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ router = APIRouter()
15
+
16
+ _connections: dict[str, list[WebSocket]] = {}
17
+
18
+
19
+ async def broadcast_progress(run_id: str, progress: RunProgress) -> None:
20
+ sockets = _connections.get(run_id, [])
21
+ if not sockets:
22
+ return
23
+
24
+ message = json.dumps({
25
+ "type": "progress",
26
+ "data": asdict(progress),
27
+ })
28
+
29
+ dead: list[WebSocket] = []
30
+ for ws in sockets:
31
+ try:
32
+ await ws.send_text(message)
33
+ except Exception:
34
+ dead.append(ws)
35
+
36
+ for ws in dead:
37
+ sockets.remove(ws)
38
+
39
+
40
+ @router.websocket("/ws")
41
+ async def websocket_endpoint(ws: WebSocket) -> None:
42
+ await ws.accept()
43
+ subscribed_run: str | None = None
44
+
45
+ try:
46
+ while True:
47
+ data = await ws.receive_text()
48
+ try:
49
+ msg = json.loads(data)
50
+ except json.JSONDecodeError:
51
+ await ws.send_text(json.dumps({"type": "error", "message": "Invalid JSON"}))
52
+ continue
53
+
54
+ if msg.get("type") == "subscribe":
55
+ run_id = msg.get("run_id", "")
56
+ if subscribed_run and subscribed_run in _connections:
57
+ conns = _connections[subscribed_run]
58
+ if ws in conns:
59
+ conns.remove(ws)
60
+
61
+ subscribed_run = run_id
62
+ _connections.setdefault(run_id, []).append(ws)
63
+ await ws.send_text(json.dumps({"type": "subscribed", "run_id": run_id}))
64
+ logger.debug("WebSocket subscribed to run %s", run_id)
65
+
66
+ elif msg.get("type") == "ping":
67
+ await ws.send_text(json.dumps({"type": "pong"}))
68
+
69
+ except WebSocketDisconnect:
70
+ logger.debug("WebSocket disconnected")
71
+ except Exception:
72
+ logger.exception("WebSocket error")
73
+ finally:
74
+ if subscribed_run and subscribed_run in _connections:
75
+ conns = _connections[subscribed_run]
76
+ if ws in conns:
77
+ conns.remove(ws)