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
overload/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.1.0"
overload/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from overload.cli import main
4
+
5
+ main()
overload/cli.py ADDED
@@ -0,0 +1,393 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import logging
7
+ import os
8
+ import sys
9
+ import webbrowser
10
+
11
+ from overload import __version__
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def main() -> None:
17
+ parser = argparse.ArgumentParser(
18
+ prog="overload",
19
+ description="Overload — Free load testing tool for Postman collections",
20
+ )
21
+ parser.add_argument("--version", action="version", version=f"overload {__version__}")
22
+ parser.add_argument("--debug", action="store_true", help="Enable debug logging")
23
+
24
+ subparsers = parser.add_subparsers(dest="command")
25
+
26
+ # UI command (also the default)
27
+ ui_parser = subparsers.add_parser("ui", help="Start the browser UI")
28
+ ui_parser.add_argument("--port", type=int, default=3000, help="Port number (default: 3000)")
29
+ ui_parser.add_argument("--no-browser", action="store_true", help="Don't open browser automatically")
30
+ ui_parser.add_argument("--host", default="127.0.0.1", help="Host to bind to (default: 127.0.0.1)")
31
+
32
+ # Run command
33
+ run_parser = subparsers.add_parser("run", help="Run a load test from CLI")
34
+ run_parser.add_argument("--collection", required=True, help="Path to Postman collection JSON")
35
+ run_parser.add_argument("--environment", help="Path to Postman environment JSON")
36
+ run_parser.add_argument(
37
+ "--pattern",
38
+ choices=["load", "stress", "spike", "soak", "ramp", "burst", "breakpoint", "custom", "ratelimit"],
39
+ default="burst",
40
+ help="Test pattern (default: burst)",
41
+ )
42
+ run_parser.add_argument("--requests", type=int, default=200, help="Total requests for burst (default: 200)")
43
+ run_parser.add_argument("--concurrency", type=int, default=20, help="Max concurrent requests (default: 20)")
44
+ run_parser.add_argument("--rps", type=int, default=50, help="Target requests per second")
45
+ run_parser.add_argument("--duration", type=int, default=300, help="Test duration in seconds")
46
+ run_parser.add_argument("--var", action="append", dest="vars", metavar="KEY=VALUE", help="Variable override")
47
+ run_parser.add_argument("--save-responses", action="store_true", help="Save response bodies")
48
+ run_parser.add_argument("--output", default="reports", help="Output directory for reports (default: reports/)")
49
+ run_parser.add_argument("--format", choices=["html", "json", "csv"], default="html", help="Report format")
50
+ run_parser.add_argument("--stages", help="Custom stages JSON: '[{\"duration\":60,\"rps\":100}]'")
51
+ run_parser.add_argument("--timeout", type=float, default=30.0, help="Request timeout in seconds")
52
+ run_parser.add_argument("--no-verify-ssl", action="store_true", help="Disable SSL verification")
53
+ run_parser.add_argument(
54
+ "--assert", action="append", dest="assertions", metavar="EXPR",
55
+ help="Assertion threshold, e.g. 'p95_latency_ms<500' (repeatable)",
56
+ )
57
+ run_parser.add_argument("--junit", metavar="PATH", help="Write JUnit XML report to PATH")
58
+ run_parser.add_argument("--open-report", action="store_true", help="Open HTML report in browser after run")
59
+ run_parser.add_argument("--config", metavar="PATH", help="Path to overload.config.yaml")
60
+
61
+ # Sequential command
62
+ seq_parser = subparsers.add_parser("sequential", help="Run collection requests sequentially")
63
+ seq_parser.add_argument("--collection", required=True, help="Path to Postman collection JSON")
64
+ seq_parser.add_argument("--environment", help="Path to Postman environment JSON")
65
+ seq_parser.add_argument("--iterations", type=int, default=1, help="Number of iterations (default: 1)")
66
+ seq_parser.add_argument("--delay", type=int, default=0, help="Delay between requests in ms (default: 0)")
67
+ seq_parser.add_argument("--var", action="append", dest="vars", metavar="KEY=VALUE", help="Variable override")
68
+ seq_parser.add_argument("--output", default=".", help="Output directory")
69
+ seq_parser.add_argument("--timeout", type=float, default=30.0, help="Request timeout in seconds")
70
+
71
+ args = parser.parse_args()
72
+
73
+ _setup_logging(args.debug if hasattr(args, "debug") else False)
74
+
75
+ if args.command == "run":
76
+ asyncio.run(_run_test(args))
77
+ elif args.command == "sequential":
78
+ asyncio.run(_run_sequential(args))
79
+ else:
80
+ _start_ui(args)
81
+
82
+
83
+ def _setup_logging(debug: bool) -> None:
84
+ level = logging.DEBUG if debug else logging.WARNING
85
+ if os.environ.get("OVERLOAD_DEBUG"):
86
+ level = logging.DEBUG
87
+ logging.basicConfig(
88
+ level=level,
89
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
90
+ datefmt="%H:%M:%S",
91
+ )
92
+
93
+
94
+ def _parse_vars(var_list: list[str] | None) -> dict[str, str]:
95
+ if not var_list:
96
+ return {}
97
+ result = {}
98
+ for v in var_list:
99
+ if "=" in v:
100
+ key, value = v.split("=", 1)
101
+ result[key.strip()] = value.strip()
102
+ return result
103
+
104
+
105
+ def _start_ui(args: argparse.Namespace) -> None:
106
+ import uvicorn
107
+
108
+ from overload.web.app import create_app
109
+
110
+ port = getattr(args, "port", 3000)
111
+ host = getattr(args, "host", "127.0.0.1")
112
+ no_browser = getattr(args, "no_browser", False)
113
+
114
+ app = create_app(working_dir=os.getcwd())
115
+
116
+ print(f"\n OVERLOAD — Load Testing Tool v{__version__}")
117
+ print(f" Starting on http://{host}:{port}")
118
+ print(" Press Ctrl+C to stop\n")
119
+
120
+ if not no_browser:
121
+ import threading
122
+ threading.Timer(1.0, lambda: webbrowser.open(f"http://{host}:{port}")).start()
123
+
124
+ uvicorn.run(app, host=host, port=port, log_level="warning")
125
+
126
+
127
+ async def _run_test(args: argparse.Namespace) -> None:
128
+ from overload.collection.environment import parse_environment
129
+ from overload.collection.parser import parse_collection
130
+ from overload.collection.variables import VariableContext
131
+ from overload.engine.http_client import HttpClient
132
+ from overload.engine.load_patterns import get_pattern
133
+ from overload.engine.models import PatternConfig, Stats, Threshold
134
+ from overload.engine.rate_limiter import run_rate_limit_test
135
+ from overload.report.exporters import export_csv, export_json
136
+ from overload.report.generator import generate_report
137
+ from overload.utils.naming import generate_run_id
138
+
139
+ file_config: dict = {}
140
+ file_thresholds: list[Threshold] = []
141
+ if args.config:
142
+ from overload.config_file import extract_config, extract_test_type, extract_thresholds, load_config
143
+ raw = load_config(args.config)
144
+ file_config = extract_config(raw)
145
+ file_thresholds = extract_thresholds(raw)
146
+ file_test_type = extract_test_type(raw)
147
+ if file_test_type and args.pattern == "burst":
148
+ args.pattern = file_test_type
149
+
150
+ print(f"\n OVERLOAD — {args.pattern.upper()} TEST")
151
+ print(f" Collection: {args.collection}")
152
+ if args.config:
153
+ print(f" Config: {args.config}")
154
+
155
+ collection = parse_collection(args.collection)
156
+ print(f" Requests: {len(collection.requests)}")
157
+
158
+ env_vars = {}
159
+ if args.environment:
160
+ env_vars = parse_environment(args.environment)
161
+
162
+ runtime_vars = _parse_vars(args.vars)
163
+ variables = VariableContext(
164
+ collection_vars=collection.variables,
165
+ environment_vars=env_vars,
166
+ runtime_vars=runtime_vars,
167
+ )
168
+
169
+ run_id = generate_run_id()
170
+ cancel_event = asyncio.Event()
171
+ print(f" Run ID: {run_id}\n")
172
+
173
+ def _cfg(cli_val, key, default, cast=None):
174
+ if cli_val != default:
175
+ return cli_val
176
+ file_val = file_config.get(key)
177
+ if file_val is not None:
178
+ return cast(file_val) if cast else file_val
179
+ return cli_val
180
+
181
+ concurrency = _cfg(args.concurrency, "concurrency", 20, int)
182
+ timeout = _cfg(args.timeout, "timeout_seconds", 30.0, float)
183
+ rps = _cfg(args.rps, "target_rps", 50, int)
184
+ duration = _cfg(args.duration, "hold_duration_seconds", 300, int)
185
+ requests = _cfg(args.requests, "total_requests", 200, int)
186
+
187
+ config = PatternConfig(
188
+ concurrency=concurrency,
189
+ timeout_seconds=timeout,
190
+ verify_ssl=not args.no_verify_ssl,
191
+ total_requests=requests,
192
+ target_rps=rps,
193
+ hold_duration_seconds=duration,
194
+ soak_rps=rps,
195
+ soak_duration_seconds=duration,
196
+ ramp_end_rps=rps,
197
+ start_rps=10,
198
+ spike_rps=rps,
199
+ rate_limit_cap=rps,
200
+ rate_limit_requests=requests,
201
+ )
202
+
203
+ if args.stages:
204
+ try:
205
+ config.stages = json.loads(args.stages)
206
+ except json.JSONDecodeError:
207
+ print(" Error: Invalid stages JSON")
208
+ sys.exit(1)
209
+ elif file_config.get("stages"):
210
+ config.stages = file_config["stages"]
211
+
212
+ stats = Stats()
213
+ ramp_rows: list[dict] = []
214
+ completed = 0
215
+
216
+ async def on_progress(progress):
217
+ nonlocal completed
218
+ if progress.completed_requests > completed:
219
+ completed = progress.completed_requests
220
+ pct = min(100, completed * 100 // max(progress.total_requests, completed, 1))
221
+ bar = "#" * (pct // 5) + "-" * (20 - pct // 5)
222
+ print(f"\r [{bar}] {pct}% ({completed} requests) — {progress.phase}", end="", flush=True)
223
+ if progress.phase == "complete":
224
+ print()
225
+
226
+ async with HttpClient(
227
+ timeout=config.timeout_seconds,
228
+ verify_ssl=config.verify_ssl,
229
+ max_connections=config.concurrency * 2,
230
+ ) as client:
231
+ await client.prepare_collection_auth(collection.auth, variables)
232
+ if args.pattern == "ratelimit":
233
+ results, ramp_rows = await run_rate_limit_test(
234
+ client, collection.requests, variables, config,
235
+ run_id, cancel_event, on_progress,
236
+ )
237
+ stats.add_all(results)
238
+ else:
239
+ pattern = get_pattern(args.pattern)
240
+ results = await pattern.execute(
241
+ client, collection.requests, variables, config,
242
+ run_id, cancel_event, on_progress,
243
+ )
244
+ stats.add_all(results)
245
+
246
+ computed = stats.compute()
247
+ if computed:
248
+ print(f"\n Results:")
249
+ print(f" Total: {computed['total']} OK: {computed['ok']} Errors: {computed['errors']}")
250
+ lat = computed["latency"]
251
+ print(f" Latency — min: {lat['min']}ms p95: {lat['p95']}ms max: {lat['max']}ms")
252
+ print(f" Duration: {computed['duration_seconds']}s Avg RPS: {computed['avg_rps']}")
253
+
254
+ thresholds: list[Threshold] = []
255
+ if args.assertions:
256
+ from overload.engine.assertions import parse_threshold
257
+ for expr in args.assertions:
258
+ try:
259
+ thresholds.append(parse_threshold(expr))
260
+ except ValueError as e:
261
+ print(f"\n Error: {e}")
262
+ sys.exit(2)
263
+ elif file_thresholds:
264
+ thresholds = file_thresholds
265
+
266
+ verdict_failed = False
267
+ verdict_data = None
268
+ if thresholds and computed:
269
+ from overload.engine.assertions import evaluate, print_verdict, write_junit_xml
270
+
271
+ verdict = evaluate(computed, thresholds)
272
+ print_verdict(verdict)
273
+ verdict_data = {
274
+ "passed": verdict.passed,
275
+ "results": [
276
+ {
277
+ "metric": r.metric,
278
+ "operator": r.operator,
279
+ "expected": r.expected,
280
+ "actual": round(r.actual, 2),
281
+ "passed": r.passed,
282
+ }
283
+ for r in verdict.results
284
+ ],
285
+ }
286
+
287
+ if args.junit:
288
+ write_junit_xml(verdict, args.junit, test_name=f"overload-{args.pattern}")
289
+ print(f" JUnit XML: {os.path.abspath(args.junit)}")
290
+
291
+ verdict_failed = not verdict.passed
292
+
293
+ report_config_dict = {"pattern": args.pattern, "concurrency": concurrency}
294
+ report_path = ""
295
+ if args.format in ("html", "json"):
296
+ report_path = generate_report(
297
+ stats, args.pattern, report_config_dict,
298
+ run_id=run_id, ramp_rows=ramp_rows, output_dir=args.output,
299
+ verdict=verdict_data,
300
+ )
301
+ if report_path:
302
+ print(f"\n Report: {os.path.abspath(report_path)}")
303
+
304
+ if args.format == "json":
305
+ json_path = export_json(stats, args.pattern, run_id, args.output, ramp_rows)
306
+ if json_path:
307
+ print(f" JSON: {os.path.abspath(json_path)}")
308
+
309
+ if args.format == "csv":
310
+ csv_path = export_csv(stats, run_id, args.output)
311
+ if csv_path:
312
+ print(f" CSV: {os.path.abspath(csv_path)}")
313
+
314
+ if getattr(args, "open_report", False) and report_path:
315
+ webbrowser.open(f"file://{os.path.abspath(report_path)}")
316
+
317
+ print()
318
+
319
+ if verdict_failed:
320
+ sys.exit(1)
321
+
322
+
323
+ async def _run_sequential(args: argparse.Namespace) -> None:
324
+ from overload.collection.environment import parse_environment
325
+ from overload.collection.parser import parse_collection
326
+ from overload.collection.variables import VariableContext
327
+ from overload.engine.http_client import HttpClient
328
+ from overload.engine.models import PatternConfig, Stats
329
+ from overload.engine.runner import run_sequential
330
+ from overload.report.generator import generate_report
331
+ from overload.utils.naming import generate_run_id
332
+
333
+ print(f"\n OVERLOAD — SEQUENTIAL RUN")
334
+ print(f" Collection: {args.collection}")
335
+
336
+ collection = parse_collection(args.collection)
337
+ print(f" Requests: {len(collection.requests)}")
338
+ print(f" Iterations: {args.iterations} Delay: {args.delay}ms")
339
+
340
+ env_vars = {}
341
+ if args.environment:
342
+ env_vars = parse_environment(args.environment)
343
+
344
+ runtime_vars = _parse_vars(args.vars)
345
+ variables = VariableContext(
346
+ collection_vars=collection.variables,
347
+ environment_vars=env_vars,
348
+ runtime_vars=runtime_vars,
349
+ )
350
+
351
+ run_id = generate_run_id()
352
+ cancel_event = asyncio.Event()
353
+ print(f" Run ID: {run_id}\n")
354
+
355
+ config = PatternConfig(
356
+ iterations=args.iterations,
357
+ delay_ms=args.delay,
358
+ timeout_seconds=args.timeout,
359
+ )
360
+
361
+ async def on_progress(progress):
362
+ print(f"\r {progress.phase} — {progress.completed_requests}/{progress.total_requests}", end="", flush=True)
363
+ if progress.phase == "complete":
364
+ print()
365
+
366
+ async with HttpClient(timeout=config.timeout_seconds) as client:
367
+ results = await run_sequential(
368
+ client, collection.requests, variables, config,
369
+ run_id, cancel_event, on_progress,
370
+ )
371
+
372
+ stats = Stats()
373
+ stats.add_all(results)
374
+ computed = stats.compute()
375
+
376
+ if computed:
377
+ print(f"\n Results:")
378
+ print(f" Total: {computed['total']} OK: {computed['ok']} Errors: {computed['errors']}")
379
+ lat = computed["latency"]
380
+ print(f" Latency — min: {lat['min']}ms p95: {lat['p95']}ms max: {lat['max']}ms")
381
+
382
+ report_config = {"iterations": args.iterations, "delay_ms": args.delay}
383
+ report_path = generate_report(
384
+ stats, "sequential", report_config,
385
+ run_id=run_id, output_dir=args.output,
386
+ )
387
+ if report_path:
388
+ print(f"\n Report: {os.path.abspath(report_path)}")
389
+ print()
390
+
391
+
392
+ if __name__ == "__main__":
393
+ main()
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+
7
+ def parse_environment(source: str | Path | dict) -> dict[str, str]:
8
+ if isinstance(source, dict):
9
+ data = source
10
+ else:
11
+ path = Path(source)
12
+ if not path.exists():
13
+ raise FileNotFoundError(f"Environment file not found: {path}")
14
+ with open(path, encoding="utf-8") as f:
15
+ data = json.load(f)
16
+
17
+ variables: dict[str, str] = {}
18
+
19
+ for entry in data.get("values", []):
20
+ if entry.get("enabled", True) and "key" in entry:
21
+ variables[entry["key"]] = entry.get("value", "")
22
+
23
+ return variables
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class CollectionVariable:
8
+ key: str
9
+ value: str
10
+ type: str = "string"
11
+
12
+
13
+ @dataclass
14
+ class AuthConfig:
15
+ type: str
16
+ params: dict[str, str] = field(default_factory=dict)
17
+
18
+ def to_dict(self) -> dict:
19
+ return {"type": self.type, "params": self.params}
20
+
21
+
22
+ @dataclass
23
+ class QueryParam:
24
+ key: str
25
+ value: str
26
+ disabled: bool = False
27
+
28
+
29
+ @dataclass
30
+ class RequestBody:
31
+ mode: str
32
+ content: str | dict | list = ""
33
+ content_type: str = ""
34
+
35
+ def to_dict(self) -> dict:
36
+ return {
37
+ "mode": self.mode,
38
+ "content": self.content,
39
+ "content_type": self.content_type,
40
+ }
41
+
42
+
43
+ @dataclass
44
+ class ParsedRequest:
45
+ name: str
46
+ method: str
47
+ url_raw: str
48
+ headers: dict[str, str] = field(default_factory=dict)
49
+ body: RequestBody = field(default_factory=lambda: RequestBody(mode="none"))
50
+ auth: AuthConfig | None = None
51
+ query_params: list[QueryParam] = field(default_factory=list)
52
+ folder_path: list[str] = field(default_factory=list)
53
+
54
+ def to_dict(self) -> dict:
55
+ return {
56
+ "name": self.name,
57
+ "method": self.method,
58
+ "url_raw": self.url_raw,
59
+ "headers": self.headers,
60
+ "body": self.body.to_dict(),
61
+ "auth": self.auth.to_dict() if self.auth else None,
62
+ "query_params": [
63
+ {"key": q.key, "value": q.value, "disabled": q.disabled}
64
+ for q in self.query_params
65
+ ],
66
+ "folder_path": self.folder_path,
67
+ }
68
+
69
+
70
+ @dataclass
71
+ class ParsedCollection:
72
+ name: str
73
+ description: str
74
+ requests: list[ParsedRequest] = field(default_factory=list)
75
+ variables: list[CollectionVariable] = field(default_factory=list)
76
+ auth: AuthConfig | None = None
77
+
78
+ def to_dict(self) -> dict:
79
+ return {
80
+ "name": self.name,
81
+ "description": self.description,
82
+ "requests": [r.to_dict() for r in self.requests],
83
+ "variables": [
84
+ {"key": v.key, "value": v.value, "type": v.type}
85
+ for v in self.variables
86
+ ],
87
+ "auth": self.auth.to_dict() if self.auth else None,
88
+ }