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,254 @@
1
+ from __future__ import annotations
2
+
3
+ import statistics
4
+ from collections import defaultdict
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+
8
+
9
+ class TestType(str, Enum):
10
+ LOAD = "load"
11
+ STRESS = "stress"
12
+ SPIKE = "spike"
13
+ SOAK = "soak"
14
+ RAMP = "ramp"
15
+ BURST = "burst"
16
+ BREAKPOINT = "breakpoint"
17
+ CUSTOM = "custom"
18
+ RATE_LIMIT = "ratelimit"
19
+ SEQUENTIAL = "sequential"
20
+
21
+
22
+ class RequestDistribution(str, Enum):
23
+ ROUND_ROBIN = "round-robin"
24
+ RANDOM = "random"
25
+
26
+
27
+ @dataclass
28
+ class RequestResult:
29
+ request_name: str
30
+ method: str
31
+ url: str
32
+ status_code: int
33
+ latency_ms: float
34
+ timestamp: float
35
+ error: str | None = None
36
+ headers_sent: dict[str, str] = field(default_factory=dict)
37
+ headers_received: dict[str, str] = field(default_factory=dict)
38
+ body_size_bytes: int = 0
39
+ response_body: str | None = None
40
+
41
+
42
+ @dataclass
43
+ class RunProgress:
44
+ run_id: str
45
+ total_requests: int
46
+ completed_requests: int
47
+ current_rps: float
48
+ phase: str
49
+ elapsed_seconds: float
50
+ error_count: int = 0
51
+ status_codes: dict[int, int] = field(default_factory=dict)
52
+ avg_latency_ms: float = 0.0
53
+ recent_results: list[dict] = field(default_factory=list)
54
+
55
+
56
+ @dataclass
57
+ class PatternConfig:
58
+ concurrency: int = 20
59
+ timeout_seconds: float = 30.0
60
+ verify_ssl: bool = True
61
+ follow_redirects: bool = True
62
+ save_responses: bool = False
63
+ distribution: RequestDistribution = RequestDistribution.ROUND_ROBIN
64
+ think_time_ms: int = 0
65
+
66
+ # Load test
67
+ target_rps: int = 50
68
+ ramp_up_seconds: int = 30
69
+ hold_duration_seconds: int = 300
70
+ ramp_down_seconds: int = 10
71
+
72
+ # Stress test
73
+ start_rps: int = 10
74
+ step_rps: int = 20
75
+ step_duration_seconds: int = 30
76
+ failure_threshold_pct: float = 80.0
77
+ max_rps: int = 500
78
+
79
+ # Spike test
80
+ baseline_rps: int = 20
81
+ spike_rps: int = 200
82
+ baseline_duration_seconds: int = 60
83
+ spike_duration_seconds: int = 30
84
+ recovery_duration_seconds: int = 60
85
+
86
+ # Soak test
87
+ soak_rps: int = 30
88
+ soak_duration_seconds: int = 1800
89
+
90
+ # Ramp test
91
+ ramp_start_rps: int = 10
92
+ ramp_end_rps: int = 200
93
+
94
+ # Burst test
95
+ total_requests: int = 200
96
+
97
+ # Breakpoint test
98
+ precision_rps: int = 5
99
+ latency_threshold_ms: float = 2000.0
100
+ error_threshold_pct: float = 10.0
101
+
102
+ # Custom test
103
+ stages: list[dict] = field(default_factory=list)
104
+
105
+ # Rate limit test
106
+ rate_limit_cap: int = 60
107
+ rate_limit_requests: int = 120
108
+
109
+ # Sequential runner
110
+ iterations: int = 1
111
+ delay_ms: int = 0
112
+
113
+
114
+ @dataclass
115
+ class Threshold:
116
+ metric: str
117
+ operator: str
118
+ value: float
119
+
120
+
121
+ @dataclass
122
+ class AssertionResult:
123
+ metric: str
124
+ operator: str
125
+ expected: float
126
+ actual: float
127
+ passed: bool
128
+
129
+
130
+ @dataclass
131
+ class Verdict:
132
+ passed: bool
133
+ results: list[AssertionResult]
134
+
135
+
136
+ class Stats:
137
+ def __init__(self) -> None:
138
+ self.results: list[RequestResult] = []
139
+
140
+ def add(self, result: RequestResult) -> None:
141
+ self.results.append(result)
142
+
143
+ def add_all(self, results: list[RequestResult]) -> None:
144
+ self.results.extend(results)
145
+
146
+ @property
147
+ def total(self) -> int:
148
+ return len(self.results)
149
+
150
+ @property
151
+ def success_count(self) -> int:
152
+ return sum(1 for r in self.results if 200 <= r.status_code < 400)
153
+
154
+ @property
155
+ def error_count(self) -> int:
156
+ return sum(
157
+ 1
158
+ for r in self.results
159
+ if r.status_code < 200 or r.status_code >= 400
160
+ )
161
+
162
+ @property
163
+ def rate_limited_count(self) -> int:
164
+ return sum(1 for r in self.results if r.status_code == 429)
165
+
166
+ def compute(self) -> dict | None:
167
+ if not self.results:
168
+ return None
169
+
170
+ status_counts: dict[int, int] = defaultdict(int)
171
+ for r in self.results:
172
+ status_counts[r.status_code] += 1
173
+
174
+ latencies = [r.latency_ms for r in self.results]
175
+ sorted_latencies = sorted(latencies)
176
+
177
+ ok = sum(v for k, v in status_counts.items() if 200 <= k < 400)
178
+ rl = status_counts.get(429, 0)
179
+ total = len(self.results)
180
+
181
+ t0 = self.results[0].timestamp
182
+ buckets: dict[int, dict[int, int]] = defaultdict(lambda: defaultdict(int))
183
+ for r in self.results:
184
+ buckets[int(r.timestamp - t0)][r.status_code] += 1
185
+
186
+ per_second = []
187
+ for sec in sorted(buckets):
188
+ b = buckets[sec]
189
+ sec_total = sum(b.values())
190
+ sec_ok = sum(v for k, v in b.items() if 200 <= k < 300)
191
+ sec_redirect = sum(v for k, v in b.items() if 300 <= k < 400)
192
+ sec_rl = b.get(429, 0)
193
+ sec_client = sum(v for k, v in b.items() if 400 <= k < 500 and k != 429)
194
+ sec_server = sum(v for k, v in b.items() if k >= 500)
195
+ sec_conn = sum(v for k, v in b.items() if k <= 0)
196
+ per_second.append({
197
+ "second": sec,
198
+ "total": sec_total,
199
+ "ok": sec_ok + sec_redirect,
200
+ "rate_limited": sec_rl,
201
+ "client_errors": sec_client,
202
+ "server_errors": sec_server,
203
+ "conn_errors": sec_conn,
204
+ "errors": sec_total - sec_ok - sec_redirect - sec_rl,
205
+ })
206
+
207
+ timeline = [
208
+ {
209
+ "timestamp": round(r.timestamp - t0, 3),
210
+ "status": r.status_code,
211
+ "latency_ms": round(r.latency_ms, 1),
212
+ "request_name": r.request_name,
213
+ }
214
+ for r in self.results
215
+ ]
216
+
217
+ request_log = []
218
+ for r in self.results:
219
+ entry = {
220
+ "timestamp": round(r.timestamp - t0, 3),
221
+ "status": r.status_code,
222
+ "latency_ms": round(r.latency_ms, 1),
223
+ "method": r.method,
224
+ "url": r.url,
225
+ "request_name": r.request_name,
226
+ "error": r.error,
227
+ }
228
+ if r.response_body is not None:
229
+ entry["response_body"] = r.response_body
230
+ request_log.append(entry)
231
+
232
+ p95_idx = int(len(sorted_latencies) * 0.95)
233
+ p99_idx = int(len(sorted_latencies) * 0.99)
234
+
235
+ return {
236
+ "total": total,
237
+ "ok": ok,
238
+ "rate_limited": rl,
239
+ "errors": total - ok - rl,
240
+ "status_codes": dict(status_counts),
241
+ "latency": {
242
+ "min": round(min(latencies), 1),
243
+ "median": round(statistics.median(latencies), 1),
244
+ "mean": round(statistics.mean(latencies), 1),
245
+ "p95": round(sorted_latencies[min(p95_idx, total - 1)], 1),
246
+ "p99": round(sorted_latencies[min(p99_idx, total - 1)], 1),
247
+ "max": round(max(latencies), 1),
248
+ },
249
+ "per_second": per_second,
250
+ "timeline": timeline,
251
+ "request_log": request_log,
252
+ "duration_seconds": round(max(self.results[-1].timestamp - t0, 0.1), 1) if total > 1 else 0.1,
253
+ "avg_rps": round(total / max(self.results[-1].timestamp - t0, 0.1), 1),
254
+ }
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ from collections.abc import Callable, Coroutine
7
+ from typing import Any
8
+
9
+ from overload.collection.models import ParsedRequest
10
+ from overload.collection.variables import VariableContext
11
+ from overload.engine.http_client import HttpClient
12
+ from overload.engine.load_patterns import _fire_one, _pick_request
13
+ from overload.engine.models import (
14
+ PatternConfig,
15
+ RequestDistribution,
16
+ RequestResult,
17
+ RunProgress,
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ ProgressCallback = Callable[[RunProgress], Coroutine[Any, Any, None]]
23
+
24
+
25
+ async def run_rate_limit_test(
26
+ client: HttpClient,
27
+ requests: list[ParsedRequest],
28
+ variables: VariableContext,
29
+ config: PatternConfig,
30
+ run_id: str,
31
+ cancel_event: asyncio.Event,
32
+ on_progress: ProgressCallback | None = None,
33
+ ) -> tuple[list[RequestResult], list[dict]]:
34
+ cap = config.rate_limit_cap
35
+ total_requests = config.rate_limit_requests
36
+ concurrency = config.concurrency
37
+ sem = asyncio.Semaphore(concurrency)
38
+ all_results: list[RequestResult] = []
39
+ ramp_rows: list[dict] = []
40
+ start_time = time.monotonic()
41
+ request_idx = 0
42
+
43
+ logger.info("Rate Limit Test: cap=%d, requests=%d, concurrency=%d", cap, total_requests, concurrency)
44
+
45
+ # Phase 1: Burst test
46
+ if on_progress:
47
+ await on_progress(RunProgress(
48
+ run_id=run_id, total_requests=total_requests,
49
+ completed_requests=0, current_rps=0,
50
+ phase=f"Burst: {total_requests} requests", elapsed_seconds=0,
51
+ ))
52
+
53
+ burst_tasks = [
54
+ asyncio.create_task(
55
+ _fire_one(
56
+ client,
57
+ _pick_request(requests, i, RequestDistribution.ROUND_ROBIN),
58
+ variables, sem,
59
+ )
60
+ )
61
+ for i in range(total_requests)
62
+ ]
63
+
64
+ for coro in asyncio.as_completed(burst_tasks):
65
+ if cancel_event.is_set():
66
+ for t in burst_tasks:
67
+ t.cancel()
68
+ break
69
+ result = await coro
70
+ all_results.append(result)
71
+ request_idx += 1
72
+
73
+ # Phase 2: Ramp to find threshold
74
+ if not cancel_event.is_set():
75
+ logger.info("Rate limit ramp: 10 -> %d req/s", cap * 2)
76
+
77
+ for rps in range(10, cap * 2 + 1, 10):
78
+ if cancel_event.is_set():
79
+ break
80
+
81
+ interval = 1.0 / rps
82
+ batch_tasks = []
83
+ batch_start = time.monotonic()
84
+
85
+ for i in range(rps):
86
+ if cancel_event.is_set():
87
+ break
88
+ delay = batch_start + i * interval - time.monotonic()
89
+ if delay > 0:
90
+ await asyncio.sleep(delay)
91
+ req = _pick_request(requests, request_idx, RequestDistribution.ROUND_ROBIN)
92
+ request_idx += 1
93
+ batch_tasks.append(
94
+ asyncio.create_task(_fire_one(client, req, variables, sem))
95
+ )
96
+
97
+ batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True)
98
+ valid = [r for r in batch_results if isinstance(r, RequestResult)]
99
+ all_results.extend(valid)
100
+
101
+ total = len(valid)
102
+ rl = sum(1 for r in valid if r.status_code == 429)
103
+ ok = sum(1 for r in valid if 200 <= r.status_code < 400)
104
+ pct = rl * 100 // total if total else 0
105
+
106
+ ramp_rows.append({"rps": rps, "ok": ok, "rate_limited": rl, "pct": pct})
107
+
108
+ logger.info("%d/s: ok=%d, 429=%d (%d%%)", rps, ok, rl, pct)
109
+
110
+ if on_progress:
111
+ elapsed = time.monotonic() - start_time
112
+ await on_progress(RunProgress(
113
+ run_id=run_id, total_requests=0,
114
+ completed_requests=len(all_results),
115
+ current_rps=rps,
116
+ phase=f"Ramp: {rps} req/s ({pct}% throttled)",
117
+ elapsed_seconds=round(elapsed, 1),
118
+ ))
119
+
120
+ if pct >= 50:
121
+ logger.info("Rate limiting confirmed at %d req/s", rps)
122
+ break
123
+
124
+ return all_results, ramp_rows
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ from collections.abc import Callable, Coroutine
7
+ from typing import Any
8
+
9
+ from overload.collection.models import ParsedRequest
10
+ from overload.collection.variables import VariableContext
11
+ from overload.engine.http_client import HttpClient
12
+ from overload.engine.models import PatternConfig, RequestResult, RunProgress
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ ProgressCallback = Callable[[RunProgress], Coroutine[Any, Any, None]]
17
+
18
+
19
+ async def run_sequential(
20
+ client: HttpClient,
21
+ requests: list[ParsedRequest],
22
+ variables: VariableContext,
23
+ config: PatternConfig,
24
+ run_id: str,
25
+ cancel_event: asyncio.Event,
26
+ on_progress: ProgressCallback | None = None,
27
+ ) -> list[RequestResult]:
28
+ iterations = config.iterations
29
+ delay_ms = config.delay_ms
30
+ total = len(requests) * iterations
31
+ all_results: list[RequestResult] = []
32
+ start_time = time.monotonic()
33
+
34
+ logger.info(
35
+ "Sequential: %d requests x %d iterations, delay=%dms",
36
+ len(requests), iterations, delay_ms,
37
+ )
38
+
39
+ if on_progress:
40
+ try:
41
+ await on_progress(RunProgress(
42
+ run_id=run_id,
43
+ total_requests=total,
44
+ completed_requests=0,
45
+ current_rps=0,
46
+ phase="Preparing sequential run...",
47
+ elapsed_seconds=0,
48
+ ))
49
+ except Exception:
50
+ logger.exception("Error in progress callback")
51
+
52
+ for iteration in range(1, iterations + 1):
53
+ if cancel_event.is_set():
54
+ break
55
+
56
+ for idx, request in enumerate(requests):
57
+ if cancel_event.is_set():
58
+ break
59
+
60
+ result = await client.execute(request, variables)
61
+ all_results.append(result)
62
+
63
+ logger.debug(
64
+ "Iter %d/%d, Req %d/%d: %s %s -> %d (%.1fms)",
65
+ iteration, iterations, idx + 1, len(requests),
66
+ result.method, result.url, result.status_code, result.latency_ms,
67
+ )
68
+
69
+ if on_progress:
70
+ elapsed = time.monotonic() - start_time
71
+ try:
72
+ await on_progress(RunProgress(
73
+ run_id=run_id,
74
+ total_requests=total,
75
+ completed_requests=len(all_results),
76
+ current_rps=len(all_results) / max(elapsed, 0.001),
77
+ phase=f"Iteration {iteration}/{iterations}: {request.name}",
78
+ elapsed_seconds=round(elapsed, 1),
79
+ ))
80
+ except Exception:
81
+ logger.exception("Error in progress callback")
82
+
83
+ if delay_ms > 0 and not (iteration == iterations and idx == len(requests) - 1):
84
+ await asyncio.sleep(delay_ms / 1000.0)
85
+
86
+ return all_results
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ import logging
6
+ import os
7
+
8
+ from overload.engine.models import Stats
9
+ from overload.utils.naming import stamped_filename
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def export_json(
15
+ stats: Stats,
16
+ test_type: str,
17
+ run_id: str,
18
+ output_dir: str = ".",
19
+ ramp_rows: list[dict] | None = None,
20
+ ) -> str:
21
+ computed = stats.compute()
22
+ if not computed:
23
+ logger.warning("No results to export")
24
+ return ""
25
+
26
+ payload = {
27
+ "meta": {"run_id": run_id, "test_type": test_type},
28
+ "stats": computed,
29
+ "ramp_rows": ramp_rows or [],
30
+ }
31
+
32
+ filename = stamped_filename("overload_results", run_id, ".json")
33
+ filepath = os.path.join(output_dir, filename)
34
+ os.makedirs(output_dir, exist_ok=True)
35
+
36
+ with open(filepath, "w", encoding="utf-8") as f:
37
+ json.dump(payload, f, indent=2)
38
+
39
+ logger.info("JSON export: %s", os.path.abspath(filepath))
40
+ return filepath
41
+
42
+
43
+ def export_csv(
44
+ stats: Stats,
45
+ run_id: str,
46
+ output_dir: str = ".",
47
+ ) -> str:
48
+ if not stats.results:
49
+ logger.warning("No results to export")
50
+ return ""
51
+
52
+ filename = stamped_filename("overload_results", run_id, ".csv")
53
+ filepath = os.path.join(output_dir, filename)
54
+ os.makedirs(output_dir, exist_ok=True)
55
+
56
+ fieldnames = [
57
+ "timestamp", "request_name", "method", "url",
58
+ "status_code", "latency_ms", "error", "body_size_bytes",
59
+ ]
60
+
61
+ with open(filepath, "w", encoding="utf-8", newline="") as f:
62
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
63
+ writer.writeheader()
64
+ for r in stats.results:
65
+ writer.writerow({
66
+ "timestamp": r.timestamp,
67
+ "request_name": r.request_name,
68
+ "method": r.method,
69
+ "url": r.url,
70
+ "status_code": r.status_code,
71
+ "latency_ms": round(r.latency_ms, 1),
72
+ "error": r.error or "",
73
+ "body_size_bytes": r.body_size_bytes,
74
+ })
75
+
76
+ logger.info("CSV export: %s", os.path.abspath(filepath))
77
+ return filepath
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from jinja2 import Environment, FileSystemLoader
9
+
10
+ from overload.engine.models import Stats
11
+ from overload.utils.naming import generate_run_id, stamped_filename
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ TEMPLATES_DIR = Path(__file__).parent / "templates"
16
+
17
+
18
+ def _create_jinja_env() -> Environment:
19
+ return Environment(
20
+ loader=FileSystemLoader(str(TEMPLATES_DIR)),
21
+ autoescape=True,
22
+ )
23
+
24
+
25
+ def generate_report(
26
+ stats: Stats,
27
+ test_type: str,
28
+ config: dict,
29
+ run_id: str | None = None,
30
+ ramp_rows: list[dict] | None = None,
31
+ output_dir: str = "reports",
32
+ verdict: dict | None = None,
33
+ ) -> str:
34
+ run_id = run_id or generate_run_id()
35
+ computed = stats.compute()
36
+ if not computed:
37
+ logger.warning("No results to generate report from")
38
+ return ""
39
+
40
+ payload: dict = {
41
+ "meta": {
42
+ "run_id": run_id,
43
+ "test_type": test_type,
44
+ "config": config,
45
+ },
46
+ "stats": computed,
47
+ "ramp_rows": ramp_rows or [],
48
+ }
49
+ if verdict is not None:
50
+ payload["verdict"] = verdict
51
+
52
+ data_json = json.dumps(payload, separators=(",", ":"))
53
+ data_json = data_json.replace("</", "<\\/")
54
+
55
+ env = _create_jinja_env()
56
+ template = env.get_template("report.html")
57
+ html = template.render(
58
+ run_id=run_id,
59
+ test_type=test_type,
60
+ data_json=data_json,
61
+ )
62
+
63
+ filename = stamped_filename("overload_report", run_id, ".html")
64
+ filepath = os.path.join(output_dir, filename)
65
+ os.makedirs(output_dir, exist_ok=True)
66
+
67
+ with open(filepath, "w", encoding="utf-8") as f:
68
+ f.write(html)
69
+
70
+ logger.info("Report generated: %s", os.path.abspath(filepath))
71
+ return filepath